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 { |
export interface CashDetails { |
||||
accounts: Account[]; |
accounts: AccountWithBalance[]; |
||||
balanceInBaseCurrency: number; |
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 { |
import { |
||||
activityDummyData, |
activityDummyData, |
||||
symbolProfileDummyData, |
assetProfileDummyData, |
||||
userDummyData |
userDummyData |
||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; |
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; |
||||
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; |
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; |
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; |
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; |
||||
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; |
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; |
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; |
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; |
||||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; |
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; |
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.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 { 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 { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; |
||||
import { parseDate } from '@ghostfolio/common/helper'; |
import { parseDate } from '@ghostfolio/common/helper'; |
||||
import { Activity } from '@ghostfolio/common/interfaces'; |
import { Activity } from '@ghostfolio/common/interfaces'; |
||||
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; |
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; |
||||
|
|
||||
import { Big } from 'big.js'; |
import { Big } from 'big.js'; |
||||
|
|
||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { |
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { |
||||
return { |
return { |
||||
CurrentRateService: jest.fn().mockImplementation(() => { |
CurrentRateService: jest.fn().mockImplementation(() => { |
||||
return CurrentRateServiceMock; |
return CurrentRateServiceMock; |
||||
}) |
}) |
||||
}; |
}; |
||||
}); |
}); |
||||
|
|
||||
jest.mock( |
jest.mock( |
||||
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', |
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', |
||||
() => { |
() => { |
||||
return { |
return { |
||||
PortfolioSnapshotService: jest.fn().mockImplementation(() => { |
PortfolioSnapshotService: jest.fn().mockImplementation(() => { |
||||
return PortfolioSnapshotServiceMock; |
return PortfolioSnapshotServiceMock; |
||||
}) |
}) |
||||
}; |
}; |
||||
} |
} |
||||
); |
); |
||||
|
|
||||
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { |
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { |
||||
return { |
return { |
||||
RedisCacheService: jest.fn().mockImplementation(() => { |
RedisCacheService: jest.fn().mockImplementation(() => { |
||||
return RedisCacheServiceMock; |
return RedisCacheServiceMock; |
||||
}) |
}) |
||||
}; |
}; |
||||
}); |
}); |
||||
|
|
||||
describe('PortfolioCalculator', () => { |
describe('PortfolioCalculator', () => { |
||||
let configurationService: ConfigurationService; |
let configurationService: ConfigurationService; |
||||
let currentRateService: CurrentRateService; |
let currentRateService: CurrentRateService; |
||||
let exchangeRateDataService: ExchangeRateDataService; |
let exchangeRateDataService: ExchangeRateDataService; |
||||
let portfolioCalculatorFactory: PortfolioCalculatorFactory; |
let portfolioCalculatorFactory: PortfolioCalculatorFactory; |
||||
let portfolioSnapshotService: PortfolioSnapshotService; |
let portfolioSnapshotService: PortfolioSnapshotService; |
||||
let redisCacheService: RedisCacheService; |
let redisCacheService: RedisCacheService; |
||||
|
|
||||
beforeEach(() => { |
beforeEach(() => { |
||||
configurationService = new ConfigurationService(); |
PortfolioSnapshotServiceMock.reset(); |
||||
|
RedisCacheServiceMock.reset(); |
||||
currentRateService = new CurrentRateService(null, null, null, null); |
|
||||
|
configurationService = new ConfigurationService(); |
||||
exchangeRateDataService = new ExchangeRateDataService( |
|
||||
null, |
currentRateService = new CurrentRateService(null, null, null, null); |
||||
null, |
|
||||
null, |
exchangeRateDataService = new ExchangeRateDataService( |
||||
null |
null, |
||||
); |
null, |
||||
|
null, |
||||
portfolioSnapshotService = new PortfolioSnapshotService(null); |
null |
||||
|
); |
||||
redisCacheService = new RedisCacheService(null, null); |
|
||||
|
portfolioSnapshotService = new PortfolioSnapshotService(null, null); |
||||
portfolioCalculatorFactory = new PortfolioCalculatorFactory( |
|
||||
configurationService, |
redisCacheService = new RedisCacheService(null, null); |
||||
currentRateService, |
|
||||
exchangeRateDataService, |
portfolioCalculatorFactory = new PortfolioCalculatorFactory( |
||||
portfolioSnapshotService, |
configurationService, |
||||
redisCacheService |
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()); |
|
||||
|
describe('get current positions', () => { |
||||
const activities: Activity[] = [ |
it.only('with BALN.SW buy and sell in two activities', async () => { |
||||
{ |
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); |
||||
...activityDummyData, |
|
||||
date: new Date('2021-11-22'), |
const activities: Activity[] = [ |
||||
feeInAssetProfileCurrency: 1.55, |
{ |
||||
feeInBaseCurrency: 1.55, |
...activityDummyData, |
||||
quantity: 2, |
assetProfile: { |
||||
SymbolProfile: { |
...assetProfileDummyData, |
||||
...symbolProfileDummyData, |
currency: 'CHF', |
||||
currency: 'CHF', |
dataSource: 'YAHOO', |
||||
dataSource: 'YAHOO', |
name: 'Bâloise Holding AG', |
||||
name: 'Bâloise Holding AG', |
symbol: 'BALN.SW' |
||||
symbol: 'BALN.SW' |
}, |
||||
}, |
date: new Date('2021-11-22'), |
||||
type: 'BUY', |
feeInAssetProfileCurrency: 1.55, |
||||
unitPriceInAssetProfileCurrency: 142.9 |
feeInBaseCurrency: 1.55, |
||||
}, |
quantity: 2, |
||||
{ |
type: 'BUY', |
||||
...activityDummyData, |
unitPriceInAssetProfileCurrency: 142.9 |
||||
date: new Date('2021-11-30'), |
}, |
||||
feeInAssetProfileCurrency: 1.65, |
{ |
||||
feeInBaseCurrency: 1.65, |
...activityDummyData, |
||||
quantity: 1, |
assetProfile: { |
||||
SymbolProfile: { |
...assetProfileDummyData, |
||||
...symbolProfileDummyData, |
currency: 'CHF', |
||||
currency: 'CHF', |
dataSource: 'YAHOO', |
||||
dataSource: 'YAHOO', |
name: 'Bâloise Holding AG', |
||||
name: 'Bâloise Holding AG', |
symbol: 'BALN.SW' |
||||
symbol: 'BALN.SW' |
}, |
||||
}, |
date: new Date('2021-11-30'), |
||||
type: 'SELL', |
feeInAssetProfileCurrency: 1.65, |
||||
unitPriceInAssetProfileCurrency: 136.6 |
feeInBaseCurrency: 1.65, |
||||
}, |
quantity: 1, |
||||
{ |
type: 'SELL', |
||||
...activityDummyData, |
unitPriceInAssetProfileCurrency: 136.6 |
||||
date: new Date('2021-11-30'), |
}, |
||||
feeInAssetProfileCurrency: 0, |
{ |
||||
feeInBaseCurrency: 0, |
...activityDummyData, |
||||
quantity: 1, |
assetProfile: { |
||||
SymbolProfile: { |
...assetProfileDummyData, |
||||
...symbolProfileDummyData, |
currency: 'CHF', |
||||
currency: 'CHF', |
dataSource: 'YAHOO', |
||||
dataSource: 'YAHOO', |
name: 'Bâloise Holding AG', |
||||
name: 'Bâloise Holding AG', |
symbol: 'BALN.SW' |
||||
symbol: 'BALN.SW' |
}, |
||||
}, |
date: new Date('2021-11-30'), |
||||
type: 'SELL', |
feeInAssetProfileCurrency: 0, |
||||
unitPriceInAssetProfileCurrency: 136.6 |
feeInBaseCurrency: 0, |
||||
} |
quantity: 1, |
||||
]; |
type: 'SELL', |
||||
|
unitPriceInAssetProfileCurrency: 136.6 |
||||
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ |
} |
||||
activities, |
]; |
||||
calculationType: PerformanceCalculationType.ROAI, |
|
||||
currency: 'CHF', |
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ |
||||
userId: userDummyData.id |
activities, |
||||
}); |
calculationType: PerformanceCalculationType.ROAI, |
||||
|
currency: 'CHF', |
||||
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); |
userId: userDummyData.id |
||||
|
}); |
||||
const investments = portfolioCalculator.getInvestments(); |
|
||||
|
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); |
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ |
|
||||
data: portfolioSnapshot.historicalData, |
const investments = portfolioCalculator.getInvestments(); |
||||
groupBy: 'month' |
|
||||
}); |
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ |
||||
|
data: portfolioSnapshot.historicalData, |
||||
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ |
groupBy: 'month' |
||||
data: portfolioSnapshot.historicalData, |
}); |
||||
groupBy: 'year' |
|
||||
}); |
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ |
||||
|
data: portfolioSnapshot.historicalData, |
||||
expect(portfolioSnapshot).toMatchObject({ |
groupBy: 'year' |
||||
currentValueInBaseCurrency: new Big('0'), |
}); |
||||
errors: [], |
|
||||
hasErrors: false, |
expect(portfolioSnapshot).toMatchObject({ |
||||
positions: [ |
currentValueInBaseCurrency: new Big('0'), |
||||
{ |
errors: [], |
||||
activitiesCount: 3, |
hasErrors: false, |
||||
averagePrice: new Big('0'), |
positions: [ |
||||
currency: 'CHF', |
{ |
||||
dataSource: 'YAHOO', |
activitiesCount: 3, |
||||
dateOfFirstActivity: '2021-11-22', |
averagePrice: new Big('0'), |
||||
dividend: new Big('0'), |
currency: 'CHF', |
||||
dividendInBaseCurrency: new Big('0'), |
dataSource: 'YAHOO', |
||||
fee: new Big('3.2'), |
dateOfFirstActivity: '2021-11-22', |
||||
feeInBaseCurrency: new Big('3.2'), |
dividend: new Big('0'), |
||||
grossPerformance: new Big('-12.6'), |
dividendInBaseCurrency: new Big('0'), |
||||
grossPerformancePercentage: new Big('-0.04408677396780965649'), |
fee: new Big('3.2'), |
||||
grossPerformancePercentageWithCurrencyEffect: new Big( |
feeInBaseCurrency: new Big('3.2'), |
||||
'-0.04408677396780965649' |
grossPerformance: new Big('-12.6'), |
||||
), |
grossPerformancePercentage: new Big('-0.04408677396780965649'), |
||||
grossPerformanceWithCurrencyEffect: new Big('-12.6'), |
grossPerformancePercentageWithCurrencyEffect: new Big( |
||||
investment: new Big('0'), |
'-0.04408677396780965649' |
||||
investmentWithCurrencyEffect: new Big('0'), |
), |
||||
netPerformancePercentageWithCurrencyEffectMap: { |
grossPerformanceWithCurrencyEffect: new Big('-12.6'), |
||||
max: new Big('-0.0552834149755073478') |
investment: new Big('0'), |
||||
}, |
investmentWithCurrencyEffect: new Big('0'), |
||||
netPerformanceWithCurrencyEffectMap: { |
netPerformancePercentageWithCurrencyEffectMap: { |
||||
max: new Big('-15.8') |
max: new Big('-0.0552834149755073478') |
||||
}, |
}, |
||||
marketPrice: 148.9, |
netPerformanceWithCurrencyEffectMap: { |
||||
marketPriceInBaseCurrency: 148.9, |
max: new Big('-15.8') |
||||
quantity: new Big('0'), |
}, |
||||
symbol: 'BALN.SW', |
marketPrice: 148.9, |
||||
tags: [], |
marketPriceInBaseCurrency: 148.9, |
||||
timeWeightedInvestment: new Big('285.80000000000000396627'), |
quantity: new Big('0'), |
||||
timeWeightedInvestmentWithCurrencyEffect: new Big( |
symbol: 'BALN.SW', |
||||
'285.80000000000000396627' |
tags: [], |
||||
), |
timeWeightedInvestment: new Big('285.80000000000000396627'), |
||||
valueInBaseCurrency: new Big('0') |
timeWeightedInvestmentWithCurrencyEffect: new Big( |
||||
} |
'285.80000000000000396627' |
||||
], |
), |
||||
totalFeesWithCurrencyEffect: new Big('3.2'), |
valueInBaseCurrency: new Big('0') |
||||
totalInterestWithCurrencyEffect: new Big('0'), |
} |
||||
totalInvestment: new Big('0'), |
], |
||||
totalInvestmentWithCurrencyEffect: new Big('0'), |
totalFeesWithCurrencyEffect: new Big('3.2'), |
||||
totalLiabilitiesWithCurrencyEffect: new Big('0') |
totalInterestWithCurrencyEffect: new Big('0'), |
||||
}); |
totalInvestment: new Big('0'), |
||||
|
totalInvestmentWithCurrencyEffect: new Big('0'), |
||||
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( |
totalLiabilitiesWithCurrencyEffect: new Big('0') |
||||
expect.objectContaining({ |
}); |
||||
netPerformance: -15.8, |
|
||||
netPerformanceInPercentage: -0.05528341497550734703, |
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( |
||||
netPerformanceInPercentageWithCurrencyEffect: -0.05528341497550734703, |
expect.objectContaining({ |
||||
netPerformanceWithCurrencyEffect: -15.8, |
netPerformance: -15.8, |
||||
totalInvestment: 0, |
netPerformanceInPercentage: -0.05528341497550734703, |
||||
totalInvestmentValueWithCurrencyEffect: 0 |
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(investments).toEqual([ |
||||
|
{ date: '2021-11-22', investment: new Big('285.8') }, |
||||
expect(investmentsByMonth).toEqual([ |
{ date: '2021-11-30', investment: new Big('0') } |
||||
{ date: '2021-11-01', investment: 0 }, |
]); |
||||
{ date: '2021-12-01', investment: 0 } |
|
||||
]); |
expect(investmentsByMonth).toEqual([ |
||||
|
{ date: '2021-11-01', investment: 0 }, |
||||
expect(investmentsByYear).toEqual([ |
{ date: '2021-12-01', investment: 0 } |
||||
{ date: '2021-01-01', investment: 0 } |
]); |
||||
]); |
|
||||
}); |
expect(investmentsByYear).toEqual([ |
||||
}); |
{ date: '2021-01-01', investment: 0 } |
||||
}); |
]); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
|
|||||
@ -1,190 +1,193 @@ |
|||||
import { |
import { |
||||
activityDummyData, |
activityDummyData, |
||||
loadExportFile, |
assetProfileDummyData, |
||||
symbolProfileDummyData, |
loadExportFile, |
||||
userDummyData |
userDummyData |
||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; |
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; |
||||
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; |
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; |
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; |
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; |
||||
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; |
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; |
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; |
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; |
||||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; |
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; |
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.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 { 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 { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; |
||||
import { parseDate } from '@ghostfolio/common/helper'; |
import { parseDate } from '@ghostfolio/common/helper'; |
||||
import { Activity, ExportResponse } from '@ghostfolio/common/interfaces'; |
import { Activity, ExportResponse } from '@ghostfolio/common/interfaces'; |
||||
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; |
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; |
||||
|
|
||||
import { Big } from 'big.js'; |
import { Big } from 'big.js'; |
||||
import { join } from 'node:path'; |
import { join } from 'node:path'; |
||||
|
|
||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { |
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { |
||||
return { |
return { |
||||
CurrentRateService: jest.fn().mockImplementation(() => { |
CurrentRateService: jest.fn().mockImplementation(() => { |
||||
return CurrentRateServiceMock; |
return CurrentRateServiceMock; |
||||
}) |
}) |
||||
}; |
}; |
||||
}); |
}); |
||||
|
|
||||
jest.mock( |
jest.mock( |
||||
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', |
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', |
||||
() => { |
() => { |
||||
return { |
return { |
||||
PortfolioSnapshotService: jest.fn().mockImplementation(() => { |
PortfolioSnapshotService: jest.fn().mockImplementation(() => { |
||||
return PortfolioSnapshotServiceMock; |
return PortfolioSnapshotServiceMock; |
||||
}) |
}) |
||||
}; |
}; |
||||
} |
} |
||||
); |
); |
||||
|
|
||||
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { |
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { |
||||
return { |
return { |
||||
RedisCacheService: jest.fn().mockImplementation(() => { |
RedisCacheService: jest.fn().mockImplementation(() => { |
||||
return RedisCacheServiceMock; |
return RedisCacheServiceMock; |
||||
}) |
}) |
||||
}; |
}; |
||||
}); |
}); |
||||
|
|
||||
describe('PortfolioCalculator', () => { |
describe('PortfolioCalculator', () => { |
||||
let exportResponse: ExportResponse; |
let exportResponse: ExportResponse; |
||||
|
|
||||
let configurationService: ConfigurationService; |
let configurationService: ConfigurationService; |
||||
let currentRateService: CurrentRateService; |
let currentRateService: CurrentRateService; |
||||
let exchangeRateDataService: ExchangeRateDataService; |
let exchangeRateDataService: ExchangeRateDataService; |
||||
let portfolioCalculatorFactory: PortfolioCalculatorFactory; |
let portfolioCalculatorFactory: PortfolioCalculatorFactory; |
||||
let portfolioSnapshotService: PortfolioSnapshotService; |
let portfolioSnapshotService: PortfolioSnapshotService; |
||||
let redisCacheService: RedisCacheService; |
let redisCacheService: RedisCacheService; |
||||
|
|
||||
beforeAll(() => { |
beforeAll(() => { |
||||
exportResponse = loadExportFile( |
exportResponse = loadExportFile( |
||||
join( |
join( |
||||
__dirname, |
__dirname, |
||||
'../../../../../../../test/import/ok/jnug-buy-and-sell-and-buy-and-sell.json' |
'../../../../../../../test/import/ok/jnug-buy-and-sell-and-buy-and-sell.json' |
||||
) |
) |
||||
); |
); |
||||
}); |
}); |
||||
|
|
||||
beforeEach(() => { |
beforeEach(() => { |
||||
configurationService = new ConfigurationService(); |
PortfolioSnapshotServiceMock.reset(); |
||||
|
RedisCacheServiceMock.reset(); |
||||
currentRateService = new CurrentRateService(null, null, null, null); |
|
||||
|
configurationService = new ConfigurationService(); |
||||
exchangeRateDataService = new ExchangeRateDataService( |
|
||||
null, |
currentRateService = new CurrentRateService(null, null, null, null); |
||||
null, |
|
||||
null, |
exchangeRateDataService = new ExchangeRateDataService( |
||||
null |
null, |
||||
); |
null, |
||||
|
null, |
||||
portfolioSnapshotService = new PortfolioSnapshotService(null); |
null |
||||
|
); |
||||
redisCacheService = new RedisCacheService(null, null); |
|
||||
|
portfolioSnapshotService = new PortfolioSnapshotService(null, null); |
||||
portfolioCalculatorFactory = new PortfolioCalculatorFactory( |
|
||||
configurationService, |
redisCacheService = new RedisCacheService(null, null); |
||||
currentRateService, |
|
||||
exchangeRateDataService, |
portfolioCalculatorFactory = new PortfolioCalculatorFactory( |
||||
portfolioSnapshotService, |
configurationService, |
||||
redisCacheService |
currentRateService, |
||||
); |
exchangeRateDataService, |
||||
}); |
portfolioSnapshotService, |
||||
|
redisCacheService |
||||
describe('get current positions', () => { |
); |
||||
it.only('with JNUG buy and sell', async () => { |
}); |
||||
jest.useFakeTimers().setSystemTime(parseDate('2025-12-28').getTime()); |
|
||||
|
describe('get current positions', () => { |
||||
const activities: Activity[] = exportResponse.activities.map( |
it.only('with JNUG buy and sell', async () => { |
||||
(activity) => ({ |
jest.useFakeTimers().setSystemTime(parseDate('2025-12-28').getTime()); |
||||
...activityDummyData, |
|
||||
...activity, |
const activities: Activity[] = exportResponse.activities.map( |
||||
date: parseDate(activity.date), |
(activity) => ({ |
||||
feeInAssetProfileCurrency: activity.fee, |
...activityDummyData, |
||||
feeInBaseCurrency: activity.fee, |
...activity, |
||||
SymbolProfile: { |
assetProfile: { |
||||
...symbolProfileDummyData, |
...assetProfileDummyData, |
||||
currency: activity.currency, |
currency: activity.currency, |
||||
dataSource: activity.dataSource, |
dataSource: activity.dataSource, |
||||
name: 'Direxion Daily Junior Gold Miners Index Bull 2X Shares', |
name: 'Direxion Daily Junior Gold Miners Index Bull 2X Shares', |
||||
symbol: activity.symbol |
symbol: activity.symbol |
||||
}, |
}, |
||||
unitPriceInAssetProfileCurrency: activity.unitPrice |
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, |
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ |
||||
userId: userDummyData.id |
activities, |
||||
}); |
calculationType: PerformanceCalculationType.ROAI, |
||||
|
currency: exportResponse.user.settings.currency, |
||||
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); |
userId: userDummyData.id |
||||
|
}); |
||||
const investments = portfolioCalculator.getInvestments(); |
|
||||
|
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); |
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ |
|
||||
data: portfolioSnapshot.historicalData, |
const investments = portfolioCalculator.getInvestments(); |
||||
groupBy: 'month' |
|
||||
}); |
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ |
||||
|
data: portfolioSnapshot.historicalData, |
||||
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ |
groupBy: 'month' |
||||
data: portfolioSnapshot.historicalData, |
}); |
||||
groupBy: 'year' |
|
||||
}); |
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ |
||||
|
data: portfolioSnapshot.historicalData, |
||||
expect(portfolioSnapshot).toMatchObject({ |
groupBy: 'year' |
||||
currentValueInBaseCurrency: new Big('0'), |
}); |
||||
errors: [], |
|
||||
hasErrors: false, |
expect(portfolioSnapshot).toMatchObject({ |
||||
positions: [ |
currentValueInBaseCurrency: new Big('0'), |
||||
{ |
errors: [], |
||||
activitiesCount: 4, |
hasErrors: false, |
||||
averagePrice: new Big('0'), |
positions: [ |
||||
currency: 'USD', |
{ |
||||
dataSource: 'YAHOO', |
activitiesCount: 4, |
||||
dateOfFirstActivity: '2025-12-11', |
averagePrice: new Big('0'), |
||||
dividend: new Big('0'), |
currency: 'USD', |
||||
dividendInBaseCurrency: new Big('0'), |
dataSource: 'YAHOO', |
||||
fee: new Big('4'), |
dateOfFirstActivity: '2025-12-11', |
||||
feeInBaseCurrency: new Big('4'), |
dividend: new Big('0'), |
||||
grossPerformance: new Big('43.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10)
|
dividendInBaseCurrency: new Big('0'), |
||||
grossPerformanceWithCurrencyEffect: new Big('43.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10)
|
fee: new Big('4'), |
||||
investment: new Big('0'), |
feeInBaseCurrency: new Big('4'), |
||||
investmentWithCurrencyEffect: new Big('0'), |
grossPerformance: new Big('43.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10)
|
||||
netPerformance: new Big('39.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10) - 4
|
grossPerformanceWithCurrencyEffect: new Big('43.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10)
|
||||
netPerformanceWithCurrencyEffectMap: { |
investment: new Big('0'), |
||||
max: new Big('39.95') // (1890.00 - 1885.05) + (2080.10 - 2041.10) - 4
|
investmentWithCurrencyEffect: new Big('0'), |
||||
}, |
netPerformance: new Big('39.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10) - 4
|
||||
marketPrice: 237.8000030517578, |
netPerformanceWithCurrencyEffectMap: { |
||||
marketPriceInBaseCurrency: 237.8000030517578, |
max: new Big('39.95') // (1890.00 - 1885.05) + (2080.10 - 2041.10) - 4
|
||||
quantity: new Big('0'), |
}, |
||||
symbol: 'JNUG', |
marketPrice: 237.8000030517578, |
||||
tags: [], |
marketPriceInBaseCurrency: 237.8000030517578, |
||||
valueInBaseCurrency: new Big('0') |
quantity: new Big('0'), |
||||
} |
symbol: 'JNUG', |
||||
], |
tags: [], |
||||
totalFeesWithCurrencyEffect: new Big('4'), |
valueInBaseCurrency: new Big('0') |
||||
totalInterestWithCurrencyEffect: new Big('0'), |
} |
||||
totalInvestment: new Big('0'), |
], |
||||
totalInvestmentWithCurrencyEffect: new Big('0'), |
totalFeesWithCurrencyEffect: new Big('4'), |
||||
totalLiabilitiesWithCurrencyEffect: new Big('0') |
totalInterestWithCurrencyEffect: new Big('0'), |
||||
}); |
totalInvestment: new Big('0'), |
||||
|
totalInvestmentWithCurrencyEffect: new Big('0'), |
||||
expect(investments).toEqual([ |
totalLiabilitiesWithCurrencyEffect: new Big('0') |
||||
{ 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(investments).toEqual([ |
||||
]); |
{ date: '2025-12-11', investment: new Big('1885.05') }, |
||||
|
{ date: '2025-12-18', investment: new Big('2041.1') }, |
||||
expect(investmentsByMonth).toEqual([ |
{ date: '2025-12-28', investment: new Big('0') } |
||||
{ date: '2025-12-01', investment: 0 } |
]); |
||||
]); |
|
||||
|
expect(investmentsByMonth).toEqual([ |
||||
expect(investmentsByYear).toEqual([ |
{ date: '2025-12-01', investment: 0 } |
||||
{ date: '2025-01-01', investment: 0 } |
]); |
||||
]); |
|
||||
}); |
expect(investmentsByYear).toEqual([ |
||||
}); |
{ date: '2025-01-01', investment: 0 } |
||||
}); |
]); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
|
|||||
@ -1,264 +1,267 @@ |
|||||
import { |
import { |
||||
activityDummyData, |
activityDummyData, |
||||
loadExportFile, |
assetProfileDummyData, |
||||
symbolProfileDummyData, |
loadExportFile, |
||||
userDummyData |
userDummyData |
||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; |
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; |
||||
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; |
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; |
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; |
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; |
||||
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; |
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; |
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; |
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; |
||||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; |
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; |
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.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 { 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 { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; |
||||
import { parseDate } from '@ghostfolio/common/helper'; |
import { parseDate } from '@ghostfolio/common/helper'; |
||||
import { Activity, ExportResponse } from '@ghostfolio/common/interfaces'; |
import { Activity, ExportResponse } from '@ghostfolio/common/interfaces'; |
||||
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; |
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; |
||||
|
|
||||
import { Big } from 'big.js'; |
import { Big } from 'big.js'; |
||||
import { join } from 'node:path'; |
import { join } from 'node:path'; |
||||
|
|
||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { |
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { |
||||
return { |
return { |
||||
CurrentRateService: jest.fn().mockImplementation(() => { |
CurrentRateService: jest.fn().mockImplementation(() => { |
||||
return CurrentRateServiceMock; |
return CurrentRateServiceMock; |
||||
}) |
}) |
||||
}; |
}; |
||||
}); |
}); |
||||
|
|
||||
jest.mock( |
jest.mock( |
||||
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', |
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', |
||||
() => { |
() => { |
||||
return { |
return { |
||||
PortfolioSnapshotService: jest.fn().mockImplementation(() => { |
PortfolioSnapshotService: jest.fn().mockImplementation(() => { |
||||
return PortfolioSnapshotServiceMock; |
return PortfolioSnapshotServiceMock; |
||||
}) |
}) |
||||
}; |
}; |
||||
} |
} |
||||
); |
); |
||||
|
|
||||
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { |
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { |
||||
return { |
return { |
||||
RedisCacheService: jest.fn().mockImplementation(() => { |
RedisCacheService: jest.fn().mockImplementation(() => { |
||||
return RedisCacheServiceMock; |
return RedisCacheServiceMock; |
||||
}) |
}) |
||||
}; |
}; |
||||
}); |
}); |
||||
|
|
||||
describe('PortfolioCalculator', () => { |
describe('PortfolioCalculator', () => { |
||||
let exportResponse: ExportResponse; |
let exportResponse: ExportResponse; |
||||
|
|
||||
let configurationService: ConfigurationService; |
let configurationService: ConfigurationService; |
||||
let currentRateService: CurrentRateService; |
let currentRateService: CurrentRateService; |
||||
let exchangeRateDataService: ExchangeRateDataService; |
let exchangeRateDataService: ExchangeRateDataService; |
||||
let portfolioCalculatorFactory: PortfolioCalculatorFactory; |
let portfolioCalculatorFactory: PortfolioCalculatorFactory; |
||||
let portfolioSnapshotService: PortfolioSnapshotService; |
let portfolioSnapshotService: PortfolioSnapshotService; |
||||
let redisCacheService: RedisCacheService; |
let redisCacheService: RedisCacheService; |
||||
|
|
||||
beforeAll(() => { |
beforeAll(() => { |
||||
exportResponse = loadExportFile( |
exportResponse = loadExportFile( |
||||
join( |
join( |
||||
__dirname, |
__dirname, |
||||
'../../../../../../../test/import/ok/novn-buy-and-sell.json' |
'../../../../../../../test/import/ok/novn-buy-and-sell.json' |
||||
) |
) |
||||
); |
); |
||||
}); |
}); |
||||
|
|
||||
beforeEach(() => { |
beforeEach(() => { |
||||
configurationService = new ConfigurationService(); |
PortfolioSnapshotServiceMock.reset(); |
||||
|
RedisCacheServiceMock.reset(); |
||||
currentRateService = new CurrentRateService(null, null, null, null); |
|
||||
|
configurationService = new ConfigurationService(); |
||||
exchangeRateDataService = new ExchangeRateDataService( |
|
||||
null, |
currentRateService = new CurrentRateService(null, null, null, null); |
||||
null, |
|
||||
null, |
exchangeRateDataService = new ExchangeRateDataService( |
||||
null |
null, |
||||
); |
null, |
||||
|
null, |
||||
portfolioSnapshotService = new PortfolioSnapshotService(null); |
null |
||||
|
); |
||||
redisCacheService = new RedisCacheService(null, null); |
|
||||
|
portfolioSnapshotService = new PortfolioSnapshotService(null, null); |
||||
portfolioCalculatorFactory = new PortfolioCalculatorFactory( |
|
||||
configurationService, |
redisCacheService = new RedisCacheService(null, null); |
||||
currentRateService, |
|
||||
exchangeRateDataService, |
portfolioCalculatorFactory = new PortfolioCalculatorFactory( |
||||
portfolioSnapshotService, |
configurationService, |
||||
redisCacheService |
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()); |
|
||||
|
describe('get current positions', () => { |
||||
const activities: Activity[] = exportResponse.activities.map( |
it.only('with NOVN.SW buy and sell', async () => { |
||||
(activity) => ({ |
jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime()); |
||||
...activityDummyData, |
|
||||
...activity, |
const activities: Activity[] = exportResponse.activities.map( |
||||
date: parseDate(activity.date), |
(activity) => ({ |
||||
feeInAssetProfileCurrency: activity.fee, |
...activityDummyData, |
||||
feeInBaseCurrency: activity.fee, |
...activity, |
||||
SymbolProfile: { |
assetProfile: { |
||||
...symbolProfileDummyData, |
...assetProfileDummyData, |
||||
currency: activity.currency, |
currency: activity.currency, |
||||
dataSource: activity.dataSource, |
dataSource: activity.dataSource, |
||||
name: 'Novartis AG', |
name: 'Novartis AG', |
||||
symbol: activity.symbol |
symbol: activity.symbol |
||||
}, |
}, |
||||
unitPriceInAssetProfileCurrency: activity.unitPrice |
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, |
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ |
||||
userId: userDummyData.id |
activities, |
||||
}); |
calculationType: PerformanceCalculationType.ROAI, |
||||
|
currency: exportResponse.user.settings.currency, |
||||
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); |
userId: userDummyData.id |
||||
|
}); |
||||
const investments = portfolioCalculator.getInvestments(); |
|
||||
|
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); |
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ |
|
||||
data: portfolioSnapshot.historicalData, |
const investments = portfolioCalculator.getInvestments(); |
||||
groupBy: 'month' |
|
||||
}); |
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ |
||||
|
data: portfolioSnapshot.historicalData, |
||||
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ |
groupBy: 'month' |
||||
data: portfolioSnapshot.historicalData, |
}); |
||||
groupBy: 'year' |
|
||||
}); |
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ |
||||
|
data: portfolioSnapshot.historicalData, |
||||
expect(portfolioSnapshot.historicalData[0]).toEqual({ |
groupBy: 'year' |
||||
date: '2022-03-06', |
}); |
||||
investmentValueWithCurrencyEffect: 0, |
|
||||
netPerformance: 0, |
expect(portfolioSnapshot.historicalData[0]).toEqual({ |
||||
netPerformanceInPercentage: 0, |
date: '2022-03-06', |
||||
netPerformanceInPercentageWithCurrencyEffect: 0, |
investmentValueWithCurrencyEffect: 0, |
||||
netPerformanceWithCurrencyEffect: 0, |
netPerformance: 0, |
||||
netWorth: 0, |
netPerformanceInPercentage: 0, |
||||
totalAccountBalance: 0, |
netPerformanceInPercentageWithCurrencyEffect: 0, |
||||
totalInvestment: 0, |
netPerformanceWithCurrencyEffect: 0, |
||||
totalInvestmentValueWithCurrencyEffect: 0, |
netWorth: 0, |
||||
value: 0, |
totalCashInBaseCurrency: 0, |
||||
valueWithCurrencyEffect: 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({ |
* Closing price on 2022-03-07 is unknown, |
||||
date: '2022-03-07', |
* hence it uses the last unit price (2022-04-11): 87.8 |
||||
investmentValueWithCurrencyEffect: 151.6, |
*/ |
||||
netPerformance: 24, // 2 * (87.8 - 75.8) = 24
|
expect(portfolioSnapshot.historicalData[1]).toEqual({ |
||||
netPerformanceInPercentage: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438
|
date: '2022-03-07', |
||||
netPerformanceInPercentageWithCurrencyEffect: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438
|
investmentValueWithCurrencyEffect: 151.6, |
||||
netPerformanceWithCurrencyEffect: 24, |
netPerformance: 24, // 2 * (87.8 - 75.8) = 24
|
||||
netWorth: 175.6, // 2 * 87.8 = 175.6
|
netPerformanceInPercentage: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438
|
||||
totalAccountBalance: 0, |
netPerformanceInPercentageWithCurrencyEffect: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438
|
||||
totalInvestment: 151.6, |
netPerformanceWithCurrencyEffect: 24, |
||||
totalInvestmentValueWithCurrencyEffect: 151.6, |
netWorth: 175.6, // 2 * 87.8 = 175.6
|
||||
value: 175.6, // 2 * 87.8 = 175.6
|
totalCashInBaseCurrency: 0, |
||||
valueWithCurrencyEffect: 175.6 |
totalInvestment: 151.6, |
||||
}); |
totalInvestmentValueWithCurrencyEffect: 151.6, |
||||
|
value: 175.6, // 2 * 87.8 = 175.6
|
||||
expect( |
valueWithCurrencyEffect: 175.6 |
||||
portfolioSnapshot.historicalData[ |
}); |
||||
portfolioSnapshot.historicalData.length - 1 |
|
||||
] |
expect( |
||||
).toEqual({ |
portfolioSnapshot.historicalData[ |
||||
date: '2022-04-11', |
portfolioSnapshot.historicalData.length - 1 |
||||
investmentValueWithCurrencyEffect: 0, |
] |
||||
netPerformance: 19.86, |
).toEqual({ |
||||
netPerformanceInPercentage: 0.13100263852242744, |
date: '2022-04-11', |
||||
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744, |
investmentValueWithCurrencyEffect: 0, |
||||
netPerformanceWithCurrencyEffect: 19.86, |
netPerformance: 19.86, |
||||
netWorth: 0, |
netPerformanceInPercentage: 0.13100263852242744, |
||||
totalAccountBalance: 0, |
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744, |
||||
totalInvestment: 0, |
netPerformanceWithCurrencyEffect: 19.86, |
||||
totalInvestmentValueWithCurrencyEffect: 0, |
netWorth: 0, |
||||
value: 0, |
totalCashInBaseCurrency: 0, |
||||
valueWithCurrencyEffect: 0 |
totalInvestment: 0, |
||||
}); |
totalInvestmentValueWithCurrencyEffect: 0, |
||||
|
value: 0, |
||||
expect(portfolioSnapshot).toMatchObject({ |
valueWithCurrencyEffect: 0 |
||||
currentValueInBaseCurrency: new Big('0'), |
}); |
||||
errors: [], |
|
||||
hasErrors: false, |
expect(portfolioSnapshot).toMatchObject({ |
||||
positions: [ |
currentValueInBaseCurrency: new Big('0'), |
||||
{ |
errors: [], |
||||
activitiesCount: 2, |
hasErrors: false, |
||||
averagePrice: new Big('0'), |
positions: [ |
||||
currency: 'CHF', |
{ |
||||
dataSource: 'YAHOO', |
activitiesCount: 2, |
||||
dateOfFirstActivity: '2022-03-07', |
averagePrice: new Big('0'), |
||||
dividend: new Big('0'), |
currency: 'CHF', |
||||
dividendInBaseCurrency: new Big('0'), |
dataSource: 'YAHOO', |
||||
fee: new Big('0'), |
dateOfFirstActivity: '2022-03-07', |
||||
feeInBaseCurrency: new Big('0'), |
dividend: new Big('0'), |
||||
grossPerformance: new Big('19.86'), |
dividendInBaseCurrency: new Big('0'), |
||||
grossPerformancePercentage: new Big('0.13100263852242744063'), |
fee: new Big('0'), |
||||
grossPerformancePercentageWithCurrencyEffect: new Big( |
feeInBaseCurrency: new Big('0'), |
||||
'0.13100263852242744063' |
grossPerformance: new Big('19.86'), |
||||
), |
grossPerformancePercentage: new Big('0.13100263852242744063'), |
||||
grossPerformanceWithCurrencyEffect: new Big('19.86'), |
grossPerformancePercentageWithCurrencyEffect: new Big( |
||||
investment: new Big('0'), |
'0.13100263852242744063' |
||||
investmentWithCurrencyEffect: new Big('0'), |
), |
||||
netPerformance: new Big('19.86'), |
grossPerformanceWithCurrencyEffect: new Big('19.86'), |
||||
netPerformancePercentage: new Big('0.13100263852242744063'), |
investment: new Big('0'), |
||||
netPerformancePercentageWithCurrencyEffectMap: { |
investmentWithCurrencyEffect: new Big('0'), |
||||
max: new Big('0.13100263852242744063') |
netPerformance: new Big('19.86'), |
||||
}, |
netPerformancePercentage: new Big('0.13100263852242744063'), |
||||
netPerformanceWithCurrencyEffectMap: { |
netPerformancePercentageWithCurrencyEffectMap: { |
||||
max: new Big('19.86') |
max: new Big('0.13100263852242744063') |
||||
}, |
}, |
||||
marketPrice: 87.8, |
netPerformanceWithCurrencyEffectMap: { |
||||
marketPriceInBaseCurrency: 87.8, |
max: new Big('19.86') |
||||
quantity: new Big('0'), |
}, |
||||
symbol: 'NOVN.SW', |
marketPrice: 87.8, |
||||
tags: [], |
marketPriceInBaseCurrency: 87.8, |
||||
timeWeightedInvestment: new Big('151.6'), |
quantity: new Big('0'), |
||||
timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'), |
symbol: 'NOVN.SW', |
||||
valueInBaseCurrency: new Big('0') |
tags: [], |
||||
} |
timeWeightedInvestment: new Big('151.6'), |
||||
], |
timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'), |
||||
totalFeesWithCurrencyEffect: new Big('0'), |
valueInBaseCurrency: new Big('0') |
||||
totalInterestWithCurrencyEffect: new Big('0'), |
} |
||||
totalInvestment: new Big('0'), |
], |
||||
totalInvestmentWithCurrencyEffect: new Big('0'), |
totalFeesWithCurrencyEffect: new Big('0'), |
||||
totalLiabilitiesWithCurrencyEffect: new Big('0') |
totalInterestWithCurrencyEffect: new Big('0'), |
||||
}); |
totalInvestment: new Big('0'), |
||||
|
totalInvestmentWithCurrencyEffect: new Big('0'), |
||||
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( |
totalLiabilitiesWithCurrencyEffect: new Big('0') |
||||
expect.objectContaining({ |
}); |
||||
netPerformance: 19.86, |
|
||||
netPerformanceInPercentage: 0.13100263852242744063, |
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( |
||||
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744063, |
expect.objectContaining({ |
||||
netPerformanceWithCurrencyEffect: 19.86, |
netPerformance: 19.86, |
||||
totalInvestment: 0, |
netPerformanceInPercentage: 0.13100263852242744063, |
||||
totalInvestmentValueWithCurrencyEffect: 0 |
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(investments).toEqual([ |
||||
|
{ date: '2022-03-07', investment: new Big('151.6') }, |
||||
expect(investmentsByMonth).toEqual([ |
{ date: '2022-04-08', investment: new Big('0') } |
||||
{ date: '2022-03-01', investment: 151.6 }, |
]); |
||||
{ date: '2022-04-01', investment: -151.6 } |
|
||||
]); |
expect(investmentsByMonth).toEqual([ |
||||
|
{ date: '2022-03-01', investment: 151.6 }, |
||||
expect(investmentsByYear).toEqual([ |
{ date: '2022-04-01', investment: -151.6 } |
||||
{ date: '2022-01-01', investment: 0 } |
]); |
||||
]); |
|
||||
}); |
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