Browse Source

Merge remote-tracking branch 'origin/dockerpush' into mr/upstream-24-08

pull/5027/head
Daniel Devaud 10 months ago
parent
commit
27d7fa9358
  1. 1
      .admin.cred
  2. 25
      .env.dev
  3. 3
      .env.example
  4. 47
      .github/workflows/docker-image-branch.yml
  5. 47
      .github/workflows/docker-image-dev.yml
  6. 5
      .github/workflows/docker-image.yml
  7. 2
      CHANGELOG.md
  8. 7
      apps/api/project.json
  9. 44
      apps/api/src/aop/logging.interceptor.ts
  10. 2
      apps/api/src/app/account-balance/account-balance.service.ts
  11. 2
      apps/api/src/app/account/account.service.ts
  12. 7
      apps/api/src/app/admin/admin.controller.ts
  13. 2
      apps/api/src/app/admin/admin.module.ts
  14. 29
      apps/api/src/app/admin/admin.service.ts
  15. 6
      apps/api/src/app/admin/update-asset-profile.dto.ts
  16. 7
      apps/api/src/app/import/import.service.ts
  17. 41
      apps/api/src/app/order/order.service.ts
  18. 606
      apps/api/src/app/portfolio/calculator/constantPortfolioReturn/portfolio-calculator.ts
  19. 52
      apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts
  20. 116
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  21. 3
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts
  22. 3
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell.spec.ts
  23. 3
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy.spec.ts
  24. 3
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts
  25. 3
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-fee.spec.ts
  26. 3
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-googl-buy.spec.ts
  27. 3
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-item.spec.ts
  28. 3
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-liability.spec.ts
  29. 3
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-msft-buy-with-dividend.spec.ts
  30. 3
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-no-orders.spec.ts
  31. 3
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  32. 3
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell.spec.ts
  33. 3
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.spec.ts
  34. 635
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts
  35. 120
      apps/api/src/app/portfolio/current-rate.service.ts
  36. 1
      apps/api/src/app/portfolio/interfaces/portfolio-holding-detail.interface.ts
  37. 5
      apps/api/src/app/portfolio/interfaces/portfolio-order-item.interface.ts
  38. 30
      apps/api/src/app/portfolio/portfolio.controller.ts
  39. 176
      apps/api/src/app/portfolio/portfolio.service.ts
  40. 5
      apps/api/src/app/tag/tag.service.ts
  41. 3
      apps/api/src/app/user/update-user-setting.dto.ts
  42. 25
      apps/api/src/helper/dateQueryHelper.ts
  43. 1
      apps/api/src/helper/portfolio.helper.ts
  44. 33
      apps/api/src/main.ts
  45. 1
      apps/api/src/services/configuration/configuration.service.ts
  46. 3
      apps/api/src/services/data-gathering/data-gathering.service.ts
  47. 10
      apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts
  48. 25
      apps/api/src/services/data-provider/data-provider.service.ts
  49. 32
      apps/api/src/services/data-provider/manual/manual.service.ts
  50. 17
      apps/api/src/services/market-data/market-data.service.ts
  51. 12
      apps/api/src/services/symbol-profile/symbol-profile-overwrite.module.ts
  52. 69
      apps/api/src/services/symbol-profile/symbol-profile-overwrite.service.ts
  53. 19
      apps/api/src/services/symbol-profile/symbol-profile.service.ts
  54. 17
      apps/api/src/services/tag/tag.service.ts
  55. 22
      apps/client/project.json
  56. 52
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  57. 31
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  58. 4
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.module.ts
  59. 13
      apps/client/src/app/components/admin-tag/admin-tag.component.html
  60. 2
      apps/client/src/app/components/admin-tag/admin-tag.component.ts
  61. 1
      apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.scss
  62. 16
      apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts
  63. 33
      apps/client/src/app/components/header/header.component.ts
  64. 34
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  65. 53
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  66. 3
      apps/client/src/app/components/toggle/toggle.component.ts
  67. 6
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  68. 13
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html
  69. 3
      apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts
  70. 48
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  71. 30
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html
  72. 2
      apps/client/src/app/services/admin.service.ts
  73. 11
      apps/client/src/app/services/data.service.ts
  74. 2
      apps/client/src/app/services/import-activities.service.ts
  75. 6
      apps/client/src/app/services/user/user.service.ts
  76. 46
      libs/common/src/lib/chunkhelper.ts
  77. 3
      libs/common/src/lib/interfaces/admin-market-data.interface.ts
  78. 3
      libs/common/src/lib/interfaces/enhanced-symbol-profile.interface.ts
  79. 2
      libs/common/src/lib/interfaces/historical-data-item.interface.ts
  80. 6
      libs/common/src/lib/interfaces/symbol-metrics.interface.ts
  81. 9
      libs/common/src/lib/types/date-range.type.ts
  82. 51
      libs/ui/src/lib/activities-table/activities-table.component.scss
  83. 38
      libs/ui/src/lib/activity-type/activity-type.component.html
  84. 50
      libs/ui/src/lib/assistant/assistant.component.ts
  85. 6
      libs/ui/src/lib/assistant/assistant.html
  86. 1
      libs/ui/src/lib/i18n.ts
  87. 185
      migrations.json
  88. 2
      package.json
  89. 22
      prisma/migrations/20231108082445_added_tags_to_holding/migration.sql
  90. 3
      prisma/schema.prisma
  91. 19068
      yarn.lock

1
.admin.cred

@ -0,0 +1 @@
14d4daf73eefed7da7c32ec19bc37e678be0244fb46c8f4965bfe9ece7384706ed58222ad9b96323893c1d845bc33a308e7524c2c79636062cbb095e0780cb51

25
.env.dev

@ -1,25 +0,0 @@
COMPOSE_PROJECT_NAME=ghostfolio-development
# CACHE
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=<INSERT_REDIS_PASSWORD>
# POSTGRES
POSTGRES_DB=ghostfolio-db
POSTGRES_USER=user
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
# VARIOUS
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
# DEVELOPMENT
# Nx 18 enables using plugins to infer targets by default
# This is disabled for existing workspaces to maintain compatibility
# For more info, see: https://nx.dev/concepts/inferred-tasks
NX_ADD_PLUGINS=false
NX_NATIVE_COMMAND_RUNNER=false

3
.env.example

@ -1,4 +1,4 @@
COMPOSE_PROJECT_NAME=ghostfolio COMPOSE_PROJECT_NAME=ghostfolio-development
# CACHE # CACHE
REDIS_HOST=localhost REDIS_HOST=localhost
@ -10,7 +10,6 @@ POSTGRES_DB=ghostfolio-db
POSTGRES_USER=user POSTGRES_USER=user
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD> POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
# VARIOUS
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING> ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
JWT_SECRET_KEY=<INSERT_RANDOM_STRING> JWT_SECRET_KEY=<INSERT_RANDOM_STRING>

47
.github/workflows/docker-image-branch.yml

@ -0,0 +1,47 @@
name: Docker image CD - Branch
on:
push:
branches:
- '*'
jobs:
build_and_push:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Docker metadata
id: meta
uses: docker/metadata-action@v4
with:
images: dandevaud/ghostfolio
tags: |
type=semver,pattern={{major}}
type=semver,pattern={{version}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v3
with:
context: .
platforms: linux/amd64,linux/arm/v7,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: dandevaud/ghostfolio:${{ github.ref_name }}
labels: ${{ steps.meta.output.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

47
.github/workflows/docker-image-dev.yml

@ -0,0 +1,47 @@
name: Docker image CD - DEV
on:
push:
branches:
- 'dockerpush'
jobs:
build_and_push:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Docker metadata
id: meta
uses: docker/metadata-action@v4
with:
images: dandevaud/ghostfolio
tags: |
type=semver,pattern={{major}}
type=semver,pattern={{version}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v3
with:
context: .
platforms: linux/amd64,linux/arm/v7,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: dandevaud/ghostfolio:beta
labels: ${{ steps.meta.output.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

5
.github/workflows/docker-image.yml

@ -4,9 +4,6 @@ on:
push: push:
tags: tags:
- '*.*.*' - '*.*.*'
pull_request:
branches:
- 'main'
jobs: jobs:
build_and_push: build_and_push:
@ -19,7 +16,7 @@ jobs:
id: meta id: meta
uses: docker/metadata-action@v4 uses: docker/metadata-action@v4
with: with:
images: ghostfolio/ghostfolio images: dandevaud/ghostfolio
tags: | tags: |
type=semver,pattern={{major}} type=semver,pattern={{major}}
type=semver,pattern={{version}} type=semver,pattern={{version}}

2
CHANGELOG.md

@ -1140,9 +1140,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Introduced the lazy-loaded activities table to the position detail dialog (experimental) - Introduced the lazy-loaded activities table to the position detail dialog (experimental)
- Improved the font weight in the value component - Improved the font weight in the value component
- Improved the language localization for Türkçe (`tr`) - Improved the language localization for Türkçe (`tr`)
- Upgraded `angular` from version `17.0.4` to `17.0.7`
- Upgraded to _Inter_ 4 font family - Upgraded to _Inter_ 4 font family
- Upgraded `Nx` from version `17.0.2` to `17.2.5`
### Fixed ### Fixed

7
apps/api/project.json

@ -60,6 +60,13 @@
"buildTarget": "api:build" "buildTarget": "api:build"
} }
}, },
"profile": {
"executor": "@nx/js:node",
"options": {
"buildTarget": "api:build",
"runtimeArgs": ["--perf-basic-prof-only-functions"]
}
},
"lint": { "lint": {
"executor": "@nx/eslint:lint", "executor": "@nx/eslint:lint",
"options": { "options": {

44
apps/api/src/aop/logging.interceptor.ts

@ -0,0 +1,44 @@
import { Logger } from '@nestjs/common';
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const methodName =
context.getClass().name + ':' + context.getHandler().name;
Logger.debug(`Before ${methodName}...`);
const now = Date.now();
return next
.handle()
.pipe(
tap(() => Logger.debug(`After ${methodName}... ${Date.now() - now}ms`))
);
}
}
export function LogPerformance(
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
const time = Date.now();
const result = originalMethod.apply(this, args);
const now = Date.now();
if (now - time > 100) {
Logger.debug(`${propertyKey} returned within: ${now - time} ms`);
}
return result;
};
return descriptor;
}

2
apps/api/src/app/account-balance/account-balance.service.ts

@ -1,3 +1,4 @@
import { LogPerformance } from '@ghostfolio/api/aop/logging.interceptor';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
@ -90,6 +91,7 @@ export class AccountBalanceService {
return accountBalance; return accountBalance;
} }
@LogPerformance
public async getAccountBalances({ public async getAccountBalances({
filters, filters,
user, user,

2
apps/api/src/app/account/account.service.ts

@ -1,3 +1,4 @@
import { LogPerformance } from '@ghostfolio/api/aop/logging.interceptor';
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
@ -149,6 +150,7 @@ export class AccountService {
}); });
} }
@LogPerformance
public async getCashDetails({ public async getCashDetails({
currency, currency,
filters = [], filters = [],

7
apps/api/src/app/admin/admin.controller.ts

@ -332,7 +332,12 @@ export class AdminController {
return this.adminService.patchAssetProfileData({ return this.adminService.patchAssetProfileData({
...assetProfileData, ...assetProfileData,
dataSource, dataSource,
symbol symbol,
tags: {
connect: assetProfileData.tags?.map(({ id }) => {
return { id };
})
}
}); });
} }

2
apps/api/src/app/admin/admin.module.ts

@ -10,6 +10,7 @@ import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-d
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 { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { SymbolProfileOverwriteModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile-overwrite.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';
@ -33,6 +34,7 @@ import { QueueModule } from './queue/queue.module';
QueueModule, QueueModule,
SubscriptionModule, SubscriptionModule,
SymbolProfileModule, SymbolProfileModule,
SymbolProfileOverwriteModule,
TransformDataSourceInRequestModule TransformDataSourceInRequestModule
], ],
controllers: [AdminController], controllers: [AdminController],

29
apps/api/src/app/admin/admin.service.ts

@ -8,6 +8,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileOverwriteService } from '@ghostfolio/api/services/symbol-profile/symbol-profile-overwrite.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
@ -31,11 +32,13 @@ import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { import {
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
DataSource,
Prisma, Prisma,
PrismaClient, PrismaClient,
Property, Property,
SymbolProfile SymbolProfile,
DataSource,
Tag,
SymbolProfileOverrides
} from '@prisma/client'; } from '@prisma/client';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
import { groupBy } from 'lodash'; import { groupBy } from 'lodash';
@ -52,7 +55,8 @@ export class AdminService {
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService, private readonly subscriptionService: SubscriptionService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService,
private readonly symbolProfileOverwriteService: SymbolProfileOverwriteService
) {} ) {}
public async addAssetProfile({ public async addAssetProfile({
@ -247,7 +251,8 @@ export class AdminService {
}, },
scraperConfiguration: true, scraperConfiguration: true,
sectors: true, sectors: true,
symbol: true symbol: true,
tags: true
} }
}), }),
this.prismaService.symbolProfile.count({ where }) this.prismaService.symbolProfile.count({ where })
@ -268,7 +273,8 @@ export class AdminService {
name, name,
Order, Order,
sectors, sectors,
symbol symbol,
tags
}) => { }) => {
const countriesCount = countries const countriesCount = countries
? Object.keys(countries).length ? Object.keys(countries).length
@ -296,7 +302,9 @@ export class AdminService {
sectorsCount, sectorsCount,
activitiesCount: _count.Order, activitiesCount: _count.Order,
date: Order?.[0]?.date, date: Order?.[0]?.date,
isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription isUsedByUsersWithSubscription:
await isUsedByUsersWithSubscription,
tags
}; };
} }
) )
@ -386,6 +394,7 @@ export class AdminService {
dataSource, dataSource,
holdings, holdings,
name, name,
tags,
scraperConfiguration, scraperConfiguration,
sectors, sectors,
symbol, symbol,
@ -527,18 +536,16 @@ export class AdminService {
})?._count ?? 0; })?._count ?? 0;
return { return {
activitiesCount,
currency,
dataSource, dataSource,
marketDataItemCount, marketDataItemCount,
symbol, symbol,
assetClass: AssetClass.LIQUIDITY, assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
countriesCount: 0, countriesCount: 0,
date: dateOfFirstActivity, currency: symbol.replace(DEFAULT_CURRENCY, ''),
id: undefined, id: undefined,
name: symbol, name: symbol,
sectorsCount: 0 sectorsCount: 0,
tags: []
}; };
}); });

6
apps/api/src/app/admin/update-asset-profile.dto.ts

@ -1,6 +1,6 @@
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code'; import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
import { AssetClass, AssetSubClass, Prisma } from '@prisma/client'; import { AssetClass, AssetSubClass, Prisma, Tag } from '@prisma/client';
import { import {
IsArray, IsArray,
IsEnum, IsEnum,
@ -35,6 +35,10 @@ export class UpdateAssetProfileDto {
@IsOptional() @IsOptional()
name?: string; name?: string;
@IsArray()
@IsOptional()
tags?: Tag[];
@IsObject() @IsObject()
@IsOptional() @IsOptional()
scraperConfiguration?: Prisma.InputJsonObject; scraperConfiguration?: Prisma.InputJsonObject;

7
apps/api/src/app/import/import.service.ts

@ -620,7 +620,12 @@ export class ImportService {
)?.[symbol] )?.[symbol]
}; };
if (type === 'BUY' || type === 'DIVIDEND' || type === 'SELL') { if (
type === 'BUY' ||
type === 'DIVIDEND' ||
type === 'SELL' ||
type === 'STAKE'
) {
if (!assetProfile?.name) { if (!assetProfile?.name) {
throw new Error( throw new Error(
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")` `activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`

41
apps/api/src/app/order/order.service.ts

@ -1,3 +1,4 @@
import { LogPerformance } from '@ghostfolio/api/aop/logging.interceptor';
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
@ -296,6 +297,7 @@ export class OrderService {
}); });
} }
@LogPerformance
public async getOrders({ public async getOrders({
endDate, endDate,
filters, filters,
@ -419,13 +421,34 @@ export class OrderService {
} }
if (filtersByTag?.length > 0) { if (filtersByTag?.length > 0) {
where.tags = { where.AND = [
some: { {
OR: filtersByTag.map(({ id }) => { OR: [
return { id }; {
}) tags: {
some: {
OR: filtersByTag.map(({ id }) => {
return {
id: id
};
})
}
}
},
{
SymbolProfile: {
tags: {
some: {
OR: filtersByTag.map(({ id }) => {
return { id };
})
}
}
}
}
]
} }
}; ];
} }
if (sortColumn) { if (sortColumn) {
@ -457,7 +480,11 @@ export class OrderService {
} }
}, },
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
SymbolProfile: true, SymbolProfile: {
include: {
tags: true
}
},
tags: true tags: true
} }
}), }),

606
apps/api/src/app/portfolio/calculator/constantPortfolioReturn/portfolio-calculator.ts

@ -0,0 +1,606 @@
import { LogPerformance } from '@ghostfolio/api/aop/logging.interceptor';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import {
getFactor,
getInterval
} from '@ghostfolio/api/helper/portfolio.helper';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { MAX_CHART_ITEMS } from '@ghostfolio/common/config';
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { DateRange } from '@ghostfolio/common/types';
import { Inject, Logger } from '@nestjs/common';
import { Big } from 'big.js';
import {
addDays,
differenceInDays,
eachDayOfInterval,
endOfDay,
format,
isAfter,
isBefore,
subDays
} from 'date-fns';
import { CurrentRateService } from '../../current-rate.service';
import { DateQuery } from '../../interfaces/date-query.interface';
import { PortfolioOrder } from '../../interfaces/portfolio-order.interface';
import { TWRPortfolioCalculator } from '../twr/portfolio-calculator';
export class CPRPortfolioCalculator extends TWRPortfolioCalculator {
private holdings: { [date: string]: { [symbol: string]: Big } } = {};
private holdingCurrencies: { [symbol: string]: string } = {};
constructor(
{
accountBalanceItems,
activities,
configurationService,
currency,
currentRateService,
dateRange,
exchangeRateDataService,
redisCacheService,
useCache,
userId
}: {
accountBalanceItems: HistoricalDataItem[];
activities: Activity[];
configurationService: ConfigurationService;
currency: string;
currentRateService: CurrentRateService;
dateRange: DateRange;
exchangeRateDataService: ExchangeRateDataService;
redisCacheService: RedisCacheService;
useCache: boolean;
userId: string;
},
@Inject()
private orderService: OrderService
) {
super({
accountBalanceItems,
activities,
configurationService,
currency,
currentRateService,
dateRange,
exchangeRateDataService,
redisCacheService,
useCache,
userId
});
}
@LogPerformance
public async getChart({
dateRange = 'max',
withDataDecimation = true,
withTimeWeightedReturn = false
}: {
dateRange?: DateRange;
withDataDecimation?: boolean;
withTimeWeightedReturn?: boolean;
}): Promise<HistoricalDataItem[]> {
const { endDate, startDate } = getInterval(dateRange, this.getStartDate());
const daysInMarket = differenceInDays(endDate, startDate) + 1;
const step = withDataDecimation
? Math.round(daysInMarket / Math.min(daysInMarket, MAX_CHART_ITEMS))
: 1;
let item = super.getChartData({
step,
end: endDate,
start: startDate
});
if (!withTimeWeightedReturn) {
return item;
} else {
let itemResult = await item;
let dates = itemResult.map((item) => parseDate(item.date));
let timeWeighted = await this.getTimeWeightedChartData({
dates
});
return itemResult.map((item) => {
let timeWeightedItem = timeWeighted.find(
(timeWeightedItem) => timeWeightedItem.date === item.date
);
if (timeWeightedItem) {
item.timeWeightedPerformance =
timeWeightedItem.netPerformanceInPercentage;
item.timeWeightedPerformanceWithCurrencyEffect =
timeWeightedItem.netPerformanceInPercentageWithCurrencyEffect;
}
return item;
});
}
}
@LogPerformance
public async getUnfilteredNetWorth(currency: string): Promise<Big> {
const activities = await this.orderService.getOrders({
userId: this.userId,
userCurrency: currency,
types: ['BUY', 'SELL', 'STAKE'],
withExcludedAccounts: true
});
const orders = this.activitiesToPortfolioOrder(activities.activities);
const start = orders.reduce(
(date, order) =>
parseDate(date.date).getTime() < parseDate(order.date).getTime()
? date
: order,
{ date: orders[0].date }
).date;
const end = new Date(Date.now());
const holdings = await this.getHoldings(orders, parseDate(start), end);
const marketMap = await this.currentRateService.getToday(
this.mapToDataGatheringItems(orders)
);
const endString = format(end, DATE_FORMAT);
let exchangeRates = await Promise.all(
Object.keys(holdings[endString]).map(async (holding) => {
let symbol = marketMap.find((m) => m.symbol === holding);
let symbolCurrency = this.getCurrencyFromActivities(orders, holding);
let exchangeRate = await this.exchangeRateDataService.toCurrencyAtDate(
1,
symbolCurrency,
this.currency,
end
);
return { symbolCurrency, exchangeRate };
})
);
let currencyRates = exchangeRates.reduce<{ [currency: string]: number }>(
(all, currency): { [currency: string]: number } => {
all[currency.symbolCurrency] ??= currency.exchangeRate;
return all;
},
{}
);
let totalInvestment = await Object.keys(holdings[endString]).reduce(
(sum, holding) => {
if (!holdings[endString][holding].toNumber()) {
return sum;
}
let symbol = marketMap.find((m) => m.symbol === holding);
if (symbol?.marketPrice === undefined) {
Logger.warn(
`Missing historical market data for ${holding} (${end})`,
'PortfolioCalculator'
);
return sum;
} else {
let symbolCurrency = this.getCurrency(holding);
let price = new Big(currencyRates[symbolCurrency]).mul(
symbol.marketPrice
);
return sum.plus(new Big(price).mul(holdings[endString][holding]));
}
},
new Big(0)
);
return totalInvestment;
}
@LogPerformance
protected async getTimeWeightedChartData({
dates
}: {
dates?: Date[];
}): Promise<HistoricalDataItem[]> {
dates = dates.sort((a, b) => a.getTime() - b.getTime());
const start = dates[0];
const end = dates[dates.length - 1];
let marketMapTask = this.computeMarketMap({
gte: start,
lt: addDays(end, 1)
});
const timelineHoldings = await this.getHoldings(
this.activities,
start,
end
);
let data: HistoricalDataItem[] = [];
const startString = format(start, DATE_FORMAT);
data.push({
date: startString,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
investmentValueWithCurrencyEffect: 0,
netPerformance: 0,
netPerformanceWithCurrencyEffect: 0,
netWorth: 0,
totalAccountBalance: 0,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0,
value: 0,
valueWithCurrencyEffect: 0
});
this.marketMap = await marketMapTask;
let totalInvestment = Object.keys(timelineHoldings[startString]).reduce(
(sum, holding) => {
return sum.plus(
timelineHoldings[startString][holding].mul(
this.marketMap[startString][holding] ?? new Big(0)
)
);
},
new Big(0)
);
let previousNetPerformanceInPercentage = new Big(0);
let previousNetPerformanceInPercentageWithCurrencyEffect = new Big(0);
for (let i = 1; i < dates.length; i++) {
const date = format(dates[i], DATE_FORMAT);
const previousDate = format(dates[i - 1], DATE_FORMAT);
const holdings = timelineHoldings[previousDate];
let newTotalInvestment = new Big(0);
let netPerformanceInPercentage = new Big(0);
let netPerformanceInPercentageWithCurrencyEffect = new Big(0);
for (const holding of Object.keys(holdings)) {
({
netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect,
newTotalInvestment
} = await this.handleSingleHolding(
previousDate,
holding,
date,
totalInvestment,
timelineHoldings,
netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect,
newTotalInvestment
));
}
totalInvestment = newTotalInvestment;
previousNetPerformanceInPercentage = previousNetPerformanceInPercentage
.plus(1)
.mul(netPerformanceInPercentage.plus(1))
.minus(1);
previousNetPerformanceInPercentageWithCurrencyEffect =
previousNetPerformanceInPercentageWithCurrencyEffect
.plus(1)
.mul(netPerformanceInPercentageWithCurrencyEffect.plus(1))
.minus(1);
data.push({
date,
netPerformanceInPercentage: previousNetPerformanceInPercentage
.mul(100)
.toNumber(),
netPerformanceInPercentageWithCurrencyEffect:
previousNetPerformanceInPercentageWithCurrencyEffect
.mul(100)
.toNumber()
});
}
return data;
}
@LogPerformance
protected async handleSingleHolding(
previousDate: string,
holding: string,
date: string,
totalInvestment: Big,
timelineHoldings: { [date: string]: { [symbol: string]: Big } },
netPerformanceInPercentage: Big,
netPerformanceInPercentageWithCurrencyEffect: Big,
newTotalInvestment: Big
) {
const previousPrice = this.marketMap[previousDate][holding];
const currentPrice = this.marketMap[date][holding] ?? previousPrice;
const previousHolding = timelineHoldings[previousDate][holding];
const priceInBaseCurrency = currentPrice
? new Big(
await this.exchangeRateDataService.toCurrencyAtDate(
currentPrice?.toNumber() ?? 0,
this.getCurrency(holding),
this.currency,
parseDate(date)
)
)
: new Big(0);
if (previousHolding.eq(0)) {
return {
netPerformanceInPercentage: netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect:
netPerformanceInPercentageWithCurrencyEffect,
newTotalInvestment: newTotalInvestment.plus(
timelineHoldings[date][holding].mul(priceInBaseCurrency)
)
};
}
if (previousPrice === undefined || currentPrice === undefined) {
Logger.warn(
`Missing historical market data for ${holding} (${previousPrice === undefined ? previousDate : date}})`,
'PortfolioCalculator'
);
return {
netPerformanceInPercentage: netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect:
netPerformanceInPercentageWithCurrencyEffect,
newTotalInvestment: newTotalInvestment.plus(
timelineHoldings[date][holding].mul(priceInBaseCurrency)
)
};
}
const previousPriceInBaseCurrency = previousPrice
? new Big(
await this.exchangeRateDataService.toCurrencyAtDate(
previousPrice?.toNumber() ?? 0,
this.getCurrency(holding),
this.currency,
parseDate(previousDate)
)
)
: new Big(0);
const portfolioWeight = totalInvestment.toNumber()
? previousHolding.mul(previousPriceInBaseCurrency).div(totalInvestment)
: new Big(0);
netPerformanceInPercentage = netPerformanceInPercentage.plus(
currentPrice.div(previousPrice).minus(1).mul(portfolioWeight)
);
netPerformanceInPercentageWithCurrencyEffect =
netPerformanceInPercentageWithCurrencyEffect.plus(
priceInBaseCurrency
.div(previousPriceInBaseCurrency)
.minus(1)
.mul(portfolioWeight)
);
newTotalInvestment = newTotalInvestment.plus(
timelineHoldings[date][holding].mul(priceInBaseCurrency)
);
return {
netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect,
newTotalInvestment
};
}
@LogPerformance
protected getCurrency(symbol: string) {
return this.getCurrencyFromActivities(this.activities, symbol);
}
@LogPerformance
protected getCurrencyFromActivities(
activities: PortfolioOrder[],
symbol: string
) {
if (!this.holdingCurrencies[symbol]) {
this.holdingCurrencies[symbol] = activities.find(
(a) => a.SymbolProfile.symbol === symbol
).SymbolProfile.currency;
}
return this.holdingCurrencies[symbol];
}
@LogPerformance
protected async getHoldings(
activities: PortfolioOrder[],
start: Date,
end: Date
) {
if (
this.holdings &&
Object.keys(this.holdings).some((h) =>
isAfter(parseDate(h), subDays(end, 1))
) &&
Object.keys(this.holdings).some((h) =>
isBefore(parseDate(h), addDays(start, 1))
)
) {
return this.holdings;
}
this.computeHoldings(activities, start, end);
return this.holdings;
}
@LogPerformance
protected async computeHoldings(
activities: PortfolioOrder[],
start: Date,
end: Date
) {
const investmentByDate = this.getInvestmentByDate(activities);
this.calculateHoldings(investmentByDate, start, end);
}
private calculateHoldings(
investmentByDate: { [date: string]: PortfolioOrder[] },
start: Date,
end: Date
) {
const transactionDates = Object.keys(investmentByDate).sort();
let dates = eachDayOfInterval({ start, end }, { step: 1 })
.map((date) => {
return resetHours(date);
})
.sort((a, b) => a.getTime() - b.getTime());
let currentHoldings: { [date: string]: { [symbol: string]: Big } } = {};
this.calculateInitialHoldings(investmentByDate, start, currentHoldings);
for (let i = 1; i < dates.length; i++) {
const dateString = format(dates[i], DATE_FORMAT);
const previousDateString = format(dates[i - 1], DATE_FORMAT);
if (transactionDates.some((d) => d === dateString)) {
let holdings = { ...currentHoldings[previousDateString] };
investmentByDate[dateString].forEach((trade) => {
holdings[trade.SymbolProfile.symbol] ??= new Big(0);
holdings[trade.SymbolProfile.symbol] = holdings[
trade.SymbolProfile.symbol
].plus(trade.quantity.mul(getFactor(trade.type)));
});
currentHoldings[dateString] = holdings;
} else {
currentHoldings[dateString] = currentHoldings[previousDateString];
}
}
this.holdings = currentHoldings;
}
@LogPerformance
protected calculateInitialHoldings(
investmentByDate: { [date: string]: PortfolioOrder[] },
start: Date,
currentHoldings: { [date: string]: { [symbol: string]: Big } }
) {
const preRangeTrades = Object.keys(investmentByDate)
.filter((date) => resetHours(new Date(date)) <= start)
.map((date) => investmentByDate[date])
.reduce((a, b) => a.concat(b), [])
.reduce((groupBySymbol, trade) => {
if (!groupBySymbol[trade.SymbolProfile.symbol]) {
groupBySymbol[trade.SymbolProfile.symbol] = [];
}
groupBySymbol[trade.SymbolProfile.symbol].push(trade);
return groupBySymbol;
}, {});
currentHoldings[format(start, DATE_FORMAT)] = {};
for (const symbol of Object.keys(preRangeTrades)) {
const trades: PortfolioOrder[] = preRangeTrades[symbol];
let startQuantity = trades.reduce((sum, trade) => {
return sum.plus(trade.quantity.mul(getFactor(trade.type)));
}, new Big(0));
currentHoldings[format(start, DATE_FORMAT)][symbol] = startQuantity;
}
}
@LogPerformance
protected getInvestmentByDate(activities: PortfolioOrder[]): {
[date: string]: PortfolioOrder[];
} {
return activities.reduce((groupedByDate, order) => {
if (!groupedByDate[order.date]) {
groupedByDate[order.date] = [];
}
groupedByDate[order.date].push(order);
return groupedByDate;
}, {});
}
@LogPerformance
protected mapToDataGatheringItems(
orders: PortfolioOrder[]
): IDataGatheringItem[] {
return orders
.map((activity) => {
return {
symbol: activity.SymbolProfile.symbol,
dataSource: activity.SymbolProfile.dataSource
};
})
.filter(
(gathering, i, arr) =>
arr.findIndex((t) => t.symbol === gathering.symbol) === i
);
}
@LogPerformance
protected async computeMarketMap(dateQuery: DateQuery): Promise<{
[date: string]: { [symbol: string]: Big };
}> {
const dataGatheringItems: IDataGatheringItem[] =
this.mapToDataGatheringItems(this.activities);
const { values: marketSymbols } = await this.currentRateService.getValues({
dataGatheringItems,
dateQuery
});
const marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
} = {};
for (const marketSymbol of marketSymbols) {
const date = format(marketSymbol.date, DATE_FORMAT);
if (!marketSymbolMap[date]) {
marketSymbolMap[date] = {};
}
if (marketSymbol.marketPrice) {
marketSymbolMap[date][marketSymbol.symbol] = new Big(
marketSymbol.marketPrice
);
}
}
return marketSymbolMap;
}
@LogPerformance
protected activitiesToPortfolioOrder(
activities: Activity[]
): PortfolioOrder[] {
return activities
.map(
({
date,
fee,
quantity,
SymbolProfile,
tags = [],
type,
unitPrice
}) => {
if (isAfter(date, new Date(Date.now()))) {
// Adapt date to today if activity is in future (e.g. liability)
// to include it in the interval
date = endOfDay(new Date(Date.now()));
}
return {
SymbolProfile,
tags,
type,
date: format(date, DATE_FORMAT),
fee: new Big(fee),
quantity: new Big(quantity),
unitPrice: new Big(unitPrice)
};
}
)
.sort((a, b) => {
return a.date?.localeCompare(b.date);
});
}
}

52
apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts

@ -1,3 +1,4 @@
import { LogPerformance } from '@ghostfolio/api/aop/logging.interceptor';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
@ -7,13 +8,16 @@ import { Filter, HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { OrderService } from '../../order/order.service';
import { CPRPortfolioCalculator } from './constantPortfolioReturn/portfolio-calculator';
import { MWRPortfolioCalculator } from './mwr/portfolio-calculator'; import { MWRPortfolioCalculator } from './mwr/portfolio-calculator';
import { PortfolioCalculator } from './portfolio-calculator'; import { PortfolioCalculator } from './portfolio-calculator';
import { TWRPortfolioCalculator } from './twr/portfolio-calculator'; import { TWRPortfolioCalculator } from './twr/portfolio-calculator';
export enum PerformanceCalculationType { export enum PerformanceCalculationType {
MWR = 'MWR', // Money-Weighted Rate of Return MWR = 'MWR', // Money-Weighted Rate of Return
TWR = 'TWR' // Time-Weighted Rate of Return TWR = 'TWR', // Time-Weighted Rate of Return
CPR = 'CPR' // Constant Portfolio Rate of Return
} }
@Injectable() @Injectable()
@ -22,9 +26,11 @@ export class PortfolioCalculatorFactory {
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly currentRateService: CurrentRateService, private readonly currentRateService: CurrentRateService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly redisCacheService: RedisCacheService private readonly redisCacheService: RedisCacheService,
private readonly orderservice: OrderService
) {} ) {}
@LogPerformance
public createCalculator({ public createCalculator({
accountBalanceItems = [], accountBalanceItems = [],
activities, activities,
@ -54,17 +60,37 @@ export class PortfolioCalculatorFactory {
redisCacheService: this.redisCacheService redisCacheService: this.redisCacheService
}); });
case PerformanceCalculationType.TWR: case PerformanceCalculationType.TWR:
return new TWRPortfolioCalculator({ return new CPRPortfolioCalculator(
accountBalanceItems, {
activities, accountBalanceItems,
currency, activities,
currentRateService: this.currentRateService, currency,
filters, currentRateService: this.currentRateService,
userId, dateRange,
configurationService: this.configurationService, useCache,
exchangeRateDataService: this.exchangeRateDataService, userId,
redisCacheService: this.redisCacheService configurationService: this.configurationService,
}); exchangeRateDataService: this.exchangeRateDataService,
redisCacheService: this.redisCacheService
},
this.orderservice
);
case PerformanceCalculationType.CPR:
return new CPRPortfolioCalculator(
{
accountBalanceItems,
activities,
currency,
currentRateService: this.currentRateService,
dateRange,
useCache,
userId,
configurationService: this.configurationService,
exchangeRateDataService: this.exchangeRateDataService,
redisCacheService: this.redisCacheService
},
this.orderservice
);
default: default:
throw new Error('Invalid calculation type'); throw new Error('Invalid calculation type');
} }

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

@ -1,3 +1,4 @@
import { LogPerformance } from '@ghostfolio/api/aop/logging.interceptor';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface'; import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface';
@ -49,18 +50,19 @@ export abstract class PortfolioCalculator {
protected activities: PortfolioOrder[]; protected activities: PortfolioOrder[];
private configurationService: ConfigurationService; private configurationService: ConfigurationService;
private currency: string; protected currency: string;
private currentRateService: CurrentRateService; protected currentRateService: CurrentRateService;
private dataProviderInfos: DataProviderInfo[]; private dataProviderInfos: DataProviderInfo[];
private endDate: Date; private endDate: Date;
private exchangeRateDataService: ExchangeRateDataService; protected exchangeRateDataService: ExchangeRateDataService;
private filters: Filter[]; private filters: Filter[];
private redisCacheService: RedisCacheService; private redisCacheService: RedisCacheService;
private snapshot: PortfolioSnapshot; private snapshot: PortfolioSnapshot;
private snapshotPromise: Promise<void>; private snapshotPromise: Promise<void>;
private startDate: Date; private startDate: Date;
private transactionPoints: TransactionPoint[]; private transactionPoints: TransactionPoint[];
private userId: string; protected userId: string;
protected marketMap: { [date: string]: { [symbol: string]: Big } } = {};
public constructor({ public constructor({
accountBalanceItems, accountBalanceItems,
@ -148,7 +150,8 @@ export abstract class PortfolioCalculator {
positions: TimelinePosition[] positions: TimelinePosition[]
): PortfolioSnapshot; ): PortfolioSnapshot;
private async computeSnapshot(): Promise<PortfolioSnapshot> { @LogPerformance
protected async computeSnapshot(): Promise<PortfolioSnapshot> {
const lastTransactionPoint = last(this.transactionPoints); const lastTransactionPoint = last(this.transactionPoints);
const transactionPoints = this.transactionPoints?.filter(({ date }) => { const transactionPoints = this.transactionPoints?.filter(({ date }) => {
@ -376,15 +379,15 @@ export abstract class PortfolioCalculator {
dataSource: item.dataSource, dataSource: item.dataSource,
fee: item.fee, fee: item.fee,
firstBuyDate: item.firstBuyDate, firstBuyDate: item.firstBuyDate,
grossPerformance: !hasErrors ? (grossPerformance ?? null) : null, grossPerformance: !hasErrors ? grossPerformance ?? null : null,
grossPerformancePercentage: !hasErrors grossPerformancePercentage: !hasErrors
? (grossPerformancePercentage ?? null) ? grossPerformancePercentage ?? null
: null, : null,
grossPerformancePercentageWithCurrencyEffect: !hasErrors grossPerformancePercentageWithCurrencyEffect: !hasErrors
? (grossPerformancePercentageWithCurrencyEffect ?? null) ? grossPerformancePercentageWithCurrencyEffect ?? null
: null, : null,
grossPerformanceWithCurrencyEffect: !hasErrors grossPerformanceWithCurrencyEffect: !hasErrors
? (grossPerformanceWithCurrencyEffect ?? null) ? grossPerformanceWithCurrencyEffect ?? null
: null, : null,
investment: totalInvestment, investment: totalInvestment,
investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect, investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect,
@ -392,15 +395,15 @@ export abstract class PortfolioCalculator {
marketSymbolMap[endDateString]?.[item.symbol]?.toNumber() ?? null, marketSymbolMap[endDateString]?.[item.symbol]?.toNumber() ?? null,
marketPriceInBaseCurrency: marketPriceInBaseCurrency:
marketPriceInBaseCurrency?.toNumber() ?? null, marketPriceInBaseCurrency?.toNumber() ?? null,
netPerformance: !hasErrors ? (netPerformance ?? null) : null, netPerformance: !hasErrors ? netPerformance ?? null : null,
netPerformancePercentage: !hasErrors netPerformancePercentage: !hasErrors
? (netPerformancePercentage ?? null) ? netPerformancePercentage ?? null
: null, : null,
netPerformancePercentageWithCurrencyEffectMap: !hasErrors netPerformancePercentageWithCurrencyEffectMap: !hasErrors
? (netPerformancePercentageWithCurrencyEffectMap ?? null) ? netPerformancePercentageWithCurrencyEffectMap ?? null
: null, : null,
netPerformanceWithCurrencyEffectMap: !hasErrors netPerformanceWithCurrencyEffectMap: !hasErrors
? (netPerformanceWithCurrencyEffectMap ?? null) ? netPerformanceWithCurrencyEffectMap ?? null
: null, : null,
quantity: item.quantity, quantity: item.quantity,
symbol: item.symbol, symbol: item.symbol,
@ -488,8 +491,8 @@ export abstract class PortfolioCalculator {
return date === dateString; return date === dateString;
}).value }).value
) )
: (accumulatedValuesByDate[lastDate] : accumulatedValuesByDate[lastDate]
?.totalAccountBalanceWithCurrencyEffect ?? new Big(0)), ?.totalAccountBalanceWithCurrencyEffect ?? new Big(0),
totalCurrentValue: ( totalCurrentValue: (
accumulatedValuesByDate[dateString]?.totalCurrentValue ?? new Big(0) accumulatedValuesByDate[dateString]?.totalCurrentValue ?? new Big(0)
).add(currentValue), ).add(currentValue),
@ -592,10 +595,12 @@ export abstract class PortfolioCalculator {
}; };
} }
@LogPerformance
public getDataProviderInfos() { public getDataProviderInfos() {
return this.dataProviderInfos; return this.dataProviderInfos;
} }
@LogPerformance
public async getDividendInBaseCurrency() { public async getDividendInBaseCurrency() {
await this.snapshotPromise; await this.snapshotPromise;
@ -606,18 +611,21 @@ export abstract class PortfolioCalculator {
); );
} }
@LogPerformance
public async getFeesInBaseCurrency() { public async getFeesInBaseCurrency() {
await this.snapshotPromise; await this.snapshotPromise;
return this.snapshot.totalFeesWithCurrencyEffect; return this.snapshot.totalFeesWithCurrencyEffect;
} }
@LogPerformance
public async getInterestInBaseCurrency() { public async getInterestInBaseCurrency() {
await this.snapshotPromise; await this.snapshotPromise;
return this.snapshot.totalInterestWithCurrencyEffect; return this.snapshot.totalInterestWithCurrencyEffect;
} }
@LogPerformance
public getInvestments(): { date: string; investment: Big }[] { public getInvestments(): { date: string; investment: Big }[] {
if (this.transactionPoints.length === 0) { if (this.transactionPoints.length === 0) {
return []; return [];
@ -635,6 +643,7 @@ export abstract class PortfolioCalculator {
}); });
} }
@LogPerformance
public getInvestmentsByGroup({ public getInvestmentsByGroup({
data, data,
groupBy groupBy
@ -658,6 +667,7 @@ export abstract class PortfolioCalculator {
})); }));
} }
@LogPerformance
public async getLiabilitiesInBaseCurrency() { public async getLiabilitiesInBaseCurrency() {
await this.snapshotPromise; await this.snapshotPromise;
@ -732,6 +742,7 @@ export abstract class PortfolioCalculator {
return { chart }; return { chart };
} }
@LogPerformance
public async getSnapshot() { public async getSnapshot() {
await this.snapshotPromise; await this.snapshotPromise;
@ -787,6 +798,7 @@ export abstract class PortfolioCalculator {
return this.transactionPoints; return this.transactionPoints;
} }
@LogPerformance
public async getValuablesInBaseCurrency() { public async getValuablesInBaseCurrency() {
await this.snapshotPromise; await this.snapshotPromise;
@ -861,7 +873,76 @@ export abstract class PortfolioCalculator {
return chartDateMap; return chartDateMap;
} }
private computeTransactionPoints() { private getChartDateMap({
endDate,
startDate,
step
}: {
endDate: Date;
startDate: Date;
step: number;
}) {
// Create a map of all relevant chart dates:
// 1. Add transaction point dates
let chartDateMap = this.transactionPoints.reduce((result, { date }) => {
result[date] = true;
return result;
}, {});
// 2. Add dates between transactions respecting the specified step size
for (let date of eachDayOfInterval(
{ end: endDate, start: startDate },
{ step }
)) {
chartDateMap[format(date, DATE_FORMAT)] = true;
}
if (step > 1) {
// Reduce the step size of last 90 days
for (let date of eachDayOfInterval(
{ end: endDate, start: subDays(endDate, 90) },
{ step: 3 }
)) {
chartDateMap[format(date, DATE_FORMAT)] = true;
}
// Reduce the step size of last 30 days
for (let date of eachDayOfInterval(
{ end: endDate, start: subDays(endDate, 30) },
{ step: 1 }
)) {
chartDateMap[format(date, DATE_FORMAT)] = true;
}
}
// Make sure the end date is present
chartDateMap[format(endDate, DATE_FORMAT)] = true;
// Make sure some key dates are present
for (let dateRange of ['1d', '1y', '5y', 'max', 'mtd', 'wtd', 'ytd']) {
const { endDate: dateRangeEnd, startDate: dateRangeStart } =
getIntervalFromDateRange(dateRange);
if (
!isBefore(dateRangeStart, startDate) &&
!isAfter(dateRangeStart, endDate)
) {
chartDateMap[format(dateRangeStart, DATE_FORMAT)] = true;
}
if (
!isBefore(dateRangeEnd, startDate) &&
!isAfter(dateRangeEnd, endDate)
) {
chartDateMap[format(dateRangeEnd, DATE_FORMAT)] = true;
}
}
return chartDateMap;
}
@LogPerformance
protected computeTransactionPoints() {
this.transactionPoints = []; this.transactionPoints = [];
const symbols: { [symbol: string]: TransactionPointSymbol } = {}; const symbols: { [symbol: string]: TransactionPointSymbol } = {};
@ -999,7 +1080,8 @@ export abstract class PortfolioCalculator {
} }
} }
private async initialize() { @LogPerformance
protected async initialize() {
const startTimeTotal = performance.now(); const startTimeTotal = performance.now();
const cachedSnapshot = await this.redisCacheService.get( const cachedSnapshot = await this.redisCacheService.get(

3
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts

@ -62,7 +62,8 @@ describe('PortfolioCalculator', () => {
configurationService, configurationService,
currentRateService, currentRateService,
exchangeRateDataService, exchangeRateDataService,
redisCacheService redisCacheService,
null
); );
}); });

3
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell.spec.ts

@ -62,7 +62,8 @@ describe('PortfolioCalculator', () => {
configurationService, configurationService,
currentRateService, currentRateService,
exchangeRateDataService, exchangeRateDataService,
redisCacheService redisCacheService,
null
); );
}); });

3
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy.spec.ts

@ -62,7 +62,8 @@ describe('PortfolioCalculator', () => {
configurationService, configurationService,
currentRateService, currentRateService,
exchangeRateDataService, exchangeRateDataService,
redisCacheService redisCacheService,
null
); );
}); });

3
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts

@ -75,7 +75,8 @@ describe('PortfolioCalculator', () => {
configurationService, configurationService,
currentRateService, currentRateService,
exchangeRateDataService, exchangeRateDataService,
redisCacheService redisCacheService,
null
); );
}); });

3
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-fee.spec.ts

@ -62,7 +62,8 @@ describe('PortfolioCalculator', () => {
configurationService, configurationService,
currentRateService, currentRateService,
exchangeRateDataService, exchangeRateDataService,
redisCacheService redisCacheService,
null
); );
}); });

3
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-googl-buy.spec.ts

@ -75,7 +75,8 @@ describe('PortfolioCalculator', () => {
configurationService, configurationService,
currentRateService, currentRateService,
exchangeRateDataService, exchangeRateDataService,
redisCacheService redisCacheService,
null
); );
}); });

3
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-item.spec.ts

@ -62,7 +62,8 @@ describe('PortfolioCalculator', () => {
configurationService, configurationService,
currentRateService, currentRateService,
exchangeRateDataService, exchangeRateDataService,
redisCacheService redisCacheService,
null
); );
}); });

3
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-liability.spec.ts

@ -61,7 +61,8 @@ describe('PortfolioCalculator', () => {
configurationService, configurationService,
currentRateService, currentRateService,
exchangeRateDataService, exchangeRateDataService,
redisCacheService redisCacheService,
null
); );
}); });

3
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-msft-buy-with-dividend.spec.ts

@ -75,7 +75,8 @@ describe('PortfolioCalculator', () => {
configurationService, configurationService,
currentRateService, currentRateService,
exchangeRateDataService, exchangeRateDataService,
redisCacheService redisCacheService,
null
); );
}); });

3
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-no-orders.spec.ts

@ -58,7 +58,8 @@ describe('PortfolioCalculator', () => {
configurationService, configurationService,
currentRateService, currentRateService,
exchangeRateDataService, exchangeRateDataService,
redisCacheService redisCacheService,
null
); );
}); });

3
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell-partially.spec.ts

@ -62,7 +62,8 @@ describe('PortfolioCalculator', () => {
configurationService, configurationService,
currentRateService, currentRateService,
exchangeRateDataService, exchangeRateDataService,
redisCacheService redisCacheService,
null
); );
}); });

3
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell.spec.ts

@ -62,7 +62,8 @@ describe('PortfolioCalculator', () => {
configurationService, configurationService,
currentRateService, currentRateService,
exchangeRateDataService, exchangeRateDataService,
redisCacheService redisCacheService,
null
); );
}); });

3
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.spec.ts

@ -29,7 +29,8 @@ describe('PortfolioCalculator', () => {
configurationService, configurationService,
currentRateService, currentRateService,
exchangeRateDataService, exchangeRateDataService,
redisCacheService redisCacheService,
null
); );
}); });

635
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts

@ -1,3 +1,4 @@
import { LogPerformance } from '@ghostfolio/api/aop/logging.interceptor';
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 { PortfolioOrderItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order-item.interface';
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
@ -11,6 +12,7 @@ import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
import { DateRange } from '@ghostfolio/common/types'; import { DateRange } from '@ghostfolio/common/types';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { import {
addDays, addDays,
@ -23,6 +25,7 @@ import {
import { cloneDeep, first, last, sortBy } from 'lodash'; import { cloneDeep, first, last, sortBy } from 'lodash';
export class TWRPortfolioCalculator extends PortfolioCalculator { export class TWRPortfolioCalculator extends PortfolioCalculator {
@LogPerformance
protected calculateOverallPerformance( protected calculateOverallPerformance(
positions: TimelinePosition[] positions: TimelinePosition[]
): PortfolioSnapshot { ): PortfolioSnapshot {
@ -112,6 +115,7 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
}; };
} }
@LogPerformance
protected getSymbolMetrics({ protected getSymbolMetrics({
chartDateMap, chartDateMap,
dataSource, dataSource,
@ -163,6 +167,7 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
let totalAccountBalanceInBaseCurrency = new Big(0); let totalAccountBalanceInBaseCurrency = new Big(0);
let totalDividend = new Big(0); let totalDividend = new Big(0);
let totalStakeRewards = new Big(0);
let totalDividendInBaseCurrency = new Big(0); let totalDividendInBaseCurrency = new Big(0);
let totalInterest = new Big(0); let totalInterest = new Big(0);
let totalInterestInBaseCurrency = new Big(0); let totalInterestInBaseCurrency = new Big(0);
@ -190,6 +195,7 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
return { return {
currentValues: {}, currentValues: {},
currentValuesWithCurrencyEffect: {}, currentValuesWithCurrencyEffect: {},
unitPrices: {},
feesWithCurrencyEffect: new Big(0), feesWithCurrencyEffect: new Big(0),
grossPerformance: new Big(0), grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0), grossPerformancePercentage: new Big(0),
@ -206,6 +212,7 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
netPerformancePercentageWithCurrencyEffectMap: {}, netPerformancePercentageWithCurrencyEffectMap: {},
netPerformanceValues: {}, netPerformanceValues: {},
netPerformanceValuesWithCurrencyEffect: {}, netPerformanceValuesWithCurrencyEffect: {},
netPerformanceValuesPercentage: {},
netPerformanceWithCurrencyEffectMap: {}, netPerformanceWithCurrencyEffectMap: {},
timeWeightedInvestment: new Big(0), timeWeightedInvestment: new Big(0),
timeWeightedInvestmentValues: {}, timeWeightedInvestmentValues: {},
@ -240,6 +247,7 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
return { return {
currentValues: {}, currentValues: {},
currentValuesWithCurrencyEffect: {}, currentValuesWithCurrencyEffect: {},
unitPrices: {},
feesWithCurrencyEffect: new Big(0), feesWithCurrencyEffect: new Big(0),
grossPerformance: new Big(0), grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0), grossPerformancePercentage: new Big(0),
@ -257,6 +265,7 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
netPerformanceWithCurrencyEffectMap: {}, netPerformanceWithCurrencyEffectMap: {},
netPerformanceValues: {}, netPerformanceValues: {},
netPerformanceValuesWithCurrencyEffect: {}, netPerformanceValuesWithCurrencyEffect: {},
netPerformanceValuesPercentage: {},
timeWeightedInvestment: new Big(0), timeWeightedInvestment: new Big(0),
timeWeightedInvestmentValues: {}, timeWeightedInvestmentValues: {},
timeWeightedInvestmentValuesWithCurrencyEffect: {}, timeWeightedInvestmentValuesWithCurrencyEffect: {},
@ -372,6 +381,316 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
let sumOfTimeWeightedInvestments = new Big(0); let sumOfTimeWeightedInvestments = new Big(0);
let sumOfTimeWeightedInvestmentsWithCurrencyEffect = new Big(0); let sumOfTimeWeightedInvestmentsWithCurrencyEffect = new Big(0);
({
totalDividend,
totalDividendInBaseCurrency,
totalInterest,
totalInterestInBaseCurrency,
totalValuables,
totalValuablesInBaseCurrency,
totalLiabilities,
totalLiabilitiesInBaseCurrency,
totalUnits,
investmentAtStartDate,
totalInvestment,
investmentAtStartDateWithCurrencyEffect,
totalInvestmentWithCurrencyEffect,
valueAtStartDate,
valueAtStartDateWithCurrencyEffect,
totalQuantityFromBuyTransactions,
totalInvestmentFromBuyTransactions,
totalInvestmentFromBuyTransactionsWithCurrencyEffect,
initialValue,
initialValueWithCurrencyEffect,
fees,
feesWithCurrencyEffect,
lastAveragePrice,
lastAveragePriceWithCurrencyEffect,
grossPerformanceFromSells,
grossPerformanceFromSellsWithCurrencyEffect,
grossPerformance,
grossPerformanceWithCurrencyEffect,
feesAtStartDate,
feesAtStartDateWithCurrencyEffect,
grossPerformanceAtStartDate,
grossPerformanceAtStartDateWithCurrencyEffect,
totalInvestmentDays,
sumOfTimeWeightedInvestments,
sumOfTimeWeightedInvestmentsWithCurrencyEffect
} = this.handleOrders(
orders,
exchangeRates,
totalDividend,
totalDividendInBaseCurrency,
totalInterest,
totalInterestInBaseCurrency,
totalValuables,
totalValuablesInBaseCurrency,
totalLiabilities,
totalLiabilitiesInBaseCurrency,
indexOfStartOrder,
unitPriceAtStartDate,
currentExchangeRate,
marketSymbolMap,
symbol,
totalUnits,
investmentAtStartDate,
totalInvestment,
investmentAtStartDateWithCurrencyEffect,
totalInvestmentWithCurrencyEffect,
valueAtStartDate,
valueAtStartDateWithCurrencyEffect,
totalQuantityFromBuyTransactions,
totalInvestmentFromBuyTransactions,
totalInvestmentFromBuyTransactionsWithCurrencyEffect,
initialValue,
initialValueWithCurrencyEffect,
fees,
feesWithCurrencyEffect,
lastAveragePrice,
lastAveragePriceWithCurrencyEffect,
grossPerformanceFromSells,
grossPerformanceFromSellsWithCurrencyEffect,
grossPerformance,
grossPerformanceWithCurrencyEffect,
feesAtStartDate,
feesAtStartDateWithCurrencyEffect,
grossPerformanceAtStartDate,
grossPerformanceAtStartDateWithCurrencyEffect,
totalInvestmentDays,
sumOfTimeWeightedInvestments,
sumOfTimeWeightedInvestmentsWithCurrencyEffect,
isChartMode,
currentValues,
currentValuesWithCurrencyEffect,
netPerformanceValues,
netPerformanceValuesWithCurrencyEffect,
investmentValuesAccumulated,
investmentValuesAccumulatedWithCurrencyEffect,
investmentValuesWithCurrencyEffect,
timeWeightedInvestmentValues,
timeWeightedInvestmentValuesWithCurrencyEffect,
indexOfEndOrder
));
const totalGrossPerformance = grossPerformance.minus(
grossPerformanceAtStartDate
);
const totalGrossPerformanceWithCurrencyEffect =
grossPerformanceWithCurrencyEffect.minus(
grossPerformanceAtStartDateWithCurrencyEffect
);
const totalNetPerformance = grossPerformance
.minus(grossPerformanceAtStartDate)
.minus(fees.minus(feesAtStartDate));
const totalNetPerformanceWithCurrencyEffect =
grossPerformanceWithCurrencyEffect
.minus(grossPerformanceAtStartDateWithCurrencyEffect)
.minus(feesWithCurrencyEffect.minus(feesAtStartDateWithCurrencyEffect));
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 netPerformancePercentageWithCurrencyEffect =
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.gt(
0
)
? totalNetPerformanceWithCurrencyEffect.div(
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect
)
: 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: ${totalNetPerformanceWithCurrencyEffect.toFixed(
2
)} / ${netPerformancePercentageWithCurrencyEffect.mul(100).toFixed(2)}%`
);
}
let unitPrices = Object.keys(marketSymbolMap)
.map((date) => {
return { [date]: marketSymbolMap[date][symbol] };
})
.reduce((map, u) => {
return { ...u, ...map };
}, {});
return {
currentValues,
currentValuesWithCurrencyEffect,
unitPrices,
feesWithCurrencyEffect,
grossPerformancePercentage,
grossPerformancePercentageWithCurrencyEffect,
initialValue,
initialValueWithCurrencyEffect,
investmentValuesAccumulated,
investmentValuesAccumulatedWithCurrencyEffect,
investmentValuesWithCurrencyEffect,
netPerformancePercentage,
netPerformancePercentageWithCurrencyEffect,
netPerformanceValues,
netPerformanceValuesWithCurrencyEffect,
netPerformanceValuesPercentage: {},
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,
netPerformanceWithCurrencyEffect: totalNetPerformanceWithCurrencyEffect,
timeWeightedInvestment:
timeWeightedAverageInvestmentBetweenStartAndEndDate,
timeWeightedInvestmentWithCurrencyEffect:
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect
};
}
@LogPerformance
protected handleOrders(
orders: PortfolioOrderItem[],
exchangeRates: { [dateString: string]: number },
totalDividend,
totalDividendInBaseCurrency,
totalInterest,
totalInterestInBaseCurrency,
totalValuables,
totalValuablesInBaseCurrency,
totalLiabilities,
totalLiabilitiesInBaseCurrency,
indexOfStartOrder: number,
unitPriceAtStartDate: Big,
currentExchangeRate: number,
marketSymbolMap: { [date: string]: { [symbol: string]: Big } },
symbol: string,
totalUnits,
investmentAtStartDate: Big,
totalInvestment,
investmentAtStartDateWithCurrencyEffect: Big,
totalInvestmentWithCurrencyEffect,
valueAtStartDate: Big,
valueAtStartDateWithCurrencyEffect: Big,
totalQuantityFromBuyTransactions,
totalInvestmentFromBuyTransactions,
totalInvestmentFromBuyTransactionsWithCurrencyEffect,
initialValue: Big,
initialValueWithCurrencyEffect: Big,
fees,
feesWithCurrencyEffect,
lastAveragePrice,
lastAveragePriceWithCurrencyEffect,
grossPerformanceFromSells,
grossPerformanceFromSellsWithCurrencyEffect,
grossPerformance,
grossPerformanceWithCurrencyEffect,
feesAtStartDate,
feesAtStartDateWithCurrencyEffect,
grossPerformanceAtStartDate,
grossPerformanceAtStartDateWithCurrencyEffect,
totalInvestmentDays: number,
sumOfTimeWeightedInvestments,
sumOfTimeWeightedInvestmentsWithCurrencyEffect,
isChartMode: boolean,
currentValues: { [date: string]: Big },
currentValuesWithCurrencyEffect: { [date: string]: Big },
netPerformanceValues: { [date: string]: Big },
netPerformanceValuesWithCurrencyEffect: { [date: string]: Big },
investmentValuesAccumulated: { [date: string]: Big },
investmentValuesAccumulatedWithCurrencyEffect: { [date: string]: Big },
investmentValuesWithCurrencyEffect: { [date: string]: Big },
timeWeightedInvestmentValues: { [date: string]: Big },
timeWeightedInvestmentValuesWithCurrencyEffect: { [date: string]: Big },
indexOfEndOrder: number
) {
for (let i = 0; i < orders.length; i += 1) { for (let i = 0; i < orders.length; i += 1) {
const order = orders[i]; const order = orders[i];
@ -388,35 +707,27 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
const exchangeRateAtOrderDate = exchangeRates[order.date]; const exchangeRateAtOrderDate = exchangeRates[order.date];
if (order.type === 'DIVIDEND') { ({
const dividend = order.quantity.mul(order.unitPrice); totalDividend,
totalDividendInBaseCurrency,
totalDividend = totalDividend.plus(dividend); totalInterest,
totalDividendInBaseCurrency = totalDividendInBaseCurrency.plus( totalInterestInBaseCurrency,
dividend.mul(exchangeRateAtOrderDate ?? 1) totalValuables,
); totalValuablesInBaseCurrency,
} else if (order.type === 'INTEREST') { totalLiabilities,
const interest = order.quantity.mul(order.unitPrice); totalLiabilitiesInBaseCurrency
} = this.handleOrderType(
totalInterest = totalInterest.plus(interest); order,
totalInterestInBaseCurrency = totalInterestInBaseCurrency.plus( totalDividend,
interest.mul(exchangeRateAtOrderDate ?? 1) totalDividendInBaseCurrency,
); exchangeRateAtOrderDate,
} else if (order.type === 'ITEM') { totalInterest,
const valuables = order.quantity.mul(order.unitPrice); totalInterestInBaseCurrency,
totalValuables,
totalValuables = totalValuables.plus(valuables); totalValuablesInBaseCurrency,
totalValuablesInBaseCurrency = totalValuablesInBaseCurrency.plus( totalLiabilities,
valuables.mul(exchangeRateAtOrderDate ?? 1) totalLiabilitiesInBaseCurrency
); ));
} 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') { if (order.itemType === 'start') {
// Take the unit price of the order as the market price if there are no // Take the unit price of the order as the market price if there are no
@ -434,7 +745,15 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
); );
} }
const unitPrice = ['BUY', 'SELL'].includes(order.type) if (order.type === 'STAKE') {
order.unitPrice = marketSymbolMap[order.date]?.[symbol] ?? new Big(0);
}
if (order.type === 'STAKE') {
order.unitPrice = marketSymbolMap[order.date]?.[symbol] ?? new Big(0);
}
const unitPrice = ['BUY', 'SELL', 'STAKE'].includes(order.type)
? order.unitPrice ? order.unitPrice
: order.unitPriceFromMarketData; : order.unitPriceFromMarketData;
@ -468,38 +787,23 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
let transactionInvestment = new Big(0); let transactionInvestment = new Big(0);
let transactionInvestmentWithCurrencyEffect = new Big(0); let transactionInvestmentWithCurrencyEffect = new Big(0);
if (order.type === 'BUY') { ({
transactionInvestment = order.quantity transactionInvestment,
.mul(order.unitPriceInBaseCurrency) transactionInvestmentWithCurrencyEffect,
.mul(getFactor(order.type)); totalQuantityFromBuyTransactions,
totalInvestmentFromBuyTransactions,
transactionInvestmentWithCurrencyEffect = order.quantity totalInvestmentFromBuyTransactionsWithCurrencyEffect
.mul(order.unitPriceInBaseCurrencyWithCurrencyEffect) } = this.handleBuyAndSellOrders(
.mul(getFactor(order.type)); order,
transactionInvestment,
totalQuantityFromBuyTransactions = transactionInvestmentWithCurrencyEffect,
totalQuantityFromBuyTransactions.plus(order.quantity); totalQuantityFromBuyTransactions,
totalInvestmentFromBuyTransactions,
totalInvestmentFromBuyTransactions = totalInvestmentFromBuyTransactionsWithCurrencyEffect,
totalInvestmentFromBuyTransactions.plus(transactionInvestment); totalUnits,
totalInvestment,
totalInvestmentFromBuyTransactionsWithCurrencyEffect = totalInvestmentWithCurrencyEffect
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) { if (PortfolioCalculator.ENABLE_LOGGING) {
console.log('order.quantity', order.quantity.toNumber()); console.log('order.quantity', order.quantity.toNumber());
@ -511,90 +815,140 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
); );
} }
const totalInvestmentBeforeTransaction = totalInvestment; let valueOfInvestment;
let valueOfInvestmentWithCurrencyEffect;
const totalInvestmentBeforeTransactionWithCurrencyEffect = let totalInvestmentBeforeTransaction;
totalInvestmentWithCurrencyEffect; let totalInvestmentBeforeTransactionWithCurrencyEffect;
({
valueOfInvestment,
valueOfInvestmentWithCurrencyEffect,
totalInvestmentBeforeTransaction,
totalInvestmentBeforeTransactionWithCurrencyEffect,
totalInvestment,
totalInvestmentWithCurrencyEffect,
initialValue,
initialValueWithCurrencyEffect,
fees,
feesWithCurrencyEffect,
totalUnits
} = this.calculateInvestmentValues(
totalInvestment,
totalInvestmentWithCurrencyEffect,
transactionInvestment,
transactionInvestmentWithCurrencyEffect,
i,
indexOfStartOrder,
initialValue,
valueOfInvestmentBeforeTransaction,
initialValueWithCurrencyEffect,
valueOfInvestmentBeforeTransactionWithCurrencyEffect,
fees,
order,
feesWithCurrencyEffect,
totalUnits
));
({
lastAveragePrice,
lastAveragePriceWithCurrencyEffect,
grossPerformanceFromSells,
grossPerformanceFromSellsWithCurrencyEffect,
grossPerformance,
grossPerformanceWithCurrencyEffect
} = this.calculatePerformances(
order,
lastAveragePrice,
lastAveragePriceWithCurrencyEffect,
grossPerformanceFromSells,
grossPerformanceFromSellsWithCurrencyEffect,
totalQuantityFromBuyTransactions,
totalInvestmentFromBuyTransactions,
totalInvestmentFromBuyTransactionsWithCurrencyEffect,
valueOfInvestment,
totalInvestment,
valueOfInvestmentWithCurrencyEffect,
totalInvestmentWithCurrencyEffect,
grossPerformance,
grossPerformanceWithCurrencyEffect
));
totalInvestment = totalInvestment.plus(transactionInvestment); if (order.itemType === 'start') {
feesAtStartDate = fees;
totalInvestmentWithCurrencyEffect = feesAtStartDateWithCurrencyEffect = feesWithCurrencyEffect;
totalInvestmentWithCurrencyEffect.plus( grossPerformanceAtStartDate = grossPerformance;
transactionInvestmentWithCurrencyEffect
);
if (i >= indexOfStartOrder && !initialValue) {
if (
i === indexOfStartOrder &&
!valueOfInvestmentBeforeTransaction.eq(0)
) {
initialValue = valueOfInvestmentBeforeTransaction;
initialValueWithCurrencyEffect =
valueOfInvestmentBeforeTransactionWithCurrencyEffect;
} else if (transactionInvestment.gt(0)) {
initialValue = transactionInvestment;
initialValueWithCurrencyEffect = grossPerformanceAtStartDateWithCurrencyEffect =
transactionInvestmentWithCurrencyEffect; grossPerformanceWithCurrencyEffect;
}
} }
fees = fees.plus(order.feeInBaseCurrency ?? 0); if (
i > indexOfStartOrder &&
feesWithCurrencyEffect = feesWithCurrencyEffect.plus( ['BUY', 'SELL', 'STAKE'].includes(order.type)
order.feeInBaseCurrencyWithCurrencyEffect ?? 0 ) {
); // Only consider periods with an investment for the calculation of
// the time weighted investment
totalUnits = totalUnits.plus(order.quantity.mul(getFactor(order.type))); ({
totalInvestmentDays,
const valueOfInvestment = totalUnits.mul(order.unitPriceInBaseCurrency); sumOfTimeWeightedInvestments,
sumOfTimeWeightedInvestmentsWithCurrencyEffect
const valueOfInvestmentWithCurrencyEffect = totalUnits.mul( } = this.calculateTimeWeightedInvestments(
order.unitPriceInBaseCurrencyWithCurrencyEffect valueOfInvestmentBeforeTransaction,
); order,
orders,
i,
totalInvestmentDays,
sumOfTimeWeightedInvestments,
valueAtStartDate,
investmentAtStartDate,
totalInvestmentBeforeTransaction,
sumOfTimeWeightedInvestmentsWithCurrencyEffect,
valueAtStartDateWithCurrencyEffect,
investmentAtStartDateWithCurrencyEffect,
totalInvestmentBeforeTransactionWithCurrencyEffect,
isChartMode,
currentValues,
valueOfInvestment,
currentValuesWithCurrencyEffect,
valueOfInvestmentWithCurrencyEffect,
netPerformanceValues,
grossPerformance,
grossPerformanceAtStartDate,
fees,
feesAtStartDate,
netPerformanceValuesWithCurrencyEffect,
grossPerformanceWithCurrencyEffect,
grossPerformanceAtStartDateWithCurrencyEffect,
feesWithCurrencyEffect,
feesAtStartDateWithCurrencyEffect,
investmentValuesAccumulated,
totalInvestment,
investmentValuesAccumulatedWithCurrencyEffect,
totalInvestmentWithCurrencyEffect,
investmentValuesWithCurrencyEffect,
transactionInvestmentWithCurrencyEffect,
timeWeightedInvestmentValues,
timeWeightedInvestmentValuesWithCurrencyEffect
));
}
const grossPerformanceFromSell = if (PortfolioCalculator.ENABLE_LOGGING) {
order.type === 'SELL' console.log('totalInvestment', totalInvestment.toNumber());
? 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 = console.log(
grossPerformanceFromSellsWithCurrencyEffect.plus( 'totalInvestmentWithCurrencyEffect',
grossPerformanceFromSellWithCurrencyEffect totalInvestmentWithCurrencyEffect.toNumber()
); );
lastAveragePrice = totalQuantityFromBuyTransactions.eq(0) console.log(
? new Big(0) 'totalGrossPerformance',
: totalInvestmentFromBuyTransactions.div( grossPerformance.minus(grossPerformanceAtStartDate).toNumber()
totalQuantityFromBuyTransactions );
);
lastAveragePriceWithCurrencyEffect = totalQuantityFromBuyTransactions.eq(
0
)
? new Big(0)
: totalInvestmentFromBuyTransactionsWithCurrencyEffect.div(
totalQuantityFromBuyTransactions
);
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log( console.log(
'grossPerformanceFromSells', 'totalGrossPerformanceWithCurrencyEffect',
grossPerformanceFromSells.toNumber() grossPerformanceWithCurrencyEffect
.minus(grossPerformanceAtStartDateWithCurrencyEffect)
.toNumber()
); );
console.log( console.log(
'grossPerformanceFromSellWithCurrencyEffect', 'grossPerformanceFromSellWithCurrencyEffect',
@ -872,9 +1226,9 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
// differs from the buying price. // differs from the buying price.
dateRange === 'max' dateRange === 'max'
? new Big(0) ? new Big(0)
: (netPerformanceValuesWithCurrencyEffect[ : netPerformanceValuesWithCurrencyEffect[
format(startDate, DATE_FORMAT) format(startDate, DATE_FORMAT)
] ?? new Big(0)) ] ?? new Big(0)
) ?? new Big(0); ) ?? new Big(0);
netPerformancePercentageWithCurrencyEffectMap[dateRange] = average.gt(0) netPerformancePercentageWithCurrencyEffectMap[dateRange] = average.gt(0)
@ -920,7 +1274,6 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
].toFixed(2)}%` ].toFixed(2)}%`
); );
} }
return { return {
currentValues, currentValues,
currentValuesWithCurrencyEffect, currentValuesWithCurrencyEffect,
@ -944,10 +1297,6 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
totalDividendInBaseCurrency, totalDividendInBaseCurrency,
totalInterest, totalInterest,
totalInterestInBaseCurrency, totalInterestInBaseCurrency,
totalInvestment,
totalInvestmentWithCurrencyEffect,
totalLiabilities,
totalLiabilitiesInBaseCurrency,
totalValuables, totalValuables,
totalValuablesInBaseCurrency, totalValuablesInBaseCurrency,
grossPerformance: totalGrossPerformance, grossPerformance: totalGrossPerformance,

120
apps/api/src/app/portfolio/current-rate.service.ts

@ -1,11 +1,14 @@
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { DateQueryHelper } from '@ghostfolio/api/helper/dateQueryHelper';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { resetHours } from '@ghostfolio/common/helper'; import { resetHours } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
DataProviderInfo, DataProviderInfo,
ResponseError ResponseError,
UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
@ -20,6 +23,8 @@ import { GetValuesParams } from './interfaces/get-values-params.interface';
@Injectable() @Injectable()
export class CurrentRateService { export class CurrentRateService {
private dateQueryHelper = new DateQueryHelper();
public constructor( public constructor(
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
@ -38,58 +43,33 @@ export class CurrentRateService {
(!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) && (!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) &&
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) && (!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
(!dateQuery.in || this.containsToday(dateQuery.in)); (!dateQuery.in || this.containsToday(dateQuery.in));
let { query, dates } = this.dateQueryHelper.handleDateQueryIn(dateQuery);
const promises: Promise<GetValueObject[]>[] = []; const promises: Promise<GetValueObject[]>[] = [];
const quoteErrors: ResponseError['errors'] = []; const quoteErrors: ResponseError['errors'] = [];
const today = resetHours(new Date()); const today = resetHours(new Date());
if (includesToday) { if (includesToday) {
promises.push( promises.push(
this.dataProviderService this.getTodayPrivate(
.getQuotes({ items: dataGatheringItems, user: this.request?.user }) dataGatheringItems,
.then((dataResultProvider) => { dataProviderInfos,
const result: GetValueObject[] = []; today,
quoteErrors
for (const dataGatheringItem of dataGatheringItems) { )
if (
dataResultProvider?.[dataGatheringItem.symbol]?.dataProviderInfo
) {
dataProviderInfos.push(
dataResultProvider[dataGatheringItem.symbol].dataProviderInfo
);
}
if (dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice) {
result.push({
dataSource: dataGatheringItem.dataSource,
date: today,
marketPrice:
dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice,
symbol: dataGatheringItem.symbol
});
} else {
quoteErrors.push({
dataSource: dataGatheringItem.dataSource,
symbol: dataGatheringItem.symbol
});
}
}
return result;
})
); );
} }
const assetProfileIdentifiers: AssetProfileIdentifier[] = const uniqueAssets: UniqueAsset[] = dataGatheringItems.map(
dataGatheringItems.map(({ dataSource, symbol }) => { ({ dataSource, symbol }) => {
return { dataSource, symbol }; return { dataSource, symbol };
}); }
);
promises.push( promises.push(
this.marketDataService this.marketDataService
.getRange({ .getRange({
assetProfileIdentifiers, dateQuery: query,
dateQuery uniqueAssets
}) })
.then((data) => { .then((data) => {
return data.map(({ dataSource, date, marketPrice, symbol }) => { return data.map(({ dataSource, date, marketPrice, symbol }) => {
@ -110,9 +90,12 @@ export class CurrentRateService {
errors: quoteErrors.map(({ dataSource, symbol }) => { errors: quoteErrors.map(({ dataSource, symbol }) => {
return { dataSource, symbol }; return { dataSource, symbol };
}), }),
values: uniqBy(values, ({ date, symbol }) => `${date}-${symbol}`) values: uniqBy(values, ({ date, symbol }) => `${date}-${symbol}`).filter(
(v) =>
dates?.length === 0 ||
dates.some((d: Date) => d.getTime() === v.date.getTime())
)
}; };
if (!isEmpty(quoteErrors)) { if (!isEmpty(quoteErrors)) {
for (const { dataSource, symbol } of quoteErrors) { for (const { dataSource, symbol } of quoteErrors) {
try { try {
@ -162,6 +145,61 @@ export class CurrentRateService {
return response; return response;
} }
public async getToday(
dataGatheringItems: IDataGatheringItem[]
): Promise<GetValueObject[]> {
const dataProviderInfos: DataProviderInfo[] = [];
const quoteErrors: UniqueAsset[] = [];
const today = resetHours(new Date());
return this.getTodayPrivate(
dataGatheringItems,
dataProviderInfos,
today,
quoteErrors
);
}
private async getTodayPrivate(
dataGatheringItems: IDataGatheringItem[],
dataProviderInfos: DataProviderInfo[],
today: Date,
quoteErrors: UniqueAsset[]
): Promise<GetValueObject[]> {
return this.dataProviderService
.getQuotes({ items: dataGatheringItems, user: this.request?.user })
.then((dataResultProvider) => {
const result: GetValueObject[] = [];
for (const dataGatheringItem of dataGatheringItems) {
if (
dataResultProvider?.[dataGatheringItem.symbol]?.dataProviderInfo
) {
dataProviderInfos.push(
dataResultProvider[dataGatheringItem.symbol].dataProviderInfo
);
}
if (dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice) {
result.push({
dataSource: dataGatheringItem.dataSource,
date: today,
marketPrice:
dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice,
symbol: dataGatheringItem.symbol
});
} else {
quoteErrors.push({
dataSource: dataGatheringItem.dataSource,
symbol: dataGatheringItem.symbol
});
}
}
return result;
});
}
private containsToday(dates: Date[]): boolean { private containsToday(dates: Date[]): boolean {
for (const date of dates) { for (const date of dates) {
if (isToday(date)) { if (isToday(date)) {

1
apps/api/src/app/portfolio/interfaces/portfolio-holding-detail.interface.ts

@ -12,6 +12,7 @@ export interface PortfolioHoldingDetail {
averagePrice: number; averagePrice: number;
dataProviderInfo: DataProviderInfo; dataProviderInfo: DataProviderInfo;
dividendInBaseCurrency: number; dividendInBaseCurrency: number;
stakeRewards: number;
dividendYieldPercent: number; dividendYieldPercent: number;
dividendYieldPercentWithCurrencyEffect: number; dividendYieldPercentWithCurrencyEffect: number;
feeInBaseCurrency: number; feeInBaseCurrency: number;

5
apps/api/src/app/portfolio/interfaces/portfolio-order-item.interface.ts

@ -10,3 +10,8 @@ export interface PortfolioOrderItem extends PortfolioOrder {
unitPriceInBaseCurrency?: Big; unitPriceInBaseCurrency?: Big;
unitPriceInBaseCurrencyWithCurrencyEffect?: Big; unitPriceInBaseCurrencyWithCurrencyEffect?: Big;
} }
export interface WithCurrencyEffect<T> {
Value: T;
WithCurrencyEffect: T;
}

30
apps/api/src/app/portfolio/portfolio.controller.ts

@ -1,3 +1,4 @@
import { LogPerformance } from '@ghostfolio/api/aop/logging.interceptor';
import { AccessService } from '@ghostfolio/api/app/access/access.service'; import { AccessService } from '@ghostfolio/api/app/access/access.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
@ -87,6 +88,7 @@ export class PortfolioController {
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('range') dateRange: DateRange = 'max', @Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string, @Query('tags') filterByTags?: string,
@Query('isAllocation') isAllocation: boolean = false,
@Query('withMarkets') withMarketsParam = 'false' @Query('withMarkets') withMarketsParam = 'false'
): Promise<PortfolioDetails & { hasError: boolean }> { ): Promise<PortfolioDetails & { hasError: boolean }> {
const withMarkets = withMarketsParam === 'true'; const withMarkets = withMarketsParam === 'true';
@ -152,6 +154,24 @@ export class PortfolioController {
portfolioPosition.investment / totalInvestment; portfolioPosition.investment / totalInvestment;
portfolioPosition.valueInPercentage = portfolioPosition.valueInPercentage =
portfolioPosition.valueInBaseCurrency / totalValue; portfolioPosition.valueInBaseCurrency / totalValue;
(portfolioPosition.assetClass = hasDetails
? portfolioPosition.assetClass
: undefined),
(portfolioPosition.assetSubClass = hasDetails
? portfolioPosition.assetSubClass
: undefined),
(portfolioPosition.countries = hasDetails
? portfolioPosition.countries
: []),
(portfolioPosition.currency = hasDetails
? portfolioPosition.currency
: undefined),
(portfolioPosition.markets = hasDetails
? portfolioPosition.markets
: undefined),
(portfolioPosition.sectors = hasDetails
? portfolioPosition.sectors
: []);
} }
for (const [name, { valueInBaseCurrency }] of Object.entries(accounts)) { for (const [name, { valueInBaseCurrency }] of Object.entries(accounts)) {
@ -392,16 +412,17 @@ export class PortfolioController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
@Version('2') @Version('2')
@LogPerformance
public async getPerformanceV2( public async getPerformanceV2(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('range') dateRange: DateRange = 'max', @Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string, @Query('tags') filterByTags?: string,
@Query('withExcludedAccounts') withExcludedAccountsParam = 'false' @Query('withExcludedAccounts') withExcludedAccounts = false,
@Query('timeWeightedPerformance') calculateTimeWeightedPerformance = false,
@Query('withItems') withItems = false
): Promise<PortfolioPerformanceResponse> { ): Promise<PortfolioPerformanceResponse> {
const withExcludedAccounts = withExcludedAccountsParam === 'true';
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
@ -413,7 +434,8 @@ export class PortfolioController {
filters, filters,
impersonationId, impersonationId,
withExcludedAccounts, withExcludedAccounts,
userId: this.request.user.id userId: this.request.user.id,
calculateTimeWeightedPerformance
}); });
if ( if (

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

@ -1,3 +1,4 @@
import { LogPerformance } from '@ghostfolio/api/aop/logging.interceptor';
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface'; import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
@ -14,6 +15,7 @@ import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
getAnnualizedPerformancePercent, getAnnualizedPerformancePercent,
@ -75,6 +77,7 @@ import {
} from 'date-fns'; } from 'date-fns';
import { isEmpty, last, uniq, uniqBy } from 'lodash'; import { isEmpty, last, uniq, uniqBy } from 'lodash';
import { CPRPortfolioCalculator } from './calculator/constantPortfolioReturn/portfolio-calculator';
import { PortfolioCalculator } from './calculator/portfolio-calculator'; import { PortfolioCalculator } from './calculator/portfolio-calculator';
import { import {
PerformanceCalculationType, PerformanceCalculationType,
@ -104,6 +107,7 @@ export class PortfolioService {
private readonly userService: UserService private readonly userService: UserService
) {} ) {}
@LogPerformance
public async getAccounts({ public async getAccounts({
filters, filters,
userId, userId,
@ -173,6 +177,7 @@ export class PortfolioService {
}); });
} }
@LogPerformance
public async getAccountsWithAggregations({ public async getAccountsWithAggregations({
filters, filters,
userId, userId,
@ -209,6 +214,7 @@ export class PortfolioService {
}; };
} }
@LogPerformance
public async getDividends({ public async getDividends({
activities, activities,
groupBy groupBy
@ -230,6 +236,7 @@ export class PortfolioService {
return dividends; return dividends;
} }
@LogPerformance
public async getInvestments({ public async getInvestments({
dateRange, dateRange,
filters, filters,
@ -306,6 +313,7 @@ export class PortfolioService {
}; };
} }
@LogPerformance
public async getDetails({ public async getDetails({
dateRange = 'max', dateRange = 'max',
filters, filters,
@ -587,6 +595,80 @@ export class PortfolioService {
}; };
} }
@LogPerformance
private calculateMarketsAllocation(
symbolProfile: EnhancedSymbolProfile,
markets: {
developedMarkets: number;
emergingMarkets: number;
otherMarkets: number;
},
marketsAdvanced: {
asiaPacific: number;
emergingMarkets: number;
europe: number;
japan: number;
northAmerica: number;
otherMarkets: number;
},
value: Big
) {
if (symbolProfile.countries.length > 0) {
for (const country of symbolProfile.countries) {
if (developedMarkets.includes(country.code)) {
markets.developedMarkets = new Big(markets.developedMarkets)
.plus(country.weight)
.toNumber();
} else if (emergingMarkets.includes(country.code)) {
markets.emergingMarkets = new Big(markets.emergingMarkets)
.plus(country.weight)
.toNumber();
} else {
markets.otherMarkets = new Big(markets.otherMarkets)
.plus(country.weight)
.toNumber();
}
if (country.code === 'JP') {
marketsAdvanced.japan = new Big(marketsAdvanced.japan)
.plus(country.weight)
.toNumber();
} else if (country.code === 'CA' || country.code === 'US') {
marketsAdvanced.northAmerica = new Big(marketsAdvanced.northAmerica)
.plus(country.weight)
.toNumber();
} else if (asiaPacificMarkets.includes(country.code)) {
marketsAdvanced.asiaPacific = new Big(marketsAdvanced.asiaPacific)
.plus(country.weight)
.toNumber();
} else if (emergingMarkets.includes(country.code)) {
marketsAdvanced.emergingMarkets = new Big(
marketsAdvanced.emergingMarkets
)
.plus(country.weight)
.toNumber();
} else if (europeMarkets.includes(country.code)) {
marketsAdvanced.europe = new Big(marketsAdvanced.europe)
.plus(country.weight)
.toNumber();
} else {
marketsAdvanced.otherMarkets = new Big(marketsAdvanced.otherMarkets)
.plus(country.weight)
.toNumber();
}
}
} else {
markets[UNKNOWN_KEY] = new Big(markets[UNKNOWN_KEY])
.plus(value)
.toNumber();
marketsAdvanced[UNKNOWN_KEY] = new Big(marketsAdvanced[UNKNOWN_KEY])
.plus(value)
.toNumber();
}
}
@LogPerformance
public async getPosition( public async getPosition(
aDataSource: DataSource, aDataSource: DataSource,
aImpersonationId: string, aImpersonationId: string,
@ -614,6 +696,7 @@ export class PortfolioService {
accounts: [], accounts: [],
averagePrice: undefined, averagePrice: undefined,
dataProviderInfo: undefined, dataProviderInfo: undefined,
stakeRewards: undefined,
dividendInBaseCurrency: undefined, dividendInBaseCurrency: undefined,
dividendYieldPercent: undefined, dividendYieldPercent: undefined,
dividendYieldPercentWithCurrencyEffect: undefined, dividendYieldPercentWithCurrencyEffect: undefined,
@ -648,7 +731,9 @@ export class PortfolioService {
const portfolioCalculator = this.calculatorFactory.createCalculator({ const portfolioCalculator = this.calculatorFactory.createCalculator({
userId, userId,
activities: orders.filter((order) => { activities: orders.filter((order) => {
return ['BUY', 'DIVIDEND', 'ITEM', 'SELL'].includes(order.type); return ['BUY', 'DIVIDEND', 'ITEM', 'SELL', 'STAKE'].includes(
order.type
);
}), }),
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: userCurrency currency: userCurrency
@ -707,6 +792,33 @@ export class PortfolioService {
) )
}); });
const stakeRewards = getSum(
orders
.filter(({ type }) => {
return type === 'STAKE';
})
.map(({ quantity }) => {
return new Big(quantity);
})
);
// Convert investment, gross and net performance to currency of user
const investment = this.exchangeRateDataService.toCurrency(
position.investment?.toNumber(),
currency,
userCurrency
);
const grossPerformance = this.exchangeRateDataService.toCurrency(
position.grossPerformance?.toNumber(),
currency,
userCurrency
);
const netPerformance = this.exchangeRateDataService.toCurrency(
position.netPerformance?.toNumber(),
currency,
userCurrency
);
const historicalData = await this.dataProviderService.getHistorical( const historicalData = await this.dataProviderService.getHistorical(
[{ dataSource, symbol: aSymbol }], [{ dataSource, symbol: aSymbol }],
'day', 'day',
@ -779,6 +891,7 @@ export class PortfolioService {
transactionCount, transactionCount,
averagePrice: averagePrice.toNumber(), averagePrice: averagePrice.toNumber(),
dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0], dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0],
stakeRewards: stakeRewards.toNumber(),
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(), dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
dividendYieldPercent: dividendYieldPercent.toNumber(), dividendYieldPercent: dividendYieldPercent.toNumber(),
dividendYieldPercentWithCurrencyEffect: dividendYieldPercentWithCurrencyEffect:
@ -867,6 +980,7 @@ export class PortfolioService {
accounts: [], accounts: [],
averagePrice: 0, averagePrice: 0,
dataProviderInfo: undefined, dataProviderInfo: undefined,
stakeRewards: 0,
dividendInBaseCurrency: 0, dividendInBaseCurrency: 0,
dividendYieldPercent: 0, dividendYieldPercent: 0,
dividendYieldPercentWithCurrencyEffect: 0, dividendYieldPercentWithCurrencyEffect: 0,
@ -890,6 +1004,7 @@ export class PortfolioService {
} }
} }
@LogPerformance
public async getPositions({ public async getPositions({
dateRange = 'max', dateRange = 'max',
filters, filters,
@ -1014,6 +1129,7 @@ export class PortfolioService {
dataProviderResponses[symbol]?.marketState ?? 'delayed', dataProviderResponses[symbol]?.marketState ?? 'delayed',
name: symbolProfileMap[symbol].name, name: symbolProfileMap[symbol].name,
netPerformance: netPerformance?.toNumber() ?? null, netPerformance: netPerformance?.toNumber() ?? null,
tags: symbolProfileMap[symbol].tags,
netPerformancePercentage: netPerformancePercentage:
netPerformancePercentage?.toNumber() ?? null, netPerformancePercentage?.toNumber() ?? null,
netPerformancePercentageWithCurrencyEffect: netPerformancePercentageWithCurrencyEffect:
@ -1033,13 +1149,15 @@ export class PortfolioService {
}; };
} }
@LogPerformance
public async getPerformance({ public async getPerformance({
dateRange = 'max', dateRange = 'max',
filters, filters,
impersonationId, impersonationId,
portfolioCalculator, portfolioCalculator,
userId, userId,
withExcludedAccounts = false withExcludedAccounts = false,
calculateTimeWeightedPerformance = false
}: { }: {
dateRange?: DateRange; dateRange?: DateRange;
filters?: Filter[]; filters?: Filter[];
@ -1047,6 +1165,7 @@ export class PortfolioService {
portfolioCalculator?: PortfolioCalculator; portfolioCalculator?: PortfolioCalculator;
userId: string; userId: string;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
calculateTimeWeightedPerformance?: boolean;
}): Promise<PortfolioPerformanceResponse> { }): Promise<PortfolioPerformanceResponse> {
userId = await this.getUserId(impersonationId, userId); userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
@ -1106,19 +1225,17 @@ export class PortfolioService {
}; };
} }
portfolioCalculator = const portfolioCalculator = this.calculatorFactory.createCalculator({
portfolioCalculator ?? accountBalanceItems,
this.calculatorFactory.createCalculator({ activities,
accountBalanceItems, dateRange,
activities, userId,
filters, calculationType: PerformanceCalculationType.TWR,
userId, currency: userCurrency,
calculationType: PerformanceCalculationType.TWR, hasFilters: filters?.length > 0,
currency: userCurrency isExperimentalFeatures:
}); this.request.user.Settings.settings.isExperimentalFeatures
});
const { errors, hasErrors, historicalData } =
await portfolioCalculator.getSnapshot();
const { endDate, startDate } = getIntervalFromDateRange(dateRange); const { endDate, startDate } = getIntervalFromDateRange(dateRange);
@ -1166,6 +1283,7 @@ export class PortfolioService {
}; };
} }
@LogPerformance
public async getReport(impersonationId: string): Promise<PortfolioReport> { public async getReport(impersonationId: string): Promise<PortfolioReport> {
const userId = await this.getUserId(impersonationId, this.request.user.id); const userId = await this.getUserId(impersonationId, this.request.user.id);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
@ -1259,6 +1377,7 @@ export class PortfolioService {
}; };
} }
@LogPerformance
public async updateTags({ public async updateTags({
dataSource, dataSource,
impersonationId, impersonationId,
@ -1277,6 +1396,7 @@ export class PortfolioService {
await this.orderService.assignTags({ dataSource, symbol, tags, userId }); await this.orderService.assignTags({ dataSource, symbol, tags, userId });
} }
@LogPerformance
private async getCashPositions({ private async getCashPositions({
cashDetails, cashDetails,
userCurrency, userCurrency,
@ -1389,6 +1509,7 @@ export class PortfolioService {
return dividendsByGroup; return dividendsByGroup;
} }
@LogPerformance
private getEmergencyFundPositionsValueInBaseCurrency({ private getEmergencyFundPositionsValueInBaseCurrency({
holdings holdings
}: { }: {
@ -1529,6 +1650,7 @@ export class PortfolioService {
return { markets, marketsAdvanced }; return { markets, marketsAdvanced };
} }
@LogPerformance
private getStreaks({ private getStreaks({
investments, investments,
savingsRate savingsRate
@ -1551,6 +1673,7 @@ export class PortfolioService {
return { currentStreak, longestStreak }; return { currentStreak, longestStreak };
} }
@LogPerformance
private async getSummary({ private async getSummary({
balanceInBaseCurrency, balanceInBaseCurrency,
emergencyFundPositionsValueInBaseCurrency, emergencyFundPositionsValueInBaseCurrency,
@ -1576,7 +1699,6 @@ export class PortfolioService {
userId, userId,
withExcludedAccounts: true withExcludedAccounts: true
}); });
const excludedActivities: Activity[] = []; const excludedActivities: Activity[] = [];
const nonExcludedActivities: Activity[] = []; const nonExcludedActivities: Activity[] = [];
@ -1661,21 +1783,25 @@ export class PortfolioService {
currency: userCurrency, currency: userCurrency,
withExcludedAccounts: true withExcludedAccounts: true
}); });
const excludedBalanceInBaseCurrency = new Big( const excludedBalanceInBaseCurrency = new Big(
cashDetailsWithExcludedAccounts.balanceInBaseCurrency cashDetailsWithExcludedAccounts.balanceInBaseCurrency
).minus(balanceInBaseCurrency); ).minus(balanceInBaseCurrency);
const excludedAccountsAndActivities = excludedBalanceInBaseCurrency let excludedAccountsAndActivities = excludedBalanceInBaseCurrency
.plus(totalOfExcludedActivities) .plus(totalOfExcludedActivities)
.toNumber(); .toNumber();
const netWorth = new Big(balanceInBaseCurrency) const netWorth =
.plus(currentValueInBaseCurrency) portfolioCalculator instanceof CPRPortfolioCalculator
.plus(valuables) ? await (portfolioCalculator as CPRPortfolioCalculator)
.plus(excludedAccountsAndActivities) .getUnfilteredNetWorth(this.getUserCurrency())
.minus(liabilities) .then((value) => value.toNumber())
.toNumber(); : new Big(balanceInBaseCurrency)
.plus(currentValueInBaseCurrency)
.plus(valuables)
.plus(excludedAccountsAndActivities)
.minus(liabilities)
.toNumber();
const daysInMarket = differenceInDays(new Date(), firstOrderDate); const daysInMarket = differenceInDays(new Date(), firstOrderDate);
@ -1739,6 +1865,7 @@ export class PortfolioService {
}; };
} }
@LogPerformance
private getSumOfActivityType({ private getSumOfActivityType({
activities, activities,
activityType, activityType,
@ -1780,6 +1907,7 @@ export class PortfolioService {
return impersonationUserId || aUserId; return impersonationUserId || aUserId;
} }
@LogPerformance
private async getValueOfAccountsAndPlatforms({ private async getValueOfAccountsAndPlatforms({
activities, activities,
filters = [], filters = [],

5
apps/api/src/app/tag/tag.service.ts

@ -51,7 +51,7 @@ export class TagService {
const tagsWithOrderCount = await this.prismaService.tag.findMany({ const tagsWithOrderCount = await this.prismaService.tag.findMany({
include: { include: {
_count: { _count: {
select: { orders: true } select: { orders: true, symbolProfile: true }
} }
} }
}); });
@ -60,7 +60,8 @@ export class TagService {
return { return {
id, id,
name, name,
activityCount: _count.orders activityCount: _count.orders,
holdingCount: _count.symbolProfile
}; };
}); });
} }

3
apps/api/src/app/user/update-user-setting.dto.ts

@ -37,6 +37,9 @@ export class UpdateUserSettingDto {
@IsIn(<DateRange[]>[ @IsIn(<DateRange[]>[
'1d', '1d',
'1w',
'1m',
'3m',
'1y', '1y',
'5y', '5y',
'max', 'max',

25
apps/api/src/helper/dateQueryHelper.ts

@ -0,0 +1,25 @@
import { resetHours } from '@ghostfolio/common/helper';
import { addDays } from 'date-fns';
import { DateQuery } from '../app/portfolio/interfaces/date-query.interface';
export class DateQueryHelper {
public handleDateQueryIn(dateQuery: DateQuery): {
query: DateQuery;
dates: Date[];
} {
let dates = [];
let query = dateQuery;
if (dateQuery.in?.length > 0) {
dates = dateQuery.in;
let end = Math.max(...dates.map((d) => d.getTime()));
let start = Math.min(...dates.map((d) => d.getTime()));
query = {
gte: resetHours(new Date(start)),
lt: resetHours(addDays(end, 1))
};
}
return { query, dates };
}
}

1
apps/api/src/helper/portfolio.helper.ts

@ -5,6 +5,7 @@ export function getFactor(activityType: ActivityType) {
switch (activityType) { switch (activityType) {
case 'BUY': case 'BUY':
case 'STAKE':
factor = 1; factor = 1;
break; break;
case 'SELL': case 'SELL':

33
apps/api/src/main.ts

@ -5,6 +5,7 @@ import type { NestExpressApplication } from '@nestjs/platform-express';
import { json } from 'body-parser'; import { json } from 'body-parser';
import helmet from 'helmet'; import helmet from 'helmet';
import { LoggingInterceptor } from './aop/logging.interceptor';
import { AppModule } from './app/app.module'; import { AppModule } from './app/app.module';
import { environment } from './environments/environment'; import { environment } from './environments/environment';
import { HtmlTemplateMiddleware } from './middlewares/html-template.middleware'; import { HtmlTemplateMiddleware } from './middlewares/html-template.middleware';
@ -13,10 +14,35 @@ async function bootstrap() {
const configApp = await NestFactory.create(AppModule); const configApp = await NestFactory.create(AppModule);
const configService = configApp.get<ConfigService>(ConfigService); const configService = configApp.get<ConfigService>(ConfigService);
let logLevelArray = [];
let logLevel = configService.get<string>('LOG_LEVEL');
Logger.log(`Log-Level: ${logLevel}`);
switch (logLevel?.toLowerCase()) {
case 'verbose':
logLevelArray = ['debug', 'error', 'log', 'verbose', 'warn'];
break;
case 'debug':
logLevelArray = ['debug', 'error', 'log', 'warn'];
break;
case 'log':
logLevelArray = ['error', 'log', 'warn'];
break;
case 'warn':
logLevelArray = ['error', 'warn'];
break;
case 'error':
logLevelArray = ['error'];
break;
default:
logLevelArray = environment.production
? ['error', 'log', 'warn']
: ['debug', 'error', 'log', 'verbose', 'warn'];
break;
}
const app = await NestFactory.create<NestExpressApplication>(AppModule, { const app = await NestFactory.create<NestExpressApplication>(AppModule, {
logger: environment.production logger: logLevelArray
? ['error', 'log', 'warn']
: ['debug', 'error', 'log', 'verbose', 'warn']
}); });
app.enableCors(); app.enableCors();
@ -25,6 +51,7 @@ async function bootstrap() {
type: VersioningType.URI type: VersioningType.URI
}); });
app.setGlobalPrefix('api', { exclude: ['sitemap.xml'] }); app.setGlobalPrefix('api', { exclude: ['sitemap.xml'] });
app.useGlobalInterceptors(new LoggingInterceptor());
app.useGlobalPipes( app.useGlobalPipes(
new ValidationPipe({ new ValidationPipe({
forbidNonWhitelisted: true, forbidNonWhitelisted: true,

1
apps/api/src/services/configuration/configuration.service.ts

@ -23,6 +23,7 @@ export class ConfigurationService {
API_KEY_RAPID_API: str({ default: '' }), API_KEY_RAPID_API: str({ default: '' }),
CACHE_QUOTES_TTL: num({ default: ms('1 minute') / 1000 }), CACHE_QUOTES_TTL: num({ default: ms('1 minute') / 1000 }),
CACHE_TTL: num({ default: 1 }), CACHE_TTL: num({ default: 1 }),
LOG_LEVEL: str({ default: '' }),
DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }), DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }),
DATA_SOURCE_IMPORT: str({ default: DataSource.YAHOO }), DATA_SOURCE_IMPORT: str({ default: DataSource.YAHOO }),
DATA_SOURCES: json({ DATA_SOURCES: json({

3
apps/api/src/services/data-gathering/data-gathering.service.ts

@ -28,6 +28,7 @@ import {
import { InjectQueue } from '@nestjs/bull'; import { InjectQueue } from '@nestjs/bull';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import AwaitLock from 'await-lock';
import { JobOptions, Queue } from 'bull'; import { JobOptions, Queue } from 'bull';
import { format, min, subDays, subYears } from 'date-fns'; import { format, min, subDays, subYears } from 'date-fns';
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
@ -47,6 +48,8 @@ export class DataGatheringService {
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) {} ) {}
lock = new AwaitLock();
public async addJobToQueue({ public async addJobToQueue({
data, data,
name, name,

10
apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts

@ -36,9 +36,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
response: Partial<SymbolProfile>; response: Partial<SymbolProfile>;
symbol: string; symbol: string;
}): Promise<Partial<SymbolProfile>> { }): Promise<Partial<SymbolProfile>> {
if ( if (!(response.assetSubClass === 'ETF')) {
!(response.assetClass === 'EQUITY' && response.assetSubClass === 'ETF')
) {
return response; return response;
} }
@ -120,10 +118,8 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
}); });
}); });
if ( if (holdings?.weight < 1 - Math.min(holdings?.count * 0.000015, 0.95)) {
holdings?.weight < TrackinsightDataEnhancerService.holdingsWeightTreshold // Skip if data is inaccurate, dependent on holdings count there might be rounding issues
) {
// Skip if data is inaccurate
return response; return response;
} }

25
apps/api/src/services/data-provider/data-provider.service.ts

@ -1,3 +1,4 @@
import { LogPerformance } from '@ghostfolio/api/aop/logging.interceptor';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
@ -344,6 +345,7 @@ export class DataProviderService {
return result; return result;
} }
@LogPerformance
public async getQuotes({ public async getQuotes({
items, items,
requestTimeout, requestTimeout,
@ -471,6 +473,8 @@ export class DataProviderService {
} }
response[symbol] = dataProviderResponse; response[symbol] = dataProviderResponse;
let quotesCacheTTL =
this.getAppropriateCacheTTL(dataProviderResponse);
this.redisCacheService.set( this.redisCacheService.set(
this.redisCacheService.getQuoteKey({ this.redisCacheService.getQuoteKey({
@ -478,7 +482,7 @@ export class DataProviderService {
dataSource: DataSource[dataSource] dataSource: DataSource[dataSource]
}), }),
JSON.stringify(response[symbol]), JSON.stringify(response[symbol]),
this.configurationService.get('CACHE_QUOTES_TTL') quotesCacheTTL
); );
for (const { for (const {
@ -561,6 +565,25 @@ export class DataProviderService {
return response; return response;
} }
private getAppropriateCacheTTL(dataProviderResponse: IDataProviderResponse) {
let quotesCacheTTL = this.configurationService.get('CACHE_QUOTES_TTL');
if (dataProviderResponse.dataSource === 'MANUAL') {
quotesCacheTTL = 14400; // 4h Cache for Manual Service
} else if (dataProviderResponse.marketState === 'closed') {
let date = new Date();
let dayOfWeek = date.getDay();
if (dayOfWeek === 0 || dayOfWeek === 6) {
quotesCacheTTL = 14400;
} else if (date.getHours() > 16) {
quotesCacheTTL = 14400;
} else {
quotesCacheTTL = 900;
}
}
return quotesCacheTTL;
}
public async search({ public async search({
includeIndices = false, includeIndices = false,
query, query,

32
apps/api/src/services/data-provider/manual/manual.service.ts

@ -13,6 +13,7 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { BatchPrismaClient } from '@ghostfolio/common/chunkhelper';
import { import {
DATE_FORMAT, DATE_FORMAT,
extractNumberFromString, extractNumberFromString,
@ -153,18 +154,25 @@ export class ManualService implements DataProviderInterface {
}) })
); );
const marketData = await this.prismaService.marketData.findMany({ const batch = new BatchPrismaClient(this.prismaService);
distinct: ['symbol'],
orderBy: { const marketData = await batch
date: 'desc' .over(symbols)
}, .with((prisma, _symbols) =>
take: symbols.length, prisma.marketData.findMany({
where: { distinct: ['symbol'],
symbol: { orderBy: {
in: symbols date: 'desc'
} },
} take: symbols.length,
}); where: {
symbol: {
in: _symbols
}
}
})
)
.then((_result) => _result.flat());
for (const { currency, symbol } of symbolProfiles) { for (const { currency, symbol } of symbolProfiles) {
let marketPrice = let marketPrice =

17
apps/api/src/services/market-data/market-data.service.ts

@ -1,7 +1,9 @@
import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto'; import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto';
import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.interface'; import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.interface';
import { DateQueryHelper } from '@ghostfolio/api/helper/dateQueryHelper';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { BatchPrismaClient } from '@ghostfolio/common/chunkhelper';
import { resetHours } from '@ghostfolio/common/helper'; import { resetHours } from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
@ -12,11 +14,20 @@ import {
MarketDataState, MarketDataState,
Prisma Prisma
} from '@prisma/client'; } from '@prisma/client';
import AwaitLock from 'await-lock';
@Injectable() @Injectable()
export class MarketDataService { export class MarketDataService {
public constructor(private readonly prismaService: PrismaService) {} public constructor(private readonly prismaService: PrismaService) {}
lock = new AwaitLock();
private dateQueryHelper = new DateQueryHelper();
lock = new AwaitLock();
private dateQueryHelper = new DateQueryHelper();
public async deleteMany({ dataSource, symbol }: AssetProfileIdentifier) { public async deleteMany({ dataSource, symbol }: AssetProfileIdentifier) {
return this.prismaService.marketData.deleteMany({ return this.prismaService.marketData.deleteMany({
where: { where: {
@ -117,7 +128,6 @@ export class MarketDataService {
where: Prisma.MarketDataWhereUniqueInput; where: Prisma.MarketDataWhereUniqueInput;
}): Promise<MarketData> { }): Promise<MarketData> {
const { data, where } = params; const { data, where } = params;
return this.prismaService.marketData.upsert({ return this.prismaService.marketData.upsert({
where, where,
create: { create: {
@ -141,7 +151,7 @@ export class MarketDataService {
data: Prisma.MarketDataUpdateInput[]; data: Prisma.MarketDataUpdateInput[];
}): Promise<MarketData[]> { }): Promise<MarketData[]> {
const upsertPromises = data.map( const upsertPromises = data.map(
({ dataSource, date, marketPrice, symbol, state }) => { async ({ dataSource, date, marketPrice, symbol, state }) => {
return this.prismaService.marketData.upsert({ return this.prismaService.marketData.upsert({
create: { create: {
dataSource: <DataSource>dataSource, dataSource: <DataSource>dataSource,
@ -164,7 +174,6 @@ export class MarketDataService {
}); });
} }
); );
return await Promise.all(upsertPromises);
return this.prismaService.$transaction(upsertPromises);
} }
} }

12
apps/api/src/services/symbol-profile/symbol-profile-overwrite.module.ts

@ -0,0 +1,12 @@
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common';
import { SymbolProfileOverwriteService } from './symbol-profile-overwrite.service';
@Module({
imports: [PrismaModule],
providers: [SymbolProfileOverwriteService],
exports: [SymbolProfileOverwriteService]
})
export class SymbolProfileOverwriteModule {}

69
apps/api/src/services/symbol-profile/symbol-profile-overwrite.service.ts

@ -0,0 +1,69 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Injectable } from '@nestjs/common';
import { DataSource, Prisma, SymbolProfileOverrides } from '@prisma/client';
@Injectable()
export class SymbolProfileOverwriteService {
public constructor(private readonly prismaService: PrismaService) {}
public async add(
assetProfileOverwrite: Prisma.SymbolProfileOverridesCreateInput
): Promise<SymbolProfileOverrides | never> {
return this.prismaService.symbolProfileOverrides.create({
data: assetProfileOverwrite
});
}
public async delete(symbolProfileId: string) {
return this.prismaService.symbolProfileOverrides.delete({
where: { symbolProfileId: symbolProfileId }
});
}
public updateSymbolProfileOverrides({
assetClass,
assetSubClass,
name,
countries,
sectors,
url,
symbolProfileId
}: Prisma.SymbolProfileOverridesUpdateInput & { symbolProfileId: string }) {
return this.prismaService.symbolProfileOverrides.update({
data: {
assetClass,
assetSubClass,
name,
countries,
sectors,
url
},
where: { symbolProfileId: symbolProfileId }
});
}
public async GetSymbolProfileId(
Symbol: string,
datasource: DataSource
): Promise<string> {
let SymbolProfileId = await this.prismaService.symbolProfile
.findFirst({
where: {
symbol: Symbol,
dataSource: datasource
}
})
.then((s) => s.id);
let symbolProfileIdSaved = await this.prismaService.symbolProfileOverrides
.findFirst({
where: {
symbolProfileId: SymbolProfileId
}
})
.then((s) => s?.symbolProfileId);
return symbolProfileIdSaved;
}
}

19
apps/api/src/services/symbol-profile/symbol-profile.service.ts

@ -1,3 +1,4 @@
import { LogPerformance } from '@ghostfolio/api/aop/logging.interceptor';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { import {
@ -10,7 +11,12 @@ import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Prisma, SymbolProfile, SymbolProfileOverrides } from '@prisma/client'; import {
Prisma,
SymbolProfile,
SymbolProfileOverrides,
Tag
} from '@prisma/client';
import { continents, countries } from 'countries-list'; import { continents, countries } from 'countries-list';
@Injectable() @Injectable()
@ -35,6 +41,7 @@ export class SymbolProfileService {
}); });
} }
@LogPerformance
public async getSymbolProfiles( public async getSymbolProfiles(
aAssetProfileIdentifiers: AssetProfileIdentifier[] aAssetProfileIdentifiers: AssetProfileIdentifier[]
): Promise<EnhancedSymbolProfile[]> { ): Promise<EnhancedSymbolProfile[]> {
@ -51,6 +58,7 @@ export class SymbolProfileService {
select: { date: true }, select: { date: true },
take: 1 take: 1
}, },
tags: true,
SymbolProfileOverrides: true SymbolProfileOverrides: true
}, },
where: { where: {
@ -76,7 +84,8 @@ export class SymbolProfileService {
_count: { _count: {
select: { Order: true } select: { Order: true }
}, },
SymbolProfileOverrides: true SymbolProfileOverrides: true,
tags: true
}, },
where: { where: {
id: { id: {
@ -134,6 +143,7 @@ export class SymbolProfileService {
dataSource, dataSource,
holdings, holdings,
name, name,
tags,
scraperConfiguration, scraperConfiguration,
sectors, sectors,
symbol, symbol,
@ -150,6 +160,7 @@ export class SymbolProfileService {
currency, currency,
holdings, holdings,
name, name,
tags,
scraperConfiguration, scraperConfiguration,
sectors, sectors,
symbolMapping, symbolMapping,
@ -166,6 +177,7 @@ export class SymbolProfileService {
Order?: { Order?: {
date: Date; date: Date;
}[]; }[];
tags?: Tag[];
SymbolProfileOverrides: SymbolProfileOverrides; SymbolProfileOverrides: SymbolProfileOverrides;
})[] })[]
): EnhancedSymbolProfile[] { ): EnhancedSymbolProfile[] {
@ -180,7 +192,8 @@ export class SymbolProfileService {
holdings: this.getHoldings(symbolProfile), holdings: this.getHoldings(symbolProfile),
scraperConfiguration: this.getScraperConfiguration(symbolProfile), scraperConfiguration: this.getScraperConfiguration(symbolProfile),
sectors: this.getSectors(symbolProfile), sectors: this.getSectors(symbolProfile),
symbolMapping: this.getSymbolMapping(symbolProfile) symbolMapping: this.getSymbolMapping(symbolProfile),
tags: symbolProfile?.tags
}; };
item.activitiesCount = symbolProfile._count.Order; item.activitiesCount = symbolProfile._count.Order;

17
apps/api/src/services/tag/tag.service.ts

@ -20,11 +20,20 @@ export class TagService {
name: 'asc' name: 'asc'
}, },
where: { where: {
orders: { OR: [
some: { {
userId orders: {
some: {
userId
}
}
},
{
symbolProfile: {
some: {}
}
} }
} ]
} }
}); });
} }

22
apps/client/project.json

@ -165,7 +165,7 @@
} }
}, },
"serve": { "serve": {
"executor": "@nx/angular:dev-server", "executor": "@nx/angular:webpack-dev-server",
"options": { "options": {
"buildTarget": "client:build", "buildTarget": "client:build",
"proxyConfig": "apps/client/proxy.conf.json", "proxyConfig": "apps/client/proxy.conf.json",
@ -175,37 +175,37 @@
}, },
"configurations": { "configurations": {
"development-de": { "development-de": {
"buildTarget": "client:build:development-de" "browserTarget": "client:build:development-de"
}, },
"development-en": { "development-en": {
"buildTarget": "client:build:development-en" "browserTarget": "client:build:development-en"
}, },
"development-es": { "development-es": {
"buildTarget": "client:build:development-es" "browserTarget": "client:build:development-es"
}, },
"development-fr": { "development-fr": {
"buildTarget": "client:build:development-fr" "browserTarget": "client:build:development-fr"
}, },
"development-it": { "development-it": {
"buildTarget": "client:build:development-it" "browserTarget": "client:build:development-it"
}, },
"development-nl": { "development-nl": {
"buildTarget": "client:build:development-nl" "browserTarget": "client:build:development-nl"
}, },
"development-pl": { "development-pl": {
"buildTarget": "client:build:development-pl" "browserTarget": "client:build:development-pl"
}, },
"development-pt": { "development-pt": {
"buildTarget": "client:build:development-pt" "browserTarget": "client:build:development-pt"
}, },
"development-tr": { "development-tr": {
"buildTarget": "client:build:development-tr" "browserTarget": "client:build:development-tr"
}, },
"development-zh": { "development-zh": {
"buildTarget": "client:build:development-zh" "buildTarget": "client:build:development-zh"
}, },
"production": { "production": {
"buildTarget": "client:build:production" "browserTarget": "client:build:production"
} }
} }
}, },

52
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts

@ -13,22 +13,27 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { COMMA, ENTER } from '@angular/cdk/keycodes';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
ElementRef,
Inject, Inject,
OnDestroy, OnDestroy,
OnInit OnInit,
ViewChild
} from '@angular/core'; } from '@angular/core';
import { FormBuilder, FormControl, Validators } from '@angular/forms'; import { FormBuilder, FormControl, Validators } from '@angular/forms';
import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar'; import { MatSnackBar } from '@angular/material/snack-bar';
import { import {
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
MarketData, MarketData,
SymbolProfile SymbolProfile,
Tag
} from '@prisma/client'; } from '@prisma/client';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { parse as csvToJson } from 'papaparse'; import { parse as csvToJson } from 'papaparse';
@ -45,6 +50,8 @@ import { AssetProfileDialogParams } from './interfaces/interfaces';
styleUrls: ['./asset-profile-dialog.component.scss'] styleUrls: ['./asset-profile-dialog.component.scss']
}) })
export class AssetProfileDialog implements OnDestroy, OnInit { export class AssetProfileDialog implements OnDestroy, OnInit {
@ViewChild('tagInput') tagInput: ElementRef<HTMLInputElement>;
public separatorKeysCodes: number[] = [ENTER, COMMA];
public assetProfileClass: string; public assetProfileClass: string;
public assetClasses = Object.keys(AssetClass).map((assetClass) => { public assetClasses = Object.keys(AssetClass).map((assetClass) => {
return { id: assetClass, label: translate(assetClass) }; return { id: assetClass, label: translate(assetClass) };
@ -63,6 +70,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
csvString: '' csvString: ''
}), }),
name: ['', Validators.required], name: ['', Validators.required],
tags: new FormControl<Tag[]>(undefined),
scraperConfiguration: '', scraperConfiguration: '',
sectors: '', sectors: '',
symbolMapping: '', symbolMapping: '',
@ -81,6 +89,8 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
[name: string]: { name: string; value: number }; [name: string]: { name: string; value: number };
}; };
public HoldingTags: { id: string; name: string }[];
private static readonly HISTORICAL_DATA_TEMPLATE = `date;marketPrice\n${format( private static readonly HISTORICAL_DATA_TEMPLATE = `date;marketPrice\n${format(
new Date(), new Date(),
DATE_FORMAT DATE_FORMAT
@ -109,6 +119,18 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
} }
public initialize() { public initialize() {
this.adminService
.fetchTags()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((tags) => {
this.HoldingTags = tags.map(({ id, name }) => {
return { id, name };
});
this.dataService.updateInfo();
this.changeDetectorRef.markForCheck();
});
this.adminService this.adminService
.fetchAdminMarketDataBySymbol({ .fetchAdminMarketDataBySymbol({
dataSource: this.data.dataSource, dataSource: this.data.dataSource,
@ -149,6 +171,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
assetClass: this.assetProfile.assetClass ?? null, assetClass: this.assetProfile.assetClass ?? null,
assetSubClass: this.assetProfile.assetSubClass ?? null, assetSubClass: this.assetProfile.assetSubClass ?? null,
comment: this.assetProfile?.comment ?? '', comment: this.assetProfile?.comment ?? '',
tags: this.assetProfile?.tags ?? [],
countries: JSON.stringify( countries: JSON.stringify(
this.assetProfile?.countries?.map(({ code, weight }) => { this.assetProfile?.countries?.map(({ code, weight }) => {
return { code, weight }; return { code, weight };
@ -164,7 +187,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
), ),
sectors: JSON.stringify(this.assetProfile?.sectors ?? []), sectors: JSON.stringify(this.assetProfile?.sectors ?? []),
symbolMapping: JSON.stringify(this.assetProfile?.symbolMapping ?? {}), symbolMapping: JSON.stringify(this.assetProfile?.symbolMapping ?? {}),
url: this.assetProfile?.url ?? '' url: this.assetProfile?.url
}); });
this.assetProfileForm.markAsPristine(); this.assetProfileForm.markAsPristine();
@ -294,9 +317,10 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
assetClass: this.assetProfileForm.get('assetClass').value, assetClass: this.assetProfileForm.get('assetClass').value,
assetSubClass: this.assetProfileForm.get('assetSubClass').value, assetSubClass: this.assetProfileForm.get('assetSubClass').value,
comment: this.assetProfileForm.get('comment').value || null, comment: this.assetProfileForm.get('comment').value || null,
tags: this.assetProfileForm.get('tags').value,
currency: this.assetProfileForm.get('currency').value, currency: this.assetProfileForm.get('currency').value,
name: this.assetProfileForm.get('name').value, name: this.assetProfileForm.get('name').value,
url: this.assetProfileForm.get('url').value || null url: this.assetProfileForm.get('url').value
}; };
try { try {
@ -364,6 +388,26 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
}); });
} }
public onRemoveTag(aTag: Tag) {
this.assetProfileForm.controls['tags'].setValue(
this.assetProfileForm.controls['tags'].value.filter(({ id }) => {
return id !== aTag.id;
})
);
this.assetProfileForm.markAsDirty();
}
public onAddTag(event: MatAutocompleteSelectedEvent) {
this.assetProfileForm.controls['tags'].setValue([
...(this.assetProfileForm.controls['tags'].value ?? []),
this.HoldingTags.find(({ id }) => {
return id === event.option.value;
})
]);
this.tagInput.nativeElement.value = '';
this.assetProfileForm.markAsDirty();
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();

31
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html

@ -266,6 +266,37 @@
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Tags</mat-label>
<mat-chip-grid #tagsChipList>
<mat-chip-row
*ngFor="let tag of assetProfileForm.controls['tags']?.value"
matChipRemove
[removable]="true"
(removed)="onRemoveTag(tag)"
>
{{ tag.name }}
<ion-icon class="ml-2" matPrefix name="close-outline"></ion-icon>
</mat-chip-row>
<input
#tagInput
name="close-outline"
[matAutocomplete]="autocompleteTags"
[matChipInputFor]="tagsChipList"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
/>
</mat-chip-grid>
<mat-autocomplete
#autocompleteTags="matAutocomplete"
(optionSelected)="onAddTag($event)"
>
<mat-option *ngFor="let tag of HoldingTags" [value]="tag.id">
{{ tag.name }}
</mat-option>
</mat-autocomplete>
</mat-form-field>
</div>
<div class="d-flex my-3"> <div class="d-flex my-3">
<div class="w-50"> <div class="w-50">
<mat-checkbox <mat-checkbox

4
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.module.ts

@ -9,8 +9,10 @@ import { TextFieldModule } from '@angular/cdk/text-field';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatChipsModule } from '@angular/material/chips';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
@ -28,6 +30,8 @@ import { AssetProfileDialog } from './asset-profile-dialog.component';
GfAssetProfileIconComponent, GfAssetProfileIconComponent,
GfCurrencySelectorComponent, GfCurrencySelectorComponent,
GfPortfolioProportionChartComponent, GfPortfolioProportionChartComponent,
MatAutocompleteModule,
MatChipsModule,
GfValueComponent, GfValueComponent,
MatButtonModule, MatButtonModule,
MatCheckboxModule, MatCheckboxModule,

13
apps/client/src/app/components/admin-tag/admin-tag.component.html

@ -47,6 +47,19 @@
{{ element.activityCount }} {{ element.activityCount }}
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="holdings">
<th
*matHeaderCellDef
class="px-1"
mat-header-cell
mat-sort-header="holdingCount"
>
<ng-container i18n>Holdings</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.holdingCount }}
</td>
</ng-container>
<ng-container matColumnDef="actions" stickyEnd> <ng-container matColumnDef="actions" stickyEnd>
<th <th

2
apps/client/src/app/components/admin-tag/admin-tag.component.ts

@ -36,7 +36,7 @@ export class AdminTagComponent implements OnInit, OnDestroy {
public dataSource: MatTableDataSource<Tag> = new MatTableDataSource(); public dataSource: MatTableDataSource<Tag> = new MatTableDataSource();
public deviceType: string; public deviceType: string;
public displayedColumns = ['name', 'activities', 'actions']; public displayedColumns = ['name', 'activities', 'holdings', 'actions'];
public tags: Tag[]; public tags: Tag[];
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();

1
apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.scss

@ -7,5 +7,6 @@
ngx-skeleton-loader { ngx-skeleton-loader {
height: 100%; height: 100%;
} }
margin-bottom: 0.5rem;
} }
} }

16
apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts

@ -54,6 +54,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
@Input() isLoading: boolean; @Input() isLoading: boolean;
@Input() locale = getLocale(); @Input() locale = getLocale();
@Input() performanceDataItems: LineChartItem[]; @Input() performanceDataItems: LineChartItem[];
@Input() timeWeightedPerformanceDataItems: LineChartItem[];
@Input() user: User; @Input() user: User;
@Output() benchmarkChanged = new EventEmitter<string>(); @Output() benchmarkChanged = new EventEmitter<string>();
@ -84,7 +85,10 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
permissions.accessAdminControl permissions.accessAdminControl
); );
if (this.performanceDataItems) { if (
this.performanceDataItems ||
this.timeWeightedPerformanceDataItems?.length > 0
) {
this.initialize(); this.initialize();
} }
} }
@ -115,6 +119,16 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
}), }),
label: $localize`Portfolio` label: $localize`Portfolio`
}, },
{
backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderWidth: 2,
borderDash: [5, 5],
data: this.timeWeightedPerformanceDataItems.map(({ date, value }) => {
return { x: parseDate(date).getTime(), y: value };
}),
label: $localize`Portfolio (time-weighted)`
},
{ {
backgroundColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`, backgroundColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
borderColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`, borderColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,

33
apps/client/src/app/components/header/header.component.ts

@ -172,18 +172,24 @@ export class HeaderComponent implements OnChanges {
const userSetting: UpdateUserSettingDto = {}; const userSetting: UpdateUserSettingDto = {};
for (const filter of filters) { for (const filter of filters) {
let filtersType: string; let filtersType = this.getFilterType(filter.type);
if (filter.type === 'ACCOUNT') { let userFilters = filters
filtersType = 'accounts'; .filter((f) => f.type === filter.type && filter.id)
} else if (filter.type === 'ASSET_CLASS') { .map((f) => f.id);
filtersType = 'assetClasses';
} else if (filter.type === 'TAG') {
filtersType = 'tags';
}
userSetting[`filters.${filtersType}`] = filter.id ? [filter.id] : null; userSetting[`filters.${filtersType}`] = userFilters.length
? userFilters
: null;
} }
['ACCOUNT', 'ASSET_CLASS', 'TAG']
.filter(
(fitlerType) =>
!filters.some((f: Filter) => f.type.toString() === fitlerType)
)
.forEach((filterType) => {
userSetting[`filters.${this.getFilterType(filterType)}`] = null;
});
this.dataService this.dataService
.putUserSetting(userSetting) .putUserSetting(userSetting)
@ -266,4 +272,13 @@ export class HeaderComponent implements OnChanges {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private getFilterType(filterType: string) {
if (filterType === 'ACCOUNT') {
return 'accounts';
} else if (filterType === 'ASSET_CLASS') {
return 'assetClasses';
} else if (filterType === 'TAG') {
return 'tags';
}
}
} }

34
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts

@ -100,6 +100,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
public dataProviderInfo: DataProviderInfo; public dataProviderInfo: DataProviderInfo;
public dataSource: MatTableDataSource<Activity>; public dataSource: MatTableDataSource<Activity>;
public dividendInBaseCurrency: number; public dividendInBaseCurrency: number;
public stakeRewards: number;
public dividendInBaseCurrencyPrecision = 2; public dividendInBaseCurrencyPrecision = 2;
public dividendYieldPercentWithCurrencyEffect: number; public dividendYieldPercentWithCurrencyEffect: number;
public feeInBaseCurrency: number; public feeInBaseCurrency: number;
@ -119,6 +120,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
public netPerformanceWithCurrencyEffectPrecision = 2; public netPerformanceWithCurrencyEffectPrecision = 2;
public quantity: number; public quantity: number;
public quantityPrecision = 2; public quantityPrecision = 2;
public stakePrecision = 2;
public reportDataGlitchMail: string; public reportDataGlitchMail: string;
public sectors: { public sectors: {
[name: string]: { name: string; value: number }; [name: string]: { name: string; value: number };
@ -186,10 +188,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
averagePrice, averagePrice,
dataProviderInfo, dataProviderInfo,
dividendInBaseCurrency, dividendInBaseCurrency,
dividendYieldPercentWithCurrencyEffect, stakeRewards,
feeInBaseCurrency,
firstBuyDate,
historicalData,
investment, investment,
marketPrice, marketPrice,
maxPrice, maxPrice,
@ -203,7 +202,11 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
SymbolProfile, SymbolProfile,
tags, tags,
transactionCount, transactionCount,
value value,
dividendYieldPercentWithCurrencyEffect,
feeInBaseCurrency,
firstBuyDate,
historicalData
}) => { }) => {
this.accounts = accounts; this.accounts = accounts;
this.activities = orders; this.activities = orders;
@ -213,6 +216,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
this.dataProviderInfo = dataProviderInfo; this.dataProviderInfo = dataProviderInfo;
this.dataSource = new MatTableDataSource(orders.reverse()); this.dataSource = new MatTableDataSource(orders.reverse());
this.dividendInBaseCurrency = dividendInBaseCurrency; this.dividendInBaseCurrency = dividendInBaseCurrency;
this.stakeRewards = stakeRewards;
if ( if (
this.data.deviceType === 'mobile' && this.data.deviceType === 'mobile' &&
@ -400,6 +404,26 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
} }
); );
if (Number.isInteger(this.quantity)) {
this.quantityPrecision = 0;
if (
orders
.filter((o) => o.type === 'STAKE')
.every((o) => Number.isInteger(o.quantity))
) {
this.stakeRewards = 0;
}
} else if (this.SymbolProfile?.assetSubClass === 'CRYPTOCURRENCY') {
if (this.quantity < 1) {
this.quantityPrecision = 7;
} else if (this.quantity < 1000) {
this.quantityPrecision = 5;
} else if (this.quantity > 10000000) {
this.quantityPrecision = 0;
}
this.stakePrecision = this.quantityPrecision;
}
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
); );

53
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html

@ -152,30 +152,35 @@
>Investment</gf-value >Investment</gf-value
> >
</div> </div>
@if (dividendInBaseCurrency && user?.settings?.isExperimentalFeatures) { <div
<div class="col-6 mb-3"> *ngIf="dividendInBaseCurrency > 0 || !stakeRewards"
<gf-value class="col-6 mb-3"
i18n >
size="medium" <gf-value
[isCurrency]="true" i18n
[locale]="data.locale" size="medium"
[precision]="dividendInBaseCurrencyPrecision" [isCurrency]="true"
[unit]="data.baseCurrency" [locale]="data.locale"
[value]="dividendInBaseCurrency" [precision]="dividendInBaseCurrencyPrecision"
>Dividend</gf-value [unit]="data.baseCurrency"
> [value]="dividendInBaseCurrency"
</div> >Dividend</gf-value
<div class="col-6 mb-3"> >
<gf-value </div>
i18n
size="medium" <div
[isPercent]="true" *ngIf="stakeRewards > 0 && dividendInBaseCurrency == 0"
[locale]="data.locale" class="col-6 mb-3"
[value]="dividendYieldPercentWithCurrencyEffect" >
>Dividend Yield</gf-value <gf-value
> i18n
</div> size="medium"
} [locale]="data.locale"
[precision]="stakePrecision"
[value]="stakeRewards"
>Stake Rewards
</gf-value>
</div>
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value <gf-value
i18n i18n

3
apps/client/src/app/components/toggle/toggle.component.ts

@ -20,6 +20,9 @@ import { FormControl } from '@angular/forms';
export class ToggleComponent implements OnChanges, OnInit { export class ToggleComponent implements OnChanges, OnInit {
public static DEFAULT_DATE_RANGE_OPTIONS: ToggleOption[] = [ public static DEFAULT_DATE_RANGE_OPTIONS: ToggleOption[] = [
{ label: $localize`Today`, value: '1d' }, { label: $localize`Today`, value: '1d' },
{ label: $localize`1W`, value: '1w' },
{ label: $localize`1M`, value: '1m' },
{ label: $localize`3M`, value: '3m' },
{ label: $localize`YTD`, value: 'ytd' }, { label: $localize`YTD`, value: 'ytd' },
{ label: $localize`1Y`, value: '1y' }, { label: $localize`1Y`, value: '1y' },
{ label: $localize`5Y`, value: '5y' }, { label: $localize`5Y`, value: '5y' },

6
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts

@ -215,6 +215,10 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.activityForm.get('quantity').value * this.activityForm.get('quantity').value *
this.activityForm.get('unitPrice').value + this.activityForm.get('unitPrice').value +
this.activityForm.get('fee').value ?? 0; this.activityForm.get('fee').value ?? 0;
} else if (this.activityForm.get('type').value === 'STAKE') {
this.total =
this.activityForm.get('quantity').value * this.currentMarketPrice ??
0;
} else { } else {
this.total = this.total =
this.activityForm.get('quantity').value * this.activityForm.get('quantity').value *
@ -268,7 +272,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
if (this.activityForm.get('searchSymbol').invalid) { if (this.activityForm.get('searchSymbol').invalid) {
this.data.activity.SymbolProfile = null; this.data.activity.SymbolProfile = null;
} else if ( } else if (
['BUY', 'DIVIDEND', 'SELL'].includes( ['BUY', 'DIVIDEND', 'SELL', 'STAKE'].includes(
this.activityForm.get('type').value this.activityForm.get('type').value
) )
) { ) {

13
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html

@ -49,6 +49,14 @@
>Revenue for lending out money</small >Revenue for lending out money</small
> >
</mat-option> </mat-option>
<mat-option class="line-height-1" value="STAKE">
<span
><b>{{ typesTranslationMap['STAKE'] }}</b></span
><br />
<small class="text-muted text-nowrap" i18n
>Stake rewards, stock dividends, free/gifted stocks</small
>
</mat-option>
<mat-option value="LIABILITY"> <mat-option value="LIABILITY">
<span <span
><b>{{ typesTranslationMap['LIABILITY'] }}</b></span ><b>{{ typesTranslationMap['LIABILITY'] }}</b></span
@ -193,7 +201,10 @@
class="mb-3" class="mb-3"
[ngClass]="{ 'd-none': activityForm.get('type')?.value === 'FEE' }" [ngClass]="{ 'd-none': activityForm.get('type')?.value === 'FEE' }"
> >
<div class="align-items-start d-flex"> <div
*ngIf="activityForm.controls['type']?.value !== 'STAKE'"
class="align-items-start d-flex"
>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label> <mat-label>
@switch (activityForm.get('type')?.value) { @switch (activityForm.get('type')?.value) {

3
apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts

@ -201,6 +201,9 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
private fetchPortfolioDetails() { private fetchPortfolioDetails() {
return this.dataService.fetchPortfolioDetails({ return this.dataService.fetchPortfolioDetails({
filters: this.userService.getFilters(), filters: this.userService.getFilters(),
parameters: {
isAllocation: true
},
withMarkets: true withMarkets: true
}); });
} }

48
apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts

@ -10,11 +10,12 @@ import {
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface'; import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { GroupBy, ToggleOption } from '@ghostfolio/common/types'; import { DateRange, GroupBy, ToggleOption } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { SymbolProfile } from '@prisma/client'; import { SymbolProfile } from '@prisma/client';
import { Big } from 'big.js';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
import { isNumber, sortBy } from 'lodash'; import { isNumber, sortBy } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
@ -32,6 +33,12 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
public benchmarks: Partial<SymbolProfile>[]; public benchmarks: Partial<SymbolProfile>[];
public bottom3: PortfolioPosition[]; public bottom3: PortfolioPosition[];
public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS; public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS;
public timeWeightedPerformanceOptions = [
{ label: $localize`No`, value: 'N' },
{ label: $localize`Both`, value: 'B' },
{ label: $localize`Only`, value: 'O' }
];
public selectedTimeWeightedPerformanceOption: string;
public daysInMarket: number; public daysInMarket: number;
public deviceType: string; public deviceType: string;
public dividendsByGroup: InvestmentItem[]; public dividendsByGroup: InvestmentItem[];
@ -53,8 +60,11 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
public performance: PortfolioPerformance; public performance: PortfolioPerformance;
public performanceDataItems: HistoricalDataItem[]; public performanceDataItems: HistoricalDataItem[];
public performanceDataItemsInPercentage: HistoricalDataItem[]; public performanceDataItemsInPercentage: HistoricalDataItem[];
public performanceDataItemsTimeWeightedInPercentage: HistoricalDataItem[] =
[];
public portfolioEvolutionDataLabel = $localize`Investment`; public portfolioEvolutionDataLabel = $localize`Investment`;
public streaks: PortfolioInvestments['streaks']; public streaks: PortfolioInvestments['streaks'];
public timeWeightedPerformance: string = 'N';
public top3: PortfolioPosition[]; public top3: PortfolioPosition[];
public unitCurrentStreak: string; public unitCurrentStreak: string;
public unitLongestStreak: string; public unitLongestStreak: string;
@ -125,6 +135,30 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
}); });
} }
public onChangeDateRange(dateRange: DateRange) {
this.dataService
.putUserSetting({ dateRange })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService.remove();
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
this.changeDetectorRef.markForCheck();
});
});
}
public onTimeWeightedPerformanceChanged(timeWeightedPerformance: string) {
this.timeWeightedPerformance = timeWeightedPerformance;
this.update();
}
public onChangeGroupBy(aMode: GroupBy) { public onChangeGroupBy(aMode: GroupBy) {
this.mode = aMode; this.mode = aMode;
this.fetchDividendsAndInvestments(); this.fetchDividendsAndInvestments();
@ -193,7 +227,9 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
this.dataService this.dataService
.fetchPortfolioPerformance({ .fetchPortfolioPerformance({
filters: this.userService.getFilters(), filters: this.userService.getFilters(),
range: this.user?.settings?.dateRange range: this.user?.settings?.dateRange,
timeWeightedPerformance:
this.timeWeightedPerformance === 'N' ? false : true
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ chart, firstOrderDate, performance }) => { .subscribe(({ chart, firstOrderDate, performance }) => {
@ -204,6 +240,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
this.performance = performance; this.performance = performance;
this.performanceDataItems = []; this.performanceDataItems = [];
this.performanceDataItemsInPercentage = []; this.performanceDataItemsInPercentage = [];
this.performanceDataItemsTimeWeightedInPercentage = [];
for (const [ for (const [
index, index,
@ -212,6 +249,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
netPerformanceInPercentageWithCurrencyEffect, netPerformanceInPercentageWithCurrencyEffect,
totalInvestmentValueWithCurrencyEffect, totalInvestmentValueWithCurrencyEffect,
valueInPercentage, valueInPercentage,
timeWeightedPerformance,
valueWithCurrencyEffect valueWithCurrencyEffect
} }
] of chart.entries()) { ] of chart.entries()) {
@ -232,6 +270,12 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
date, date,
value: netPerformanceInPercentageWithCurrencyEffect value: netPerformanceInPercentageWithCurrencyEffect
}); });
if ((this.timeWeightedPerformance ?? 'N') !== 'N') {
this.performanceDataItemsTimeWeightedInPercentage.push({
date,
value: chart[index].timeWeightedPerformance
});
}
} }
this.isLoadingInvestmentChart = false; this.isLoadingInvestmentChart = false;

30
apps/client/src/app/pages/portfolio/analysis/analysis-page.html

@ -11,10 +11,36 @@
[daysInMarket]="daysInMarket" [daysInMarket]="daysInMarket"
[isLoading]="isLoadingBenchmarkComparator || isLoadingInvestmentChart" [isLoading]="isLoadingBenchmarkComparator || isLoadingInvestmentChart"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[performanceDataItems]="performanceDataItemsInPercentage" [performanceDataItems]="
timeWeightedPerformance === 'O'
? []
: performanceDataItemsInPercentage
"
[timeWeightedPerformanceDataItems]="
timeWeightedPerformance === 'N'
? []
: performanceDataItemsTimeWeightedInPercentage
"
[user]="user" [user]="user"
(benchmarkChanged)="onChangeBenchmark($event)" (benchmarkChanged)="onChangeBenchmark($event)"
/> ></gf-benchmark-comparator>
<div>
<div class="col-md-6 col-xs-12 d-flex">
<div
class="align-items-center d-flex flex-grow-1 h6 mb-0 py-2 text-truncate"
>
<span i18n>Include time-weighted performance </span>
<gf-toggle
[defaultValue]="timeWeightedPerformance"
[isLoading]="
isLoadingBenchmarkComparator || isLoadingInvestmentChart
"
[options]="timeWeightedPerformanceOptions"
(change)="onTimeWeightedPerformanceChanged($event.value)"
></gf-toggle>
</div>
</div>
</div>
</div> </div>
</div> </div>

2
apps/client/src/app/services/admin.service.ts

@ -219,6 +219,7 @@ export class AdminService {
sectors, sectors,
symbol, symbol,
symbolMapping, symbolMapping,
tags,
url url
}: AssetProfileIdentifier & UpdateAssetProfileDto) { }: AssetProfileIdentifier & UpdateAssetProfileDto) {
return this.http.patch<EnhancedSymbolProfile>( return this.http.patch<EnhancedSymbolProfile>(
@ -233,6 +234,7 @@ export class AdminService {
scraperConfiguration, scraperConfiguration,
sectors, sectors,
symbolMapping, symbolMapping,
tags,
url url
} }
); );

11
apps/client/src/app/services/data.service.ts

@ -462,15 +462,18 @@ export class DataService {
public fetchPortfolioDetails({ public fetchPortfolioDetails({
filters, filters,
parameters,
withMarkets = false withMarkets = false
}: { }: {
filters?: Filter[]; filters?: Filter[];
parameters?: any;
withMarkets?: boolean; withMarkets?: boolean;
} = {}): Observable<PortfolioDetails> { } = {}): Observable<PortfolioDetails> {
let params = this.buildFiltersAsQueryParams({ filters }); let params = this.buildFiltersAsQueryParams({ filters });
if (withMarkets) { if (withMarkets) {
params = params.append('withMarkets', withMarkets); params = params.append('withMarkets', withMarkets);
params = parameters ? params.appendAll(parameters) : params;
} }
return this.http return this.http
@ -566,11 +569,13 @@ export class DataService {
filters, filters,
range, range,
withExcludedAccounts = false, withExcludedAccounts = false,
timeWeightedPerformance = false,
withItems = false withItems = false
}: { }: {
filters?: Filter[]; filters?: Filter[];
range: DateRange; range: DateRange;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
timeWeightedPerformance?: boolean;
withItems?: boolean; withItems?: boolean;
}): Observable<PortfolioPerformanceResponse> { }): Observable<PortfolioPerformanceResponse> {
let params = this.buildFiltersAsQueryParams({ filters }); let params = this.buildFiltersAsQueryParams({ filters });
@ -579,6 +584,12 @@ export class DataService {
if (withExcludedAccounts) { if (withExcludedAccounts) {
params = params.append('withExcludedAccounts', withExcludedAccounts); params = params.append('withExcludedAccounts', withExcludedAccounts);
} }
if (timeWeightedPerformance) {
params = params.append(
'timeWeightedPerformance',
timeWeightedPerformance
);
}
if (withItems) { if (withItems) {
params = params.append('withItems', withItems); params = params.append('withItems', withItems);

2
apps/client/src/app/services/import-activities.service.ts

@ -348,6 +348,8 @@ export class ImportActivitiesService {
return 'LIABILITY'; return 'LIABILITY';
case 'sell': case 'sell':
return 'SELL'; return 'SELL';
case 'stake':
return 'STAKE';
default: default:
break; break;
} }

6
apps/client/src/app/services/user/user.service.ts

@ -53,21 +53,21 @@ export class UserService extends ObservableStore<UserStoreState> {
if (user?.settings['filters.accounts']) { if (user?.settings['filters.accounts']) {
filters.push({ filters.push({
id: user.settings['filters.accounts'][0], id: user.settings['filters.accounts'].join(','),
type: 'ACCOUNT' type: 'ACCOUNT'
}); });
} }
if (user?.settings['filters.assetClasses']) { if (user?.settings['filters.assetClasses']) {
filters.push({ filters.push({
id: user.settings['filters.assetClasses'][0], id: user.settings['filters.assetClasses'].join(','),
type: 'ASSET_CLASS' type: 'ASSET_CLASS'
}); });
} }
if (user?.settings['filters.tags']) { if (user?.settings['filters.tags']) {
filters.push({ filters.push({
id: user.settings['filters.tags'][0], id: user.settings['filters.tags'].join(','),
type: 'TAG' type: 'TAG'
}); });
} }

46
libs/common/src/lib/chunkhelper.ts

@ -0,0 +1,46 @@
import { Prisma, PrismaClient } from '@prisma/client';
class Chunk<T> implements Iterable<T[] | undefined> {
protected constructor(
private readonly values: readonly T[],
private readonly size: number
) {}
*[Symbol.iterator]() {
const copy = [...this.values];
if (copy.length === 0) yield undefined;
while (copy.length) yield copy.splice(0, this.size);
}
map<U>(mapper: (items?: T[]) => U): U[] {
return Array.from(this).map((items) => mapper(items));
}
static of<U>(values: readonly U[]) {
return {
by: (size: number) => new Chunk(values, size)
};
}
}
export type Queryable<T, Result> = (
p: PrismaClient,
vs?: T[]
) => Prisma.PrismaPromise<Result>;
export class BatchPrismaClient {
constructor(
private readonly prisma: PrismaClient,
private readonly size = 32_000
) {}
over<T>(values: readonly T[]) {
return {
with: <Result>(queryable: Queryable<T, Result>) =>
this.prisma.$transaction(
Chunk.of(values)
.by(this.size)
.map((vs) => queryable(this.prisma, vs))
)
};
}
}

3
libs/common/src/lib/interfaces/admin-market-data.interface.ts

@ -1,4 +1,4 @@
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client'; import { AssetClass, AssetSubClass, DataSource, Tag } from '@prisma/client';
export interface AdminMarketData { export interface AdminMarketData {
count: number; count: number;
@ -20,4 +20,5 @@ export interface AdminMarketDataItem {
name: string; name: string;
sectorsCount: number; sectorsCount: number;
symbol: string; symbol: string;
tags: Tag[];
} }

3
libs/common/src/lib/interfaces/enhanced-symbol-profile.interface.ts

@ -1,4 +1,4 @@
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client'; import { AssetClass, AssetSubClass, DataSource, Tag } from '@prisma/client';
import { Country } from './country.interface'; import { Country } from './country.interface';
import { DataProviderInfo } from './data-provider-info.interface'; import { DataProviderInfo } from './data-provider-info.interface';
@ -30,4 +30,5 @@ export interface EnhancedSymbolProfile {
symbolMapping?: { [key: string]: string }; symbolMapping?: { [key: string]: string };
updatedAt: Date; updatedAt: Date;
url?: string; url?: string;
tags?: Tag[];
} }

2
libs/common/src/lib/interfaces/historical-data-item.interface.ts

@ -16,5 +16,7 @@ export interface HistoricalDataItem {
totalInvestmentValueWithCurrencyEffect?: number; totalInvestmentValueWithCurrencyEffect?: number;
value?: number; value?: number;
valueInPercentage?: number; valueInPercentage?: number;
timeWeightedPerformance?: number;
timeWeightedPerformanceWithCurrencyEffect?: number;
valueWithCurrencyEffect?: number; valueWithCurrencyEffect?: number;
} }

6
libs/common/src/lib/interfaces/symbol-metrics.interface.ts

@ -28,6 +28,9 @@ export interface SymbolMetrics {
}; };
netPerformance: Big; netPerformance: Big;
netPerformancePercentage: Big; netPerformancePercentage: Big;
netPerformanceValuesPercentage: {
[date: string]: Big;
};
netPerformancePercentageWithCurrencyEffectMap: { [key: DateRange]: Big }; netPerformancePercentageWithCurrencyEffectMap: { [key: DateRange]: Big };
netPerformanceValues: { netPerformanceValues: {
[date: string]: Big; [date: string]: Big;
@ -49,6 +52,9 @@ export interface SymbolMetrics {
totalInterestInBaseCurrency: Big; totalInterestInBaseCurrency: Big;
totalInvestment: Big; totalInvestment: Big;
totalInvestmentWithCurrencyEffect: Big; totalInvestmentWithCurrencyEffect: Big;
unitPrices: {
[date: string]: Big;
};
totalLiabilities: Big; totalLiabilities: Big;
totalLiabilitiesInBaseCurrency: Big; totalLiabilitiesInBaseCurrency: Big;
totalValuables: Big; totalValuables: Big;

9
libs/common/src/lib/types/date-range.type.ts

@ -1,9 +1,12 @@
export type DateRange = export type DateRange =
| '1d' | '1d'
| 'wtd'
| '1w'
| 'mtd'
| '1m'
| '3m'
| 'ytd'
| '1y' | '1y'
| '5y' | '5y'
| 'max' | 'max'
| 'mtd'
| 'wtd'
| 'ytd'
| string; // '2024', '2023', '2022', etc. | string; // '2024', '2023', '2022', etc.

51
libs/ui/src/lib/activities-table/activities-table.component.scss

@ -1,3 +1,54 @@
:host { :host {
display: block; display: block;
.activities {
overflow-x: auto;
.mat-mdc-table {
th {
::ng-deep {
.mat-sort-header-container {
justify-content: inherit;
}
}
}
.mat-mdc-row {
.type-badge {
background-color: rgba(var(--palette-foreground-text), 0.05);
border-radius: 1rem;
line-height: 1em;
ion-icon {
font-size: 1rem;
}
&.buy {
color: var(--green);
}
&.dividend {
color: var(--blue);
}
&.stake {
color: var(--blue);
}
&.item {
color: var(--purple);
}
&.liability {
color: var(--red);
}
&.sell {
color: var(--orange);
}
}
}
}
}
}
:host-context(.is-dark-theme) {
.mat-mdc-table {
.type-badge {
background-color: rgba(
var(--palette-foreground-text-dark),
0.1
) !important;
}
}
} }

38
libs/ui/src/lib/activity-type/activity-type.component.html

@ -7,21 +7,31 @@
interest: activityType === 'INTEREST', interest: activityType === 'INTEREST',
item: activityType === 'ITEM', item: activityType === 'ITEM',
liability: activityType === 'LIABILITY', liability: activityType === 'LIABILITY',
sell: activityType === 'SELL' sell: activityType === 'SELL',
stake: activityType === 'STAKE'
}" }"
> >
@if (activityType === 'BUY') { <ion-icon
<ion-icon name="arrow-up-circle-outline" /> *ngIf="activityType === 'BUY'"
} @else if (activityType === 'DIVIDEND' || activityType === 'INTEREST') { name="arrow-up-circle-outline"
<ion-icon name="add-circle-outline" /> ></ion-icon>
} @else if (activityType === 'FEE') { <ion-icon
<ion-icon name="hammer-outline" /> *ngIf="
} @else if (activityType === 'ITEM') { activityType === 'DIVIDEND' ||
<ion-icon name="cube-outline" /> activityType === 'INTEREST' ||
} @else if (activityType === 'LIABILITY') { activityType === 'STAKE'
<ion-icon name="flame-outline" /> "
} @else if (activityType === 'SELL') { name="add-circle-outline"
<ion-icon name="arrow-down-circle-outline" /> ></ion-icon>
} <ion-icon *ngIf="activityType === 'FEE'" name="hammer-outline"></ion-icon>
<ion-icon *ngIf="activityType === 'ITEM'" name="cube-outline"></ion-icon>
<ion-icon
*ngIf="activityType === 'LIABILITY'"
name="flame-outline"
></ion-icon>
<ion-icon
*ngIf="activityType === 'SELL'"
name="arrow-down-circle-outline"
></ion-icon>
<span class="d-none d-lg-block mx-1">{{ activityTypeLabel }}</span> <span class="d-none d-lg-block mx-1">{{ activityTypeLabel }}</span>
</div> </div>

50
libs/ui/src/lib/assistant/assistant.component.ts

@ -129,9 +129,9 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
public dateRangeFormControl = new FormControl<string>(undefined); public dateRangeFormControl = new FormControl<string>(undefined);
public dateRangeOptions: IDateRangeOption[] = []; public dateRangeOptions: IDateRangeOption[] = [];
public filterForm = this.formBuilder.group({ public filterForm = this.formBuilder.group({
account: new FormControl<string>(undefined), account: new FormControl<string[]>(undefined),
assetClass: new FormControl<string>(undefined), assetClass: new FormControl<string[]>(undefined),
tag: new FormControl<string>(undefined) tag: new FormControl<string[]>(undefined)
}); });
public isLoading = false; public isLoading = false;
public isOpen = false; public isOpen = false;
@ -258,9 +258,9 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
this.filterForm.setValue( this.filterForm.setValue(
{ {
account: this.user?.settings?.['filters.accounts']?.[0] ?? null, account: this.user?.settings?.['filters.accounts'] ?? null,
assetClass: this.user?.settings?.['filters.assetClasses']?.[0] ?? null, assetClass: this.user?.settings?.['filters.assetClasses'] ?? null,
tag: this.user?.settings?.['filters.tags']?.[0] ?? null tag: this.user?.settings?.['filters.tags'] ?? null
}, },
{ {
emitEvent: false emitEvent: false
@ -268,7 +268,7 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
); );
} }
public hasFilter(aFormValue: { [key: string]: string }) { public hasFilter(aFormValue: { [key: string]: string[] }) {
return Object.values(aFormValue).some((value) => { return Object.values(aFormValue).some((value) => {
return !!value; return !!value;
}); });
@ -298,20 +298,28 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
} }
public onApplyFilters() { public onApplyFilters() {
this.filtersChanged.emit([ let accountFilters =
{ this.filterForm
id: this.filterForm.get('account').value, .get('account')
type: 'ACCOUNT' .value?.reduce(
}, (arr, val) => [...arr, { id: val, type: 'ACCOUNT' }],
{ []
id: this.filterForm.get('assetClass').value, ) ?? [];
type: 'ASSET_CLASS' let assetClassFilters =
}, this.filterForm
{ .get('assetClass')
id: this.filterForm.get('tag').value, .value?.reduce(
type: 'TAG' (arr, val) => [...arr, { id: val, type: 'ASSET_CLASS' }],
} []
]); ) ?? [];
let tagFilters =
this.filterForm
.get('tag')
.value?.reduce((arr, val) => [...arr, { id: val, type: 'TAG' }], []) ??
[];
let filters = [...accountFilters, ...assetClassFilters];
filters = [...filters, ...tagFilters];
this.filtersChanged.emit(filters);
this.onCloseAssistant(); this.onCloseAssistant();
} }

6
libs/ui/src/lib/assistant/assistant.html

@ -105,7 +105,7 @@
<div class="mb-3"> <div class="mb-3">
<mat-form-field appearance="outline" class="w-100 without-hint"> <mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Accounts</mat-label> <mat-label i18n>Accounts</mat-label>
<mat-select formControlName="account"> <mat-select formControlName="account" multiple>
<mat-option [value]="null" /> <mat-option [value]="null" />
@for (account of accounts; track account.id) { @for (account of accounts; track account.id) {
<mat-option [value]="account.id"> <mat-option [value]="account.id">
@ -125,7 +125,7 @@
<div class="mb-3"> <div class="mb-3">
<mat-form-field appearance="outline" class="w-100 without-hint"> <mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Tags</mat-label> <mat-label i18n>Tags</mat-label>
<mat-select formControlName="tag"> <mat-select formControlName="tag" multiple>
<mat-option [value]="null" /> <mat-option [value]="null" />
@for (tag of tags; track tag.id) { @for (tag of tags; track tag.id) {
<mat-option [value]="tag.id">{{ tag.label }}</mat-option> <mat-option [value]="tag.id">{{ tag.label }}</mat-option>
@ -136,7 +136,7 @@
<div class="mb-3"> <div class="mb-3">
<mat-form-field appearance="outline" class="w-100 without-hint"> <mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Asset Classes</mat-label> <mat-label i18n>Asset Classes</mat-label>
<mat-select formControlName="assetClass"> <mat-select formControlName="assetClass" multiple>
<mat-option [value]="null" /> <mat-option [value]="null" />
@for (assetClass of assetClasses; track assetClass.id) { @for (assetClass of assetClasses; track assetClass.id) {
<mat-option [value]="assetClass.id">{{ <mat-option [value]="assetClass.id">{{

1
libs/ui/src/lib/i18n.ts

@ -38,6 +38,7 @@ const locales = {
ITEM: $localize`Valuable`, ITEM: $localize`Valuable`,
LIABILITY: $localize`Liability`, LIABILITY: $localize`Liability`,
SELL: $localize`Sell`, SELL: $localize`Sell`,
STAKE: $localize`Stake`,
// AssetClass (enum) // AssetClass (enum)
CASH: $localize`Cash`, CASH: $localize`Cash`,

185
migrations.json

@ -0,0 +1,185 @@
{
"migrations": [
{
"cli": "nx",
"version": "17.3.0-beta.6",
"description": "Updates the nx wrapper.",
"implementation": "./src/migrations/update-17-3-0/update-nxw",
"package": "nx",
"name": "17.3.0-update-nx-wrapper"
},
{
"cli": "nx",
"version": "18.0.0-beta.2",
"description": "Updates nx.json to disabled adding plugins when generating projects in an existing Nx workspace",
"implementation": "./src/migrations/update-18-0-0/disable-crystal-for-existing-workspaces",
"x-repair-skip": true,
"package": "nx",
"name": "18.0.0-disable-adding-plugins-for-existing-workspaces"
},
{
"version": "18.1.0-beta.3",
"description": "Moves affected.defaultBase to defaultBase in `nx.json`",
"implementation": "./src/migrations/update-17-2-0/move-default-base",
"package": "nx",
"name": "move-default-base-to-nx-json-root"
},
{
"cli": "nx",
"version": "18.1.0-beta.3",
"description": "Update to Cypress ^13.6.6 if the workspace is using Cypress v13 to ensure workspaces don't use v13.6.5 which has an issue when verifying Cypress.",
"implementation": "./src/migrations/update-18-1-0/update-cypress-version-13-6-6",
"package": "@nx/cypress",
"name": "update-cypress-version-13-6-6"
},
{
"cli": "nx",
"version": "17.2.6-beta.1",
"description": "Rename workspace rules from @nx/workspace/name to @nx/workspace-name",
"implementation": "./src/migrations/update-17-2-6-rename-workspace-rules/rename-workspace-rules",
"package": "@nx/eslint-plugin",
"name": "update-17-2-6-rename-workspace-rules"
},
{
"version": "17.1.0-beta.2",
"description": "Move jest executor options to nx.json targetDefaults",
"implementation": "./src/migrations/update-17-1-0/move-options-to-target-defaults",
"package": "@nx/jest",
"name": "move-options-to-target-defaults"
},
{
"cli": "nx",
"version": "17.1.0-beta.5",
"requires": {
"@angular/core": ">=17.0.0"
},
"description": "Update the @angular/cli package version to ~17.0.0.",
"factory": "./src/migrations/update-17-1-0/update-angular-cli",
"package": "@nx/angular",
"name": "update-angular-cli-version-17-0-0"
},
{
"cli": "nx",
"version": "17.1.0-beta.5",
"requires": {
"@angular/core": ">=17.0.0"
},
"description": "Rename 'browserTarget' to 'buildTarget'.",
"factory": "./src/migrations/update-17-1-0/browser-target-to-build-target",
"package": "@nx/angular",
"name": "rename-browser-target-to-build-target"
},
{
"cli": "nx",
"version": "17.1.0-beta.5",
"requires": {
"@angular/core": ">=17.0.0"
},
"description": "Replace usages of '@nguniversal/builders' with '@angular-devkit/build-angular'.",
"factory": "./src/migrations/update-17-1-0/replace-nguniversal-builders",
"package": "@nx/angular",
"name": "replace-nguniversal-builders"
},
{
"cli": "nx",
"version": "17.1.0-beta.5",
"requires": {
"@angular/core": ">=17.0.0"
},
"description": "Replace usages of '@nguniversal/' packages with '@angular/ssr'.",
"factory": "./src/migrations/update-17-1-0/replace-nguniversal-engines",
"package": "@nx/angular",
"name": "replace-nguniversal-engines"
},
{
"cli": "nx",
"version": "17.1.0-beta.5",
"requires": {
"@angular/core": ">=17.0.0"
},
"description": "Replace the deep imports from 'zone.js/dist/zone' and 'zone.js/dist/zone-testing' with 'zone.js' and 'zone.js/testing'.",
"factory": "./src/migrations/update-17-1-0/update-zone-js-deep-import",
"package": "@nx/angular",
"name": "update-zone-js-deep-import"
},
{
"cli": "nx",
"version": "17.2.0-beta.2",
"description": "Rename '@nx/angular:webpack-dev-server' executor to '@nx/angular:dev-server'",
"factory": "./src/migrations/update-17-2-0/rename-webpack-dev-server",
"package": "@nx/angular",
"name": "rename-webpack-dev-server-executor"
},
{
"cli": "nx",
"version": "17.3.0-beta.10",
"requires": {
"@angular/core": ">=17.1.0"
},
"description": "Update the @angular/cli package version to ~17.1.0.",
"factory": "./src/migrations/update-17-3-0/update-angular-cli",
"package": "@nx/angular",
"name": "update-angular-cli-version-17-1-0"
},
{
"cli": "nx",
"version": "17.3.0-beta.10",
"requires": {
"@angular/core": ">=17.1.0"
},
"description": "Add 'browser-sync' as dev dependency when '@angular-devkit/build-angular:ssr-dev-server' or '@nx/angular:module-federation-dev-ssr' is used.",
"factory": "./src/migrations/update-17-3-0/add-browser-sync-dependency",
"package": "@nx/angular",
"name": "add-browser-sync-dependency"
},
{
"cli": "nx",
"version": "17.3.0-beta.10",
"requires": {
"@angular/core": ">=17.1.0"
},
"description": "Add 'autoprefixer' as dev dependency when '@nx/angular:ng-packagr-lite' or '@nx/angular:package` is used.",
"factory": "./src/migrations/update-17-3-0/add-autoprefixer-dependency",
"package": "@nx/angular",
"name": "add-autoprefixer-dependency"
},
{
"cli": "nx",
"version": "18.0.0-beta.0",
"description": "Add NX_MF_DEV_SERVER_STATIC_REMOTES to inputs for task hashing when '@nx/angular:webpack-browser' is used for Module Federation.",
"factory": "./src/migrations/update-18-0-0/add-mf-env-var-to-target-defaults",
"package": "@nx/angular",
"name": "add-module-federation-env-var-to-target-defaults"
},
{
"cli": "nx",
"version": "18.1.0-beta.1",
"requires": {
"@angular/core": ">=17.2.0"
},
"description": "Update the @angular/cli package version to ~17.2.0.",
"factory": "./src/migrations/update-18-1-0/update-angular-cli",
"package": "@nx/angular",
"name": "update-angular-cli-version-17-2-0"
},
{
"cli": "nx",
"version": "18.1.1-beta.0",
"description": "Ensure targetDefaults inputs for task hashing when '@nx/angular:webpack-browser' is used are correct for Module Federation.",
"factory": "./src/migrations/update-18-1-1/fix-target-defaults-inputs",
"package": "@nx/angular",
"name": "fix-target-defaults-for-webpack-browser"
},
{
"cli": "nx",
"version": "18.2.0-beta.0",
"requires": {
"@angular/core": ">=17.3.0"
},
"description": "Update the @angular/cli package version to ~17.3.0.",
"factory": "./src/migrations/update-18-2-0/update-angular-cli",
"package": "@nx/angular",
"name": "update-angular-cli-version-17-3-0"
}
]
}

2
package.json

@ -50,6 +50,7 @@
"ts-node": "ts-node", "ts-node": "ts-node",
"update": "nx migrate latest", "update": "nx migrate latest",
"watch:server": "nx run api:copy-assets && nx run api:build --watch", "watch:server": "nx run api:copy-assets && nx run api:build --watch",
"profile:server": "nx run api:profile",
"watch:test": "nx test --watch", "watch:test": "nx test --watch",
"workspace-generator": "nx workspace-generator" "workspace-generator": "nx workspace-generator"
}, },
@ -89,6 +90,7 @@
"@simplewebauthn/server": "9.0.3", "@simplewebauthn/server": "9.0.3",
"@stripe/stripe-js": "3.5.0", "@stripe/stripe-js": "3.5.0",
"alphavantage": "2.2.0", "alphavantage": "2.2.0",
"await-lock": "^2.2.2",
"big.js": "6.2.1", "big.js": "6.2.1",
"body-parser": "1.20.2", "body-parser": "1.20.2",
"bootstrap": "4.6.0", "bootstrap": "4.6.0",

22
prisma/migrations/20231108082445_added_tags_to_holding/migration.sql

@ -0,0 +1,22 @@
-- AlterEnum
ALTER TYPE "Type" ADD VALUE IF NOT EXISTS 'STAKE';
-- CreateTable
CREATE TABLE IF NOT EXISTS "_SymbolProfileToTag" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "_SymbolProfileToTag_AB_unique" ON "_SymbolProfileToTag"("A", "B");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "_SymbolProfileToTag_B_index" ON "_SymbolProfileToTag"("B");
-- AddForeignKey
ALTER TABLE "_SymbolProfileToTag" DROP CONSTRAINT IF EXISTS "_SymbolProfileToTag_A_fkey" ;
ALTER TABLE "_SymbolProfileToTag" ADD CONSTRAINT "_SymbolProfileToTag_A_fkey" FOREIGN KEY ("A") REFERENCES "SymbolProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_SymbolProfileToTag" DROP CONSTRAINT IF EXISTS "_SymbolProfileToTag_B_fkey" ;
ALTER TABLE "_SymbolProfileToTag" ADD CONSTRAINT "_SymbolProfileToTag_B_fkey" FOREIGN KEY ("B") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;

3
prisma/schema.prisma

@ -175,6 +175,7 @@ model SymbolProfile {
symbolMapping Json? symbolMapping Json?
url String? url String?
Order Order[] Order Order[]
tags Tag[]
SymbolProfileOverrides SymbolProfileOverrides? SymbolProfileOverrides SymbolProfileOverrides?
@@unique([dataSource, symbol]) @@unique([dataSource, symbol])
@ -215,6 +216,7 @@ model Tag {
id String @id @default(uuid()) id String @id @default(uuid())
name String @unique name String @unique
orders Order[] orders Order[]
symbolProfile SymbolProfile[]
@@index([name]) @@index([name])
} }
@ -306,6 +308,7 @@ enum Type {
ITEM ITEM
LIABILITY LIABILITY
SELL SELL
STAKE
} }
enum ViewMode { enum ViewMode {

19068
yarn.lock

File diff suppressed because it is too large
Loading…
Cancel
Save