Browse Source

Merge branch 'dockerpush' into main

pull/5027/head
dandevaud 2 years ago
committed by GitHub
parent
commit
b46761ac7a
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      .admin.cred
  2. 47
      .github/workflows/docker-image-dev.yml
  3. 5
      .github/workflows/docker-image.yml
  4. 44
      apps/api/src/app/admin/admin.controller.ts
  5. 4
      apps/api/src/app/admin/admin.module.ts
  6. 92
      apps/api/src/app/admin/admin.service.ts
  7. 14
      apps/api/src/app/admin/update-asset-profile.dto.ts
  8. 39
      apps/api/src/app/order/order.service.ts
  9. 14
      apps/api/src/app/portfolio/current-rate.service.ts
  10. 1
      apps/api/src/app/portfolio/interfaces/portfolio-position-detail.interface.ts
  11. 84
      apps/api/src/app/portfolio/portfolio-calculator.ts
  12. 65
      apps/api/src/app/portfolio/portfolio.controller.ts
  13. 387
      apps/api/src/app/portfolio/portfolio.service.ts
  14. 5
      apps/api/src/app/tag/tag.service.ts
  15. 2
      apps/api/src/app/user/update-user-setting.dto.ts
  16. 23
      apps/api/src/helper/dateQueryHelper.ts
  17. 28
      apps/api/src/services/data-gathering/data-gathering.service.ts
  18. 8
      apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts
  19. 32
      apps/api/src/services/data-provider/manual/manual.service.ts
  20. 77
      apps/api/src/services/market-data/market-data.service.ts
  21. 11
      apps/api/src/services/symbol-profile/symbol-profile-overwrite.module.ts
  22. 68
      apps/api/src/services/symbol-profile/symbol-profile-overwrite.service.ts
  23. 17
      apps/api/src/services/symbol-profile/symbol-profile.service.ts
  24. 17
      apps/api/src/services/tag/tag.service.ts
  25. 48
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  26. 35
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  27. 4
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.module.ts
  28. 13
      apps/client/src/app/components/admin-tag/admin-tag.component.html
  29. 2
      apps/client/src/app/components/admin-tag/admin-tag.component.ts
  30. 12
      apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.component.ts
  31. 19
      apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.html
  32. 3
      apps/client/src/app/components/toggle/toggle.component.ts
  33. 6
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  34. 17
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html
  35. 6
      apps/client/src/app/services/admin.service.ts
  36. 2
      apps/client/src/app/services/import-activities.service.ts
  37. 46
      libs/common/src/lib/chunkhelper.ts
  38. 3
      libs/common/src/lib/interfaces/admin-market-data.interface.ts
  39. 3
      libs/common/src/lib/interfaces/enhanced-symbol-profile.interface.ts
  40. 2
      libs/common/src/lib/types/date-range.type.ts
  41. 42
      libs/ui/src/lib/activities-table/activities-table.component.scss
  42. 9
      libs/ui/src/lib/activity-type/activity-type.component.html
  43. 1
      libs/ui/src/lib/i18n.ts
  44. 1
      package.json
  45. 22
      prisma/migrations/20231108082445_added_tags_to_holding/migration.sql
  46. 3
      prisma/schema.prisma
  47. 11
      yarn.lock

1
.admin.cred

@ -0,0 +1 @@
14d4daf73eefed7da7c32ec19bc37e678be0244fb46c8f4965bfe9ece7384706ed58222ad9b96323893c1d845bc33a308e7524c2c79636062cbb095e0780cb51

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}}

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

@ -448,11 +448,45 @@ export class AdminController {
); );
} }
return this.adminService.patchAssetProfileData({ if (dataSource === 'MANUAL') {
...assetProfileData, await this.adminService.patchAssetProfileData({
dataSource, dataSource,
symbol symbol,
}); tags: {
set: []
}
});
return this.adminService.patchAssetProfileData({
...assetProfileData,
dataSource,
symbol,
tags: {
connect: assetProfileData.tags?.map(({ id }) => {
return { id };
})
}
});
} else {
await this.adminService.patchAssetProfileData({
dataSource,
symbol,
tags: {
set: []
}
});
return this.adminService.patchAssetProfileData({
...assetProfileData,
dataSource,
symbol,
tags: {
connect: assetProfileData.tags?.map(({ id }) => {
return { id };
})
}
});
}
} }
@Put('settings/:key') @Put('settings/:key')

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

@ -13,6 +13,7 @@ import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller'; import { AdminController } from './admin.controller';
import { AdminService } from './admin.service'; import { AdminService } from './admin.service';
import { QueueModule } from './queue/queue.module'; import { QueueModule } from './queue/queue.module';
import { SymbolProfileOverwriteModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile-overwrite.module';
@Module({ @Module({
imports: [ imports: [
@ -26,7 +27,8 @@ import { QueueModule } from './queue/queue.module';
PropertyModule, PropertyModule,
QueueModule, QueueModule,
SubscriptionModule, SubscriptionModule,
SymbolProfileModule SymbolProfileModule,
SymbolProfileOverwriteModule
], ],
controllers: [AdminController], controllers: [AdminController],
providers: [AdminService], providers: [AdminService],

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

@ -6,6 +6,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,
@ -25,10 +26,12 @@ import { MarketDataPreset } from '@ghostfolio/common/types';
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { import {
AssetSubClass, AssetSubClass,
DataSource,
Prisma, Prisma,
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';
@ -43,7 +46,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({
@ -218,7 +222,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 })
@ -236,7 +241,8 @@ export class AdminService {
name, name,
Order, Order,
sectors, sectors,
symbol symbol,
tags
}) => { }) => {
const countriesCount = countries ? Object.keys(countries).length : 0; const countriesCount = countries ? Object.keys(countries).length : 0;
const marketDataItemCount = const marketDataItemCount =
@ -260,7 +266,8 @@ export class AdminService {
marketDataItemCount, marketDataItemCount,
sectorsCount, sectorsCount,
activitiesCount: _count.Order, activitiesCount: _count.Order,
date: Order?.[0]?.date date: Order?.[0]?.date,
tags
}; };
} }
); );
@ -322,20 +329,70 @@ export class AdminService {
comment, comment,
dataSource, dataSource,
name, name,
tags,
scraperConfiguration, scraperConfiguration,
symbol, symbol,
symbolMapping symbolMapping
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) { }: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
await this.symbolProfileService.updateSymbolProfile({ if (dataSource === 'MANUAL') {
assetClass, await this.symbolProfileService.updateSymbolProfile({
assetSubClass, assetClass,
comment, assetSubClass,
dataSource, comment,
name, dataSource,
scraperConfiguration, name,
symbol, tags,
symbolMapping scraperConfiguration,
}); symbol,
symbolMapping
});
} else {
await this.symbolProfileService.updateSymbolProfile({
comment,
dataSource,
name,
tags,
scraperConfiguration,
symbol,
symbolMapping
});
let symbolProfileId =
await this.symbolProfileOverwriteService.GetSymbolProfileId(
symbol,
dataSource
);
if (symbolProfileId) {
await this.symbolProfileOverwriteService.updateSymbolProfileOverrides({
assetClass,
assetSubClass,
symbolProfileId
});
} else {
let profiles = await this.symbolProfileService.getSymbolProfiles([
{
dataSource,
symbol
}
]);
symbolProfileId = profiles[0].id;
await this.symbolProfileOverwriteService.add({
SymbolProfile: {
connect: {
dataSource_symbol: {
dataSource,
symbol
}
}
}
});
await this.symbolProfileOverwriteService.updateSymbolProfileOverrides({
assetClass,
assetSubClass,
symbolProfileId
});
}
}
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([ const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
{ {
@ -390,7 +447,8 @@ export class AdminService {
countriesCount: 0, countriesCount: 0,
currency: symbol.replace(DEFAULT_CURRENCY, ''), currency: symbol.replace(DEFAULT_CURRENCY, ''),
name: symbol, name: symbol,
sectorsCount: 0 sectorsCount: 0,
tags: []
}; };
}); });

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

@ -1,5 +1,11 @@
import { AssetClass, AssetSubClass, Prisma } from '@prisma/client'; import { AssetClass, AssetSubClass, Prisma, Tag } from '@prisma/client';
import { IsEnum, IsObject, IsOptional, IsString } from 'class-validator'; import {
IsArray,
IsEnum,
IsObject,
IsOptional,
IsString
} from 'class-validator';
export class UpdateAssetProfileDto { export class UpdateAssetProfileDto {
@IsEnum(AssetClass, { each: true }) @IsEnum(AssetClass, { each: true })
@ -18,6 +24,10 @@ export class UpdateAssetProfileDto {
@IsOptional() @IsOptional()
name?: string; name?: string;
@IsArray()
@IsOptional()
tags?: Tag[];
@IsObject() @IsObject()
@IsOptional() @IsOptional()
scraperConfiguration?: Prisma.InputJsonObject; scraperConfiguration?: Prisma.InputJsonObject;

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

@ -298,13 +298,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 (types) { if (types) {
@ -330,7 +351,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
}, },
orderBy: { date: 'asc' } orderBy: { date: 'asc' }

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

@ -14,9 +14,12 @@ import { flatten, isEmpty, uniqBy } from 'lodash';
import { GetValueObject } from './interfaces/get-value-object.interface'; import { GetValueObject } from './interfaces/get-value-object.interface';
import { GetValuesObject } from './interfaces/get-values-object.interface'; import { GetValuesObject } from './interfaces/get-values-object.interface';
import { GetValuesParams } from './interfaces/get-values-params.interface'; import { GetValuesParams } from './interfaces/get-values-params.interface';
import { DateQueryHelper } from '@ghostfolio/api/helper/dateQueryHelper';
@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 exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
@ -34,7 +37,7 @@ 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());
@ -89,7 +92,7 @@ export class CurrentRateService {
promises.push( promises.push(
this.marketDataService this.marketDataService
.getRange({ .getRange({
dateQuery, dateQuery: query,
uniqueAssets uniqueAssets
}) })
.then((data) => { .then((data) => {
@ -116,9 +119,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 {

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

@ -10,6 +10,7 @@ export interface PortfolioPositionDetail {
averagePrice: number; averagePrice: number;
dataProviderInfo: DataProviderInfo; dataProviderInfo: DataProviderInfo;
dividendInBaseCurrency: number; dividendInBaseCurrency: number;
stakeRewards: number;
feeInBaseCurrency: number; feeInBaseCurrency: number;
firstBuyDate: string; firstBuyDate: string;
grossPerformance: number; grossPerformance: number;

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

@ -3,6 +3,7 @@ import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfac
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper'; import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import { import {
DataProviderInfo, DataProviderInfo,
HistoricalDataItem,
ResponseError, ResponseError,
TimelinePosition TimelinePosition
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
@ -92,7 +93,7 @@ export class PortfolioCalculator {
let investment = new Big(0); let investment = new Big(0);
if (newQuantity.gt(0)) { if (newQuantity.gt(0)) {
if (order.type === 'BUY') { if (order.type === 'BUY' || order.type === 'STAKE') {
investment = oldAccumulatedSymbol.investment.plus( investment = oldAccumulatedSymbol.investment.plus(
order.quantity.mul(unitPrice) order.quantity.mul(unitPrice)
); );
@ -279,46 +280,29 @@ export class PortfolioCalculator {
}; };
} }
for (const currentDate of dates) { return dates.map((date) => {
const dateString = format(currentDate, DATE_FORMAT); const dateString = format(date, DATE_FORMAT);
let totalCurrentValue = new Big(0);
let totalInvestmentValue = new Big(0);
let maxTotalInvestmentValue = new Big(0);
let totalNetPerformanceValue = new Big(0);
for (const symbol of Object.keys(valuesBySymbol)) { for (const symbol of Object.keys(valuesBySymbol)) {
const symbolValues = valuesBySymbol[symbol]; const symbolValues = valuesBySymbol[symbol];
const currentValue = totalCurrentValue = totalCurrentValue.plus(
symbolValues.currentValues?.[dateString] ?? new Big(0); symbolValues.currentValues?.[dateString] ?? new Big(0)
const investmentValue = );
symbolValues.investmentValues?.[dateString] ?? new Big(0); totalInvestmentValue = totalInvestmentValue.plus(
const maxInvestmentValue = symbolValues.investmentValues?.[dateString] ?? new Big(0)
symbolValues.maxInvestmentValues?.[dateString] ?? new Big(0); );
const netPerformanceValue = maxTotalInvestmentValue = maxTotalInvestmentValue.plus(
symbolValues.netPerformanceValues?.[dateString] ?? new Big(0); symbolValues.maxInvestmentValues?.[dateString] ?? new Big(0)
);
valuesByDate[dateString] = { totalNetPerformanceValue = totalNetPerformanceValue.plus(
totalCurrentValue: ( symbolValues.netPerformanceValues?.[dateString] ?? new Big(0)
valuesByDate[dateString]?.totalCurrentValue ?? new Big(0) );
).add(currentValue),
totalInvestmentValue: (
valuesByDate[dateString]?.totalInvestmentValue ?? new Big(0)
).add(investmentValue),
maxTotalInvestmentValue: (
valuesByDate[dateString]?.maxTotalInvestmentValue ?? new Big(0)
).add(maxInvestmentValue),
totalNetPerformanceValue: (
valuesByDate[dateString]?.totalNetPerformanceValue ?? new Big(0)
).add(netPerformanceValue)
};
} }
}
return Object.entries(valuesByDate).map(([date, values]) => {
const {
maxTotalInvestmentValue,
totalCurrentValue,
totalInvestmentValue,
totalNetPerformanceValue
} = values;
const netPerformanceInPercentage = maxTotalInvestmentValue.eq(0) const netPerformanceInPercentage = maxTotalInvestmentValue.eq(0)
? 0 ? 0
: totalNetPerformanceValue : totalNetPerformanceValue
@ -327,7 +311,7 @@ export class PortfolioCalculator {
.toNumber(); .toNumber();
return { return {
date, date: dateString,
netPerformanceInPercentage, netPerformanceInPercentage,
netPerformance: totalNetPerformanceValue.toNumber(), netPerformance: totalNetPerformanceValue.toNumber(),
totalInvestment: totalInvestmentValue.toNumber(), totalInvestment: totalInvestmentValue.toNumber(),
@ -756,7 +740,7 @@ export class PortfolioCalculator {
totalInvestment = totalInvestment.plus(currentPosition.investment); totalInvestment = totalInvestment.plus(currentPosition.investment);
if (currentPosition.grossPerformance) { if (currentPosition.grossPerformance !== null) {
grossPerformance = grossPerformance.plus( grossPerformance = grossPerformance.plus(
currentPosition.grossPerformance currentPosition.grossPerformance
); );
@ -766,7 +750,7 @@ export class PortfolioCalculator {
hasErrors = true; hasErrors = true;
} }
if (currentPosition.grossPerformancePercentage) { if (currentPosition.grossPerformancePercentage !== null) {
// Use the average from the initial value and the current investment as // Use the average from the initial value and the current investment as
// a weight // a weight
const weight = (initialValues[currentPosition.symbol] ?? new Big(0)) const weight = (initialValues[currentPosition.symbol] ?? new Big(0))
@ -934,6 +918,7 @@ export class PortfolioCalculator {
switch (type) { switch (type) {
case 'BUY': case 'BUY':
case 'STAKE':
factor = 1; factor = 1;
break; break;
case 'SELL': case 'SELL':
@ -1090,6 +1075,20 @@ export class PortfolioCalculator {
marketSymbolMap[format(day, DATE_FORMAT)]?.[symbol] ?? marketSymbolMap[format(day, DATE_FORMAT)]?.[symbol] ??
lastUnitPrice lastUnitPrice
}); });
} else {
let orderIndex = orders.findIndex(
(o) => o.date === format(day, DATE_FORMAT) && o.type === 'STAKE'
);
if (orderIndex >= 0) {
let order = orders[orderIndex];
orders.splice(orderIndex, 1);
orders.push({
...order,
unitPrice:
marketSymbolMap[format(day, DATE_FORMAT)]?.[symbol] ??
lastUnitPrice
});
}
} }
lastUnitPrice = last(orders).unitPrice; lastUnitPrice = last(orders).unitPrice;
@ -1159,7 +1158,7 @@ export class PortfolioCalculator {
} }
const transactionInvestment = const transactionInvestment =
order.type === 'BUY' order.type === 'BUY' || order.type === 'STAKE'
? order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type)) ? order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
: totalUnits.gt(0) : totalUnits.gt(0)
? totalInvestment ? totalInvestment
@ -1192,6 +1191,11 @@ export class PortfolioCalculator {
initialValue = valueOfInvestmentBeforeTransaction; initialValue = valueOfInvestmentBeforeTransaction;
} else if (transactionInvestment.gt(0)) { } else if (transactionInvestment.gt(0)) {
initialValue = transactionInvestment; initialValue = transactionInvestment;
} else if (order.type === 'STAKE') {
// For Parachain Rewards or Stock SpinOffs, first transactionInvestment might be 0 if the symbol has been acquired for free
initialValue = order.quantity.mul(
marketSymbolMap[order.date]?.[order.symbol] ?? new Big(0)
);
} }
} }

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

@ -110,21 +110,38 @@ export class PortfolioController {
impersonationId || impersonationId ||
this.userService.isRestrictedView(this.request.user) this.userService.isRestrictedView(this.request.user)
) { ) {
const totalInvestment = Object.values(holdings) let investmentTuple: [number, number] = [0, 0];
.map((portfolioPosition) => { for (let holding of Object.entries(holdings)) {
return portfolioPosition.investment; var portfolioPosition = holding[1];
}) investmentTuple[0] += portfolioPosition.investment;
.reduce((a, b) => a + b, 0); investmentTuple[1] += this.exchangeRateDataService.toCurrency(
portfolioPosition.quantity * portfolioPosition.marketPrice,
const totalValue = Object.values(holdings) portfolioPosition.currency,
.map((portfolioPosition) => { this.request.user.Settings.settings.baseCurrency
return this.exchangeRateDataService.toCurrency( );
portfolioPosition.quantity * portfolioPosition.marketPrice, }
portfolioPosition.currency, const totalInvestment = investmentTuple[0];
this.request.user.Settings.settings.baseCurrency
); const totalValue = investmentTuple[1];
})
.reduce((a, b) => a + b, 0); if (hasDetails === false) {
portfolioSummary = nullifyValuesInObject(summary, [
'cash',
'committedFunds',
'currentGrossPerformance',
'currentNetPerformance',
'currentValue',
'dividend',
'emergencyFund',
'excludedAccountsAndActivities',
'fees',
'items',
'liabilities',
'netWorth',
'totalBuy',
'totalSell'
]);
}
for (const [symbol, portfolioPosition] of Object.entries(holdings)) { for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
portfolioPosition.grossPerformance = null; portfolioPosition.grossPerformance = null;
@ -134,6 +151,24 @@ export class PortfolioController {
portfolioPosition.quantity = null; portfolioPosition.quantity = null;
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)) {

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

@ -75,6 +75,7 @@ import {
set, set,
setDayOfYear, setDayOfYear,
subDays, subDays,
subMonths,
subYears subYears
} from 'date-fns'; } from 'date-fns';
import { isEmpty, last, sortBy, uniq, uniqBy } from 'lodash'; import { isEmpty, last, sortBy, uniq, uniqBy } from 'lodash';
@ -470,11 +471,9 @@ export class PortfolioService {
} }
const portfolioItemsNow: { [symbol: string]: TimelinePosition } = {}; const portfolioItemsNow: { [symbol: string]: TimelinePosition } = {};
for (const position of currentPositions.positions) {
portfolioItemsNow[position.symbol] = position;
}
for (const item of currentPositions.positions) { for (const item of currentPositions.positions) {
portfolioItemsNow[item.symbol] = item;
if (item.quantity.lte(0)) { if (item.quantity.lte(0)) {
// Ignore positions without any quantity // Ignore positions without any quantity
continue; continue;
@ -500,59 +499,12 @@ export class PortfolioService {
otherMarkets: 0 otherMarkets: 0
}; };
if (symbolProfile.countries.length > 0) { this.calculateMarketsAllocation(
for (const country of symbolProfile.countries) { symbolProfile,
if (developedMarkets.includes(country.code)) { markets,
markets.developedMarkets = new Big(markets.developedMarkets) marketsAdvanced,
.plus(country.weight) value
.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();
}
holdings[item.symbol] = { holdings[item.symbol] = {
markets, markets,
@ -585,6 +537,68 @@ export class PortfolioService {
}; };
} }
await this.handleCashPosition(
filters,
isFilteredByAccount,
cashDetails,
userCurrency,
filteredValueInBaseCurrency,
holdings
);
const { accounts, platforms } = await this.getValueOfAccountsAndPlatforms({
filters,
orders,
portfolioItemsNow,
userCurrency,
userId,
withExcludedAccounts
});
filteredValueInBaseCurrency = await this.handleEmergencyFunds(
filters,
cashDetails,
userCurrency,
filteredValueInBaseCurrency,
emergencyFund,
orders,
accounts,
holdings
);
const summary = await this.getSummary({
impersonationId,
userCurrency,
userId,
balanceInBaseCurrency: cashDetails.balanceInBaseCurrency,
emergencyFundPositionsValueInBaseCurrency:
this.getEmergencyFundPositionsValueInBaseCurrency({
holdings
})
});
return {
accounts,
holdings,
platforms,
summary,
filteredValueInBaseCurrency: filteredValueInBaseCurrency.toNumber(),
filteredValueInPercentage: summary.netWorth
? filteredValueInBaseCurrency.div(summary.netWorth).toNumber()
: 0,
hasErrors: currentPositions.hasErrors,
totalValueInBaseCurrency: summary.netWorth
};
}
private async handleCashPosition(
filters: Filter[],
isFilteredByAccount: boolean,
cashDetails: CashDetails,
userCurrency: string,
filteredValueInBaseCurrency: Big,
holdings: { [symbol: string]: PortfolioPosition }
) {
const isFilteredByCash = filters?.some((filter) => { const isFilteredByCash = filters?.some((filter) => {
return filter.type === 'ASSET_CLASS' && filter.id === 'CASH'; return filter.type === 'ASSET_CLASS' && filter.id === 'CASH';
}); });
@ -600,16 +614,26 @@ export class PortfolioService {
holdings[symbol] = cashPositions[symbol]; holdings[symbol] = cashPositions[symbol];
} }
} }
}
const { accounts, platforms } = await this.getValueOfAccountsAndPlatforms({ private async handleEmergencyFunds(
filters, filters: Filter[],
orders, cashDetails: CashDetails,
portfolioItemsNow, userCurrency: string,
userCurrency, filteredValueInBaseCurrency: Big,
userId, emergencyFund: Big,
withExcludedAccounts orders: Activity[],
}); accounts: {
[id: string]: {
balance: number;
currency: string;
name: string;
valueInBaseCurrency: number;
valueInPercentage?: number;
};
},
holdings: { [symbol: string]: PortfolioPosition }
) {
if ( if (
filters?.length === 1 && filters?.length === 1 &&
filters[0].id === EMERGENCY_FUND_TAG_ID && filters[0].id === EMERGENCY_FUND_TAG_ID &&
@ -644,30 +668,79 @@ export class PortfolioService {
valueInBaseCurrency: emergencyFundInCash valueInBaseCurrency: emergencyFundInCash
}; };
} }
return filteredValueInBaseCurrency;
}
const summary = await this.getSummary({ private calculateMarketsAllocation(
impersonationId, symbolProfile: EnhancedSymbolProfile,
userCurrency, markets: {
userId, developedMarkets: number;
balanceInBaseCurrency: cashDetails.balanceInBaseCurrency, emergingMarkets: number;
emergencyFundPositionsValueInBaseCurrency: otherMarkets: number;
this.getEmergencyFundPositionsValueInBaseCurrency({ },
holdings 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();
}
return { if (country.code === 'JP') {
accounts, marketsAdvanced.japan = new Big(marketsAdvanced.japan)
holdings, .plus(country.weight)
platforms, .toNumber();
summary, } else if (country.code === 'CA' || country.code === 'US') {
filteredValueInBaseCurrency: filteredValueInBaseCurrency.toNumber(), marketsAdvanced.northAmerica = new Big(marketsAdvanced.northAmerica)
filteredValueInPercentage: summary.netWorth .plus(country.weight)
? filteredValueInBaseCurrency.div(summary.netWorth).toNumber() .toNumber();
: 0, } else if (asiaPacificMarkets.includes(country.code)) {
hasErrors: currentPositions.hasErrors, marketsAdvanced.asiaPacific = new Big(marketsAdvanced.asiaPacific)
totalValueInBaseCurrency: summary.netWorth .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();
}
} }
public async getPosition( public async getPosition(
@ -700,6 +773,7 @@ export class PortfolioService {
averagePrice: undefined, averagePrice: undefined,
dataProviderInfo: undefined, dataProviderInfo: undefined,
dividendInBaseCurrency: undefined, dividendInBaseCurrency: undefined,
stakeRewards: undefined,
feeInBaseCurrency: undefined, feeInBaseCurrency: undefined,
firstBuyDate: undefined, firstBuyDate: undefined,
grossPerformance: undefined, grossPerformance: undefined,
@ -728,7 +802,11 @@ export class PortfolioService {
.filter((order) => { .filter((order) => {
tags = tags.concat(order.tags); tags = tags.concat(order.tags);
return order.type === 'BUY' || order.type === 'SELL'; return (
order.type === 'BUY' ||
order.type === 'SELL' ||
order.type === 'STAKE'
);
}) })
.map((order) => ({ .map((order) => ({
currency: order.SymbolProfile.currency, currency: order.SymbolProfile.currency,
@ -784,6 +862,16 @@ 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 // Convert investment, gross and net performance to currency of user
const investment = this.exchangeRateDataService.toCurrency( const investment = this.exchangeRateDataService.toCurrency(
position.investment?.toNumber(), position.investment?.toNumber(),
@ -878,6 +966,7 @@ export class PortfolioService {
averagePrice: averagePrice.toNumber(), averagePrice: averagePrice.toNumber(),
dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0], dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0],
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(), dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
stakeRewards: stakeRewards.toNumber(),
feeInBaseCurrency: this.exchangeRateDataService.toCurrency( feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
fee.toNumber(), fee.toNumber(),
SymbolProfile.currency, SymbolProfile.currency,
@ -941,6 +1030,7 @@ export class PortfolioService {
averagePrice: 0, averagePrice: 0,
dataProviderInfo: undefined, dataProviderInfo: undefined,
dividendInBaseCurrency: 0, dividendInBaseCurrency: 0,
stakeRewards: 0,
feeInBaseCurrency: 0, feeInBaseCurrency: 0,
firstBuyDate: undefined, firstBuyDate: undefined,
grossPerformance: undefined, grossPerformance: undefined,
@ -1049,6 +1139,7 @@ export class PortfolioService {
dataProviderResponses[position.symbol]?.marketState ?? 'delayed', dataProviderResponses[position.symbol]?.marketState ?? 'delayed',
name: symbolProfileMap[position.symbol].name, name: symbolProfileMap[position.symbol].name,
netPerformance: position.netPerformance?.toNumber() ?? null, netPerformance: position.netPerformance?.toNumber() ?? null,
tags: symbolProfileMap[position.symbol].tags,
netPerformancePercentage: netPerformancePercentage:
position.netPerformancePercentage?.toNumber() ?? null, position.netPerformancePercentage?.toNumber() ?? null,
quantity: new Big(position.quantity).toNumber() quantity: new Big(position.quantity).toNumber()
@ -1580,6 +1671,28 @@ export class PortfolioService {
setDayOfYear(new Date().setHours(0, 0, 0, 0), 1) setDayOfYear(new Date().setHours(0, 0, 0, 0), 1)
]); ]);
break; break;
case '1w':
portfolioStart = max([
portfolioStart,
subDays(new Date().setHours(0, 0, 0, 0), 7)
]);
break;
case '1m':
portfolioStart = max([
portfolioStart,
subMonths(new Date().setHours(0, 0, 0, 0), 1)
]);
break;
case '3m':
portfolioStart = max([
portfolioStart,
subMonths(new Date().setHours(0, 0, 0, 0), 3)
]);
break;
case '1y': case '1y':
portfolioStart = max([ portfolioStart = max([
portfolioStart, portfolioStart,
@ -1639,60 +1752,68 @@ export class PortfolioService {
userId userId
}); });
const activities = await this.orderService.getOrders({ const ordersRaw = await this.orderService.getOrders({
userCurrency, userCurrency,
userId userId,
}); withExcludedAccounts: true
const excludedActivities = (
await this.orderService.getOrders({
userCurrency,
userId,
withExcludedAccounts: true
})
).filter(({ Account: account }) => {
return account?.isExcluded ?? false;
}); });
const activities: Activity[] = [];
const excludedActivities: Activity[] = [];
let dividend = 0;
let fees = 0;
let items = 0;
let interest = 0;
let liabilities = 0;
let totalBuy = 0;
let totalSell = 0;
for (let order of ordersRaw) {
if (order.Account?.isExcluded ?? false) {
excludedActivities.push(order);
} else {
activities.push(order);
fees += this.exchangeRateDataService.toCurrency(
order.fee,
order.SymbolProfile.currency,
userCurrency
);
let amount = this.exchangeRateDataService.toCurrency(
new Big(order.quantity).mul(order.unitPrice).toNumber(),
order.SymbolProfile.currency,
userCurrency
);
switch (order.type) {
case 'DIVIDEND':
dividend += amount;
break;
case 'ITEM':
items += amount;
break;
case 'SELL':
totalSell += amount;
break;
case 'BUY':
totalBuy += amount;
break;
case 'LIABILITY':
liabilities += amount;
break;
case 'INTEREST':
interest += amount;
break;
}
}
}
const dividend = this.getSumOfActivityType({
activities,
userCurrency,
activityType: 'DIVIDEND'
}).toNumber();
const emergencyFund = new Big( const emergencyFund = new Big(
Math.max( Math.max(
emergencyFundPositionsValueInBaseCurrency, emergencyFundPositionsValueInBaseCurrency,
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0 (user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
) )
); );
const fees = this.getFees({ activities, userCurrency }).toNumber();
const firstOrderDate = activities[0]?.date;
const interest = this.getSumOfActivityType({
activities,
userCurrency,
activityType: 'INTEREST'
}).toNumber();
const items = this.getSumOfActivityType({
activities,
userCurrency,
activityType: 'ITEM'
}).toNumber();
const liabilities = this.getSumOfActivityType({
activities,
userCurrency,
activityType: 'LIABILITY'
}).toNumber();
const totalBuy = this.getSumOfActivityType({ const firstOrderDate = activities[0]?.date;
activities,
userCurrency,
activityType: 'BUY'
}).toNumber();
const totalSell = this.getSumOfActivityType({
activities,
userCurrency,
activityType: 'SELL'
}).toNumber();
const cash = new Big(balanceInBaseCurrency) const cash = new Big(balanceInBaseCurrency)
.minus(emergencyFund) .minus(emergencyFund)
@ -1836,7 +1957,7 @@ export class PortfolioService {
userCurrency, userCurrency,
userId, userId,
withExcludedAccounts, withExcludedAccounts,
types: ['BUY', 'SELL'] types: ['BUY', 'SELL', 'STAKE']
}); });
if (orders.length <= 0) { if (orders.length <= 0) {

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

@ -50,7 +50,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 }
} }
} }
}); });
@ -59,7 +59,8 @@ export class TagService {
return { return {
id, id,
name, name,
activityCount: _count.orders activityCount: _count.orders,
holdingCount: _count.symbolProfile
}; };
}); });
} }

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

@ -29,7 +29,7 @@ export class UpdateUserSettingDto {
@IsOptional() @IsOptional()
colorScheme?: ColorScheme; colorScheme?: ColorScheme;
@IsIn(<DateRange[]>['1d', '1y', '5y', 'max', 'ytd']) @IsIn(<DateRange[]>['1d', '1w', '1m', '3m', '1y', '5y', 'max', 'ytd'])
@IsOptional() @IsOptional()
dateRange?: DateRange; dateRange?: DateRange;

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

@ -0,0 +1,23 @@
import { resetHours } from '@ghostfolio/common/helper';
import { DateQuery } from '../app/portfolio/interfaces/date-query.interface';
import { addDays } from 'date-fns';
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 };
}
}

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

@ -24,6 +24,7 @@ import { DataSource } from '@prisma/client';
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';
import AwaitLock from 'await-lock';
@Injectable() @Injectable()
export class DataGatheringService { export class DataGatheringService {
@ -40,6 +41,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,
@ -100,16 +103,21 @@ export class DataGatheringService {
historicalData[symbol][format(date, DATE_FORMAT)].marketPrice; historicalData[symbol][format(date, DATE_FORMAT)].marketPrice;
if (marketPrice) { if (marketPrice) {
return await this.prismaService.marketData.upsert({ await this.lock.acquireAsync();
create: { try {
dataSource, return await this.prismaService.marketData.upsert({
date, create: {
marketPrice, dataSource,
symbol date,
}, marketPrice,
update: { marketPrice }, symbol
where: { dataSource_date_symbol: { dataSource, date, symbol } } },
}); update: { marketPrice },
where: { dataSource_date_symbol: { dataSource, date, symbol } }
});
} finally {
this.lock.release();
}
} }
} catch (error) { } catch (error) {
Logger.error(error, 'DataGatheringService'); Logger.error(error, 'DataGatheringService');

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

@ -29,9 +29,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;
} }
@ -113,8 +111,8 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
}); });
}); });
if (holdings?.weight < 0.95) { if (holdings?.weight < 1 - Math.min(holdings?.count * 0.000015, 0.95)) {
// Skip if data is inaccurate // Skip if data is inaccurate, dependent on holdings count there might be rounding issues
return response; return response;
} }

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

@ -6,6 +6,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 { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config'; import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
import { import {
DATE_FORMAT, DATE_FORMAT,
@ -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 symbolProfile of symbolProfiles) { for (const symbolProfile of symbolProfiles) {
response[symbolProfile.symbol] = { response[symbolProfile.symbol] = {

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

@ -3,6 +3,7 @@ import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.i
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 { resetHours } from '@ghostfolio/common/helper'; import { resetHours } from '@ghostfolio/common/helper';
import { BatchPrismaClient } from '@ghostfolio/common/chunkhelper';
import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { import {
@ -11,11 +12,17 @@ import {
MarketDataState, MarketDataState,
Prisma Prisma
} from '@prisma/client'; } from '@prisma/client';
import { DateQueryHelper } from '@ghostfolio/api/helper/dateQueryHelper';
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();
public async deleteMany({ dataSource, symbol }: UniqueAsset) { public async deleteMany({ dataSource, symbol }: UniqueAsset) {
return this.prismaService.marketData.deleteMany({ return this.prismaService.marketData.deleteMany({
where: { where: {
@ -116,18 +123,22 @@ export class MarketDataService {
where: Prisma.MarketDataWhereUniqueInput; where: Prisma.MarketDataWhereUniqueInput;
}): Promise<MarketData> { }): Promise<MarketData> {
const { data, where } = params; const { data, where } = params;
await this.lock.acquireAsync();
return this.prismaService.marketData.upsert({ try {
where, return this.prismaService.marketData.upsert({
create: { where,
dataSource: where.dataSource_date_symbol.dataSource, create: {
date: where.dataSource_date_symbol.date, dataSource: where.dataSource_date_symbol.dataSource,
marketPrice: data.marketPrice, date: where.dataSource_date_symbol.date,
state: data.state, marketPrice: data.marketPrice,
symbol: where.dataSource_date_symbol.symbol state: data.state,
}, symbol: where.dataSource_date_symbol.symbol
update: { marketPrice: data.marketPrice, state: data.state } },
}); update: { marketPrice: data.marketPrice, state: data.state }
});
} finally {
this.lock.release();
}
} }
/** /**
@ -140,30 +151,34 @@ 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({ await this.lock.acquireAsync();
create: { try {
dataSource: <DataSource>dataSource, return this.prismaService.marketData.upsert({
date: <Date>date, create: {
marketPrice: <number>marketPrice,
state: <MarketDataState>state,
symbol: <string>symbol
},
update: {
marketPrice: <number>marketPrice,
state: <MarketDataState>state
},
where: {
dataSource_date_symbol: {
dataSource: <DataSource>dataSource, dataSource: <DataSource>dataSource,
date: <Date>date, date: <Date>date,
marketPrice: <number>marketPrice,
state: <MarketDataState>state,
symbol: <string>symbol symbol: <string>symbol
},
update: {
marketPrice: <number>marketPrice,
state: <MarketDataState>state
},
where: {
dataSource_date_symbol: {
dataSource: <DataSource>dataSource,
date: <Date>date,
symbol: <string>symbol
}
} }
} });
}); } finally {
this.lock.release();
}
} }
); );
return await Promise.all(upsertPromises);
return this.prismaService.$transaction(upsertPromises);
} }
} }

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

@ -0,0 +1,11 @@
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 {}

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

@ -0,0 +1,68 @@
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;
}
}

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

@ -8,7 +8,12 @@ import {
import { Country } from '@ghostfolio/common/interfaces/country.interface'; 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()
@ -49,6 +54,7 @@ export class SymbolProfileService {
select: { date: true }, select: { date: true },
take: 1 take: 1
}, },
tags: true,
SymbolProfileOverrides: true SymbolProfileOverrides: true
}, },
where: { where: {
@ -72,7 +78,8 @@ export class SymbolProfileService {
_count: { _count: {
select: { Order: true } select: { Order: true }
}, },
SymbolProfileOverrides: true SymbolProfileOverrides: true,
tags: true
}, },
where: { where: {
id: { id: {
@ -91,6 +98,7 @@ export class SymbolProfileService {
comment, comment,
dataSource, dataSource,
name, name,
tags,
scraperConfiguration, scraperConfiguration,
symbol, symbol,
symbolMapping symbolMapping
@ -101,6 +109,7 @@ export class SymbolProfileService {
assetSubClass, assetSubClass,
comment, comment,
name, name,
tags,
scraperConfiguration, scraperConfiguration,
symbolMapping symbolMapping
}, },
@ -114,6 +123,7 @@ export class SymbolProfileService {
Order?: { Order?: {
date: Date; date: Date;
}[]; }[];
tags?: Tag[];
SymbolProfileOverrides: SymbolProfileOverrides; SymbolProfileOverrides: SymbolProfileOverrides;
})[] })[]
): EnhancedSymbolProfile[] { ): EnhancedSymbolProfile[] {
@ -127,7 +137,8 @@ export class SymbolProfileService {
dateOfFirstActivity: <Date>undefined, dateOfFirstActivity: <Date>undefined,
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

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

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

@ -2,9 +2,11 @@ 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 { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
@ -22,7 +24,8 @@ 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';
@ -30,6 +33,8 @@ import { EMPTY, Subject } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators'; import { catchError, takeUntil } from 'rxjs/operators';
import { AssetProfileDialogParams } from './interfaces/interfaces'; import { AssetProfileDialogParams } from './interfaces/interfaces';
import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import { COMMA, ENTER } from '@angular/cdk/keycodes';
@Component({ @Component({
host: { class: 'd-flex flex-column h-100' }, host: { class: 'd-flex flex-column h-100' },
@ -39,6 +44,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) };
@ -55,6 +62,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
csvString: '' csvString: ''
}), }),
name: ['', Validators.required], name: ['', Validators.required],
tags: new FormControl<Tag[]>(undefined),
scraperConfiguration: '', scraperConfiguration: '',
symbolMapping: '' symbolMapping: ''
}); });
@ -69,6 +77,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
@ -92,6 +102,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,
@ -132,6 +154,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,
historicalData: { historicalData: {
csvString: AssetProfileDialog.HISTORICAL_DATA_TEMPLATE csvString: AssetProfileDialog.HISTORICAL_DATA_TEMPLATE
}, },
@ -249,6 +272,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
assetSubClass: this.assetProfileForm.controls['assetSubClass'].value, assetSubClass: this.assetProfileForm.controls['assetSubClass'].value,
comment: this.assetProfileForm.controls['comment'].value ?? null, comment: this.assetProfileForm.controls['comment'].value ?? null,
name: this.assetProfileForm.controls['name'].value, name: this.assetProfileForm.controls['name'].value,
tags: this.assetProfileForm.controls['tags'].value,
scraperConfiguration, scraperConfiguration,
symbolMapping symbolMapping
}; };
@ -277,6 +301,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();

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

@ -183,7 +183,7 @@
<input formControlName="name" matInput type="text" /> <input formControlName="name" matInput type="text" />
</mat-form-field> </mat-form-field>
</div> </div>
<div *ngIf="assetProfile?.dataSource === 'MANUAL'" class="mt-3"> <div class="mt-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 Class</mat-label> <mat-label i18n>Asset Class</mat-label>
<mat-select formControlName="assetClass"> <mat-select formControlName="assetClass">
@ -196,7 +196,7 @@
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
<div *ngIf="assetProfile?.dataSource === 'MANUAL'" class="mt-3"> <div class="mt-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 Sub Class</mat-label> <mat-label i18n>Asset Sub Class</mat-label>
<mat-select formControlName="assetSubClass"> <mat-select formControlName="assetSubClass">
@ -209,6 +209,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

@ -14,6 +14,8 @@ import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-propo
import { GfValueModule } from '@ghostfolio/ui/value'; import { GfValueModule } from '@ghostfolio/ui/value';
import { AssetProfileDialog } from './asset-profile-dialog.component'; import { AssetProfileDialog } from './asset-profile-dialog.component';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatChipsModule } from '@angular/material/chips';
@NgModule({ @NgModule({
declarations: [AssetProfileDialog], declarations: [AssetProfileDialog],
@ -22,6 +24,8 @@ import { AssetProfileDialog } from './asset-profile-dialog.component';
FormsModule, FormsModule,
GfAdminMarketDataDetailModule, GfAdminMarketDataDetailModule,
GfPortfolioProportionChartModule, GfPortfolioProportionChartModule,
MatAutocompleteModule,
MatChipsModule,
GfValueModule, GfValueModule,
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

@ -33,7 +33,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>();

12
apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.component.ts

@ -40,6 +40,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
}; };
public dataProviderInfo: DataProviderInfo; public dataProviderInfo: DataProviderInfo;
public dividendInBaseCurrency: number; public dividendInBaseCurrency: number;
public stakeRewards: number;
public feeInBaseCurrency: number; public feeInBaseCurrency: number;
public firstBuyDate: string; public firstBuyDate: string;
public grossPerformance: number; public grossPerformance: number;
@ -54,6 +55,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
public orders: OrderWithAccount[]; public orders: OrderWithAccount[];
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 };
@ -84,6 +86,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
averagePrice, averagePrice,
dataProviderInfo, dataProviderInfo,
dividendInBaseCurrency, dividendInBaseCurrency,
stakeRewards,
feeInBaseCurrency, feeInBaseCurrency,
firstBuyDate, firstBuyDate,
grossPerformance, grossPerformance,
@ -107,6 +110,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
this.countries = {}; this.countries = {};
this.dataProviderInfo = dataProviderInfo; this.dataProviderInfo = dataProviderInfo;
this.dividendInBaseCurrency = dividendInBaseCurrency; this.dividendInBaseCurrency = dividendInBaseCurrency;
this.stakeRewards = stakeRewards;
this.feeInBaseCurrency = feeInBaseCurrency; this.feeInBaseCurrency = feeInBaseCurrency;
this.firstBuyDate = firstBuyDate; this.firstBuyDate = firstBuyDate;
this.grossPerformance = grossPerformance; this.grossPerformance = grossPerformance;
@ -226,6 +230,13 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
if (Number.isInteger(this.quantity)) { if (Number.isInteger(this.quantity)) {
this.quantityPrecision = 0; 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') { } else if (this.SymbolProfile?.assetSubClass === 'CRYPTOCURRENCY') {
if (this.quantity < 1) { if (this.quantity < 1) {
this.quantityPrecision = 7; this.quantityPrecision = 7;
@ -234,6 +245,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
} else if (this.quantity > 10000000) { } else if (this.quantity > 10000000) {
this.quantityPrecision = 0; this.quantityPrecision = 0;
} }
this.stakePrecision = this.quantityPrecision;
} }
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();

19
apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.html

@ -126,7 +126,10 @@
>Investment</gf-value >Investment</gf-value
> >
</div> </div>
<div class="col-6 mb-3"> <div
*ngIf="dividendInBaseCurrency > 0 || !stakeRewards"
class="col-6 mb-3"
>
<gf-value <gf-value
i18n i18n
size="medium" size="medium"
@ -137,6 +140,20 @@
>Dividend</gf-value >Dividend</gf-value
> >
</div> </div>
<div
*ngIf="stakeRewards > 0 && dividendInBaseCurrency == 0"
class="col-6 mb-3"
>
<gf-value
i18n
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

@ -19,6 +19,9 @@ import { ToggleOption } from '@ghostfolio/common/types';
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

@ -229,6 +229,10 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.activityForm.controls['quantity'].value * this.activityForm.controls['quantity'].value *
this.activityForm.controls['unitPrice'].value + this.activityForm.controls['unitPrice'].value +
this.activityForm.controls['fee'].value ?? 0; this.activityForm.controls['fee'].value ?? 0;
} else if (this.activityForm.controls['type'].value === 'STAKE') {
this.total =
this.activityForm.controls['quantity'].value *
this.currentMarketPrice ?? 0;
} else { } else {
this.total = this.total =
this.activityForm.controls['quantity'].value * this.activityForm.controls['quantity'].value *
@ -265,7 +269,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
if (this.activityForm.controls['searchSymbol'].invalid) { if (this.activityForm.controls['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.controls['type'].value this.activityForm.controls['type'].value
) )
) { ) {

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

@ -12,9 +12,8 @@
<mat-label i18n>Type</mat-label> <mat-label i18n>Type</mat-label>
<mat-select formControlName="type"> <mat-select formControlName="type">
<mat-select-trigger <mat-select-trigger
>{{ typesTranslationMap[activityForm.controls['type'].value] >{{ typesTranslationMap[activityForm.controls['type'].value] }}
}}</mat-select-trigger </mat-select-trigger>
>
<mat-option value="BUY"> <mat-option value="BUY">
<span><b>{{ typesTranslationMap['BUY'] }}</b></span> <span><b>{{ typesTranslationMap['BUY'] }}</b></span>
<small class="d-block line-height-1 text-muted text-nowrap" i18n <small class="d-block line-height-1 text-muted text-nowrap" i18n
@ -39,6 +38,13 @@
>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><b>{{ typesTranslationMap['LIABILITY'] }}</b></span> <span><b>{{ typesTranslationMap['LIABILITY'] }}</b></span>
<small class="d-block line-height-1 text-muted text-nowrap" i18n <small class="d-block line-height-1 text-muted text-nowrap" i18n
@ -158,7 +164,10 @@
class="mb-3" class="mb-3"
[ngClass]="{ 'd-none': activityForm.controls['type']?.value === 'FEE' }" [ngClass]="{ 'd-none': activityForm.controls['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
><ng-container [ngSwitch]="activityForm.controls['type']?.value"> ><ng-container [ngSwitch]="activityForm.controls['type']?.value">

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

@ -209,7 +209,8 @@ export class AdminService {
name, name,
scraperConfiguration, scraperConfiguration,
symbol, symbol,
symbolMapping symbolMapping,
tags
}: UniqueAsset & UpdateAssetProfileDto) { }: UniqueAsset & UpdateAssetProfileDto) {
return this.http.patch<EnhancedSymbolProfile>( return this.http.patch<EnhancedSymbolProfile>(
`/api/v1/admin/profile-data/${dataSource}/${symbol}`, `/api/v1/admin/profile-data/${dataSource}/${symbol}`,
@ -219,7 +220,8 @@ export class AdminService {
comment, comment,
name, name,
scraperConfiguration, scraperConfiguration,
symbolMapping symbolMapping,
tags
} }
); );
} }

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

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

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;
@ -16,4 +16,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 { ScraperConfiguration } from './scraper-configuration.interface'; import { ScraperConfiguration } from './scraper-configuration.interface';
@ -23,4 +23,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/types/date-range.type.ts

@ -1 +1 @@
export type DateRange = '1d' | '1y' | '5y' | 'max' | 'ytd'; export type DateRange = '1d' | '1w' | '1m' | '3m' | '1y' | '5y' | 'max' | 'ytd';

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

@ -1,11 +1,8 @@
@import 'apps/client/src/styles/ghostfolio-style'; @import 'apps/client/src/styles/ghostfolio-style';
:host { :host {
display: block; display: block;
.activities { .activities {
overflow-x: auto; overflow-x: auto;
.mat-mdc-table { .mat-mdc-table {
th { th {
::ng-deep { ::ng-deep {
@ -14,6 +11,45 @@
} }
} }
} }
.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;
} }
} }
} }

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

@ -7,7 +7,8 @@
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'
}" }"
> >
<ion-icon <ion-icon
@ -15,7 +16,11 @@
name="arrow-up-circle-outline" name="arrow-up-circle-outline"
></ion-icon> ></ion-icon>
<ion-icon <ion-icon
*ngIf="activityType === 'DIVIDEND' || activityType === 'INTEREST'" *ngIf="
activityType === 'DIVIDEND' ||
activityType === 'INTEREST' ||
activityType === 'STAKE'
"
name="add-circle-outline" name="add-circle-outline"
></ion-icon> ></ion-icon>
<ion-icon *ngIf="activityType === 'FEE'" name="hammer-outline"></ion-icon> <ion-icon *ngIf="activityType === 'FEE'" name="hammer-outline"></ion-icon>

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

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

1
package.json

@ -86,6 +86,7 @@
"@simplewebauthn/server": "8.3.2", "@simplewebauthn/server": "8.3.2",
"@stripe/stripe-js": "1.47.0", "@stripe/stripe-js": "1.47.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.1", "body-parser": "1.20.1",
"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

@ -144,6 +144,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])
@ -175,6 +176,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[]
} }
model User { model User {
@ -256,6 +258,7 @@ enum Type {
ITEM ITEM
LIABILITY LIABILITY
SELL SELL
STAKE
} }
enum ViewMode { enum ViewMode {

11
yarn.lock

@ -8,9 +8,9 @@
integrity sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA== integrity sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==
"@adobe/css-tools@^4.0.1": "@adobe/css-tools@^4.0.1":
version "4.3.1" version "4.3.2"
resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.3.1.tgz#abfccb8ca78075a2b6187345c26243c1a0842f28" resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.3.2.tgz#a6abc715fb6884851fca9dad37fc34739a04fd11"
integrity sha512-/62yikz7NLScCGAAST5SHdnjaDJQBDq0M2muyRTpf2VQhw6StBg2ALiu73zSJQ4fMVLA+0uBhBHAle7Wg+2kSg== integrity sha512-DA5a1C0gD/pLOvhv33YMrbf2FK3oUzwNl9oOJqE4XVjuEtt6XIakRcsd7eLiOSPkp1kTRQGICTA8cKra/vFbjw==
"@ampproject/remapping@2.2.1", "@ampproject/remapping@^2.2.0": "@ampproject/remapping@2.2.1", "@ampproject/remapping@^2.2.0":
version "2.2.1" version "2.2.1"
@ -7176,6 +7176,11 @@ available-typed-arrays@^1.0.5:
resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz#92f95616501069d07d10edb2fc37d3e1c65123b7" resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz#92f95616501069d07d10edb2fc37d3e1c65123b7"
integrity sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw== integrity sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==
await-lock@^2.2.2:
version "2.2.2"
resolved "https://registry.yarnpkg.com/await-lock/-/await-lock-2.2.2.tgz#a95a9b269bfd2f69d22b17a321686f551152bcef"
integrity sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==
aws-sign2@~0.7.0: aws-sign2@~0.7.0:
version "0.7.0" version "0.7.0"
resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8" resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8"

Loading…
Cancel
Save