mirror of https://github.com/ghostfolio/ghostfolio
755 changed files with 107426 additions and 75363 deletions
@ -1,151 +0,0 @@ |
|||||
{ |
|
||||
"root": true, |
|
||||
"ignorePatterns": ["**/*"], |
|
||||
"plugins": ["@nx"], |
|
||||
"overrides": [ |
|
||||
{ |
|
||||
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"], |
|
||||
"rules": { |
|
||||
"@nx/enforce-module-boundaries": [ |
|
||||
"warn", |
|
||||
{ |
|
||||
"enforceBuildableLibDependency": true, |
|
||||
"allow": [], |
|
||||
"depConstraints": [ |
|
||||
{ |
|
||||
"sourceTag": "*", |
|
||||
"onlyDependOnLibsWithTags": ["*"] |
|
||||
} |
|
||||
] |
|
||||
} |
|
||||
], |
|
||||
"@typescript-eslint/no-extra-semi": "error", |
|
||||
"no-extra-semi": "off" |
|
||||
} |
|
||||
}, |
|
||||
{ |
|
||||
"files": ["*.ts", "*.tsx"], |
|
||||
"extends": ["plugin:@nx/typescript"] |
|
||||
}, |
|
||||
{ |
|
||||
"files": ["*.js", "*.jsx"], |
|
||||
"extends": ["plugin:@nx/javascript"] |
|
||||
}, |
|
||||
{ |
|
||||
"files": ["*.ts"], |
|
||||
"plugins": ["eslint-plugin-import", "@typescript-eslint"], |
|
||||
"extends": [ |
|
||||
"plugin:@typescript-eslint/recommended-type-checked", |
|
||||
"plugin:@typescript-eslint/stylistic-type-checked" |
|
||||
], |
|
||||
"rules": { |
|
||||
"@typescript-eslint/consistent-indexed-object-style": "off", |
|
||||
"@typescript-eslint/dot-notation": "off", |
|
||||
"@typescript-eslint/explicit-member-accessibility": [ |
|
||||
"off", |
|
||||
{ |
|
||||
"accessibility": "explicit" |
|
||||
} |
|
||||
], |
|
||||
"@typescript-eslint/member-ordering": "warn", |
|
||||
"@typescript-eslint/naming-convention": [ |
|
||||
"off", |
|
||||
{ |
|
||||
"selector": "default", |
|
||||
"format": ["camelCase"], |
|
||||
"leadingUnderscore": "allow", |
|
||||
"trailingUnderscore": "allow" |
|
||||
}, |
|
||||
{ |
|
||||
"selector": ["variable", "classProperty", "typeProperty"], |
|
||||
"format": ["camelCase", "UPPER_CASE"], |
|
||||
"leadingUnderscore": "allow", |
|
||||
"trailingUnderscore": "allow" |
|
||||
}, |
|
||||
{ |
|
||||
"selector": "objectLiteralProperty", |
|
||||
"format": null |
|
||||
}, |
|
||||
{ |
|
||||
"selector": "enumMember", |
|
||||
"format": ["camelCase", "UPPER_CASE", "PascalCase"] |
|
||||
}, |
|
||||
{ |
|
||||
"selector": "typeLike", |
|
||||
"format": ["PascalCase"] |
|
||||
} |
|
||||
], |
|
||||
"@typescript-eslint/no-empty-interface": "warn", |
|
||||
"@typescript-eslint/no-inferrable-types": [ |
|
||||
"warn", |
|
||||
{ |
|
||||
"ignoreParameters": true |
|
||||
} |
|
||||
], |
|
||||
"@typescript-eslint/no-non-null-assertion": "warn", |
|
||||
"@typescript-eslint/no-shadow": [ |
|
||||
"warn", |
|
||||
{ |
|
||||
"hoist": "all" |
|
||||
} |
|
||||
], |
|
||||
"@typescript-eslint/unified-signatures": "error", |
|
||||
"@typescript-eslint/no-loss-of-precision": "warn", |
|
||||
"@typescript-eslint/no-var-requires": "warn", |
|
||||
"@typescript-eslint/ban-types": "warn", |
|
||||
"arrow-body-style": "off", |
|
||||
"constructor-super": "error", |
|
||||
"eqeqeq": ["error", "smart"], |
|
||||
"guard-for-in": "warn", |
|
||||
"id-blacklist": "off", |
|
||||
"id-match": "off", |
|
||||
"import/no-deprecated": "warn", |
|
||||
"no-bitwise": "error", |
|
||||
"no-caller": "error", |
|
||||
"no-debugger": "error", |
|
||||
"no-empty": "off", |
|
||||
"no-eval": "error", |
|
||||
"no-fallthrough": "error", |
|
||||
"no-new-wrappers": "error", |
|
||||
"no-restricted-imports": ["error", "rxjs/Rx"], |
|
||||
"no-undef-init": "error", |
|
||||
"no-underscore-dangle": "off", |
|
||||
"no-var": "error", |
|
||||
"radix": "error", |
|
||||
"no-unsafe-optional-chaining": "warn", |
|
||||
"no-extra-boolean-cast": "warn", |
|
||||
"no-empty-pattern": "warn", |
|
||||
"no-useless-catch": "warn", |
|
||||
"no-unsafe-finally": "warn", |
|
||||
"no-prototype-builtins": "warn", |
|
||||
"no-async-promise-executor": "warn", |
|
||||
"no-constant-condition": "warn", |
|
||||
|
|
||||
// The following rules are part of @typescript-eslint/recommended-type-checked |
|
||||
// and can be remove once solved |
|
||||
"@typescript-eslint/await-thenable": "warn", |
|
||||
"@typescript-eslint/ban-ts-comment": "warn", |
|
||||
"@typescript-eslint/no-base-to-string": "warn", |
|
||||
"@typescript-eslint/no-explicit-any": "warn", |
|
||||
"@typescript-eslint/no-floating-promises": "warn", |
|
||||
"@typescript-eslint/no-misused-promises": "warn", |
|
||||
"@typescript-eslint/no-redundant-type-constituents": "warn", |
|
||||
"@typescript-eslint/no-unnecessary-type-assertion": "warn", |
|
||||
"@typescript-eslint/no-unsafe-argument": "warn", |
|
||||
"@typescript-eslint/no-unsafe-assignment": "warn", |
|
||||
"@typescript-eslint/no-unsafe-enum-comparison": "warn", |
|
||||
"@typescript-eslint/no-unsafe-member-access": "warn", |
|
||||
"@typescript-eslint/no-unsafe-return": "warn", |
|
||||
"@typescript-eslint/no-unsafe-call": "warn", |
|
||||
"@typescript-eslint/require-await": "warn", |
|
||||
"@typescript-eslint/restrict-template-expressions": "warn", |
|
||||
"@typescript-eslint/unbound-method": "warn", |
|
||||
|
|
||||
// The following rules are part of @typescript-eslint/stylistic-type-checked |
|
||||
// and can be remove once solved |
|
||||
"@typescript-eslint/prefer-nullish-coalescing": "warn" // TODO: Requires strictNullChecks: true |
|
||||
} |
|
||||
} |
|
||||
], |
|
||||
"extends": ["plugin:storybook/recommended"] |
|
||||
} |
|
@ -0,0 +1,40 @@ |
|||||
|
name: Extract locales |
||||
|
|
||||
|
on: |
||||
|
push: |
||||
|
branches: |
||||
|
- main |
||||
|
|
||||
|
permissions: |
||||
|
contents: write |
||||
|
pull-requests: write |
||||
|
|
||||
|
jobs: |
||||
|
extract_locales: |
||||
|
runs-on: ubuntu-latest |
||||
|
steps: |
||||
|
- name: Checkout code |
||||
|
uses: actions/checkout@v4 |
||||
|
with: |
||||
|
fetch-depth: 0 |
||||
|
|
||||
|
- name: Install dependencies |
||||
|
run: npm ci |
||||
|
|
||||
|
- name: Extract locales |
||||
|
run: npm run extract-locales |
||||
|
|
||||
|
- name: Check changes |
||||
|
id: verify-changed-files |
||||
|
uses: tj-actions/verify-changed-files@v20 |
||||
|
|
||||
|
- name: Create pull request |
||||
|
if: steps.verify-changed-files.outputs.files_changed == 'true' |
||||
|
uses: peter-evans/create-pull-request@v7 |
||||
|
with: |
||||
|
author: 'github-actions[bot] <github-actions[bot]@users.noreply.github.com>' |
||||
|
branch: 'feature/update-locales' |
||||
|
commit-message: 'Update locales' |
||||
|
delete-branch: true |
||||
|
title: 'Feature/update locales' |
||||
|
token: ${{ secrets.GITHUB_TOKEN }} |
@ -1 +1 @@ |
|||||
v20 |
v22 |
||||
|
File diff suppressed because it is too large
@ -0,0 +1,13 @@ |
|||||
|
# Security Policy |
||||
|
|
||||
|
## Reporting Security Issues |
||||
|
|
||||
|
If you discover a security vulnerability in this repository, please report it to security[at]ghostfol.io. We will acknowledge your report and provide guidance on the next steps. |
||||
|
|
||||
|
To help us resolve the issue, please include the following details: |
||||
|
|
||||
|
- A description of the vulnerability |
||||
|
- Steps to reproduce the vulnerability |
||||
|
- Affected versions of the software |
||||
|
|
||||
|
We appreciate your responsible disclosure and will work to address the issue promptly. |
@ -1,22 +0,0 @@ |
|||||
{ |
|
||||
"extends": "../../.eslintrc.json", |
|
||||
"ignorePatterns": ["!**/*"], |
|
||||
"rules": {}, |
|
||||
"overrides": [ |
|
||||
{ |
|
||||
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"], |
|
||||
"parserOptions": { |
|
||||
"project": ["apps/api/tsconfig.*?.json"] |
|
||||
}, |
|
||||
"rules": {} |
|
||||
}, |
|
||||
{ |
|
||||
"files": ["*.ts", "*.tsx"], |
|
||||
"rules": {} |
|
||||
}, |
|
||||
{ |
|
||||
"files": ["*.js", "*.jsx"], |
|
||||
"rules": {} |
|
||||
} |
|
||||
] |
|
||||
} |
|
@ -0,0 +1,31 @@ |
|||||
|
const baseConfig = require('../../eslint.config.cjs'); |
||||
|
|
||||
|
module.exports = [ |
||||
|
{ |
||||
|
ignores: ['**/dist'] |
||||
|
}, |
||||
|
...baseConfig, |
||||
|
{ |
||||
|
rules: {} |
||||
|
}, |
||||
|
{ |
||||
|
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], |
||||
|
// Override or add rules here |
||||
|
rules: {}, |
||||
|
languageOptions: { |
||||
|
parserOptions: { |
||||
|
project: ['apps/api/tsconfig.*?.json'] |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
{ |
||||
|
files: ['**/*.ts', '**/*.tsx'], |
||||
|
// Override or add rules here |
||||
|
rules: {} |
||||
|
}, |
||||
|
{ |
||||
|
files: ['**/*.js', '**/*.jsx'], |
||||
|
// Override or add rules here |
||||
|
rules: {} |
||||
|
} |
||||
|
]; |
@ -0,0 +1,92 @@ |
|||||
|
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code'; |
||||
|
|
||||
|
import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client'; |
||||
|
import { |
||||
|
IsArray, |
||||
|
IsBoolean, |
||||
|
IsEnum, |
||||
|
IsObject, |
||||
|
IsOptional, |
||||
|
IsString, |
||||
|
IsUrl |
||||
|
} from 'class-validator'; |
||||
|
|
||||
|
export class CreateAssetProfileDto { |
||||
|
@IsEnum(AssetClass, { each: true }) |
||||
|
@IsOptional() |
||||
|
assetClass?: AssetClass; |
||||
|
|
||||
|
@IsEnum(AssetSubClass, { each: true }) |
||||
|
@IsOptional() |
||||
|
assetSubClass?: AssetSubClass; |
||||
|
|
||||
|
@IsOptional() |
||||
|
@IsString() |
||||
|
comment?: string; |
||||
|
|
||||
|
@IsArray() |
||||
|
@IsOptional() |
||||
|
countries?: Prisma.InputJsonArray; |
||||
|
|
||||
|
@IsCurrencyCode() |
||||
|
currency: string; |
||||
|
|
||||
|
@IsOptional() |
||||
|
@IsString() |
||||
|
cusip?: string; |
||||
|
|
||||
|
@IsEnum(DataSource) |
||||
|
dataSource: DataSource; |
||||
|
|
||||
|
@IsOptional() |
||||
|
@IsString() |
||||
|
figi?: string; |
||||
|
|
||||
|
@IsOptional() |
||||
|
@IsString() |
||||
|
figiComposite?: string; |
||||
|
|
||||
|
@IsOptional() |
||||
|
@IsString() |
||||
|
figiShareClass?: string; |
||||
|
|
||||
|
@IsArray() |
||||
|
@IsOptional() |
||||
|
holdings?: Prisma.InputJsonArray; |
||||
|
|
||||
|
@IsBoolean() |
||||
|
@IsOptional() |
||||
|
isActive?: boolean; |
||||
|
|
||||
|
@IsOptional() |
||||
|
@IsString() |
||||
|
isin?: string; |
||||
|
|
||||
|
@IsOptional() |
||||
|
@IsString() |
||||
|
name?: string; |
||||
|
|
||||
|
@IsObject() |
||||
|
@IsOptional() |
||||
|
scraperConfiguration?: Prisma.InputJsonObject; |
||||
|
|
||||
|
@IsArray() |
||||
|
@IsOptional() |
||||
|
sectors?: Prisma.InputJsonArray; |
||||
|
|
||||
|
@IsString() |
||||
|
symbol: string; |
||||
|
|
||||
|
@IsObject() |
||||
|
@IsOptional() |
||||
|
symbolMapping?: { |
||||
|
[dataProvider: string]: string; |
||||
|
}; |
||||
|
|
||||
|
@IsOptional() |
||||
|
@IsUrl({ |
||||
|
protocols: ['https'], |
||||
|
require_protocol: true |
||||
|
}) |
||||
|
url?: string; |
||||
|
} |
@ -0,0 +1,59 @@ |
|||||
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; |
||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
||||
|
import { ApiService } from '@ghostfolio/api/services/api/api.service'; |
||||
|
import { AiPromptResponse } from '@ghostfolio/common/interfaces'; |
||||
|
import { permissions } from '@ghostfolio/common/permissions'; |
||||
|
import type { AiPromptMode, RequestWithUser } from '@ghostfolio/common/types'; |
||||
|
|
||||
|
import { |
||||
|
Controller, |
||||
|
Get, |
||||
|
Inject, |
||||
|
Param, |
||||
|
Query, |
||||
|
UseGuards |
||||
|
} from '@nestjs/common'; |
||||
|
import { REQUEST } from '@nestjs/core'; |
||||
|
import { AuthGuard } from '@nestjs/passport'; |
||||
|
|
||||
|
import { AiService } from './ai.service'; |
||||
|
|
||||
|
@Controller('ai') |
||||
|
export class AiController { |
||||
|
public constructor( |
||||
|
private readonly aiService: AiService, |
||||
|
private readonly apiService: ApiService, |
||||
|
@Inject(REQUEST) private readonly request: RequestWithUser |
||||
|
) {} |
||||
|
|
||||
|
@Get('prompt/:mode') |
||||
|
@HasPermission(permissions.readAiPrompt) |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
public async getPrompt( |
||||
|
@Param('mode') mode: AiPromptMode, |
||||
|
@Query('accounts') filterByAccounts?: string, |
||||
|
@Query('assetClasses') filterByAssetClasses?: string, |
||||
|
@Query('dataSource') filterByDataSource?: string, |
||||
|
@Query('symbol') filterBySymbol?: string, |
||||
|
@Query('tags') filterByTags?: string |
||||
|
): Promise<AiPromptResponse> { |
||||
|
const filters = this.apiService.buildFiltersFromQueryParams({ |
||||
|
filterByAccounts, |
||||
|
filterByAssetClasses, |
||||
|
filterByDataSource, |
||||
|
filterBySymbol, |
||||
|
filterByTags |
||||
|
}); |
||||
|
|
||||
|
const prompt = await this.aiService.getPrompt({ |
||||
|
filters, |
||||
|
mode, |
||||
|
impersonationId: undefined, |
||||
|
languageCode: this.request.user.settings.settings.language, |
||||
|
userCurrency: this.request.user.settings.settings.baseCurrency, |
||||
|
userId: this.request.user.id |
||||
|
}); |
||||
|
|
||||
|
return { prompt }; |
||||
|
} |
||||
|
} |
@ -0,0 +1,59 @@ |
|||||
|
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; |
||||
|
import { AccountService } from '@ghostfolio/api/app/account/account.service'; |
||||
|
import { OrderModule } from '@ghostfolio/api/app/order/order.module'; |
||||
|
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; |
||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; |
||||
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; |
||||
|
import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service'; |
||||
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; |
||||
|
import { UserModule } from '@ghostfolio/api/app/user/user.module'; |
||||
|
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; |
||||
|
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; |
||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.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 { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module'; |
||||
|
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; |
||||
|
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; |
||||
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; |
||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
||||
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; |
||||
|
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; |
||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; |
||||
|
|
||||
|
import { Module } from '@nestjs/common'; |
||||
|
|
||||
|
import { AiController } from './ai.controller'; |
||||
|
import { AiService } from './ai.service'; |
||||
|
|
||||
|
@Module({ |
||||
|
controllers: [AiController], |
||||
|
imports: [ |
||||
|
ApiModule, |
||||
|
BenchmarkModule, |
||||
|
ConfigurationModule, |
||||
|
DataProviderModule, |
||||
|
ExchangeRateDataModule, |
||||
|
I18nModule, |
||||
|
ImpersonationModule, |
||||
|
MarketDataModule, |
||||
|
OrderModule, |
||||
|
PortfolioSnapshotQueueModule, |
||||
|
PrismaModule, |
||||
|
PropertyModule, |
||||
|
RedisCacheModule, |
||||
|
SymbolProfileModule, |
||||
|
UserModule |
||||
|
], |
||||
|
providers: [ |
||||
|
AccountBalanceService, |
||||
|
AccountService, |
||||
|
AiService, |
||||
|
CurrentRateService, |
||||
|
MarketDataService, |
||||
|
PortfolioCalculatorFactory, |
||||
|
PortfolioService, |
||||
|
RulesService |
||||
|
] |
||||
|
}) |
||||
|
export class AiModule {} |
@ -0,0 +1,100 @@ |
|||||
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; |
||||
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; |
||||
|
import { |
||||
|
PROPERTY_API_KEY_OPENROUTER, |
||||
|
PROPERTY_OPENROUTER_MODEL |
||||
|
} from '@ghostfolio/common/config'; |
||||
|
import { Filter } from '@ghostfolio/common/interfaces'; |
||||
|
import type { AiPromptMode } from '@ghostfolio/common/types'; |
||||
|
|
||||
|
import { Injectable } from '@nestjs/common'; |
||||
|
import { createOpenRouter } from '@openrouter/ai-sdk-provider'; |
||||
|
import { generateText } from 'ai'; |
||||
|
|
||||
|
@Injectable() |
||||
|
export class AiService { |
||||
|
public constructor( |
||||
|
private readonly portfolioService: PortfolioService, |
||||
|
private readonly propertyService: PropertyService |
||||
|
) {} |
||||
|
|
||||
|
public async generateText({ prompt }: { prompt: string }) { |
||||
|
const openRouterApiKey = await this.propertyService.getByKey<string>( |
||||
|
PROPERTY_API_KEY_OPENROUTER |
||||
|
); |
||||
|
|
||||
|
const openRouterModel = await this.propertyService.getByKey<string>( |
||||
|
PROPERTY_OPENROUTER_MODEL |
||||
|
); |
||||
|
|
||||
|
const openRouterService = createOpenRouter({ |
||||
|
apiKey: openRouterApiKey |
||||
|
}); |
||||
|
|
||||
|
return generateText({ |
||||
|
prompt, |
||||
|
model: openRouterService.chat(openRouterModel) |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public async getPrompt({ |
||||
|
filters, |
||||
|
impersonationId, |
||||
|
languageCode, |
||||
|
mode, |
||||
|
userCurrency, |
||||
|
userId |
||||
|
}: { |
||||
|
filters?: Filter[]; |
||||
|
impersonationId: string; |
||||
|
languageCode: string; |
||||
|
mode: AiPromptMode; |
||||
|
userCurrency: string; |
||||
|
userId: string; |
||||
|
}) { |
||||
|
const { holdings } = await this.portfolioService.getDetails({ |
||||
|
filters, |
||||
|
impersonationId, |
||||
|
userId |
||||
|
}); |
||||
|
|
||||
|
const holdingsTable = [ |
||||
|
'| Name | Symbol | Currency | Asset Class | Asset Sub Class | Allocation in Percentage |', |
||||
|
'| --- | --- | --- | --- | --- | --- |', |
||||
|
...Object.values(holdings) |
||||
|
.sort((a, b) => { |
||||
|
return b.allocationInPercentage - a.allocationInPercentage; |
||||
|
}) |
||||
|
.map( |
||||
|
({ |
||||
|
allocationInPercentage, |
||||
|
assetClass, |
||||
|
assetSubClass, |
||||
|
currency, |
||||
|
name, |
||||
|
symbol |
||||
|
}) => { |
||||
|
return `| ${name} | ${symbol} | ${currency} | ${assetClass} | ${assetSubClass} | ${(allocationInPercentage * 100).toFixed(3)}% |`; |
||||
|
} |
||||
|
) |
||||
|
]; |
||||
|
|
||||
|
if (mode === 'portfolio') { |
||||
|
return holdingsTable.join('\n'); |
||||
|
} |
||||
|
|
||||
|
return [ |
||||
|
`You are a neutral financial assistant. Please analyze the following investment portfolio (base currency being ${userCurrency}) in simple words.`, |
||||
|
...holdingsTable, |
||||
|
'Structure your answer with these sections:', |
||||
|
'Overview: Briefly summarize the portfolio’s composition and allocation rationale.', |
||||
|
'Risk Assessment: Identify potential risks, including market volatility, concentration, and sectoral imbalances.', |
||||
|
'Advantages: Highlight strengths, focusing on growth potential, diversification, or other benefits.', |
||||
|
'Disadvantages: Point out weaknesses, such as overexposure or lack of defensive assets.', |
||||
|
'Target Group: Discuss who this portfolio might suit (e.g., risk tolerance, investment goals, life stages, and experience levels).', |
||||
|
'Optimization Ideas: Offer ideas to complement the portfolio, ensuring they are constructive and neutral in tone.', |
||||
|
'Conclusion: Provide a concise summary highlighting key insights.', |
||||
|
`Provide your answer in the following language: ${languageCode}.` |
||||
|
].join('\n'); |
||||
|
} |
||||
|
} |
@ -0,0 +1,46 @@ |
|||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
||||
|
import { interpolate } from '@ghostfolio/common/helper'; |
||||
|
|
||||
|
import { |
||||
|
Controller, |
||||
|
Get, |
||||
|
Param, |
||||
|
Res, |
||||
|
Version, |
||||
|
VERSION_NEUTRAL |
||||
|
} from '@nestjs/common'; |
||||
|
import { Response } from 'express'; |
||||
|
import { readFileSync } from 'fs'; |
||||
|
import { join } from 'path'; |
||||
|
|
||||
|
@Controller('assets') |
||||
|
export class AssetsController { |
||||
|
private webManifest = ''; |
||||
|
|
||||
|
public constructor( |
||||
|
public readonly configurationService: ConfigurationService |
||||
|
) { |
||||
|
try { |
||||
|
this.webManifest = readFileSync( |
||||
|
join(__dirname, 'assets', 'site.webmanifest'), |
||||
|
'utf8' |
||||
|
); |
||||
|
} catch {} |
||||
|
} |
||||
|
|
||||
|
@Get('/:languageCode/site.webmanifest') |
||||
|
@Version(VERSION_NEUTRAL) |
||||
|
public getWebManifest( |
||||
|
@Param('languageCode') languageCode: string, |
||||
|
@Res() response: Response |
||||
|
): void { |
||||
|
const rootUrl = this.configurationService.get('ROOT_URL'); |
||||
|
const webManifest = interpolate(this.webManifest, { |
||||
|
languageCode, |
||||
|
rootUrl |
||||
|
}); |
||||
|
|
||||
|
response.setHeader('Content-Type', 'application/json'); |
||||
|
response.send(webManifest); |
||||
|
} |
||||
|
} |
@ -0,0 +1,11 @@ |
|||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
||||
|
|
||||
|
import { Module } from '@nestjs/common'; |
||||
|
|
||||
|
import { AssetsController } from './assets.controller'; |
||||
|
|
||||
|
@Module({ |
||||
|
controllers: [AssetsController], |
||||
|
providers: [ConfigurationService] |
||||
|
}) |
||||
|
export class AssetsModule {} |
@ -0,0 +1,65 @@ |
|||||
|
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; |
||||
|
import { AccountService } from '@ghostfolio/api/app/account/account.service'; |
||||
|
import { OrderModule } from '@ghostfolio/api/app/order/order.module'; |
||||
|
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; |
||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; |
||||
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; |
||||
|
import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service'; |
||||
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; |
||||
|
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; |
||||
|
import { UserModule } from '@ghostfolio/api/app/user/user.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 { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; |
||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.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 { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module'; |
||||
|
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; |
||||
|
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; |
||||
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; |
||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
||||
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; |
||||
|
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; |
||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; |
||||
|
|
||||
|
import { Module } from '@nestjs/common'; |
||||
|
|
||||
|
import { BenchmarksController } from './benchmarks.controller'; |
||||
|
import { BenchmarksService } from './benchmarks.service'; |
||||
|
|
||||
|
@Module({ |
||||
|
controllers: [BenchmarksController], |
||||
|
imports: [ |
||||
|
ApiModule, |
||||
|
ConfigurationModule, |
||||
|
DataProviderModule, |
||||
|
ExchangeRateDataModule, |
||||
|
I18nModule, |
||||
|
ImpersonationModule, |
||||
|
MarketDataModule, |
||||
|
OrderModule, |
||||
|
PortfolioSnapshotQueueModule, |
||||
|
PrismaModule, |
||||
|
PropertyModule, |
||||
|
RedisCacheModule, |
||||
|
SymbolModule, |
||||
|
SymbolProfileModule, |
||||
|
TransformDataSourceInRequestModule, |
||||
|
TransformDataSourceInResponseModule, |
||||
|
UserModule |
||||
|
], |
||||
|
providers: [ |
||||
|
AccountBalanceService, |
||||
|
AccountService, |
||||
|
BenchmarkService, |
||||
|
BenchmarksService, |
||||
|
CurrentRateService, |
||||
|
MarketDataService, |
||||
|
PortfolioCalculatorFactory, |
||||
|
PortfolioService, |
||||
|
RulesService |
||||
|
] |
||||
|
}) |
||||
|
export class BenchmarksModule {} |
@ -0,0 +1,163 @@ |
|||||
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; |
||||
|
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; |
||||
|
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.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 { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper'; |
||||
|
import { |
||||
|
AssetProfileIdentifier, |
||||
|
BenchmarkMarketDataDetails, |
||||
|
Filter |
||||
|
} from '@ghostfolio/common/interfaces'; |
||||
|
import { DateRange, UserWithSettings } from '@ghostfolio/common/types'; |
||||
|
|
||||
|
import { Injectable, Logger } from '@nestjs/common'; |
||||
|
import { format, isSameDay } from 'date-fns'; |
||||
|
import { isNumber } from 'lodash'; |
||||
|
|
||||
|
@Injectable() |
||||
|
export class BenchmarksService { |
||||
|
public constructor( |
||||
|
private readonly benchmarkService: BenchmarkService, |
||||
|
private readonly exchangeRateDataService: ExchangeRateDataService, |
||||
|
private readonly marketDataService: MarketDataService, |
||||
|
private readonly portfolioService: PortfolioService, |
||||
|
private readonly symbolService: SymbolService |
||||
|
) {} |
||||
|
|
||||
|
public async getMarketDataForUser({ |
||||
|
dataSource, |
||||
|
dateRange, |
||||
|
endDate = new Date(), |
||||
|
filters, |
||||
|
impersonationId, |
||||
|
startDate, |
||||
|
symbol, |
||||
|
user, |
||||
|
withExcludedAccounts |
||||
|
}: { |
||||
|
dateRange: DateRange; |
||||
|
endDate?: Date; |
||||
|
filters?: Filter[]; |
||||
|
impersonationId: string; |
||||
|
startDate: Date; |
||||
|
user: UserWithSettings; |
||||
|
withExcludedAccounts?: boolean; |
||||
|
} & AssetProfileIdentifier): Promise<BenchmarkMarketDataDetails> { |
||||
|
const marketData: { date: string; value: number }[] = []; |
||||
|
const userCurrency = user.settings.settings.baseCurrency; |
||||
|
const userId = user.id; |
||||
|
|
||||
|
const { chart } = await this.portfolioService.getPerformance({ |
||||
|
dateRange, |
||||
|
filters, |
||||
|
impersonationId, |
||||
|
userId, |
||||
|
withExcludedAccounts |
||||
|
}); |
||||
|
|
||||
|
const [currentSymbolItem, marketDataItems] = await Promise.all([ |
||||
|
this.symbolService.get({ |
||||
|
dataGatheringItem: { |
||||
|
dataSource, |
||||
|
symbol |
||||
|
} |
||||
|
}), |
||||
|
this.marketDataService.marketDataItems({ |
||||
|
orderBy: { |
||||
|
date: 'asc' |
||||
|
}, |
||||
|
where: { |
||||
|
dataSource, |
||||
|
symbol, |
||||
|
date: { |
||||
|
in: chart.map(({ date }) => { |
||||
|
return resetHours(parseDate(date)); |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
}) |
||||
|
]); |
||||
|
|
||||
|
const exchangeRates = |
||||
|
await this.exchangeRateDataService.getExchangeRatesByCurrency({ |
||||
|
startDate, |
||||
|
currencies: [currentSymbolItem.currency], |
||||
|
targetCurrency: userCurrency |
||||
|
}); |
||||
|
|
||||
|
const exchangeRateAtStartDate = |
||||
|
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[ |
||||
|
format(startDate, DATE_FORMAT) |
||||
|
]; |
||||
|
|
||||
|
const marketPriceAtStartDate = marketDataItems?.find(({ date }) => { |
||||
|
return isSameDay(date, startDate); |
||||
|
})?.marketPrice; |
||||
|
|
||||
|
if (!marketPriceAtStartDate) { |
||||
|
Logger.error( |
||||
|
`No historical market data has been found for ${symbol} (${dataSource}) at ${format( |
||||
|
startDate, |
||||
|
DATE_FORMAT |
||||
|
)}`,
|
||||
|
'BenchmarkService' |
||||
|
); |
||||
|
|
||||
|
return { marketData }; |
||||
|
} |
||||
|
|
||||
|
for (const marketDataItem of marketDataItems) { |
||||
|
const exchangeRate = |
||||
|
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[ |
||||
|
format(marketDataItem.date, DATE_FORMAT) |
||||
|
]; |
||||
|
|
||||
|
const exchangeRateFactor = |
||||
|
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate) |
||||
|
? exchangeRate / exchangeRateAtStartDate |
||||
|
: 1; |
||||
|
|
||||
|
marketData.push({ |
||||
|
date: format(marketDataItem.date, DATE_FORMAT), |
||||
|
value: |
||||
|
marketPriceAtStartDate === 0 |
||||
|
? 0 |
||||
|
: this.benchmarkService.calculateChangeInPercentage( |
||||
|
marketPriceAtStartDate, |
||||
|
marketDataItem.marketPrice * exchangeRateFactor |
||||
|
) * 100 |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
const includesEndDate = isSameDay( |
||||
|
parseDate(marketData.at(-1).date), |
||||
|
endDate |
||||
|
); |
||||
|
|
||||
|
if (currentSymbolItem?.marketPrice && !includesEndDate) { |
||||
|
const exchangeRate = |
||||
|
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[ |
||||
|
format(endDate, DATE_FORMAT) |
||||
|
]; |
||||
|
|
||||
|
const exchangeRateFactor = |
||||
|
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate) |
||||
|
? exchangeRate / exchangeRateAtStartDate |
||||
|
: 1; |
||||
|
|
||||
|
marketData.push({ |
||||
|
date: format(endDate, DATE_FORMAT), |
||||
|
value: |
||||
|
this.benchmarkService.calculateChangeInPercentage( |
||||
|
marketPriceAtStartDate, |
||||
|
currentSymbolItem.marketPrice * exchangeRateFactor |
||||
|
) * 100 |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
marketData |
||||
|
}; |
||||
|
} |
||||
|
} |
@ -0,0 +1,51 @@ |
|||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
||||
|
import { |
||||
|
DATE_FORMAT, |
||||
|
getYesterday, |
||||
|
interpolate |
||||
|
} from '@ghostfolio/common/helper'; |
||||
|
|
||||
|
import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common'; |
||||
|
import { format } from 'date-fns'; |
||||
|
import { Response } from 'express'; |
||||
|
import { readFileSync } from 'fs'; |
||||
|
import { join } from 'path'; |
||||
|
|
||||
|
import { SitemapService } from './sitemap.service'; |
||||
|
|
||||
|
@Controller('sitemap.xml') |
||||
|
export class SitemapController { |
||||
|
public sitemapXml = ''; |
||||
|
|
||||
|
public constructor( |
||||
|
private readonly configurationService: ConfigurationService, |
||||
|
private readonly sitemapService: SitemapService |
||||
|
) { |
||||
|
try { |
||||
|
this.sitemapXml = readFileSync( |
||||
|
join(__dirname, 'assets', 'sitemap.xml'), |
||||
|
'utf8' |
||||
|
); |
||||
|
} catch {} |
||||
|
} |
||||
|
|
||||
|
@Get() |
||||
|
@Version(VERSION_NEUTRAL) |
||||
|
public getSitemapXml(@Res() response: Response) { |
||||
|
const currentDate = format(getYesterday(), DATE_FORMAT); |
||||
|
|
||||
|
response.setHeader('content-type', 'application/xml'); |
||||
|
response.send( |
||||
|
interpolate(this.sitemapXml, { |
||||
|
personalFinanceTools: this.configurationService.get( |
||||
|
'ENABLE_FEATURE_SUBSCRIPTION' |
||||
|
) |
||||
|
? this.sitemapService.getPersonalFinanceTools({ currentDate }) |
||||
|
: '', |
||||
|
publicRoutes: this.sitemapService.getPublicRoutes({ |
||||
|
currentDate |
||||
|
}) |
||||
|
}) |
||||
|
); |
||||
|
} |
||||
|
} |
@ -1,11 +1,14 @@ |
|||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; |
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; |
||||
|
import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module'; |
||||
|
|
||||
import { Module } from '@nestjs/common'; |
import { Module } from '@nestjs/common'; |
||||
|
|
||||
import { SitemapController } from './sitemap.controller'; |
import { SitemapController } from './sitemap.controller'; |
||||
|
import { SitemapService } from './sitemap.service'; |
||||
|
|
||||
@Module({ |
@Module({ |
||||
controllers: [SitemapController], |
controllers: [SitemapController], |
||||
imports: [ConfigurationModule] |
imports: [ConfigurationModule, I18nModule], |
||||
|
providers: [SitemapService] |
||||
}) |
}) |
||||
export class SitemapModule {} |
export class SitemapModule {} |
@ -0,0 +1,116 @@ |
|||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
||||
|
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; |
||||
|
import { SUPPORTED_LANGUAGE_CODES } from '@ghostfolio/common/config'; |
||||
|
import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools'; |
||||
|
import { PublicRoute } from '@ghostfolio/common/routes/interfaces/public-route.interface'; |
||||
|
import { publicRoutes } from '@ghostfolio/common/routes/routes'; |
||||
|
|
||||
|
import { Injectable } from '@nestjs/common'; |
||||
|
|
||||
|
@Injectable() |
||||
|
export class SitemapService { |
||||
|
private static readonly TRANSLATION_TAGGED_MESSAGE_REGEX = |
||||
|
/:.*@@(?<id>[a-zA-Z0-9.]+):(?<message>.+)/; |
||||
|
|
||||
|
public constructor( |
||||
|
private readonly configurationService: ConfigurationService, |
||||
|
private readonly i18nService: I18nService |
||||
|
) {} |
||||
|
|
||||
|
public getPersonalFinanceTools({ currentDate }: { currentDate: string }) { |
||||
|
const rootUrl = this.configurationService.get('ROOT_URL'); |
||||
|
|
||||
|
return SUPPORTED_LANGUAGE_CODES.flatMap((languageCode) => { |
||||
|
return personalFinanceTools.map(({ alias, key }) => { |
||||
|
const route = |
||||
|
publicRoutes.resources.subRoutes.personalFinanceTools.subRoutes |
||||
|
.product; |
||||
|
const params = { |
||||
|
currentDate, |
||||
|
languageCode, |
||||
|
rootUrl, |
||||
|
urlPostfix: alias ?? key |
||||
|
}; |
||||
|
|
||||
|
return this.createRouteSitemapUrl({ ...params, route }); |
||||
|
}); |
||||
|
}).join('\n'); |
||||
|
} |
||||
|
|
||||
|
public getPublicRoutes({ currentDate }: { currentDate: string }) { |
||||
|
const rootUrl = this.configurationService.get('ROOT_URL'); |
||||
|
|
||||
|
return SUPPORTED_LANGUAGE_CODES.flatMap((languageCode) => { |
||||
|
const params = { |
||||
|
currentDate, |
||||
|
languageCode, |
||||
|
rootUrl |
||||
|
}; |
||||
|
|
||||
|
return [ |
||||
|
this.createRouteSitemapUrl(params), |
||||
|
...this.createSitemapUrls(params, publicRoutes) |
||||
|
]; |
||||
|
}).join('\n'); |
||||
|
} |
||||
|
|
||||
|
private createRouteSitemapUrl({ |
||||
|
currentDate, |
||||
|
languageCode, |
||||
|
rootUrl, |
||||
|
route, |
||||
|
urlPostfix |
||||
|
}: { |
||||
|
currentDate: string; |
||||
|
languageCode: string; |
||||
|
rootUrl: string; |
||||
|
route?: PublicRoute; |
||||
|
urlPostfix?: string; |
||||
|
}): string { |
||||
|
const segments = |
||||
|
route?.routerLink.map((link) => { |
||||
|
const match = link.match( |
||||
|
SitemapService.TRANSLATION_TAGGED_MESSAGE_REGEX |
||||
|
); |
||||
|
|
||||
|
const segment = match |
||||
|
? (this.i18nService.getTranslation({ |
||||
|
languageCode, |
||||
|
id: match.groups.id |
||||
|
}) ?? match.groups.message) |
||||
|
: link; |
||||
|
|
||||
|
return segment.replace(/^\/+|\/+$/, ''); |
||||
|
}) ?? []; |
||||
|
|
||||
|
const location = |
||||
|
[rootUrl, languageCode, ...segments].join('/') + |
||||
|
(urlPostfix ? `-${urlPostfix}` : ''); |
||||
|
|
||||
|
return [ |
||||
|
' <url>', |
||||
|
` <loc>${location}</loc>`, |
||||
|
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`, |
||||
|
' </url>' |
||||
|
].join('\n'); |
||||
|
} |
||||
|
|
||||
|
private createSitemapUrls( |
||||
|
params: { currentDate: string; languageCode: string; rootUrl: string }, |
||||
|
routes: Record<string, PublicRoute> |
||||
|
): string[] { |
||||
|
return Object.values(routes).flatMap((route) => { |
||||
|
if (route.excludeFromSitemap) { |
||||
|
return []; |
||||
|
} |
||||
|
|
||||
|
const urls = [this.createRouteSitemapUrl({ ...params, route })]; |
||||
|
|
||||
|
if (route.subRoutes) { |
||||
|
urls.push(...this.createSitemapUrls(params, route.subRoutes)); |
||||
|
} |
||||
|
|
||||
|
return urls; |
||||
|
}); |
||||
|
} |
||||
|
} |
@ -0,0 +1,10 @@ |
|||||
|
import { IsOptional, IsString } from 'class-validator'; |
||||
|
|
||||
|
export class CreateTagDto { |
||||
|
@IsString() |
||||
|
name: string; |
||||
|
|
||||
|
@IsOptional() |
||||
|
@IsString() |
||||
|
userId?: string; |
||||
|
} |
@ -0,0 +1,12 @@ |
|||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
||||
|
import { TagModule } from '@ghostfolio/api/services/tag/tag.module'; |
||||
|
|
||||
|
import { Module } from '@nestjs/common'; |
||||
|
|
||||
|
import { TagsController } from './tags.controller'; |
||||
|
|
||||
|
@Module({ |
||||
|
controllers: [TagsController], |
||||
|
imports: [PrismaModule, TagModule] |
||||
|
}) |
||||
|
export class TagsModule {} |
@ -0,0 +1,13 @@ |
|||||
|
import { IsOptional, IsString } from 'class-validator'; |
||||
|
|
||||
|
export class UpdateTagDto { |
||||
|
@IsString() |
||||
|
id: string; |
||||
|
|
||||
|
@IsString() |
||||
|
name: string; |
||||
|
|
||||
|
@IsOptional() |
||||
|
@IsString() |
||||
|
userId?: string; |
||||
|
} |
@ -0,0 +1,10 @@ |
|||||
|
import { DataSource } from '@prisma/client'; |
||||
|
import { IsEnum, IsString } from 'class-validator'; |
||||
|
|
||||
|
export class CreateWatchlistItemDto { |
||||
|
@IsEnum(DataSource) |
||||
|
dataSource: DataSource; |
||||
|
|
||||
|
@IsString() |
||||
|
symbol: string; |
||||
|
} |
@ -0,0 +1,100 @@ |
|||||
|
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 { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; |
||||
|
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; |
||||
|
import { WatchlistResponse } from '@ghostfolio/common/interfaces'; |
||||
|
import { permissions } from '@ghostfolio/common/permissions'; |
||||
|
import { RequestWithUser } from '@ghostfolio/common/types'; |
||||
|
|
||||
|
import { |
||||
|
Body, |
||||
|
Controller, |
||||
|
Delete, |
||||
|
Get, |
||||
|
Headers, |
||||
|
HttpException, |
||||
|
Inject, |
||||
|
Param, |
||||
|
Post, |
||||
|
UseGuards, |
||||
|
UseInterceptors |
||||
|
} from '@nestjs/common'; |
||||
|
import { REQUEST } from '@nestjs/core'; |
||||
|
import { AuthGuard } from '@nestjs/passport'; |
||||
|
import { DataSource } from '@prisma/client'; |
||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
||||
|
|
||||
|
import { CreateWatchlistItemDto } from './create-watchlist-item.dto'; |
||||
|
import { WatchlistService } from './watchlist.service'; |
||||
|
|
||||
|
@Controller('watchlist') |
||||
|
export class WatchlistController { |
||||
|
public constructor( |
||||
|
private readonly impersonationService: ImpersonationService, |
||||
|
@Inject(REQUEST) private readonly request: RequestWithUser, |
||||
|
private readonly watchlistService: WatchlistService |
||||
|
) {} |
||||
|
|
||||
|
@Post() |
||||
|
@HasPermission(permissions.createWatchlistItem) |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor) |
||||
|
public async createWatchlistItem(@Body() data: CreateWatchlistItemDto) { |
||||
|
return this.watchlistService.createWatchlistItem({ |
||||
|
dataSource: data.dataSource, |
||||
|
symbol: data.symbol, |
||||
|
userId: this.request.user.id |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
@Delete(':dataSource/:symbol') |
||||
|
@HasPermission(permissions.deleteWatchlistItem) |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor) |
||||
|
public async deleteWatchlistItem( |
||||
|
@Param('dataSource') dataSource: DataSource, |
||||
|
@Param('symbol') symbol: string |
||||
|
) { |
||||
|
const watchlistItems = await this.watchlistService.getWatchlistItems( |
||||
|
this.request.user.id |
||||
|
); |
||||
|
|
||||
|
const watchlistItem = watchlistItems.find((item) => { |
||||
|
return item.dataSource === dataSource && item.symbol === symbol; |
||||
|
}); |
||||
|
|
||||
|
if (!watchlistItem) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.NOT_FOUND), |
||||
|
StatusCodes.NOT_FOUND |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return this.watchlistService.deleteWatchlistItem({ |
||||
|
dataSource, |
||||
|
symbol, |
||||
|
userId: this.request.user.id |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
@Get() |
||||
|
@HasPermission(permissions.readWatchlist) |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
@UseInterceptors(TransformDataSourceInResponseInterceptor) |
||||
|
public async getWatchlistItems( |
||||
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string |
||||
|
): Promise<WatchlistResponse> { |
||||
|
const impersonationUserId = |
||||
|
await this.impersonationService.validateImpersonationId(impersonationId); |
||||
|
|
||||
|
const watchlist = await this.watchlistService.getWatchlistItems( |
||||
|
impersonationUserId || this.request.user.id |
||||
|
); |
||||
|
|
||||
|
return { |
||||
|
watchlist |
||||
|
}; |
||||
|
} |
||||
|
} |
@ -1,36 +1,31 @@ |
|||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; |
|
||||
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; |
|
||||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.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 { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; |
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; |
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; |
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.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 { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; |
||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; |
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; |
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; |
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; |
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; |
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; |
||||
|
|
||||
import { Module } from '@nestjs/common'; |
import { Module } from '@nestjs/common'; |
||||
|
|
||||
import { BenchmarkController } from './benchmark.controller'; |
import { WatchlistController } from './watchlist.controller'; |
||||
import { BenchmarkService } from './benchmark.service'; |
import { WatchlistService } from './watchlist.service'; |
||||
|
|
||||
@Module({ |
@Module({ |
||||
controllers: [BenchmarkController], |
controllers: [WatchlistController], |
||||
exports: [BenchmarkService], |
|
||||
imports: [ |
imports: [ |
||||
ConfigurationModule, |
BenchmarkModule, |
||||
|
DataGatheringModule, |
||||
DataProviderModule, |
DataProviderModule, |
||||
ExchangeRateDataModule, |
ImpersonationModule, |
||||
MarketDataModule, |
MarketDataModule, |
||||
PrismaModule, |
PrismaModule, |
||||
PropertyModule, |
|
||||
RedisCacheModule, |
|
||||
SymbolModule, |
|
||||
SymbolProfileModule, |
SymbolProfileModule, |
||||
TransformDataSourceInRequestModule, |
TransformDataSourceInRequestModule, |
||||
TransformDataSourceInResponseModule |
TransformDataSourceInResponseModule |
||||
], |
], |
||||
providers: [BenchmarkService] |
providers: [WatchlistService] |
||||
}) |
}) |
||||
export class BenchmarkModule {} |
export class WatchlistModule {} |
@ -0,0 +1,150 @@ |
|||||
|
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; |
||||
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.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 { WatchlistResponse } from '@ghostfolio/common/interfaces'; |
||||
|
|
||||
|
import { BadRequestException, Injectable } from '@nestjs/common'; |
||||
|
import { DataSource, Prisma } from '@prisma/client'; |
||||
|
|
||||
|
@Injectable() |
||||
|
export class WatchlistService { |
||||
|
public constructor( |
||||
|
private readonly benchmarkService: BenchmarkService, |
||||
|
private readonly dataGatheringService: DataGatheringService, |
||||
|
private readonly dataProviderService: DataProviderService, |
||||
|
private readonly marketDataService: MarketDataService, |
||||
|
private readonly prismaService: PrismaService, |
||||
|
private readonly symbolProfileService: SymbolProfileService |
||||
|
) {} |
||||
|
|
||||
|
public async createWatchlistItem({ |
||||
|
dataSource, |
||||
|
symbol, |
||||
|
userId |
||||
|
}: { |
||||
|
dataSource: DataSource; |
||||
|
symbol: string; |
||||
|
userId: string; |
||||
|
}): Promise<void> { |
||||
|
const symbolProfile = await this.prismaService.symbolProfile.findUnique({ |
||||
|
where: { |
||||
|
dataSource_symbol: { dataSource, symbol } |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
if (!symbolProfile) { |
||||
|
const assetProfiles = await this.dataProviderService.getAssetProfiles([ |
||||
|
{ dataSource, symbol } |
||||
|
]); |
||||
|
|
||||
|
if (!assetProfiles[symbol]?.currency) { |
||||
|
throw new BadRequestException( |
||||
|
`Asset profile not found for ${symbol} (${dataSource})` |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
await this.symbolProfileService.add( |
||||
|
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
await this.dataGatheringService.gatherSymbol({ |
||||
|
dataSource, |
||||
|
symbol |
||||
|
}); |
||||
|
|
||||
|
await this.prismaService.user.update({ |
||||
|
data: { |
||||
|
watchlist: { |
||||
|
connect: { |
||||
|
dataSource_symbol: { dataSource, symbol } |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
where: { id: userId } |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public async deleteWatchlistItem({ |
||||
|
dataSource, |
||||
|
symbol, |
||||
|
userId |
||||
|
}: { |
||||
|
dataSource: DataSource; |
||||
|
symbol: string; |
||||
|
userId: string; |
||||
|
}) { |
||||
|
await this.prismaService.user.update({ |
||||
|
data: { |
||||
|
watchlist: { |
||||
|
disconnect: { |
||||
|
dataSource_symbol: { dataSource, symbol } |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
where: { id: userId } |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public async getWatchlistItems( |
||||
|
userId: string |
||||
|
): Promise<WatchlistResponse['watchlist']> { |
||||
|
const user = await this.prismaService.user.findUnique({ |
||||
|
select: { |
||||
|
watchlist: { |
||||
|
select: { dataSource: true, symbol: true } |
||||
|
} |
||||
|
}, |
||||
|
where: { id: userId } |
||||
|
}); |
||||
|
|
||||
|
const [assetProfiles, quotes] = await Promise.all([ |
||||
|
this.symbolProfileService.getSymbolProfiles(user.watchlist), |
||||
|
this.dataProviderService.getQuotes({ |
||||
|
items: user.watchlist.map(({ dataSource, symbol }) => { |
||||
|
return { dataSource, symbol }; |
||||
|
}) |
||||
|
}) |
||||
|
]); |
||||
|
|
||||
|
const watchlist = await Promise.all( |
||||
|
user.watchlist.map(async ({ dataSource, symbol }) => { |
||||
|
const assetProfile = assetProfiles.find((profile) => { |
||||
|
return profile.dataSource === dataSource && profile.symbol === symbol; |
||||
|
}); |
||||
|
|
||||
|
const allTimeHigh = await this.marketDataService.getMax({ |
||||
|
dataSource, |
||||
|
symbol |
||||
|
}); |
||||
|
|
||||
|
const performancePercent = |
||||
|
this.benchmarkService.calculateChangeInPercentage( |
||||
|
allTimeHigh?.marketPrice, |
||||
|
quotes[symbol]?.marketPrice |
||||
|
); |
||||
|
|
||||
|
return { |
||||
|
dataSource, |
||||
|
symbol, |
||||
|
marketCondition: |
||||
|
this.benchmarkService.getMarketCondition(performancePercent), |
||||
|
name: assetProfile?.name, |
||||
|
performances: { |
||||
|
allTimeHigh: { |
||||
|
performancePercent, |
||||
|
date: allTimeHigh?.date |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
}) |
||||
|
); |
||||
|
|
||||
|
return watchlist.sort((a, b) => { |
||||
|
return a.name.localeCompare(b.name); |
||||
|
}); |
||||
|
} |
||||
|
} |
@ -0,0 +1,10 @@ |
|||||
|
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto'; |
||||
|
import { AccountBalance } from '@ghostfolio/common/interfaces'; |
||||
|
|
||||
|
import { IsArray, IsOptional } from 'class-validator'; |
||||
|
|
||||
|
export class CreateAccountWithBalancesDto extends CreateAccountDto { |
||||
|
@IsArray() |
||||
|
@IsOptional() |
||||
|
balances?: AccountBalance[]; |
||||
|
} |
@ -0,0 +1,17 @@ |
|||||
|
import { MarketData } from '@ghostfolio/common/interfaces'; |
||||
|
|
||||
|
import { DataSource } from '@prisma/client'; |
||||
|
import { IsArray, IsEnum, IsOptional } from 'class-validator'; |
||||
|
|
||||
|
import { CreateAssetProfileDto } from '../admin/create-asset-profile.dto'; |
||||
|
|
||||
|
export class CreateAssetProfileWithMarketDataDto extends CreateAssetProfileDto { |
||||
|
@IsEnum([DataSource.MANUAL], { |
||||
|
message: `dataSource must be '${DataSource.MANUAL}'` |
||||
|
}) |
||||
|
dataSource: DataSource; |
||||
|
|
||||
|
@IsArray() |
||||
|
@IsOptional() |
||||
|
marketData?: MarketData[]; |
||||
|
} |
@ -1,18 +1,26 @@ |
|||||
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto'; |
|
||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; |
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; |
||||
|
|
||||
import { Type } from 'class-transformer'; |
import { Type } from 'class-transformer'; |
||||
import { IsArray, IsOptional, ValidateNested } from 'class-validator'; |
import { IsArray, IsOptional, ValidateNested } from 'class-validator'; |
||||
|
|
||||
|
import { CreateAccountWithBalancesDto } from './create-account-with-balances.dto'; |
||||
|
import { CreateAssetProfileWithMarketDataDto } from './create-asset-profile-with-market-data.dto'; |
||||
|
|
||||
export class ImportDataDto { |
export class ImportDataDto { |
||||
@IsOptional() |
|
||||
@IsArray() |
@IsArray() |
||||
@Type(() => CreateAccountDto) |
@IsOptional() |
||||
|
@Type(() => CreateAccountWithBalancesDto) |
||||
@ValidateNested({ each: true }) |
@ValidateNested({ each: true }) |
||||
accounts: CreateAccountDto[]; |
accounts?: CreateAccountWithBalancesDto[]; |
||||
|
|
||||
@IsArray() |
@IsArray() |
||||
@Type(() => CreateOrderDto) |
@Type(() => CreateOrderDto) |
||||
@ValidateNested({ each: true }) |
@ValidateNested({ each: true }) |
||||
activities: CreateOrderDto[]; |
activities: CreateOrderDto[]; |
||||
|
|
||||
|
@IsArray() |
||||
|
@IsOptional() |
||||
|
@Type(() => CreateAssetProfileWithMarketDataDto) |
||||
|
@ValidateNested({ each: true }) |
||||
|
assetProfiles?: CreateAssetProfileWithMarketDataDto[]; |
||||
} |
} |
||||
|
@ -0,0 +1,240 @@ |
|||||
|
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; |
||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; |
||||
|
import { |
||||
|
activityDummyData, |
||||
|
loadActivityExportFile, |
||||
|
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 { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; |
||||
|
|
||||
|
import { Big } from 'big.js'; |
||||
|
import { join } from 'path'; |
||||
|
|
||||
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { |
||||
|
return { |
||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
|
CurrentRateService: jest.fn().mockImplementation(() => { |
||||
|
return CurrentRateServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
jest.mock( |
||||
|
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', |
||||
|
() => { |
||||
|
return { |
||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
|
PortfolioSnapshotService: jest.fn().mockImplementation(() => { |
||||
|
return PortfolioSnapshotServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { |
||||
|
return { |
||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
|
RedisCacheService: jest.fn().mockImplementation(() => { |
||||
|
return RedisCacheServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
describe('PortfolioCalculator', () => { |
||||
|
let activityDtos: CreateOrderDto[]; |
||||
|
|
||||
|
let configurationService: ConfigurationService; |
||||
|
let currentRateService: CurrentRateService; |
||||
|
let exchangeRateDataService: ExchangeRateDataService; |
||||
|
let portfolioCalculatorFactory: PortfolioCalculatorFactory; |
||||
|
let portfolioSnapshotService: PortfolioSnapshotService; |
||||
|
let redisCacheService: RedisCacheService; |
||||
|
|
||||
|
beforeAll(() => { |
||||
|
activityDtos = loadActivityExportFile( |
||||
|
join(__dirname, '../../../../../../../test/import/ok/btceur.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 BTCUSD buy (in EUR)', async () => { |
||||
|
jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime()); |
||||
|
|
||||
|
const activities: Activity[] = activityDtos.map((activity) => ({ |
||||
|
...activityDummyData, |
||||
|
...activity, |
||||
|
date: parseDate(activity.date), |
||||
|
feeInAssetProfileCurrency: 4.46, |
||||
|
SymbolProfile: { |
||||
|
...symbolProfileDummyData, |
||||
|
currency: 'USD', |
||||
|
dataSource: activity.dataSource, |
||||
|
name: 'Bitcoin', |
||||
|
symbol: activity.symbol |
||||
|
}, |
||||
|
unitPriceInAssetProfileCurrency: 44558.42 |
||||
|
})); |
||||
|
|
||||
|
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ |
||||
|
activities, |
||||
|
calculationType: PerformanceCalculationType.ROAI, |
||||
|
currency: 'USD', |
||||
|
userId: userDummyData.id |
||||
|
}); |
||||
|
|
||||
|
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); |
||||
|
|
||||
|
const investments = portfolioCalculator.getInvestments(); |
||||
|
|
||||
|
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ |
||||
|
data: portfolioSnapshot.historicalData, |
||||
|
groupBy: 'month' |
||||
|
}); |
||||
|
|
||||
|
expect(portfolioSnapshot.historicalData[0]).toEqual({ |
||||
|
date: '2021-12-11', |
||||
|
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 2021-12-12: 50098.3 |
||||
|
*/ |
||||
|
expect(portfolioSnapshot.historicalData[1]).toEqual({ |
||||
|
date: '2021-12-12', |
||||
|
investmentValueWithCurrencyEffect: 44558.42, |
||||
|
netPerformance: 5535.42, // 1 * (50098.3 - 44558.42) - 4.46 = 5535.42
|
||||
|
netPerformanceInPercentage: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412
|
||||
|
netPerformanceInPercentageWithCurrencyEffect: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412
|
||||
|
netPerformanceWithCurrencyEffect: 5535.42, |
||||
|
netWorth: 50098.3, // 1 * 50098.3 = 50098.3
|
||||
|
totalAccountBalance: 0, |
||||
|
totalInvestment: 44558.42, |
||||
|
totalInvestmentValueWithCurrencyEffect: 44558.42, |
||||
|
value: 50098.3, // 1 * 50098.3 = 50098.3
|
||||
|
valueWithCurrencyEffect: 50098.3 |
||||
|
}); |
||||
|
|
||||
|
expect( |
||||
|
portfolioSnapshot.historicalData[ |
||||
|
portfolioSnapshot.historicalData.length - 1 |
||||
|
] |
||||
|
).toEqual({ |
||||
|
date: '2022-01-14', |
||||
|
investmentValueWithCurrencyEffect: 0, |
||||
|
netPerformance: -1463.18, |
||||
|
netPerformanceInPercentage: -0.032837340282712, |
||||
|
netPerformanceInPercentageWithCurrencyEffect: -0.032837340282712, |
||||
|
netPerformanceWithCurrencyEffect: -1463.18, |
||||
|
netWorth: 43099.7, |
||||
|
totalAccountBalance: 0, |
||||
|
totalInvestment: 44558.42, |
||||
|
totalInvestmentValueWithCurrencyEffect: 44558.42, |
||||
|
value: 43099.7, |
||||
|
valueWithCurrencyEffect: 43099.7 |
||||
|
}); |
||||
|
|
||||
|
expect(portfolioSnapshot).toMatchObject({ |
||||
|
currentValueInBaseCurrency: new Big('43099.7'), |
||||
|
errors: [], |
||||
|
hasErrors: false, |
||||
|
positions: [ |
||||
|
{ |
||||
|
averagePrice: new Big('44558.42'), |
||||
|
currency: 'USD', |
||||
|
dataSource: 'YAHOO', |
||||
|
dividend: new Big('0'), |
||||
|
dividendInBaseCurrency: new Big('0'), |
||||
|
fee: new Big('4.46'), |
||||
|
feeInBaseCurrency: new Big('4.46'), |
||||
|
firstBuyDate: '2021-12-12', |
||||
|
grossPerformance: new Big('-1458.72'), |
||||
|
grossPerformancePercentage: new Big('-0.03273724696701543726'), |
||||
|
grossPerformancePercentageWithCurrencyEffect: new Big( |
||||
|
'-0.03273724696701543726' |
||||
|
), |
||||
|
grossPerformanceWithCurrencyEffect: new Big('-1458.72'), |
||||
|
investment: new Big('44558.42'), |
||||
|
investmentWithCurrencyEffect: new Big('44558.42'), |
||||
|
netPerformance: new Big('-1463.18'), |
||||
|
netPerformancePercentage: new Big('-0.03283734028271199921'), |
||||
|
netPerformancePercentageWithCurrencyEffectMap: { |
||||
|
max: new Big('-0.03283734028271199921') |
||||
|
}, |
||||
|
netPerformanceWithCurrencyEffectMap: { |
||||
|
max: new Big('-1463.18') |
||||
|
}, |
||||
|
marketPrice: 43099.7, |
||||
|
marketPriceInBaseCurrency: 43099.7, |
||||
|
quantity: new Big('1'), |
||||
|
symbol: 'BTCUSD', |
||||
|
tags: [], |
||||
|
timeWeightedInvestment: new Big('44558.42'), |
||||
|
timeWeightedInvestmentWithCurrencyEffect: new Big('44558.42'), |
||||
|
transactionCount: 1, |
||||
|
valueInBaseCurrency: new Big('43099.7') |
||||
|
} |
||||
|
], |
||||
|
totalFeesWithCurrencyEffect: new Big('4.46'), |
||||
|
totalInterestWithCurrencyEffect: new Big('0'), |
||||
|
totalInvestment: new Big('44558.42'), |
||||
|
totalInvestmentWithCurrencyEffect: new Big('44558.42'), |
||||
|
totalLiabilitiesWithCurrencyEffect: new Big('0') |
||||
|
}); |
||||
|
|
||||
|
expect(investments).toEqual([ |
||||
|
{ date: '2021-12-12', investment: new Big('44558.42') } |
||||
|
]); |
||||
|
|
||||
|
expect(investmentsByMonth).toEqual([ |
||||
|
{ date: '2021-12-01', investment: 44558.42 }, |
||||
|
{ date: '2022-01-01', investment: 0 } |
||||
|
]); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
@ -0,0 +1,240 @@ |
|||||
|
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; |
||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; |
||||
|
import { |
||||
|
activityDummyData, |
||||
|
loadActivityExportFile, |
||||
|
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 { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; |
||||
|
|
||||
|
import { Big } from 'big.js'; |
||||
|
import { join } from 'path'; |
||||
|
|
||||
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { |
||||
|
return { |
||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
|
CurrentRateService: jest.fn().mockImplementation(() => { |
||||
|
return CurrentRateServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
jest.mock( |
||||
|
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', |
||||
|
() => { |
||||
|
return { |
||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
|
PortfolioSnapshotService: jest.fn().mockImplementation(() => { |
||||
|
return PortfolioSnapshotServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { |
||||
|
return { |
||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
|
RedisCacheService: jest.fn().mockImplementation(() => { |
||||
|
return RedisCacheServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
describe('PortfolioCalculator', () => { |
||||
|
let activityDtos: CreateOrderDto[]; |
||||
|
|
||||
|
let configurationService: ConfigurationService; |
||||
|
let currentRateService: CurrentRateService; |
||||
|
let exchangeRateDataService: ExchangeRateDataService; |
||||
|
let portfolioCalculatorFactory: PortfolioCalculatorFactory; |
||||
|
let portfolioSnapshotService: PortfolioSnapshotService; |
||||
|
let redisCacheService: RedisCacheService; |
||||
|
|
||||
|
beforeAll(() => { |
||||
|
activityDtos = loadActivityExportFile( |
||||
|
join(__dirname, '../../../../../../../test/import/ok/btcusd.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 BTCUSD buy (in USD)', async () => { |
||||
|
jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime()); |
||||
|
|
||||
|
const activities: Activity[] = activityDtos.map((activity) => ({ |
||||
|
...activityDummyData, |
||||
|
...activity, |
||||
|
date: parseDate(activity.date), |
||||
|
feeInAssetProfileCurrency: 4.46, |
||||
|
SymbolProfile: { |
||||
|
...symbolProfileDummyData, |
||||
|
currency: 'USD', |
||||
|
dataSource: activity.dataSource, |
||||
|
name: 'Bitcoin', |
||||
|
symbol: activity.symbol |
||||
|
}, |
||||
|
unitPriceInAssetProfileCurrency: 44558.42 |
||||
|
})); |
||||
|
|
||||
|
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ |
||||
|
activities, |
||||
|
calculationType: PerformanceCalculationType.ROAI, |
||||
|
currency: 'USD', |
||||
|
userId: userDummyData.id |
||||
|
}); |
||||
|
|
||||
|
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); |
||||
|
|
||||
|
const investments = portfolioCalculator.getInvestments(); |
||||
|
|
||||
|
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ |
||||
|
data: portfolioSnapshot.historicalData, |
||||
|
groupBy: 'month' |
||||
|
}); |
||||
|
|
||||
|
expect(portfolioSnapshot.historicalData[0]).toEqual({ |
||||
|
date: '2021-12-11', |
||||
|
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 2021-12-12: 50098.3 |
||||
|
*/ |
||||
|
expect(portfolioSnapshot.historicalData[1]).toEqual({ |
||||
|
date: '2021-12-12', |
||||
|
investmentValueWithCurrencyEffect: 44558.42, |
||||
|
netPerformance: 5535.42, // 1 * (50098.3 - 44558.42) - 4.46 = 5535.42
|
||||
|
netPerformanceInPercentage: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412
|
||||
|
netPerformanceInPercentageWithCurrencyEffect: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412
|
||||
|
netPerformanceWithCurrencyEffect: 5535.42, // 1 * (50098.3 - 44558.42) - 4.46 = 5535.42
|
||||
|
netWorth: 50098.3, // 1 * 50098.3 = 50098.3
|
||||
|
totalAccountBalance: 0, |
||||
|
totalInvestment: 44558.42, |
||||
|
totalInvestmentValueWithCurrencyEffect: 44558.42, |
||||
|
value: 50098.3, // 1 * 50098.3 = 50098.3
|
||||
|
valueWithCurrencyEffect: 50098.3 |
||||
|
}); |
||||
|
|
||||
|
expect( |
||||
|
portfolioSnapshot.historicalData[ |
||||
|
portfolioSnapshot.historicalData.length - 1 |
||||
|
] |
||||
|
).toEqual({ |
||||
|
date: '2022-01-14', |
||||
|
investmentValueWithCurrencyEffect: 0, |
||||
|
netPerformance: -1463.18, |
||||
|
netPerformanceInPercentage: -0.032837340282712, |
||||
|
netPerformanceInPercentageWithCurrencyEffect: -0.032837340282712, |
||||
|
netPerformanceWithCurrencyEffect: -1463.18, |
||||
|
netWorth: 43099.7, |
||||
|
totalAccountBalance: 0, |
||||
|
totalInvestment: 44558.42, |
||||
|
totalInvestmentValueWithCurrencyEffect: 44558.42, |
||||
|
value: 43099.7, |
||||
|
valueWithCurrencyEffect: 43099.7 |
||||
|
}); |
||||
|
|
||||
|
expect(portfolioSnapshot).toMatchObject({ |
||||
|
currentValueInBaseCurrency: new Big('43099.7'), |
||||
|
errors: [], |
||||
|
hasErrors: false, |
||||
|
positions: [ |
||||
|
{ |
||||
|
averagePrice: new Big('44558.42'), |
||||
|
currency: 'USD', |
||||
|
dataSource: 'YAHOO', |
||||
|
dividend: new Big('0'), |
||||
|
dividendInBaseCurrency: new Big('0'), |
||||
|
fee: new Big('4.46'), |
||||
|
feeInBaseCurrency: new Big('4.46'), |
||||
|
firstBuyDate: '2021-12-12', |
||||
|
grossPerformance: new Big('-1458.72'), |
||||
|
grossPerformancePercentage: new Big('-0.03273724696701543726'), |
||||
|
grossPerformancePercentageWithCurrencyEffect: new Big( |
||||
|
'-0.03273724696701543726' |
||||
|
), |
||||
|
grossPerformanceWithCurrencyEffect: new Big('-1458.72'), |
||||
|
investment: new Big('44558.42'), |
||||
|
investmentWithCurrencyEffect: new Big('44558.42'), |
||||
|
netPerformance: new Big('-1463.18'), |
||||
|
netPerformancePercentage: new Big('-0.03283734028271199921'), |
||||
|
netPerformancePercentageWithCurrencyEffectMap: { |
||||
|
max: new Big('-0.03283734028271199921') |
||||
|
}, |
||||
|
netPerformanceWithCurrencyEffectMap: { |
||||
|
max: new Big('-1463.18') |
||||
|
}, |
||||
|
marketPrice: 43099.7, |
||||
|
marketPriceInBaseCurrency: 43099.7, |
||||
|
quantity: new Big('1'), |
||||
|
symbol: 'BTCUSD', |
||||
|
tags: [], |
||||
|
timeWeightedInvestment: new Big('44558.42'), |
||||
|
timeWeightedInvestmentWithCurrencyEffect: new Big('44558.42'), |
||||
|
transactionCount: 1, |
||||
|
valueInBaseCurrency: new Big('43099.7') |
||||
|
} |
||||
|
], |
||||
|
totalFeesWithCurrencyEffect: new Big('4.46'), |
||||
|
totalInterestWithCurrencyEffect: new Big('0'), |
||||
|
totalInvestment: new Big('44558.42'), |
||||
|
totalInvestmentWithCurrencyEffect: new Big('44558.42'), |
||||
|
totalLiabilitiesWithCurrencyEffect: new Big('0') |
||||
|
}); |
||||
|
|
||||
|
expect(investments).toEqual([ |
||||
|
{ date: '2021-12-12', investment: new Big('44558.42') } |
||||
|
]); |
||||
|
|
||||
|
expect(investmentsByMonth).toEqual([ |
||||
|
{ date: '2021-12-01', investment: 44558.42 }, |
||||
|
{ date: '2022-01-01', investment: 0 } |
||||
|
]); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
@ -0,0 +1,985 @@ |
|||||
|
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; |
||||
|
import { PortfolioOrderItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order-item.interface'; |
||||
|
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; |
||||
|
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; |
||||
|
import { DATE_FORMAT } from '@ghostfolio/common/helper'; |
||||
|
import { |
||||
|
AssetProfileIdentifier, |
||||
|
SymbolMetrics |
||||
|
} from '@ghostfolio/common/interfaces'; |
||||
|
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models'; |
||||
|
import { DateRange } from '@ghostfolio/common/types'; |
||||
|
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; |
||||
|
|
||||
|
import { Logger } from '@nestjs/common'; |
||||
|
import { Big } from 'big.js'; |
||||
|
import { addMilliseconds, differenceInDays, format, isBefore } from 'date-fns'; |
||||
|
import { cloneDeep, sortBy } from 'lodash'; |
||||
|
|
||||
|
export class RoaiPortfolioCalculator extends PortfolioCalculator { |
||||
|
private chartDates: string[]; |
||||
|
|
||||
|
protected calculateOverallPerformance( |
||||
|
positions: TimelinePosition[] |
||||
|
): PortfolioSnapshot { |
||||
|
let currentValueInBaseCurrency = new Big(0); |
||||
|
let grossPerformance = new Big(0); |
||||
|
let grossPerformanceWithCurrencyEffect = new Big(0); |
||||
|
let hasErrors = false; |
||||
|
let netPerformance = new Big(0); |
||||
|
let totalFeesWithCurrencyEffect = new Big(0); |
||||
|
const totalInterestWithCurrencyEffect = new Big(0); |
||||
|
let totalInvestment = new Big(0); |
||||
|
let totalInvestmentWithCurrencyEffect = new Big(0); |
||||
|
let totalTimeWeightedInvestment = new Big(0); |
||||
|
let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0); |
||||
|
|
||||
|
for (const currentPosition of positions) { |
||||
|
if (currentPosition.feeInBaseCurrency) { |
||||
|
totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus( |
||||
|
currentPosition.feeInBaseCurrency |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
if (currentPosition.valueInBaseCurrency) { |
||||
|
currentValueInBaseCurrency = currentValueInBaseCurrency.plus( |
||||
|
currentPosition.valueInBaseCurrency |
||||
|
); |
||||
|
} else { |
||||
|
hasErrors = true; |
||||
|
} |
||||
|
|
||||
|
if (currentPosition.investment) { |
||||
|
totalInvestment = totalInvestment.plus(currentPosition.investment); |
||||
|
|
||||
|
totalInvestmentWithCurrencyEffect = |
||||
|
totalInvestmentWithCurrencyEffect.plus( |
||||
|
currentPosition.investmentWithCurrencyEffect |
||||
|
); |
||||
|
} else { |
||||
|
hasErrors = true; |
||||
|
} |
||||
|
|
||||
|
if (currentPosition.grossPerformance) { |
||||
|
grossPerformance = grossPerformance.plus( |
||||
|
currentPosition.grossPerformance |
||||
|
); |
||||
|
|
||||
|
grossPerformanceWithCurrencyEffect = |
||||
|
grossPerformanceWithCurrencyEffect.plus( |
||||
|
currentPosition.grossPerformanceWithCurrencyEffect |
||||
|
); |
||||
|
|
||||
|
netPerformance = netPerformance.plus(currentPosition.netPerformance); |
||||
|
} else if (!currentPosition.quantity.eq(0)) { |
||||
|
hasErrors = true; |
||||
|
} |
||||
|
|
||||
|
if (currentPosition.timeWeightedInvestment) { |
||||
|
totalTimeWeightedInvestment = totalTimeWeightedInvestment.plus( |
||||
|
currentPosition.timeWeightedInvestment |
||||
|
); |
||||
|
|
||||
|
totalTimeWeightedInvestmentWithCurrencyEffect = |
||||
|
totalTimeWeightedInvestmentWithCurrencyEffect.plus( |
||||
|
currentPosition.timeWeightedInvestmentWithCurrencyEffect |
||||
|
); |
||||
|
} else if (!currentPosition.quantity.eq(0)) { |
||||
|
Logger.warn( |
||||
|
`Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`, |
||||
|
'PortfolioCalculator' |
||||
|
); |
||||
|
|
||||
|
hasErrors = true; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
currentValueInBaseCurrency, |
||||
|
hasErrors, |
||||
|
positions, |
||||
|
totalFeesWithCurrencyEffect, |
||||
|
totalInterestWithCurrencyEffect, |
||||
|
totalInvestment, |
||||
|
totalInvestmentWithCurrencyEffect, |
||||
|
activitiesCount: this.activities.filter(({ type }) => { |
||||
|
return ['BUY', 'SELL'].includes(type); |
||||
|
}).length, |
||||
|
createdAt: new Date(), |
||||
|
errors: [], |
||||
|
historicalData: [], |
||||
|
totalLiabilitiesWithCurrencyEffect: new Big(0) |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
protected getPerformanceCalculationType() { |
||||
|
return PerformanceCalculationType.ROAI; |
||||
|
} |
||||
|
|
||||
|
protected getSymbolMetrics({ |
||||
|
chartDateMap, |
||||
|
dataSource, |
||||
|
end, |
||||
|
exchangeRates, |
||||
|
marketSymbolMap, |
||||
|
start, |
||||
|
symbol |
||||
|
}: { |
||||
|
chartDateMap?: { [date: string]: boolean }; |
||||
|
end: Date; |
||||
|
exchangeRates: { [dateString: string]: number }; |
||||
|
marketSymbolMap: { |
||||
|
[date: string]: { [symbol: string]: Big }; |
||||
|
}; |
||||
|
start: Date; |
||||
|
} & AssetProfileIdentifier): SymbolMetrics { |
||||
|
const currentExchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)]; |
||||
|
const currentValues: { [date: string]: Big } = {}; |
||||
|
const currentValuesWithCurrencyEffect: { [date: string]: Big } = {}; |
||||
|
let fees = new Big(0); |
||||
|
let feesAtStartDate = new Big(0); |
||||
|
let feesAtStartDateWithCurrencyEffect = new Big(0); |
||||
|
let feesWithCurrencyEffect = new Big(0); |
||||
|
let grossPerformance = new Big(0); |
||||
|
let grossPerformanceWithCurrencyEffect = new Big(0); |
||||
|
let grossPerformanceAtStartDate = new Big(0); |
||||
|
let grossPerformanceAtStartDateWithCurrencyEffect = new Big(0); |
||||
|
let grossPerformanceFromSells = new Big(0); |
||||
|
let grossPerformanceFromSellsWithCurrencyEffect = new Big(0); |
||||
|
let initialValue: Big; |
||||
|
let initialValueWithCurrencyEffect: Big; |
||||
|
let investmentAtStartDate: Big; |
||||
|
let investmentAtStartDateWithCurrencyEffect: Big; |
||||
|
const investmentValuesAccumulated: { [date: string]: Big } = {}; |
||||
|
const investmentValuesAccumulatedWithCurrencyEffect: { |
||||
|
[date: string]: Big; |
||||
|
} = {}; |
||||
|
const investmentValuesWithCurrencyEffect: { [date: string]: Big } = {}; |
||||
|
let lastAveragePrice = new Big(0); |
||||
|
let lastAveragePriceWithCurrencyEffect = new Big(0); |
||||
|
const netPerformanceValues: { [date: string]: Big } = {}; |
||||
|
const netPerformanceValuesWithCurrencyEffect: { [date: string]: Big } = {}; |
||||
|
const timeWeightedInvestmentValues: { [date: string]: Big } = {}; |
||||
|
|
||||
|
const timeWeightedInvestmentValuesWithCurrencyEffect: { |
||||
|
[date: string]: Big; |
||||
|
} = {}; |
||||
|
|
||||
|
const totalAccountBalanceInBaseCurrency = new Big(0); |
||||
|
let totalDividend = new Big(0); |
||||
|
let totalDividendInBaseCurrency = new Big(0); |
||||
|
let totalInterest = new Big(0); |
||||
|
let totalInterestInBaseCurrency = new Big(0); |
||||
|
let totalInvestment = new Big(0); |
||||
|
let totalInvestmentFromBuyTransactions = new Big(0); |
||||
|
let totalInvestmentFromBuyTransactionsWithCurrencyEffect = new Big(0); |
||||
|
let totalInvestmentWithCurrencyEffect = new Big(0); |
||||
|
let totalLiabilities = new Big(0); |
||||
|
let totalLiabilitiesInBaseCurrency = new Big(0); |
||||
|
let totalQuantityFromBuyTransactions = new Big(0); |
||||
|
let totalUnits = new Big(0); |
||||
|
let valueAtStartDate: Big; |
||||
|
let valueAtStartDateWithCurrencyEffect: Big; |
||||
|
|
||||
|
// Clone orders to keep the original values in this.orders
|
||||
|
let orders: PortfolioOrderItem[] = cloneDeep( |
||||
|
this.activities.filter(({ SymbolProfile }) => { |
||||
|
return SymbolProfile.symbol === symbol; |
||||
|
}) |
||||
|
); |
||||
|
|
||||
|
if (orders.length <= 0) { |
||||
|
return { |
||||
|
currentValues: {}, |
||||
|
currentValuesWithCurrencyEffect: {}, |
||||
|
feesWithCurrencyEffect: new Big(0), |
||||
|
grossPerformance: new Big(0), |
||||
|
grossPerformancePercentage: new Big(0), |
||||
|
grossPerformancePercentageWithCurrencyEffect: new Big(0), |
||||
|
grossPerformanceWithCurrencyEffect: new Big(0), |
||||
|
hasErrors: false, |
||||
|
initialValue: new Big(0), |
||||
|
initialValueWithCurrencyEffect: new Big(0), |
||||
|
investmentValuesAccumulated: {}, |
||||
|
investmentValuesAccumulatedWithCurrencyEffect: {}, |
||||
|
investmentValuesWithCurrencyEffect: {}, |
||||
|
netPerformance: new Big(0), |
||||
|
netPerformancePercentage: new Big(0), |
||||
|
netPerformancePercentageWithCurrencyEffectMap: {}, |
||||
|
netPerformanceValues: {}, |
||||
|
netPerformanceValuesWithCurrencyEffect: {}, |
||||
|
netPerformanceWithCurrencyEffectMap: {}, |
||||
|
timeWeightedInvestment: new Big(0), |
||||
|
timeWeightedInvestmentValues: {}, |
||||
|
timeWeightedInvestmentValuesWithCurrencyEffect: {}, |
||||
|
timeWeightedInvestmentWithCurrencyEffect: new Big(0), |
||||
|
totalAccountBalanceInBaseCurrency: new Big(0), |
||||
|
totalDividend: new Big(0), |
||||
|
totalDividendInBaseCurrency: new Big(0), |
||||
|
totalInterest: new Big(0), |
||||
|
totalInterestInBaseCurrency: new Big(0), |
||||
|
totalInvestment: new Big(0), |
||||
|
totalInvestmentWithCurrencyEffect: new Big(0), |
||||
|
totalLiabilities: new Big(0), |
||||
|
totalLiabilitiesInBaseCurrency: new Big(0) |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
const dateOfFirstTransaction = new Date(orders[0].date); |
||||
|
|
||||
|
const endDateString = format(end, DATE_FORMAT); |
||||
|
const startDateString = format(start, DATE_FORMAT); |
||||
|
|
||||
|
const unitPriceAtStartDate = marketSymbolMap[startDateString]?.[symbol]; |
||||
|
let unitPriceAtEndDate = marketSymbolMap[endDateString]?.[symbol]; |
||||
|
|
||||
|
let latestActivity = orders.at(-1); |
||||
|
|
||||
|
if ( |
||||
|
dataSource === 'MANUAL' && |
||||
|
['BUY', 'SELL'].includes(latestActivity?.type) && |
||||
|
latestActivity?.unitPrice && |
||||
|
!unitPriceAtEndDate |
||||
|
) { |
||||
|
// For BUY / SELL activities with a MANUAL data source where no historical market price is available,
|
||||
|
// the calculation should fall back to using the activity’s unit price.
|
||||
|
unitPriceAtEndDate = latestActivity.unitPrice; |
||||
|
} |
||||
|
|
||||
|
if ( |
||||
|
!unitPriceAtEndDate || |
||||
|
(!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start)) |
||||
|
) { |
||||
|
return { |
||||
|
currentValues: {}, |
||||
|
currentValuesWithCurrencyEffect: {}, |
||||
|
feesWithCurrencyEffect: new Big(0), |
||||
|
grossPerformance: new Big(0), |
||||
|
grossPerformancePercentage: new Big(0), |
||||
|
grossPerformancePercentageWithCurrencyEffect: new Big(0), |
||||
|
grossPerformanceWithCurrencyEffect: new Big(0), |
||||
|
hasErrors: true, |
||||
|
initialValue: new Big(0), |
||||
|
initialValueWithCurrencyEffect: new Big(0), |
||||
|
investmentValuesAccumulated: {}, |
||||
|
investmentValuesAccumulatedWithCurrencyEffect: {}, |
||||
|
investmentValuesWithCurrencyEffect: {}, |
||||
|
netPerformance: new Big(0), |
||||
|
netPerformancePercentage: new Big(0), |
||||
|
netPerformancePercentageWithCurrencyEffectMap: {}, |
||||
|
netPerformanceWithCurrencyEffectMap: {}, |
||||
|
netPerformanceValues: {}, |
||||
|
netPerformanceValuesWithCurrencyEffect: {}, |
||||
|
timeWeightedInvestment: new Big(0), |
||||
|
timeWeightedInvestmentValues: {}, |
||||
|
timeWeightedInvestmentValuesWithCurrencyEffect: {}, |
||||
|
timeWeightedInvestmentWithCurrencyEffect: new Big(0), |
||||
|
totalAccountBalanceInBaseCurrency: new Big(0), |
||||
|
totalDividend: new Big(0), |
||||
|
totalDividendInBaseCurrency: new Big(0), |
||||
|
totalInterest: new Big(0), |
||||
|
totalInterestInBaseCurrency: new Big(0), |
||||
|
totalInvestment: new Big(0), |
||||
|
totalInvestmentWithCurrencyEffect: new Big(0), |
||||
|
totalLiabilities: new Big(0), |
||||
|
totalLiabilitiesInBaseCurrency: new Big(0) |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
// Add a synthetic order at the start and the end date
|
||||
|
orders.push({ |
||||
|
date: startDateString, |
||||
|
fee: new Big(0), |
||||
|
feeInBaseCurrency: new Big(0), |
||||
|
itemType: 'start', |
||||
|
quantity: new Big(0), |
||||
|
SymbolProfile: { |
||||
|
dataSource, |
||||
|
symbol |
||||
|
}, |
||||
|
type: 'BUY', |
||||
|
unitPrice: unitPriceAtStartDate |
||||
|
}); |
||||
|
|
||||
|
orders.push({ |
||||
|
date: endDateString, |
||||
|
fee: new Big(0), |
||||
|
feeInBaseCurrency: new Big(0), |
||||
|
itemType: 'end', |
||||
|
SymbolProfile: { |
||||
|
dataSource, |
||||
|
symbol |
||||
|
}, |
||||
|
quantity: new Big(0), |
||||
|
type: 'BUY', |
||||
|
unitPrice: unitPriceAtEndDate |
||||
|
}); |
||||
|
|
||||
|
let lastUnitPrice: Big; |
||||
|
|
||||
|
const ordersByDate: { [date: string]: PortfolioOrderItem[] } = {}; |
||||
|
|
||||
|
for (const order of orders) { |
||||
|
ordersByDate[order.date] = ordersByDate[order.date] ?? []; |
||||
|
ordersByDate[order.date].push(order); |
||||
|
} |
||||
|
|
||||
|
if (!this.chartDates) { |
||||
|
this.chartDates = Object.keys(chartDateMap).sort(); |
||||
|
} |
||||
|
|
||||
|
for (const dateString of this.chartDates) { |
||||
|
if (dateString < startDateString) { |
||||
|
continue; |
||||
|
} else if (dateString > endDateString) { |
||||
|
break; |
||||
|
} |
||||
|
|
||||
|
if (ordersByDate[dateString]?.length > 0) { |
||||
|
for (const order of ordersByDate[dateString]) { |
||||
|
order.unitPriceFromMarketData = |
||||
|
marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice; |
||||
|
} |
||||
|
} else { |
||||
|
orders.push({ |
||||
|
date: dateString, |
||||
|
fee: new Big(0), |
||||
|
feeInBaseCurrency: new Big(0), |
||||
|
quantity: new Big(0), |
||||
|
SymbolProfile: { |
||||
|
dataSource, |
||||
|
symbol |
||||
|
}, |
||||
|
type: 'BUY', |
||||
|
unitPrice: marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice, |
||||
|
unitPriceFromMarketData: |
||||
|
marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
latestActivity = orders.at(-1); |
||||
|
|
||||
|
lastUnitPrice = |
||||
|
latestActivity.unitPriceFromMarketData ?? latestActivity.unitPrice; |
||||
|
} |
||||
|
|
||||
|
// Sort orders so that the start and end placeholder order are at the correct
|
||||
|
// position
|
||||
|
orders = sortBy(orders, ({ date, itemType }) => { |
||||
|
let sortIndex = new Date(date); |
||||
|
|
||||
|
if (itemType === 'end') { |
||||
|
sortIndex = addMilliseconds(sortIndex, 1); |
||||
|
} else if (itemType === 'start') { |
||||
|
sortIndex = addMilliseconds(sortIndex, -1); |
||||
|
} |
||||
|
|
||||
|
return sortIndex.getTime(); |
||||
|
}); |
||||
|
|
||||
|
const indexOfStartOrder = orders.findIndex(({ itemType }) => { |
||||
|
return itemType === 'start'; |
||||
|
}); |
||||
|
|
||||
|
const indexOfEndOrder = orders.findIndex(({ itemType }) => { |
||||
|
return itemType === 'end'; |
||||
|
}); |
||||
|
|
||||
|
let totalInvestmentDays = 0; |
||||
|
let sumOfTimeWeightedInvestments = new Big(0); |
||||
|
let sumOfTimeWeightedInvestmentsWithCurrencyEffect = new Big(0); |
||||
|
|
||||
|
for (let i = 0; i < orders.length; i += 1) { |
||||
|
const order = orders[i]; |
||||
|
|
||||
|
if (PortfolioCalculator.ENABLE_LOGGING) { |
||||
|
console.log(); |
||||
|
console.log(); |
||||
|
console.log( |
||||
|
i + 1, |
||||
|
order.date, |
||||
|
order.type, |
||||
|
order.itemType ? `(${order.itemType})` : '' |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const exchangeRateAtOrderDate = exchangeRates[order.date]; |
||||
|
|
||||
|
if (order.type === 'DIVIDEND') { |
||||
|
const dividend = order.quantity.mul(order.unitPrice); |
||||
|
|
||||
|
totalDividend = totalDividend.plus(dividend); |
||||
|
totalDividendInBaseCurrency = totalDividendInBaseCurrency.plus( |
||||
|
dividend.mul(exchangeRateAtOrderDate ?? 1) |
||||
|
); |
||||
|
} else if (order.type === 'INTEREST') { |
||||
|
const interest = order.quantity.mul(order.unitPrice); |
||||
|
|
||||
|
totalInterest = totalInterest.plus(interest); |
||||
|
totalInterestInBaseCurrency = totalInterestInBaseCurrency.plus( |
||||
|
interest.mul(exchangeRateAtOrderDate ?? 1) |
||||
|
); |
||||
|
} else if (order.type === 'LIABILITY') { |
||||
|
const liabilities = order.quantity.mul(order.unitPrice); |
||||
|
|
||||
|
totalLiabilities = totalLiabilities.plus(liabilities); |
||||
|
totalLiabilitiesInBaseCurrency = totalLiabilitiesInBaseCurrency.plus( |
||||
|
liabilities.mul(exchangeRateAtOrderDate ?? 1) |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
if (order.itemType === 'start') { |
||||
|
// Take the unit price of the order as the market price if there are no
|
||||
|
// orders of this symbol before the start date
|
||||
|
order.unitPrice = |
||||
|
indexOfStartOrder === 0 |
||||
|
? orders[i + 1]?.unitPrice |
||||
|
: unitPriceAtStartDate; |
||||
|
} |
||||
|
|
||||
|
if (order.fee) { |
||||
|
order.feeInBaseCurrency = order.fee.mul(currentExchangeRate ?? 1); |
||||
|
order.feeInBaseCurrencyWithCurrencyEffect = order.fee.mul( |
||||
|
exchangeRateAtOrderDate ?? 1 |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const unitPrice = ['BUY', 'SELL'].includes(order.type) |
||||
|
? order.unitPrice |
||||
|
: order.unitPriceFromMarketData; |
||||
|
|
||||
|
if (unitPrice) { |
||||
|
order.unitPriceInBaseCurrency = unitPrice.mul(currentExchangeRate ?? 1); |
||||
|
|
||||
|
order.unitPriceInBaseCurrencyWithCurrencyEffect = unitPrice.mul( |
||||
|
exchangeRateAtOrderDate ?? 1 |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const marketPriceInBaseCurrency = |
||||
|
order.unitPriceFromMarketData?.mul(currentExchangeRate ?? 1) ?? |
||||
|
new Big(0); |
||||
|
const marketPriceInBaseCurrencyWithCurrencyEffect = |
||||
|
order.unitPriceFromMarketData?.mul(exchangeRateAtOrderDate ?? 1) ?? |
||||
|
new Big(0); |
||||
|
|
||||
|
const valueOfInvestmentBeforeTransaction = totalUnits.mul( |
||||
|
marketPriceInBaseCurrency |
||||
|
); |
||||
|
|
||||
|
const valueOfInvestmentBeforeTransactionWithCurrencyEffect = |
||||
|
totalUnits.mul(marketPriceInBaseCurrencyWithCurrencyEffect); |
||||
|
|
||||
|
if (!investmentAtStartDate && i >= indexOfStartOrder) { |
||||
|
investmentAtStartDate = totalInvestment ?? new Big(0); |
||||
|
|
||||
|
investmentAtStartDateWithCurrencyEffect = |
||||
|
totalInvestmentWithCurrencyEffect ?? new Big(0); |
||||
|
|
||||
|
valueAtStartDate = valueOfInvestmentBeforeTransaction; |
||||
|
|
||||
|
valueAtStartDateWithCurrencyEffect = |
||||
|
valueOfInvestmentBeforeTransactionWithCurrencyEffect; |
||||
|
} |
||||
|
|
||||
|
let transactionInvestment = new Big(0); |
||||
|
let transactionInvestmentWithCurrencyEffect = new Big(0); |
||||
|
|
||||
|
if (order.type === 'BUY') { |
||||
|
transactionInvestment = order.quantity |
||||
|
.mul(order.unitPriceInBaseCurrency) |
||||
|
.mul(getFactor(order.type)); |
||||
|
|
||||
|
transactionInvestmentWithCurrencyEffect = order.quantity |
||||
|
.mul(order.unitPriceInBaseCurrencyWithCurrencyEffect) |
||||
|
.mul(getFactor(order.type)); |
||||
|
|
||||
|
totalQuantityFromBuyTransactions = |
||||
|
totalQuantityFromBuyTransactions.plus(order.quantity); |
||||
|
|
||||
|
totalInvestmentFromBuyTransactions = |
||||
|
totalInvestmentFromBuyTransactions.plus(transactionInvestment); |
||||
|
|
||||
|
totalInvestmentFromBuyTransactionsWithCurrencyEffect = |
||||
|
totalInvestmentFromBuyTransactionsWithCurrencyEffect.plus( |
||||
|
transactionInvestmentWithCurrencyEffect |
||||
|
); |
||||
|
} else if (order.type === 'SELL') { |
||||
|
if (totalUnits.gt(0)) { |
||||
|
transactionInvestment = totalInvestment |
||||
|
.div(totalUnits) |
||||
|
.mul(order.quantity) |
||||
|
.mul(getFactor(order.type)); |
||||
|
transactionInvestmentWithCurrencyEffect = |
||||
|
totalInvestmentWithCurrencyEffect |
||||
|
.div(totalUnits) |
||||
|
.mul(order.quantity) |
||||
|
.mul(getFactor(order.type)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (PortfolioCalculator.ENABLE_LOGGING) { |
||||
|
console.log('order.quantity', order.quantity.toNumber()); |
||||
|
console.log('transactionInvestment', transactionInvestment.toNumber()); |
||||
|
|
||||
|
console.log( |
||||
|
'transactionInvestmentWithCurrencyEffect', |
||||
|
transactionInvestmentWithCurrencyEffect.toNumber() |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const totalInvestmentBeforeTransaction = totalInvestment; |
||||
|
|
||||
|
const totalInvestmentBeforeTransactionWithCurrencyEffect = |
||||
|
totalInvestmentWithCurrencyEffect; |
||||
|
|
||||
|
totalInvestment = totalInvestment.plus(transactionInvestment); |
||||
|
|
||||
|
totalInvestmentWithCurrencyEffect = |
||||
|
totalInvestmentWithCurrencyEffect.plus( |
||||
|
transactionInvestmentWithCurrencyEffect |
||||
|
); |
||||
|
|
||||
|
if (i >= indexOfStartOrder && !initialValue) { |
||||
|
if ( |
||||
|
i === indexOfStartOrder && |
||||
|
!valueOfInvestmentBeforeTransaction.eq(0) |
||||
|
) { |
||||
|
initialValue = valueOfInvestmentBeforeTransaction; |
||||
|
|
||||
|
initialValueWithCurrencyEffect = |
||||
|
valueOfInvestmentBeforeTransactionWithCurrencyEffect; |
||||
|
} else if (transactionInvestment.gt(0)) { |
||||
|
initialValue = transactionInvestment; |
||||
|
|
||||
|
initialValueWithCurrencyEffect = |
||||
|
transactionInvestmentWithCurrencyEffect; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
fees = fees.plus(order.feeInBaseCurrency ?? 0); |
||||
|
|
||||
|
feesWithCurrencyEffect = feesWithCurrencyEffect.plus( |
||||
|
order.feeInBaseCurrencyWithCurrencyEffect ?? 0 |
||||
|
); |
||||
|
|
||||
|
totalUnits = totalUnits.plus(order.quantity.mul(getFactor(order.type))); |
||||
|
|
||||
|
const valueOfInvestment = totalUnits.mul(marketPriceInBaseCurrency); |
||||
|
|
||||
|
const valueOfInvestmentWithCurrencyEffect = totalUnits.mul( |
||||
|
marketPriceInBaseCurrencyWithCurrencyEffect |
||||
|
); |
||||
|
|
||||
|
const grossPerformanceFromSell = |
||||
|
order.type === 'SELL' |
||||
|
? order.unitPriceInBaseCurrency |
||||
|
.minus(lastAveragePrice) |
||||
|
.mul(order.quantity) |
||||
|
: new Big(0); |
||||
|
|
||||
|
const grossPerformanceFromSellWithCurrencyEffect = |
||||
|
order.type === 'SELL' |
||||
|
? order.unitPriceInBaseCurrencyWithCurrencyEffect |
||||
|
.minus(lastAveragePriceWithCurrencyEffect) |
||||
|
.mul(order.quantity) |
||||
|
: new Big(0); |
||||
|
|
||||
|
grossPerformanceFromSells = grossPerformanceFromSells.plus( |
||||
|
grossPerformanceFromSell |
||||
|
); |
||||
|
|
||||
|
grossPerformanceFromSellsWithCurrencyEffect = |
||||
|
grossPerformanceFromSellsWithCurrencyEffect.plus( |
||||
|
grossPerformanceFromSellWithCurrencyEffect |
||||
|
); |
||||
|
|
||||
|
lastAveragePrice = totalQuantityFromBuyTransactions.eq(0) |
||||
|
? new Big(0) |
||||
|
: totalInvestmentFromBuyTransactions.div( |
||||
|
totalQuantityFromBuyTransactions |
||||
|
); |
||||
|
|
||||
|
lastAveragePriceWithCurrencyEffect = totalQuantityFromBuyTransactions.eq( |
||||
|
0 |
||||
|
) |
||||
|
? new Big(0) |
||||
|
: totalInvestmentFromBuyTransactionsWithCurrencyEffect.div( |
||||
|
totalQuantityFromBuyTransactions |
||||
|
); |
||||
|
|
||||
|
if (PortfolioCalculator.ENABLE_LOGGING) { |
||||
|
console.log( |
||||
|
'grossPerformanceFromSells', |
||||
|
grossPerformanceFromSells.toNumber() |
||||
|
); |
||||
|
console.log( |
||||
|
'grossPerformanceFromSellWithCurrencyEffect', |
||||
|
grossPerformanceFromSellWithCurrencyEffect.toNumber() |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const newGrossPerformance = valueOfInvestment |
||||
|
.minus(totalInvestment) |
||||
|
.plus(grossPerformanceFromSells); |
||||
|
|
||||
|
const newGrossPerformanceWithCurrencyEffect = |
||||
|
valueOfInvestmentWithCurrencyEffect |
||||
|
.minus(totalInvestmentWithCurrencyEffect) |
||||
|
.plus(grossPerformanceFromSellsWithCurrencyEffect); |
||||
|
|
||||
|
grossPerformance = newGrossPerformance; |
||||
|
|
||||
|
grossPerformanceWithCurrencyEffect = |
||||
|
newGrossPerformanceWithCurrencyEffect; |
||||
|
|
||||
|
if (order.itemType === 'start') { |
||||
|
feesAtStartDate = fees; |
||||
|
feesAtStartDateWithCurrencyEffect = feesWithCurrencyEffect; |
||||
|
grossPerformanceAtStartDate = grossPerformance; |
||||
|
|
||||
|
grossPerformanceAtStartDateWithCurrencyEffect = |
||||
|
grossPerformanceWithCurrencyEffect; |
||||
|
} |
||||
|
|
||||
|
if (i > indexOfStartOrder) { |
||||
|
// Only consider periods with an investment for the calculation of
|
||||
|
// the time weighted investment
|
||||
|
if ( |
||||
|
valueOfInvestmentBeforeTransaction.gt(0) && |
||||
|
['BUY', 'SELL'].includes(order.type) |
||||
|
) { |
||||
|
// Calculate the number of days since the previous order
|
||||
|
const orderDate = new Date(order.date); |
||||
|
const previousOrderDate = new Date(orders[i - 1].date); |
||||
|
|
||||
|
let daysSinceLastOrder = differenceInDays( |
||||
|
orderDate, |
||||
|
previousOrderDate |
||||
|
); |
||||
|
if (daysSinceLastOrder <= 0) { |
||||
|
// The time between two activities on the same day is unknown
|
||||
|
// -> Set it to the smallest floating point number greater than 0
|
||||
|
daysSinceLastOrder = Number.EPSILON; |
||||
|
} |
||||
|
|
||||
|
// Sum up the total investment days since the start date to calculate
|
||||
|
// the time weighted investment
|
||||
|
totalInvestmentDays += daysSinceLastOrder; |
||||
|
|
||||
|
sumOfTimeWeightedInvestments = sumOfTimeWeightedInvestments.add( |
||||
|
valueAtStartDate |
||||
|
.minus(investmentAtStartDate) |
||||
|
.plus(totalInvestmentBeforeTransaction) |
||||
|
.mul(daysSinceLastOrder) |
||||
|
); |
||||
|
|
||||
|
sumOfTimeWeightedInvestmentsWithCurrencyEffect = |
||||
|
sumOfTimeWeightedInvestmentsWithCurrencyEffect.add( |
||||
|
valueAtStartDateWithCurrencyEffect |
||||
|
.minus(investmentAtStartDateWithCurrencyEffect) |
||||
|
.plus(totalInvestmentBeforeTransactionWithCurrencyEffect) |
||||
|
.mul(daysSinceLastOrder) |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
currentValues[order.date] = valueOfInvestment; |
||||
|
|
||||
|
currentValuesWithCurrencyEffect[order.date] = |
||||
|
valueOfInvestmentWithCurrencyEffect; |
||||
|
|
||||
|
netPerformanceValues[order.date] = grossPerformance |
||||
|
.minus(grossPerformanceAtStartDate) |
||||
|
.minus(fees.minus(feesAtStartDate)); |
||||
|
|
||||
|
netPerformanceValuesWithCurrencyEffect[order.date] = |
||||
|
grossPerformanceWithCurrencyEffect |
||||
|
.minus(grossPerformanceAtStartDateWithCurrencyEffect) |
||||
|
.minus( |
||||
|
feesWithCurrencyEffect.minus(feesAtStartDateWithCurrencyEffect) |
||||
|
); |
||||
|
|
||||
|
investmentValuesAccumulated[order.date] = totalInvestment; |
||||
|
|
||||
|
investmentValuesAccumulatedWithCurrencyEffect[order.date] = |
||||
|
totalInvestmentWithCurrencyEffect; |
||||
|
|
||||
|
investmentValuesWithCurrencyEffect[order.date] = ( |
||||
|
investmentValuesWithCurrencyEffect[order.date] ?? new Big(0) |
||||
|
).add(transactionInvestmentWithCurrencyEffect); |
||||
|
|
||||
|
// If duration is effectively zero (first day), use the actual investment as the base.
|
||||
|
// Otherwise, use the calculated time-weighted average.
|
||||
|
timeWeightedInvestmentValues[order.date] = |
||||
|
totalInvestmentDays > Number.EPSILON |
||||
|
? sumOfTimeWeightedInvestments.div(totalInvestmentDays) |
||||
|
: totalInvestment.gt(0) |
||||
|
? totalInvestment |
||||
|
: new Big(0); |
||||
|
|
||||
|
timeWeightedInvestmentValuesWithCurrencyEffect[order.date] = |
||||
|
totalInvestmentDays > Number.EPSILON |
||||
|
? sumOfTimeWeightedInvestmentsWithCurrencyEffect.div( |
||||
|
totalInvestmentDays |
||||
|
) |
||||
|
: totalInvestmentWithCurrencyEffect.gt(0) |
||||
|
? totalInvestmentWithCurrencyEffect |
||||
|
: new Big(0); |
||||
|
} |
||||
|
|
||||
|
if (PortfolioCalculator.ENABLE_LOGGING) { |
||||
|
console.log('totalInvestment', totalInvestment.toNumber()); |
||||
|
|
||||
|
console.log( |
||||
|
'totalInvestmentWithCurrencyEffect', |
||||
|
totalInvestmentWithCurrencyEffect.toNumber() |
||||
|
); |
||||
|
|
||||
|
console.log( |
||||
|
'totalGrossPerformance', |
||||
|
grossPerformance.minus(grossPerformanceAtStartDate).toNumber() |
||||
|
); |
||||
|
|
||||
|
console.log( |
||||
|
'totalGrossPerformanceWithCurrencyEffect', |
||||
|
grossPerformanceWithCurrencyEffect |
||||
|
.minus(grossPerformanceAtStartDateWithCurrencyEffect) |
||||
|
.toNumber() |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
if (i === indexOfEndOrder) { |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const totalGrossPerformance = grossPerformance.minus( |
||||
|
grossPerformanceAtStartDate |
||||
|
); |
||||
|
|
||||
|
const totalGrossPerformanceWithCurrencyEffect = |
||||
|
grossPerformanceWithCurrencyEffect.minus( |
||||
|
grossPerformanceAtStartDateWithCurrencyEffect |
||||
|
); |
||||
|
|
||||
|
const totalNetPerformance = grossPerformance |
||||
|
.minus(grossPerformanceAtStartDate) |
||||
|
.minus(fees.minus(feesAtStartDate)); |
||||
|
|
||||
|
const timeWeightedAverageInvestmentBetweenStartAndEndDate = |
||||
|
totalInvestmentDays > 0 |
||||
|
? sumOfTimeWeightedInvestments.div(totalInvestmentDays) |
||||
|
: new Big(0); |
||||
|
|
||||
|
const timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect = |
||||
|
totalInvestmentDays > 0 |
||||
|
? sumOfTimeWeightedInvestmentsWithCurrencyEffect.div( |
||||
|
totalInvestmentDays |
||||
|
) |
||||
|
: new Big(0); |
||||
|
|
||||
|
const grossPerformancePercentage = |
||||
|
timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0) |
||||
|
? totalGrossPerformance.div( |
||||
|
timeWeightedAverageInvestmentBetweenStartAndEndDate |
||||
|
) |
||||
|
: new Big(0); |
||||
|
|
||||
|
const grossPerformancePercentageWithCurrencyEffect = |
||||
|
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.gt( |
||||
|
0 |
||||
|
) |
||||
|
? totalGrossPerformanceWithCurrencyEffect.div( |
||||
|
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect |
||||
|
) |
||||
|
: new Big(0); |
||||
|
|
||||
|
const feesPerUnit = totalUnits.gt(0) |
||||
|
? fees.minus(feesAtStartDate).div(totalUnits) |
||||
|
: new Big(0); |
||||
|
|
||||
|
const feesPerUnitWithCurrencyEffect = totalUnits.gt(0) |
||||
|
? feesWithCurrencyEffect |
||||
|
.minus(feesAtStartDateWithCurrencyEffect) |
||||
|
.div(totalUnits) |
||||
|
: new Big(0); |
||||
|
|
||||
|
const netPerformancePercentage = |
||||
|
timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0) |
||||
|
? totalNetPerformance.div( |
||||
|
timeWeightedAverageInvestmentBetweenStartAndEndDate |
||||
|
) |
||||
|
: new Big(0); |
||||
|
|
||||
|
const netPerformancePercentageWithCurrencyEffectMap: { |
||||
|
[key: DateRange]: Big; |
||||
|
} = {}; |
||||
|
|
||||
|
const netPerformanceWithCurrencyEffectMap: { |
||||
|
[key: DateRange]: Big; |
||||
|
} = {}; |
||||
|
|
||||
|
for (const dateRange of [ |
||||
|
'1d', |
||||
|
'1y', |
||||
|
'5y', |
||||
|
'max', |
||||
|
'mtd', |
||||
|
'wtd', |
||||
|
'ytd' |
||||
|
// TODO:
|
||||
|
// ...eachYearOfInterval({ end, start })
|
||||
|
// .filter((date) => {
|
||||
|
// return !isThisYear(date);
|
||||
|
// })
|
||||
|
// .map((date) => {
|
||||
|
// return format(date, 'yyyy');
|
||||
|
// })
|
||||
|
] as DateRange[]) { |
||||
|
const dateInterval = getIntervalFromDateRange(dateRange); |
||||
|
const endDate = dateInterval.endDate; |
||||
|
let startDate = dateInterval.startDate; |
||||
|
|
||||
|
if (isBefore(startDate, start)) { |
||||
|
startDate = start; |
||||
|
} |
||||
|
|
||||
|
const rangeEndDateString = format(endDate, DATE_FORMAT); |
||||
|
const rangeStartDateString = format(startDate, DATE_FORMAT); |
||||
|
|
||||
|
const currentValuesAtDateRangeStartWithCurrencyEffect = |
||||
|
currentValuesWithCurrencyEffect[rangeStartDateString] ?? new Big(0); |
||||
|
|
||||
|
const investmentValuesAccumulatedAtStartDateWithCurrencyEffect = |
||||
|
investmentValuesAccumulatedWithCurrencyEffect[rangeStartDateString] ?? |
||||
|
new Big(0); |
||||
|
|
||||
|
const grossPerformanceAtDateRangeStartWithCurrencyEffect = |
||||
|
currentValuesAtDateRangeStartWithCurrencyEffect.minus( |
||||
|
investmentValuesAccumulatedAtStartDateWithCurrencyEffect |
||||
|
); |
||||
|
|
||||
|
let average = new Big(0); |
||||
|
let dayCount = 0; |
||||
|
|
||||
|
for (let i = this.chartDates.length - 1; i >= 0; i -= 1) { |
||||
|
const date = this.chartDates[i]; |
||||
|
|
||||
|
if (date > rangeEndDateString) { |
||||
|
continue; |
||||
|
} else if (date < rangeStartDateString) { |
||||
|
break; |
||||
|
} |
||||
|
|
||||
|
if ( |
||||
|
investmentValuesAccumulatedWithCurrencyEffect[date] instanceof Big && |
||||
|
investmentValuesAccumulatedWithCurrencyEffect[date].gt(0) |
||||
|
) { |
||||
|
average = average.add( |
||||
|
investmentValuesAccumulatedWithCurrencyEffect[date].add( |
||||
|
grossPerformanceAtDateRangeStartWithCurrencyEffect |
||||
|
) |
||||
|
); |
||||
|
|
||||
|
dayCount++; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (dayCount > 0) { |
||||
|
average = average.div(dayCount); |
||||
|
} |
||||
|
|
||||
|
netPerformanceWithCurrencyEffectMap[dateRange] = |
||||
|
netPerformanceValuesWithCurrencyEffect[rangeEndDateString]?.minus( |
||||
|
// If the date range is 'max', take 0 as a start value. Otherwise,
|
||||
|
// the value of the end of the day of the start date is taken which
|
||||
|
// differs from the buying price.
|
||||
|
dateRange === 'max' |
||||
|
? new Big(0) |
||||
|
: (netPerformanceValuesWithCurrencyEffect[rangeStartDateString] ?? |
||||
|
new Big(0)) |
||||
|
) ?? new Big(0); |
||||
|
|
||||
|
netPerformancePercentageWithCurrencyEffectMap[dateRange] = average.gt(0) |
||||
|
? netPerformanceWithCurrencyEffectMap[dateRange].div(average) |
||||
|
: new Big(0); |
||||
|
} |
||||
|
|
||||
|
if (PortfolioCalculator.ENABLE_LOGGING) { |
||||
|
console.log( |
||||
|
` |
||||
|
${symbol} |
||||
|
Unit price: ${orders[indexOfStartOrder].unitPrice.toFixed( |
||||
|
2 |
||||
|
)} -> ${unitPriceAtEndDate.toFixed(2)} |
||||
|
Total investment: ${totalInvestment.toFixed(2)} |
||||
|
Total investment with currency effect: ${totalInvestmentWithCurrencyEffect.toFixed( |
||||
|
2 |
||||
|
)} |
||||
|
Time weighted investment: ${timeWeightedAverageInvestmentBetweenStartAndEndDate.toFixed( |
||||
|
2 |
||||
|
)} |
||||
|
Time weighted investment with currency effect: ${timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.toFixed( |
||||
|
2 |
||||
|
)} |
||||
|
Total dividend: ${totalDividend.toFixed(2)} |
||||
|
Gross performance: ${totalGrossPerformance.toFixed( |
||||
|
2 |
||||
|
)} / ${grossPerformancePercentage.mul(100).toFixed(2)}% |
||||
|
Gross performance with currency effect: ${totalGrossPerformanceWithCurrencyEffect.toFixed( |
||||
|
2 |
||||
|
)} / ${grossPerformancePercentageWithCurrencyEffect |
||||
|
.mul(100) |
||||
|
.toFixed(2)}% |
||||
|
Fees per unit: ${feesPerUnit.toFixed(2)} |
||||
|
Fees per unit with currency effect: ${feesPerUnitWithCurrencyEffect.toFixed( |
||||
|
2 |
||||
|
)} |
||||
|
Net performance: ${totalNetPerformance.toFixed( |
||||
|
2 |
||||
|
)} / ${netPerformancePercentage.mul(100).toFixed(2)}% |
||||
|
Net performance with currency effect: ${netPerformancePercentageWithCurrencyEffectMap[ |
||||
|
'max' |
||||
|
].toFixed(2)}%` |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
currentValues, |
||||
|
currentValuesWithCurrencyEffect, |
||||
|
feesWithCurrencyEffect, |
||||
|
grossPerformancePercentage, |
||||
|
grossPerformancePercentageWithCurrencyEffect, |
||||
|
initialValue, |
||||
|
initialValueWithCurrencyEffect, |
||||
|
investmentValuesAccumulated, |
||||
|
investmentValuesAccumulatedWithCurrencyEffect, |
||||
|
investmentValuesWithCurrencyEffect, |
||||
|
netPerformancePercentage, |
||||
|
netPerformancePercentageWithCurrencyEffectMap, |
||||
|
netPerformanceValues, |
||||
|
netPerformanceValuesWithCurrencyEffect, |
||||
|
netPerformanceWithCurrencyEffectMap, |
||||
|
timeWeightedInvestmentValues, |
||||
|
timeWeightedInvestmentValuesWithCurrencyEffect, |
||||
|
totalAccountBalanceInBaseCurrency, |
||||
|
totalDividend, |
||||
|
totalDividendInBaseCurrency, |
||||
|
totalInterest, |
||||
|
totalInterestInBaseCurrency, |
||||
|
totalInvestment, |
||||
|
totalInvestmentWithCurrencyEffect, |
||||
|
totalLiabilities, |
||||
|
totalLiabilitiesInBaseCurrency, |
||||
|
grossPerformance: totalGrossPerformance, |
||||
|
grossPerformanceWithCurrencyEffect: |
||||
|
totalGrossPerformanceWithCurrencyEffect, |
||||
|
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate), |
||||
|
netPerformance: totalNetPerformance, |
||||
|
timeWeightedInvestment: |
||||
|
timeWeightedAverageInvestmentBetweenStartAndEndDate, |
||||
|
timeWeightedInvestmentWithCurrencyEffect: |
||||
|
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect |
||||
|
}; |
||||
|
} |
||||
|
} |
@ -0,0 +1,29 @@ |
|||||
|
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; |
||||
|
import { |
||||
|
AssetProfileIdentifier, |
||||
|
SymbolMetrics |
||||
|
} from '@ghostfolio/common/interfaces'; |
||||
|
import { PortfolioSnapshot } from '@ghostfolio/common/models'; |
||||
|
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; |
||||
|
|
||||
|
export class RoiPortfolioCalculator extends PortfolioCalculator { |
||||
|
protected calculateOverallPerformance(): PortfolioSnapshot { |
||||
|
throw new Error('Method not implemented.'); |
||||
|
} |
||||
|
|
||||
|
protected getPerformanceCalculationType() { |
||||
|
return PerformanceCalculationType.ROI; |
||||
|
} |
||||
|
|
||||
|
protected getSymbolMetrics({}: { |
||||
|
end: Date; |
||||
|
exchangeRates: { [dateString: string]: number }; |
||||
|
marketSymbolMap: { |
||||
|
[date: string]: { [symbol: string]: Big }; |
||||
|
}; |
||||
|
start: Date; |
||||
|
step?: number; |
||||
|
} & AssetProfileIdentifier): SymbolMetrics { |
||||
|
throw new Error('Method not implemented.'); |
||||
|
} |
||||
|
} |
@ -1,965 +1,29 @@ |
|||||
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; |
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; |
||||
import { PortfolioOrderItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order-item.interface'; |
|
||||
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; |
|
||||
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; |
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper'; |
|
||||
import { |
import { |
||||
AssetProfileIdentifier, |
AssetProfileIdentifier, |
||||
SymbolMetrics |
SymbolMetrics |
||||
} from '@ghostfolio/common/interfaces'; |
} from '@ghostfolio/common/interfaces'; |
||||
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models'; |
import { PortfolioSnapshot } from '@ghostfolio/common/models'; |
||||
import { DateRange } from '@ghostfolio/common/types'; |
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; |
||||
|
|
||||
import { Logger } from '@nestjs/common'; |
export class TwrPortfolioCalculator extends PortfolioCalculator { |
||||
import { Big } from 'big.js'; |
protected calculateOverallPerformance(): PortfolioSnapshot { |
||||
import { addMilliseconds, differenceInDays, format, isBefore } from 'date-fns'; |
throw new Error('Method not implemented.'); |
||||
import { cloneDeep, first, last, sortBy } from 'lodash'; |
|
||||
|
|
||||
export class TWRPortfolioCalculator extends PortfolioCalculator { |
|
||||
private chartDates: string[]; |
|
||||
|
|
||||
protected calculateOverallPerformance( |
|
||||
positions: TimelinePosition[] |
|
||||
): PortfolioSnapshot { |
|
||||
let currentValueInBaseCurrency = new Big(0); |
|
||||
let grossPerformance = new Big(0); |
|
||||
let grossPerformanceWithCurrencyEffect = new Big(0); |
|
||||
let hasErrors = false; |
|
||||
let netPerformance = new Big(0); |
|
||||
let totalFeesWithCurrencyEffect = new Big(0); |
|
||||
const totalInterestWithCurrencyEffect = new Big(0); |
|
||||
let totalInvestment = new Big(0); |
|
||||
let totalInvestmentWithCurrencyEffect = new Big(0); |
|
||||
let totalTimeWeightedInvestment = new Big(0); |
|
||||
let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0); |
|
||||
|
|
||||
for (const currentPosition of positions) { |
|
||||
if (currentPosition.feeInBaseCurrency) { |
|
||||
totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus( |
|
||||
currentPosition.feeInBaseCurrency |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
if (currentPosition.valueInBaseCurrency) { |
|
||||
currentValueInBaseCurrency = currentValueInBaseCurrency.plus( |
|
||||
currentPosition.valueInBaseCurrency |
|
||||
); |
|
||||
} else { |
|
||||
hasErrors = true; |
|
||||
} |
|
||||
|
|
||||
if (currentPosition.investment) { |
|
||||
totalInvestment = totalInvestment.plus(currentPosition.investment); |
|
||||
|
|
||||
totalInvestmentWithCurrencyEffect = |
|
||||
totalInvestmentWithCurrencyEffect.plus( |
|
||||
currentPosition.investmentWithCurrencyEffect |
|
||||
); |
|
||||
} else { |
|
||||
hasErrors = true; |
|
||||
} |
} |
||||
|
|
||||
if (currentPosition.grossPerformance) { |
protected getPerformanceCalculationType() { |
||||
grossPerformance = grossPerformance.plus( |
return PerformanceCalculationType.TWR; |
||||
currentPosition.grossPerformance |
|
||||
); |
|
||||
|
|
||||
grossPerformanceWithCurrencyEffect = |
|
||||
grossPerformanceWithCurrencyEffect.plus( |
|
||||
currentPosition.grossPerformanceWithCurrencyEffect |
|
||||
); |
|
||||
|
|
||||
netPerformance = netPerformance.plus(currentPosition.netPerformance); |
|
||||
} else if (!currentPosition.quantity.eq(0)) { |
|
||||
hasErrors = true; |
|
||||
} |
} |
||||
|
|
||||
if (currentPosition.timeWeightedInvestment) { |
protected getSymbolMetrics({}: { |
||||
totalTimeWeightedInvestment = totalTimeWeightedInvestment.plus( |
|
||||
currentPosition.timeWeightedInvestment |
|
||||
); |
|
||||
|
|
||||
totalTimeWeightedInvestmentWithCurrencyEffect = |
|
||||
totalTimeWeightedInvestmentWithCurrencyEffect.plus( |
|
||||
currentPosition.timeWeightedInvestmentWithCurrencyEffect |
|
||||
); |
|
||||
} else if (!currentPosition.quantity.eq(0)) { |
|
||||
Logger.warn( |
|
||||
`Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`, |
|
||||
'PortfolioCalculator' |
|
||||
); |
|
||||
|
|
||||
hasErrors = true; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
return { |
|
||||
currentValueInBaseCurrency, |
|
||||
hasErrors, |
|
||||
positions, |
|
||||
totalFeesWithCurrencyEffect, |
|
||||
totalInterestWithCurrencyEffect, |
|
||||
totalInvestment, |
|
||||
totalInvestmentWithCurrencyEffect, |
|
||||
errors: [], |
|
||||
historicalData: [], |
|
||||
totalLiabilitiesWithCurrencyEffect: new Big(0), |
|
||||
totalValuablesWithCurrencyEffect: new Big(0) |
|
||||
}; |
|
||||
} |
|
||||
|
|
||||
protected getSymbolMetrics({ |
|
||||
chartDateMap, |
|
||||
dataSource, |
|
||||
end, |
|
||||
exchangeRates, |
|
||||
marketSymbolMap, |
|
||||
start, |
|
||||
symbol |
|
||||
}: { |
|
||||
chartDateMap?: { [date: string]: boolean }; |
|
||||
end: Date; |
end: Date; |
||||
exchangeRates: { [dateString: string]: number }; |
exchangeRates: { [dateString: string]: number }; |
||||
marketSymbolMap: { |
marketSymbolMap: { |
||||
[date: string]: { [symbol: string]: Big }; |
[date: string]: { [symbol: string]: Big }; |
||||
}; |
}; |
||||
start: Date; |
start: Date; |
||||
|
step?: number; |
||||
} & AssetProfileIdentifier): SymbolMetrics { |
} & AssetProfileIdentifier): SymbolMetrics { |
||||
const currentExchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)]; |
throw new Error('Method not implemented.'); |
||||
const currentValues: { [date: string]: Big } = {}; |
|
||||
const currentValuesWithCurrencyEffect: { [date: string]: Big } = {}; |
|
||||
let fees = new Big(0); |
|
||||
let feesAtStartDate = new Big(0); |
|
||||
let feesAtStartDateWithCurrencyEffect = new Big(0); |
|
||||
let feesWithCurrencyEffect = new Big(0); |
|
||||
let grossPerformance = new Big(0); |
|
||||
let grossPerformanceWithCurrencyEffect = new Big(0); |
|
||||
let grossPerformanceAtStartDate = new Big(0); |
|
||||
let grossPerformanceAtStartDateWithCurrencyEffect = new Big(0); |
|
||||
let grossPerformanceFromSells = new Big(0); |
|
||||
let grossPerformanceFromSellsWithCurrencyEffect = new Big(0); |
|
||||
let initialValue: Big; |
|
||||
let initialValueWithCurrencyEffect: Big; |
|
||||
let investmentAtStartDate: Big; |
|
||||
let investmentAtStartDateWithCurrencyEffect: Big; |
|
||||
const investmentValuesAccumulated: { [date: string]: Big } = {}; |
|
||||
const investmentValuesAccumulatedWithCurrencyEffect: { |
|
||||
[date: string]: Big; |
|
||||
} = {}; |
|
||||
const investmentValuesWithCurrencyEffect: { [date: string]: Big } = {}; |
|
||||
let lastAveragePrice = new Big(0); |
|
||||
let lastAveragePriceWithCurrencyEffect = new Big(0); |
|
||||
const netPerformanceValues: { [date: string]: Big } = {}; |
|
||||
const netPerformanceValuesWithCurrencyEffect: { [date: string]: Big } = {}; |
|
||||
const timeWeightedInvestmentValues: { [date: string]: Big } = {}; |
|
||||
|
|
||||
const timeWeightedInvestmentValuesWithCurrencyEffect: { |
|
||||
[date: string]: Big; |
|
||||
} = {}; |
|
||||
|
|
||||
const totalAccountBalanceInBaseCurrency = new Big(0); |
|
||||
let totalDividend = new Big(0); |
|
||||
let totalDividendInBaseCurrency = new Big(0); |
|
||||
let totalInterest = new Big(0); |
|
||||
let totalInterestInBaseCurrency = new Big(0); |
|
||||
let totalInvestment = new Big(0); |
|
||||
let totalInvestmentFromBuyTransactions = new Big(0); |
|
||||
let totalInvestmentFromBuyTransactionsWithCurrencyEffect = new Big(0); |
|
||||
let totalInvestmentWithCurrencyEffect = new Big(0); |
|
||||
let totalLiabilities = new Big(0); |
|
||||
let totalLiabilitiesInBaseCurrency = new Big(0); |
|
||||
let totalQuantityFromBuyTransactions = new Big(0); |
|
||||
let totalUnits = new Big(0); |
|
||||
let totalValuables = new Big(0); |
|
||||
let totalValuablesInBaseCurrency = new Big(0); |
|
||||
let valueAtStartDate: Big; |
|
||||
let valueAtStartDateWithCurrencyEffect: Big; |
|
||||
|
|
||||
// Clone orders to keep the original values in this.orders
|
|
||||
let orders: PortfolioOrderItem[] = cloneDeep( |
|
||||
this.activities.filter(({ SymbolProfile }) => { |
|
||||
return SymbolProfile.symbol === symbol; |
|
||||
}) |
|
||||
); |
|
||||
|
|
||||
if (orders.length <= 0) { |
|
||||
return { |
|
||||
currentValues: {}, |
|
||||
currentValuesWithCurrencyEffect: {}, |
|
||||
feesWithCurrencyEffect: new Big(0), |
|
||||
grossPerformance: new Big(0), |
|
||||
grossPerformancePercentage: new Big(0), |
|
||||
grossPerformancePercentageWithCurrencyEffect: new Big(0), |
|
||||
grossPerformanceWithCurrencyEffect: new Big(0), |
|
||||
hasErrors: false, |
|
||||
initialValue: new Big(0), |
|
||||
initialValueWithCurrencyEffect: new Big(0), |
|
||||
investmentValuesAccumulated: {}, |
|
||||
investmentValuesAccumulatedWithCurrencyEffect: {}, |
|
||||
investmentValuesWithCurrencyEffect: {}, |
|
||||
netPerformance: new Big(0), |
|
||||
netPerformancePercentage: new Big(0), |
|
||||
netPerformancePercentageWithCurrencyEffectMap: {}, |
|
||||
netPerformanceValues: {}, |
|
||||
netPerformanceValuesWithCurrencyEffect: {}, |
|
||||
netPerformanceWithCurrencyEffectMap: {}, |
|
||||
timeWeightedInvestment: new Big(0), |
|
||||
timeWeightedInvestmentValues: {}, |
|
||||
timeWeightedInvestmentValuesWithCurrencyEffect: {}, |
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big(0), |
|
||||
totalAccountBalanceInBaseCurrency: new Big(0), |
|
||||
totalDividend: new Big(0), |
|
||||
totalDividendInBaseCurrency: new Big(0), |
|
||||
totalInterest: new Big(0), |
|
||||
totalInterestInBaseCurrency: new Big(0), |
|
||||
totalInvestment: new Big(0), |
|
||||
totalInvestmentWithCurrencyEffect: new Big(0), |
|
||||
totalLiabilities: new Big(0), |
|
||||
totalLiabilitiesInBaseCurrency: new Big(0), |
|
||||
totalValuables: new Big(0), |
|
||||
totalValuablesInBaseCurrency: new Big(0) |
|
||||
}; |
|
||||
} |
|
||||
|
|
||||
const dateOfFirstTransaction = new Date(first(orders).date); |
|
||||
|
|
||||
const endDateString = format(end, DATE_FORMAT); |
|
||||
const startDateString = format(start, DATE_FORMAT); |
|
||||
|
|
||||
const unitPriceAtStartDate = marketSymbolMap[startDateString]?.[symbol]; |
|
||||
const unitPriceAtEndDate = marketSymbolMap[endDateString]?.[symbol]; |
|
||||
|
|
||||
if ( |
|
||||
!unitPriceAtEndDate || |
|
||||
(!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start)) |
|
||||
) { |
|
||||
return { |
|
||||
currentValues: {}, |
|
||||
currentValuesWithCurrencyEffect: {}, |
|
||||
feesWithCurrencyEffect: new Big(0), |
|
||||
grossPerformance: new Big(0), |
|
||||
grossPerformancePercentage: new Big(0), |
|
||||
grossPerformancePercentageWithCurrencyEffect: new Big(0), |
|
||||
grossPerformanceWithCurrencyEffect: new Big(0), |
|
||||
hasErrors: true, |
|
||||
initialValue: new Big(0), |
|
||||
initialValueWithCurrencyEffect: new Big(0), |
|
||||
investmentValuesAccumulated: {}, |
|
||||
investmentValuesAccumulatedWithCurrencyEffect: {}, |
|
||||
investmentValuesWithCurrencyEffect: {}, |
|
||||
netPerformance: new Big(0), |
|
||||
netPerformancePercentage: new Big(0), |
|
||||
netPerformancePercentageWithCurrencyEffectMap: {}, |
|
||||
netPerformanceWithCurrencyEffectMap: {}, |
|
||||
netPerformanceValues: {}, |
|
||||
netPerformanceValuesWithCurrencyEffect: {}, |
|
||||
timeWeightedInvestment: new Big(0), |
|
||||
timeWeightedInvestmentValues: {}, |
|
||||
timeWeightedInvestmentValuesWithCurrencyEffect: {}, |
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big(0), |
|
||||
totalAccountBalanceInBaseCurrency: new Big(0), |
|
||||
totalDividend: new Big(0), |
|
||||
totalDividendInBaseCurrency: new Big(0), |
|
||||
totalInterest: new Big(0), |
|
||||
totalInterestInBaseCurrency: new Big(0), |
|
||||
totalInvestment: new Big(0), |
|
||||
totalInvestmentWithCurrencyEffect: new Big(0), |
|
||||
totalLiabilities: new Big(0), |
|
||||
totalLiabilitiesInBaseCurrency: new Big(0), |
|
||||
totalValuables: new Big(0), |
|
||||
totalValuablesInBaseCurrency: new Big(0) |
|
||||
}; |
|
||||
} |
|
||||
|
|
||||
// Add a synthetic order at the start and the end date
|
|
||||
orders.push({ |
|
||||
date: startDateString, |
|
||||
fee: new Big(0), |
|
||||
feeInBaseCurrency: new Big(0), |
|
||||
itemType: 'start', |
|
||||
quantity: new Big(0), |
|
||||
SymbolProfile: { |
|
||||
dataSource, |
|
||||
symbol |
|
||||
}, |
|
||||
type: 'BUY', |
|
||||
unitPrice: unitPriceAtStartDate |
|
||||
}); |
|
||||
|
|
||||
orders.push({ |
|
||||
date: endDateString, |
|
||||
fee: new Big(0), |
|
||||
feeInBaseCurrency: new Big(0), |
|
||||
itemType: 'end', |
|
||||
SymbolProfile: { |
|
||||
dataSource, |
|
||||
symbol |
|
||||
}, |
|
||||
quantity: new Big(0), |
|
||||
type: 'BUY', |
|
||||
unitPrice: unitPriceAtEndDate |
|
||||
}); |
|
||||
|
|
||||
let lastUnitPrice: Big; |
|
||||
|
|
||||
const ordersByDate: { [date: string]: PortfolioOrderItem[] } = {}; |
|
||||
|
|
||||
for (const order of orders) { |
|
||||
ordersByDate[order.date] = ordersByDate[order.date] ?? []; |
|
||||
ordersByDate[order.date].push(order); |
|
||||
} |
|
||||
|
|
||||
if (!this.chartDates) { |
|
||||
this.chartDates = Object.keys(chartDateMap).sort(); |
|
||||
} |
|
||||
|
|
||||
for (const dateString of this.chartDates) { |
|
||||
if (dateString < startDateString) { |
|
||||
continue; |
|
||||
} else if (dateString > endDateString) { |
|
||||
break; |
|
||||
} |
|
||||
|
|
||||
if (ordersByDate[dateString]?.length > 0) { |
|
||||
for (const order of ordersByDate[dateString]) { |
|
||||
order.unitPriceFromMarketData = |
|
||||
marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice; |
|
||||
} |
|
||||
} else { |
|
||||
orders.push({ |
|
||||
date: dateString, |
|
||||
fee: new Big(0), |
|
||||
feeInBaseCurrency: new Big(0), |
|
||||
quantity: new Big(0), |
|
||||
SymbolProfile: { |
|
||||
dataSource, |
|
||||
symbol |
|
||||
}, |
|
||||
type: 'BUY', |
|
||||
unitPrice: marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice, |
|
||||
unitPriceFromMarketData: |
|
||||
marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
const lastOrder = last(orders); |
|
||||
|
|
||||
lastUnitPrice = lastOrder.unitPriceFromMarketData ?? lastOrder.unitPrice; |
|
||||
} |
|
||||
|
|
||||
// Sort orders so that the start and end placeholder order are at the correct
|
|
||||
// position
|
|
||||
orders = sortBy(orders, ({ date, itemType }) => { |
|
||||
let sortIndex = new Date(date); |
|
||||
|
|
||||
if (itemType === 'end') { |
|
||||
sortIndex = addMilliseconds(sortIndex, 1); |
|
||||
} else if (itemType === 'start') { |
|
||||
sortIndex = addMilliseconds(sortIndex, -1); |
|
||||
} |
|
||||
|
|
||||
return sortIndex.getTime(); |
|
||||
}); |
|
||||
|
|
||||
const indexOfStartOrder = orders.findIndex(({ itemType }) => { |
|
||||
return itemType === 'start'; |
|
||||
}); |
|
||||
|
|
||||
const indexOfEndOrder = orders.findIndex(({ itemType }) => { |
|
||||
return itemType === 'end'; |
|
||||
}); |
|
||||
|
|
||||
let totalInvestmentDays = 0; |
|
||||
let sumOfTimeWeightedInvestments = new Big(0); |
|
||||
let sumOfTimeWeightedInvestmentsWithCurrencyEffect = new Big(0); |
|
||||
|
|
||||
for (let i = 0; i < orders.length; i += 1) { |
|
||||
const order = orders[i]; |
|
||||
|
|
||||
if (PortfolioCalculator.ENABLE_LOGGING) { |
|
||||
console.log(); |
|
||||
console.log(); |
|
||||
console.log( |
|
||||
i + 1, |
|
||||
order.date, |
|
||||
order.type, |
|
||||
order.itemType ? `(${order.itemType})` : '' |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
const exchangeRateAtOrderDate = exchangeRates[order.date]; |
|
||||
|
|
||||
if (order.type === 'DIVIDEND') { |
|
||||
const dividend = order.quantity.mul(order.unitPrice); |
|
||||
|
|
||||
totalDividend = totalDividend.plus(dividend); |
|
||||
totalDividendInBaseCurrency = totalDividendInBaseCurrency.plus( |
|
||||
dividend.mul(exchangeRateAtOrderDate ?? 1) |
|
||||
); |
|
||||
} else if (order.type === 'INTEREST') { |
|
||||
const interest = order.quantity.mul(order.unitPrice); |
|
||||
|
|
||||
totalInterest = totalInterest.plus(interest); |
|
||||
totalInterestInBaseCurrency = totalInterestInBaseCurrency.plus( |
|
||||
interest.mul(exchangeRateAtOrderDate ?? 1) |
|
||||
); |
|
||||
} else if (order.type === 'ITEM') { |
|
||||
const valuables = order.quantity.mul(order.unitPrice); |
|
||||
|
|
||||
totalValuables = totalValuables.plus(valuables); |
|
||||
totalValuablesInBaseCurrency = totalValuablesInBaseCurrency.plus( |
|
||||
valuables.mul(exchangeRateAtOrderDate ?? 1) |
|
||||
); |
|
||||
} else if (order.type === 'LIABILITY') { |
|
||||
const liabilities = order.quantity.mul(order.unitPrice); |
|
||||
|
|
||||
totalLiabilities = totalLiabilities.plus(liabilities); |
|
||||
totalLiabilitiesInBaseCurrency = totalLiabilitiesInBaseCurrency.plus( |
|
||||
liabilities.mul(exchangeRateAtOrderDate ?? 1) |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
if (order.itemType === 'start') { |
|
||||
// Take the unit price of the order as the market price if there are no
|
|
||||
// orders of this symbol before the start date
|
|
||||
order.unitPrice = |
|
||||
indexOfStartOrder === 0 |
|
||||
? orders[i + 1]?.unitPrice |
|
||||
: unitPriceAtStartDate; |
|
||||
} |
|
||||
|
|
||||
if (order.fee) { |
|
||||
order.feeInBaseCurrency = order.fee.mul(currentExchangeRate ?? 1); |
|
||||
order.feeInBaseCurrencyWithCurrencyEffect = order.fee.mul( |
|
||||
exchangeRateAtOrderDate ?? 1 |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
const unitPrice = ['BUY', 'SELL'].includes(order.type) |
|
||||
? order.unitPrice |
|
||||
: order.unitPriceFromMarketData; |
|
||||
|
|
||||
if (unitPrice) { |
|
||||
order.unitPriceInBaseCurrency = unitPrice.mul(currentExchangeRate ?? 1); |
|
||||
|
|
||||
order.unitPriceInBaseCurrencyWithCurrencyEffect = unitPrice.mul( |
|
||||
exchangeRateAtOrderDate ?? 1 |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
const valueOfInvestmentBeforeTransaction = totalUnits.mul( |
|
||||
order.unitPriceInBaseCurrency |
|
||||
); |
|
||||
|
|
||||
const valueOfInvestmentBeforeTransactionWithCurrencyEffect = |
|
||||
totalUnits.mul(order.unitPriceInBaseCurrencyWithCurrencyEffect); |
|
||||
|
|
||||
if (!investmentAtStartDate && i >= indexOfStartOrder) { |
|
||||
investmentAtStartDate = totalInvestment ?? new Big(0); |
|
||||
|
|
||||
investmentAtStartDateWithCurrencyEffect = |
|
||||
totalInvestmentWithCurrencyEffect ?? new Big(0); |
|
||||
|
|
||||
valueAtStartDate = valueOfInvestmentBeforeTransaction; |
|
||||
|
|
||||
valueAtStartDateWithCurrencyEffect = |
|
||||
valueOfInvestmentBeforeTransactionWithCurrencyEffect; |
|
||||
} |
|
||||
|
|
||||
let transactionInvestment = new Big(0); |
|
||||
let transactionInvestmentWithCurrencyEffect = new Big(0); |
|
||||
|
|
||||
if (order.type === 'BUY') { |
|
||||
transactionInvestment = order.quantity |
|
||||
.mul(order.unitPriceInBaseCurrency) |
|
||||
.mul(getFactor(order.type)); |
|
||||
|
|
||||
transactionInvestmentWithCurrencyEffect = order.quantity |
|
||||
.mul(order.unitPriceInBaseCurrencyWithCurrencyEffect) |
|
||||
.mul(getFactor(order.type)); |
|
||||
|
|
||||
totalQuantityFromBuyTransactions = |
|
||||
totalQuantityFromBuyTransactions.plus(order.quantity); |
|
||||
|
|
||||
totalInvestmentFromBuyTransactions = |
|
||||
totalInvestmentFromBuyTransactions.plus(transactionInvestment); |
|
||||
|
|
||||
totalInvestmentFromBuyTransactionsWithCurrencyEffect = |
|
||||
totalInvestmentFromBuyTransactionsWithCurrencyEffect.plus( |
|
||||
transactionInvestmentWithCurrencyEffect |
|
||||
); |
|
||||
} else if (order.type === 'SELL') { |
|
||||
if (totalUnits.gt(0)) { |
|
||||
transactionInvestment = totalInvestment |
|
||||
.div(totalUnits) |
|
||||
.mul(order.quantity) |
|
||||
.mul(getFactor(order.type)); |
|
||||
transactionInvestmentWithCurrencyEffect = |
|
||||
totalInvestmentWithCurrencyEffect |
|
||||
.div(totalUnits) |
|
||||
.mul(order.quantity) |
|
||||
.mul(getFactor(order.type)); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
if (PortfolioCalculator.ENABLE_LOGGING) { |
|
||||
console.log('order.quantity', order.quantity.toNumber()); |
|
||||
console.log('transactionInvestment', transactionInvestment.toNumber()); |
|
||||
|
|
||||
console.log( |
|
||||
'transactionInvestmentWithCurrencyEffect', |
|
||||
transactionInvestmentWithCurrencyEffect.toNumber() |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
const totalInvestmentBeforeTransaction = totalInvestment; |
|
||||
|
|
||||
const totalInvestmentBeforeTransactionWithCurrencyEffect = |
|
||||
totalInvestmentWithCurrencyEffect; |
|
||||
|
|
||||
totalInvestment = totalInvestment.plus(transactionInvestment); |
|
||||
|
|
||||
totalInvestmentWithCurrencyEffect = |
|
||||
totalInvestmentWithCurrencyEffect.plus( |
|
||||
transactionInvestmentWithCurrencyEffect |
|
||||
); |
|
||||
|
|
||||
if (i >= indexOfStartOrder && !initialValue) { |
|
||||
if ( |
|
||||
i === indexOfStartOrder && |
|
||||
!valueOfInvestmentBeforeTransaction.eq(0) |
|
||||
) { |
|
||||
initialValue = valueOfInvestmentBeforeTransaction; |
|
||||
|
|
||||
initialValueWithCurrencyEffect = |
|
||||
valueOfInvestmentBeforeTransactionWithCurrencyEffect; |
|
||||
} else if (transactionInvestment.gt(0)) { |
|
||||
initialValue = transactionInvestment; |
|
||||
|
|
||||
initialValueWithCurrencyEffect = |
|
||||
transactionInvestmentWithCurrencyEffect; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
fees = fees.plus(order.feeInBaseCurrency ?? 0); |
|
||||
|
|
||||
feesWithCurrencyEffect = feesWithCurrencyEffect.plus( |
|
||||
order.feeInBaseCurrencyWithCurrencyEffect ?? 0 |
|
||||
); |
|
||||
|
|
||||
totalUnits = totalUnits.plus(order.quantity.mul(getFactor(order.type))); |
|
||||
|
|
||||
const valueOfInvestment = totalUnits.mul(order.unitPriceInBaseCurrency); |
|
||||
|
|
||||
const valueOfInvestmentWithCurrencyEffect = totalUnits.mul( |
|
||||
order.unitPriceInBaseCurrencyWithCurrencyEffect |
|
||||
); |
|
||||
|
|
||||
const grossPerformanceFromSell = |
|
||||
order.type === 'SELL' |
|
||||
? order.unitPriceInBaseCurrency |
|
||||
.minus(lastAveragePrice) |
|
||||
.mul(order.quantity) |
|
||||
: new Big(0); |
|
||||
|
|
||||
const grossPerformanceFromSellWithCurrencyEffect = |
|
||||
order.type === 'SELL' |
|
||||
? order.unitPriceInBaseCurrencyWithCurrencyEffect |
|
||||
.minus(lastAveragePriceWithCurrencyEffect) |
|
||||
.mul(order.quantity) |
|
||||
: new Big(0); |
|
||||
|
|
||||
grossPerformanceFromSells = grossPerformanceFromSells.plus( |
|
||||
grossPerformanceFromSell |
|
||||
); |
|
||||
|
|
||||
grossPerformanceFromSellsWithCurrencyEffect = |
|
||||
grossPerformanceFromSellsWithCurrencyEffect.plus( |
|
||||
grossPerformanceFromSellWithCurrencyEffect |
|
||||
); |
|
||||
|
|
||||
lastAveragePrice = totalQuantityFromBuyTransactions.eq(0) |
|
||||
? new Big(0) |
|
||||
: totalInvestmentFromBuyTransactions.div( |
|
||||
totalQuantityFromBuyTransactions |
|
||||
); |
|
||||
|
|
||||
lastAveragePriceWithCurrencyEffect = totalQuantityFromBuyTransactions.eq( |
|
||||
0 |
|
||||
) |
|
||||
? new Big(0) |
|
||||
: totalInvestmentFromBuyTransactionsWithCurrencyEffect.div( |
|
||||
totalQuantityFromBuyTransactions |
|
||||
); |
|
||||
|
|
||||
if (PortfolioCalculator.ENABLE_LOGGING) { |
|
||||
console.log( |
|
||||
'grossPerformanceFromSells', |
|
||||
grossPerformanceFromSells.toNumber() |
|
||||
); |
|
||||
console.log( |
|
||||
'grossPerformanceFromSellWithCurrencyEffect', |
|
||||
grossPerformanceFromSellWithCurrencyEffect.toNumber() |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
const newGrossPerformance = valueOfInvestment |
|
||||
.minus(totalInvestment) |
|
||||
.plus(grossPerformanceFromSells); |
|
||||
|
|
||||
const newGrossPerformanceWithCurrencyEffect = |
|
||||
valueOfInvestmentWithCurrencyEffect |
|
||||
.minus(totalInvestmentWithCurrencyEffect) |
|
||||
.plus(grossPerformanceFromSellsWithCurrencyEffect); |
|
||||
|
|
||||
grossPerformance = newGrossPerformance; |
|
||||
|
|
||||
grossPerformanceWithCurrencyEffect = |
|
||||
newGrossPerformanceWithCurrencyEffect; |
|
||||
|
|
||||
if (order.itemType === 'start') { |
|
||||
feesAtStartDate = fees; |
|
||||
feesAtStartDateWithCurrencyEffect = feesWithCurrencyEffect; |
|
||||
grossPerformanceAtStartDate = grossPerformance; |
|
||||
|
|
||||
grossPerformanceAtStartDateWithCurrencyEffect = |
|
||||
grossPerformanceWithCurrencyEffect; |
|
||||
} |
|
||||
|
|
||||
if (i > indexOfStartOrder) { |
|
||||
// Only consider periods with an investment for the calculation of
|
|
||||
// the time weighted investment
|
|
||||
if ( |
|
||||
valueOfInvestmentBeforeTransaction.gt(0) && |
|
||||
['BUY', 'SELL'].includes(order.type) |
|
||||
) { |
|
||||
// Calculate the number of days since the previous order
|
|
||||
const orderDate = new Date(order.date); |
|
||||
const previousOrderDate = new Date(orders[i - 1].date); |
|
||||
|
|
||||
let daysSinceLastOrder = differenceInDays( |
|
||||
orderDate, |
|
||||
previousOrderDate |
|
||||
); |
|
||||
if (daysSinceLastOrder <= 0) { |
|
||||
// The time between two activities on the same day is unknown
|
|
||||
// -> Set it to the smallest floating point number greater than 0
|
|
||||
daysSinceLastOrder = Number.EPSILON; |
|
||||
} |
|
||||
|
|
||||
// Sum up the total investment days since the start date to calculate
|
|
||||
// the time weighted investment
|
|
||||
totalInvestmentDays += daysSinceLastOrder; |
|
||||
|
|
||||
sumOfTimeWeightedInvestments = sumOfTimeWeightedInvestments.add( |
|
||||
valueAtStartDate |
|
||||
.minus(investmentAtStartDate) |
|
||||
.plus(totalInvestmentBeforeTransaction) |
|
||||
.mul(daysSinceLastOrder) |
|
||||
); |
|
||||
|
|
||||
sumOfTimeWeightedInvestmentsWithCurrencyEffect = |
|
||||
sumOfTimeWeightedInvestmentsWithCurrencyEffect.add( |
|
||||
valueAtStartDateWithCurrencyEffect |
|
||||
.minus(investmentAtStartDateWithCurrencyEffect) |
|
||||
.plus(totalInvestmentBeforeTransactionWithCurrencyEffect) |
|
||||
.mul(daysSinceLastOrder) |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
currentValues[order.date] = valueOfInvestment; |
|
||||
|
|
||||
currentValuesWithCurrencyEffect[order.date] = |
|
||||
valueOfInvestmentWithCurrencyEffect; |
|
||||
|
|
||||
netPerformanceValues[order.date] = grossPerformance |
|
||||
.minus(grossPerformanceAtStartDate) |
|
||||
.minus(fees.minus(feesAtStartDate)); |
|
||||
|
|
||||
netPerformanceValuesWithCurrencyEffect[order.date] = |
|
||||
grossPerformanceWithCurrencyEffect |
|
||||
.minus(grossPerformanceAtStartDateWithCurrencyEffect) |
|
||||
.minus( |
|
||||
feesWithCurrencyEffect.minus(feesAtStartDateWithCurrencyEffect) |
|
||||
); |
|
||||
|
|
||||
investmentValuesAccumulated[order.date] = totalInvestment; |
|
||||
|
|
||||
investmentValuesAccumulatedWithCurrencyEffect[order.date] = |
|
||||
totalInvestmentWithCurrencyEffect; |
|
||||
|
|
||||
investmentValuesWithCurrencyEffect[order.date] = ( |
|
||||
investmentValuesWithCurrencyEffect[order.date] ?? new Big(0) |
|
||||
).add(transactionInvestmentWithCurrencyEffect); |
|
||||
|
|
||||
timeWeightedInvestmentValues[order.date] = |
|
||||
totalInvestmentDays > 0 |
|
||||
? sumOfTimeWeightedInvestments.div(totalInvestmentDays) |
|
||||
: new Big(0); |
|
||||
|
|
||||
timeWeightedInvestmentValuesWithCurrencyEffect[order.date] = |
|
||||
totalInvestmentDays > 0 |
|
||||
? sumOfTimeWeightedInvestmentsWithCurrencyEffect.div( |
|
||||
totalInvestmentDays |
|
||||
) |
|
||||
: new Big(0); |
|
||||
} |
|
||||
|
|
||||
if (PortfolioCalculator.ENABLE_LOGGING) { |
|
||||
console.log('totalInvestment', totalInvestment.toNumber()); |
|
||||
|
|
||||
console.log( |
|
||||
'totalInvestmentWithCurrencyEffect', |
|
||||
totalInvestmentWithCurrencyEffect.toNumber() |
|
||||
); |
|
||||
|
|
||||
console.log( |
|
||||
'totalGrossPerformance', |
|
||||
grossPerformance.minus(grossPerformanceAtStartDate).toNumber() |
|
||||
); |
|
||||
|
|
||||
console.log( |
|
||||
'totalGrossPerformanceWithCurrencyEffect', |
|
||||
grossPerformanceWithCurrencyEffect |
|
||||
.minus(grossPerformanceAtStartDateWithCurrencyEffect) |
|
||||
.toNumber() |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
if (i === indexOfEndOrder) { |
|
||||
break; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
const totalGrossPerformance = grossPerformance.minus( |
|
||||
grossPerformanceAtStartDate |
|
||||
); |
|
||||
|
|
||||
const totalGrossPerformanceWithCurrencyEffect = |
|
||||
grossPerformanceWithCurrencyEffect.minus( |
|
||||
grossPerformanceAtStartDateWithCurrencyEffect |
|
||||
); |
|
||||
|
|
||||
const totalNetPerformance = grossPerformance |
|
||||
.minus(grossPerformanceAtStartDate) |
|
||||
.minus(fees.minus(feesAtStartDate)); |
|
||||
|
|
||||
const timeWeightedAverageInvestmentBetweenStartAndEndDate = |
|
||||
totalInvestmentDays > 0 |
|
||||
? sumOfTimeWeightedInvestments.div(totalInvestmentDays) |
|
||||
: new Big(0); |
|
||||
|
|
||||
const timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect = |
|
||||
totalInvestmentDays > 0 |
|
||||
? sumOfTimeWeightedInvestmentsWithCurrencyEffect.div( |
|
||||
totalInvestmentDays |
|
||||
) |
|
||||
: new Big(0); |
|
||||
|
|
||||
const grossPerformancePercentage = |
|
||||
timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0) |
|
||||
? totalGrossPerformance.div( |
|
||||
timeWeightedAverageInvestmentBetweenStartAndEndDate |
|
||||
) |
|
||||
: new Big(0); |
|
||||
|
|
||||
const grossPerformancePercentageWithCurrencyEffect = |
|
||||
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.gt( |
|
||||
0 |
|
||||
) |
|
||||
? totalGrossPerformanceWithCurrencyEffect.div( |
|
||||
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect |
|
||||
) |
|
||||
: new Big(0); |
|
||||
|
|
||||
const feesPerUnit = totalUnits.gt(0) |
|
||||
? fees.minus(feesAtStartDate).div(totalUnits) |
|
||||
: new Big(0); |
|
||||
|
|
||||
const feesPerUnitWithCurrencyEffect = totalUnits.gt(0) |
|
||||
? feesWithCurrencyEffect |
|
||||
.minus(feesAtStartDateWithCurrencyEffect) |
|
||||
.div(totalUnits) |
|
||||
: new Big(0); |
|
||||
|
|
||||
const netPerformancePercentage = |
|
||||
timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0) |
|
||||
? totalNetPerformance.div( |
|
||||
timeWeightedAverageInvestmentBetweenStartAndEndDate |
|
||||
) |
|
||||
: new Big(0); |
|
||||
|
|
||||
const netPerformancePercentageWithCurrencyEffectMap: { |
|
||||
[key: DateRange]: Big; |
|
||||
} = {}; |
|
||||
|
|
||||
const netPerformanceWithCurrencyEffectMap: { |
|
||||
[key: DateRange]: Big; |
|
||||
} = {}; |
|
||||
|
|
||||
for (const dateRange of [ |
|
||||
'1d', |
|
||||
'1y', |
|
||||
'5y', |
|
||||
'max', |
|
||||
'mtd', |
|
||||
'wtd', |
|
||||
'ytd' |
|
||||
// TODO:
|
|
||||
// ...eachYearOfInterval({ end, start })
|
|
||||
// .filter((date) => {
|
|
||||
// return !isThisYear(date);
|
|
||||
// })
|
|
||||
// .map((date) => {
|
|
||||
// return format(date, 'yyyy');
|
|
||||
// })
|
|
||||
] as DateRange[]) { |
|
||||
const dateInterval = getIntervalFromDateRange(dateRange); |
|
||||
const endDate = dateInterval.endDate; |
|
||||
let startDate = dateInterval.startDate; |
|
||||
|
|
||||
if (isBefore(startDate, start)) { |
|
||||
startDate = start; |
|
||||
} |
|
||||
|
|
||||
const rangeEndDateString = format(endDate, DATE_FORMAT); |
|
||||
const rangeStartDateString = format(startDate, DATE_FORMAT); |
|
||||
|
|
||||
const currentValuesAtDateRangeStartWithCurrencyEffect = |
|
||||
currentValuesWithCurrencyEffect[rangeStartDateString] ?? new Big(0); |
|
||||
|
|
||||
const investmentValuesAccumulatedAtStartDateWithCurrencyEffect = |
|
||||
investmentValuesAccumulatedWithCurrencyEffect[rangeStartDateString] ?? |
|
||||
new Big(0); |
|
||||
|
|
||||
const grossPerformanceAtDateRangeStartWithCurrencyEffect = |
|
||||
currentValuesAtDateRangeStartWithCurrencyEffect.minus( |
|
||||
investmentValuesAccumulatedAtStartDateWithCurrencyEffect |
|
||||
); |
|
||||
|
|
||||
let average = new Big(0); |
|
||||
let dayCount = 0; |
|
||||
|
|
||||
for (let i = this.chartDates.length - 1; i >= 0; i -= 1) { |
|
||||
const date = this.chartDates[i]; |
|
||||
|
|
||||
if (date > rangeEndDateString) { |
|
||||
continue; |
|
||||
} else if (date < rangeStartDateString) { |
|
||||
break; |
|
||||
} |
|
||||
|
|
||||
if ( |
|
||||
investmentValuesAccumulatedWithCurrencyEffect[date] instanceof Big && |
|
||||
investmentValuesAccumulatedWithCurrencyEffect[date].gt(0) |
|
||||
) { |
|
||||
average = average.add( |
|
||||
investmentValuesAccumulatedWithCurrencyEffect[date].add( |
|
||||
grossPerformanceAtDateRangeStartWithCurrencyEffect |
|
||||
) |
|
||||
); |
|
||||
|
|
||||
dayCount++; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
if (dayCount > 0) { |
|
||||
average = average.div(dayCount); |
|
||||
} |
|
||||
|
|
||||
netPerformanceWithCurrencyEffectMap[dateRange] = |
|
||||
netPerformanceValuesWithCurrencyEffect[rangeEndDateString]?.minus( |
|
||||
// If the date range is 'max', take 0 as a start value. Otherwise,
|
|
||||
// the value of the end of the day of the start date is taken which
|
|
||||
// differs from the buying price.
|
|
||||
dateRange === 'max' |
|
||||
? new Big(0) |
|
||||
: (netPerformanceValuesWithCurrencyEffect[rangeStartDateString] ?? |
|
||||
new Big(0)) |
|
||||
) ?? new Big(0); |
|
||||
|
|
||||
netPerformancePercentageWithCurrencyEffectMap[dateRange] = average.gt(0) |
|
||||
? netPerformanceWithCurrencyEffectMap[dateRange].div(average) |
|
||||
: new Big(0); |
|
||||
} |
|
||||
|
|
||||
if (PortfolioCalculator.ENABLE_LOGGING) { |
|
||||
console.log( |
|
||||
` |
|
||||
${symbol} |
|
||||
Unit price: ${orders[indexOfStartOrder].unitPrice.toFixed( |
|
||||
2 |
|
||||
)} -> ${unitPriceAtEndDate.toFixed(2)} |
|
||||
Total investment: ${totalInvestment.toFixed(2)} |
|
||||
Total investment with currency effect: ${totalInvestmentWithCurrencyEffect.toFixed( |
|
||||
2 |
|
||||
)} |
|
||||
Time weighted investment: ${timeWeightedAverageInvestmentBetweenStartAndEndDate.toFixed( |
|
||||
2 |
|
||||
)} |
|
||||
Time weighted investment with currency effect: ${timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.toFixed( |
|
||||
2 |
|
||||
)} |
|
||||
Total dividend: ${totalDividend.toFixed(2)} |
|
||||
Gross performance: ${totalGrossPerformance.toFixed( |
|
||||
2 |
|
||||
)} / ${grossPerformancePercentage.mul(100).toFixed(2)}% |
|
||||
Gross performance with currency effect: ${totalGrossPerformanceWithCurrencyEffect.toFixed( |
|
||||
2 |
|
||||
)} / ${grossPerformancePercentageWithCurrencyEffect |
|
||||
.mul(100) |
|
||||
.toFixed(2)}% |
|
||||
Fees per unit: ${feesPerUnit.toFixed(2)} |
|
||||
Fees per unit with currency effect: ${feesPerUnitWithCurrencyEffect.toFixed( |
|
||||
2 |
|
||||
)} |
|
||||
Net performance: ${totalNetPerformance.toFixed( |
|
||||
2 |
|
||||
)} / ${netPerformancePercentage.mul(100).toFixed(2)}% |
|
||||
Net performance with currency effect: ${netPerformancePercentageWithCurrencyEffectMap[ |
|
||||
'max' |
|
||||
].toFixed(2)}%` |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
return { |
|
||||
currentValues, |
|
||||
currentValuesWithCurrencyEffect, |
|
||||
feesWithCurrencyEffect, |
|
||||
grossPerformancePercentage, |
|
||||
grossPerformancePercentageWithCurrencyEffect, |
|
||||
initialValue, |
|
||||
initialValueWithCurrencyEffect, |
|
||||
investmentValuesAccumulated, |
|
||||
investmentValuesAccumulatedWithCurrencyEffect, |
|
||||
investmentValuesWithCurrencyEffect, |
|
||||
netPerformancePercentage, |
|
||||
netPerformancePercentageWithCurrencyEffectMap, |
|
||||
netPerformanceValues, |
|
||||
netPerformanceValuesWithCurrencyEffect, |
|
||||
netPerformanceWithCurrencyEffectMap, |
|
||||
timeWeightedInvestmentValues, |
|
||||
timeWeightedInvestmentValuesWithCurrencyEffect, |
|
||||
totalAccountBalanceInBaseCurrency, |
|
||||
totalDividend, |
|
||||
totalDividendInBaseCurrency, |
|
||||
totalInterest, |
|
||||
totalInterestInBaseCurrency, |
|
||||
totalInvestment, |
|
||||
totalInvestmentWithCurrencyEffect, |
|
||||
totalLiabilities, |
|
||||
totalLiabilitiesInBaseCurrency, |
|
||||
totalValuables, |
|
||||
totalValuablesInBaseCurrency, |
|
||||
grossPerformance: totalGrossPerformance, |
|
||||
grossPerformanceWithCurrencyEffect: |
|
||||
totalGrossPerformanceWithCurrencyEffect, |
|
||||
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate), |
|
||||
netPerformance: totalNetPerformance, |
|
||||
timeWeightedInvestment: |
|
||||
timeWeightedAverageInvestmentBetweenStartAndEndDate, |
|
||||
timeWeightedInvestmentWithCurrencyEffect: |
|
||||
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect |
|
||||
}; |
|
||||
} |
} |
||||
} |
} |
||||
|
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue