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. 36
      apps/api/src/app/admin/admin.controller.ts
  5. 4
      apps/api/src/app/admin/admin.module.ts
  6. 72
      apps/api/src/app/admin/admin.service.ts
  7. 14
      apps/api/src/app/admin/update-asset-profile.dto.ts
  8. 31
      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. 57
      apps/api/src/app/portfolio/portfolio.controller.ts
  13. 383
      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. 8
      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. 14
      apps/api/src/services/data-provider/manual/manual.service.ts
  20. 23
      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. 9
      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:
tags:
- '*.*.*'
pull_request:
branches:
- 'main'
jobs:
build_and_push:
@ -19,7 +16,7 @@ jobs:
id: meta
uses: docker/metadata-action@v4
with:
images: ghostfolio/ghostfolio
images: dandevaud/ghostfolio
tags: |
type=semver,pattern={{major}}
type=semver,pattern={{version}}

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

@ -448,11 +448,45 @@ export class AdminController {
);
}
if (dataSource === 'MANUAL') {
await this.adminService.patchAssetProfileData({
dataSource,
symbol,
tags: {
set: []
}
});
return this.adminService.patchAssetProfileData({
...assetProfileData,
dataSource,
symbol
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')

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

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

72
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 { PrismaService } from '@ghostfolio/api/services/prisma/prisma.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 {
DEFAULT_CURRENCY,
@ -25,10 +26,12 @@ import { MarketDataPreset } from '@ghostfolio/common/types';
import { BadRequestException, Injectable } from '@nestjs/common';
import {
AssetSubClass,
DataSource,
Prisma,
Property,
SymbolProfile
SymbolProfile,
DataSource,
Tag,
SymbolProfileOverrides
} from '@prisma/client';
import { differenceInDays } from 'date-fns';
import { groupBy } from 'lodash';
@ -43,7 +46,8 @@ export class AdminService {
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService,
private readonly symbolProfileService: SymbolProfileService
private readonly symbolProfileService: SymbolProfileService,
private readonly symbolProfileOverwriteService: SymbolProfileOverwriteService
) {}
public async addAssetProfile({
@ -218,7 +222,8 @@ export class AdminService {
},
scraperConfiguration: true,
sectors: true,
symbol: true
symbol: true,
tags: true
}
}),
this.prismaService.symbolProfile.count({ where })
@ -236,7 +241,8 @@ export class AdminService {
name,
Order,
sectors,
symbol
symbol,
tags
}) => {
const countriesCount = countries ? Object.keys(countries).length : 0;
const marketDataItemCount =
@ -260,7 +266,8 @@ export class AdminService {
marketDataItemCount,
sectorsCount,
activitiesCount: _count.Order,
date: Order?.[0]?.date
date: Order?.[0]?.date,
tags
};
}
);
@ -322,20 +329,70 @@ export class AdminService {
comment,
dataSource,
name,
tags,
scraperConfiguration,
symbol,
symbolMapping
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
if (dataSource === 'MANUAL') {
await this.symbolProfileService.updateSymbolProfile({
assetClass,
assetSubClass,
comment,
dataSource,
name,
tags,
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([
{
@ -390,7 +447,8 @@ export class AdminService {
countriesCount: 0,
currency: symbol.replace(DEFAULT_CURRENCY, ''),
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 { IsEnum, IsObject, IsOptional, IsString } from 'class-validator';
import { AssetClass, AssetSubClass, Prisma, Tag } from '@prisma/client';
import {
IsArray,
IsEnum,
IsObject,
IsOptional,
IsString
} from 'class-validator';
export class UpdateAssetProfileDto {
@IsEnum(AssetClass, { each: true })
@ -18,6 +24,10 @@ export class UpdateAssetProfileDto {
@IsOptional()
name?: string;
@IsArray()
@IsOptional()
tags?: Tag[];
@IsObject()
@IsOptional()
scraperConfiguration?: Prisma.InputJsonObject;

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

@ -298,13 +298,34 @@ export class OrderService {
}
if (filtersByTag?.length > 0) {
where.tags = {
where.AND = [
{
OR: [
{
tags: {
some: {
OR: filtersByTag.map(({ id }) => {
return {
id: id
};
})
}
}
},
{
SymbolProfile: {
tags: {
some: {
OR: filtersByTag.map(({ id }) => {
return { id };
})
}
};
}
}
}
]
}
];
}
if (types) {
@ -330,7 +351,11 @@ export class OrderService {
}
},
// eslint-disable-next-line @typescript-eslint/naming-convention
SymbolProfile: true,
SymbolProfile: {
include: {
tags: true
}
},
tags: true
},
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 { GetValuesObject } from './interfaces/get-values-object.interface';
import { GetValuesParams } from './interfaces/get-values-params.interface';
import { DateQueryHelper } from '@ghostfolio/api/helper/dateQueryHelper';
@Injectable()
export class CurrentRateService {
private dateQueryHelper = new DateQueryHelper();
public constructor(
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
@ -34,7 +37,7 @@ export class CurrentRateService {
(!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) &&
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
(!dateQuery.in || this.containsToday(dateQuery.in));
let { query, dates } = this.dateQueryHelper.handleDateQueryIn(dateQuery);
const promises: Promise<GetValueObject[]>[] = [];
const quoteErrors: ResponseError['errors'] = [];
const today = resetHours(new Date());
@ -89,7 +92,7 @@ export class CurrentRateService {
promises.push(
this.marketDataService
.getRange({
dateQuery,
dateQuery: query,
uniqueAssets
})
.then((data) => {
@ -116,9 +119,12 @@ export class CurrentRateService {
errors: quoteErrors.map(({ 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)) {
for (const { dataSource, symbol } of quoteErrors) {
try {

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

@ -10,6 +10,7 @@ export interface PortfolioPositionDetail {
averagePrice: number;
dataProviderInfo: DataProviderInfo;
dividendInBaseCurrency: number;
stakeRewards: number;
feeInBaseCurrency: number;
firstBuyDate: string;
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 {
DataProviderInfo,
HistoricalDataItem,
ResponseError,
TimelinePosition
} from '@ghostfolio/common/interfaces';
@ -92,7 +93,7 @@ export class PortfolioCalculator {
let investment = new Big(0);
if (newQuantity.gt(0)) {
if (order.type === 'BUY') {
if (order.type === 'BUY' || order.type === 'STAKE') {
investment = oldAccumulatedSymbol.investment.plus(
order.quantity.mul(unitPrice)
);
@ -279,46 +280,29 @@ export class PortfolioCalculator {
};
}
for (const currentDate of dates) {
const dateString = format(currentDate, DATE_FORMAT);
return dates.map((date) => {
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)) {
const symbolValues = valuesBySymbol[symbol];
const currentValue =
symbolValues.currentValues?.[dateString] ?? new Big(0);
const investmentValue =
symbolValues.investmentValues?.[dateString] ?? new Big(0);
const maxInvestmentValue =
symbolValues.maxInvestmentValues?.[dateString] ?? new Big(0);
const netPerformanceValue =
symbolValues.netPerformanceValues?.[dateString] ?? new Big(0);
valuesByDate[dateString] = {
totalCurrentValue: (
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)
};
}
totalCurrentValue = totalCurrentValue.plus(
symbolValues.currentValues?.[dateString] ?? new Big(0)
);
totalInvestmentValue = totalInvestmentValue.plus(
symbolValues.investmentValues?.[dateString] ?? new Big(0)
);
maxTotalInvestmentValue = maxTotalInvestmentValue.plus(
symbolValues.maxInvestmentValues?.[dateString] ?? new Big(0)
);
totalNetPerformanceValue = totalNetPerformanceValue.plus(
symbolValues.netPerformanceValues?.[dateString] ?? new Big(0)
);
}
return Object.entries(valuesByDate).map(([date, values]) => {
const {
maxTotalInvestmentValue,
totalCurrentValue,
totalInvestmentValue,
totalNetPerformanceValue
} = values;
const netPerformanceInPercentage = maxTotalInvestmentValue.eq(0)
? 0
: totalNetPerformanceValue
@ -327,7 +311,7 @@ export class PortfolioCalculator {
.toNumber();
return {
date,
date: dateString,
netPerformanceInPercentage,
netPerformance: totalNetPerformanceValue.toNumber(),
totalInvestment: totalInvestmentValue.toNumber(),
@ -756,7 +740,7 @@ export class PortfolioCalculator {
totalInvestment = totalInvestment.plus(currentPosition.investment);
if (currentPosition.grossPerformance) {
if (currentPosition.grossPerformance !== null) {
grossPerformance = grossPerformance.plus(
currentPosition.grossPerformance
);
@ -766,7 +750,7 @@ export class PortfolioCalculator {
hasErrors = true;
}
if (currentPosition.grossPerformancePercentage) {
if (currentPosition.grossPerformancePercentage !== null) {
// Use the average from the initial value and the current investment as
// a weight
const weight = (initialValues[currentPosition.symbol] ?? new Big(0))
@ -934,6 +918,7 @@ export class PortfolioCalculator {
switch (type) {
case 'BUY':
case 'STAKE':
factor = 1;
break;
case 'SELL':
@ -1090,6 +1075,20 @@ export class PortfolioCalculator {
marketSymbolMap[format(day, DATE_FORMAT)]?.[symbol] ??
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;
@ -1159,7 +1158,7 @@ export class PortfolioCalculator {
}
const transactionInvestment =
order.type === 'BUY'
order.type === 'BUY' || order.type === 'STAKE'
? order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
: totalUnits.gt(0)
? totalInvestment
@ -1192,6 +1191,11 @@ export class PortfolioCalculator {
initialValue = valueOfInvestmentBeforeTransaction;
} else if (transactionInvestment.gt(0)) {
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)
);
}
}

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

@ -110,21 +110,38 @@ export class PortfolioController {
impersonationId ||
this.userService.isRestrictedView(this.request.user)
) {
const totalInvestment = Object.values(holdings)
.map((portfolioPosition) => {
return portfolioPosition.investment;
})
.reduce((a, b) => a + b, 0);
const totalValue = Object.values(holdings)
.map((portfolioPosition) => {
return this.exchangeRateDataService.toCurrency(
let investmentTuple: [number, number] = [0, 0];
for (let holding of Object.entries(holdings)) {
var portfolioPosition = holding[1];
investmentTuple[0] += portfolioPosition.investment;
investmentTuple[1] += this.exchangeRateDataService.toCurrency(
portfolioPosition.quantity * portfolioPosition.marketPrice,
portfolioPosition.currency,
this.request.user.Settings.settings.baseCurrency
);
})
.reduce((a, b) => a + b, 0);
}
const totalInvestment = investmentTuple[0];
const totalValue = investmentTuple[1];
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)) {
portfolioPosition.grossPerformance = null;
@ -134,6 +151,24 @@ export class PortfolioController {
portfolioPosition.quantity = null;
portfolioPosition.valueInPercentage =
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)) {

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

@ -75,6 +75,7 @@ import {
set,
setDayOfYear,
subDays,
subMonths,
subYears
} from 'date-fns';
import { isEmpty, last, sortBy, uniq, uniqBy } from 'lodash';
@ -470,11 +471,9 @@ export class PortfolioService {
}
const portfolioItemsNow: { [symbol: string]: TimelinePosition } = {};
for (const position of currentPositions.positions) {
portfolioItemsNow[position.symbol] = position;
}
for (const item of currentPositions.positions) {
portfolioItemsNow[item.symbol] = item;
if (item.quantity.lte(0)) {
// Ignore positions without any quantity
continue;
@ -500,59 +499,12 @@ export class PortfolioService {
otherMarkets: 0
};
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();
}
this.calculateMarketsAllocation(
symbolProfile,
markets,
marketsAdvanced,
value
);
holdings[item.symbol] = {
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) => {
return filter.type === 'ASSET_CLASS' && filter.id === 'CASH';
});
@ -600,16 +614,26 @@ export class PortfolioService {
holdings[symbol] = cashPositions[symbol];
}
}
}
const { accounts, platforms } = await this.getValueOfAccountsAndPlatforms({
filters,
orders,
portfolioItemsNow,
userCurrency,
userId,
withExcludedAccounts
});
private async handleEmergencyFunds(
filters: Filter[],
cashDetails: CashDetails,
userCurrency: string,
filteredValueInBaseCurrency: Big,
emergencyFund: Big,
orders: Activity[],
accounts: {
[id: string]: {
balance: number;
currency: string;
name: string;
valueInBaseCurrency: number;
valueInPercentage?: number;
};
},
holdings: { [symbol: string]: PortfolioPosition }
) {
if (
filters?.length === 1 &&
filters[0].id === EMERGENCY_FUND_TAG_ID &&
@ -644,30 +668,79 @@ export class PortfolioService {
valueInBaseCurrency: emergencyFundInCash
};
}
return filteredValueInBaseCurrency;
}
const summary = await this.getSummary({
impersonationId,
userCurrency,
userId,
balanceInBaseCurrency: cashDetails.balanceInBaseCurrency,
emergencyFundPositionsValueInBaseCurrency:
this.getEmergencyFundPositionsValueInBaseCurrency({
holdings
})
});
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();
}
return {
accounts,
holdings,
platforms,
summary,
filteredValueInBaseCurrency: filteredValueInBaseCurrency.toNumber(),
filteredValueInPercentage: summary.netWorth
? filteredValueInBaseCurrency.div(summary.netWorth).toNumber()
: 0,
hasErrors: currentPositions.hasErrors,
totalValueInBaseCurrency: summary.netWorth
};
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();
}
}
public async getPosition(
@ -700,6 +773,7 @@ export class PortfolioService {
averagePrice: undefined,
dataProviderInfo: undefined,
dividendInBaseCurrency: undefined,
stakeRewards: undefined,
feeInBaseCurrency: undefined,
firstBuyDate: undefined,
grossPerformance: undefined,
@ -728,7 +802,11 @@ export class PortfolioService {
.filter((order) => {
tags = tags.concat(order.tags);
return order.type === 'BUY' || order.type === 'SELL';
return (
order.type === 'BUY' ||
order.type === 'SELL' ||
order.type === 'STAKE'
);
})
.map((order) => ({
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
const investment = this.exchangeRateDataService.toCurrency(
position.investment?.toNumber(),
@ -878,6 +966,7 @@ export class PortfolioService {
averagePrice: averagePrice.toNumber(),
dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0],
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
stakeRewards: stakeRewards.toNumber(),
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
fee.toNumber(),
SymbolProfile.currency,
@ -941,6 +1030,7 @@ export class PortfolioService {
averagePrice: 0,
dataProviderInfo: undefined,
dividendInBaseCurrency: 0,
stakeRewards: 0,
feeInBaseCurrency: 0,
firstBuyDate: undefined,
grossPerformance: undefined,
@ -1049,6 +1139,7 @@ export class PortfolioService {
dataProviderResponses[position.symbol]?.marketState ?? 'delayed',
name: symbolProfileMap[position.symbol].name,
netPerformance: position.netPerformance?.toNumber() ?? null,
tags: symbolProfileMap[position.symbol].tags,
netPerformancePercentage:
position.netPerformancePercentage?.toNumber() ?? null,
quantity: new Big(position.quantity).toNumber()
@ -1580,6 +1671,28 @@ export class PortfolioService {
setDayOfYear(new Date().setHours(0, 0, 0, 0), 1)
]);
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':
portfolioStart = max([
portfolioStart,
@ -1639,60 +1752,68 @@ export class PortfolioService {
userId
});
const activities = await this.orderService.getOrders({
userCurrency,
userId
});
const excludedActivities = (
await this.orderService.getOrders({
const ordersRaw = 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(
Math.max(
emergencyFundPositionsValueInBaseCurrency,
(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({
activities,
userCurrency,
activityType: 'BUY'
}).toNumber();
const totalSell = this.getSumOfActivityType({
activities,
userCurrency,
activityType: 'SELL'
}).toNumber();
const firstOrderDate = activities[0]?.date;
const cash = new Big(balanceInBaseCurrency)
.minus(emergencyFund)
@ -1836,7 +1957,7 @@ export class PortfolioService {
userCurrency,
userId,
withExcludedAccounts,
types: ['BUY', 'SELL']
types: ['BUY', 'SELL', 'STAKE']
});
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({
include: {
_count: {
select: { orders: true }
select: { orders: true, symbolProfile: true }
}
}
});
@ -59,7 +59,8 @@ export class TagService {
return {
id,
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()
colorScheme?: ColorScheme;
@IsIn(<DateRange[]>['1d', '1y', '5y', 'max', 'ytd'])
@IsIn(<DateRange[]>['1d', '1w', '1m', '3m', '1y', '5y', 'max', 'ytd'])
@IsOptional()
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 };
}
}

8
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 { format, min, subDays, subYears } from 'date-fns';
import { isEmpty } from 'lodash';
import AwaitLock from 'await-lock';
@Injectable()
export class DataGatheringService {
@ -40,6 +41,8 @@ export class DataGatheringService {
private readonly symbolProfileService: SymbolProfileService
) {}
lock = new AwaitLock();
public async addJobToQueue({
data,
name,
@ -100,6 +103,8 @@ export class DataGatheringService {
historicalData[symbol][format(date, DATE_FORMAT)].marketPrice;
if (marketPrice) {
await this.lock.acquireAsync();
try {
return await this.prismaService.marketData.upsert({
create: {
dataSource,
@ -110,6 +115,9 @@ export class DataGatheringService {
update: { marketPrice },
where: { dataSource_date_symbol: { dataSource, date, symbol } }
});
} finally {
this.lock.release();
}
}
} catch (error) {
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>;
symbol: string;
}): Promise<Partial<SymbolProfile>> {
if (
!(response.assetClass === 'EQUITY' && response.assetSubClass === 'ETF')
) {
if (!(response.assetSubClass === 'ETF')) {
return response;
}
@ -113,8 +111,8 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
});
});
if (holdings?.weight < 0.95) {
// Skip if data is inaccurate
if (holdings?.weight < 1 - Math.min(holdings?.count * 0.000015, 0.95)) {
// Skip if data is inaccurate, dependent on holdings count there might be rounding issues
return response;
}

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

@ -6,6 +6,7 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.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 {
DATE_FORMAT,
@ -153,7 +154,12 @@ export class ManualService implements DataProviderInterface {
})
);
const marketData = await this.prismaService.marketData.findMany({
const batch = new BatchPrismaClient(this.prismaService);
const marketData = await batch
.over(symbols)
.with((prisma, _symbols) =>
prisma.marketData.findMany({
distinct: ['symbol'],
orderBy: {
date: 'desc'
@ -161,10 +167,12 @@ export class ManualService implements DataProviderInterface {
take: symbols.length,
where: {
symbol: {
in: symbols
in: _symbols
}
}
});
})
)
.then((_result) => _result.flat());
for (const symbolProfile of symbolProfiles) {
response[symbolProfile.symbol] = {

23
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 { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { resetHours } from '@ghostfolio/common/helper';
import { BatchPrismaClient } from '@ghostfolio/common/chunkhelper';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import {
@ -11,11 +12,17 @@ import {
MarketDataState,
Prisma
} from '@prisma/client';
import { DateQueryHelper } from '@ghostfolio/api/helper/dateQueryHelper';
import AwaitLock from 'await-lock';
@Injectable()
export class MarketDataService {
public constructor(private readonly prismaService: PrismaService) {}
lock = new AwaitLock();
private dateQueryHelper = new DateQueryHelper();
public async deleteMany({ dataSource, symbol }: UniqueAsset) {
return this.prismaService.marketData.deleteMany({
where: {
@ -116,7 +123,8 @@ export class MarketDataService {
where: Prisma.MarketDataWhereUniqueInput;
}): Promise<MarketData> {
const { data, where } = params;
await this.lock.acquireAsync();
try {
return this.prismaService.marketData.upsert({
where,
create: {
@ -128,6 +136,9 @@ export class MarketDataService {
},
update: { marketPrice: data.marketPrice, state: data.state }
});
} finally {
this.lock.release();
}
}
/**
@ -140,7 +151,9 @@ export class MarketDataService {
data: Prisma.MarketDataUpdateInput[];
}): Promise<MarketData[]> {
const upsertPromises = data.map(
({ dataSource, date, marketPrice, symbol, state }) => {
async ({ dataSource, date, marketPrice, symbol, state }) => {
await this.lock.acquireAsync();
try {
return this.prismaService.marketData.upsert({
create: {
dataSource: <DataSource>dataSource,
@ -161,9 +174,11 @@ export class MarketDataService {
}
}
});
} finally {
this.lock.release();
}
}
);
return this.prismaService.$transaction(upsertPromises);
return await Promise.all(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 { Sector } from '@ghostfolio/common/interfaces/sector.interface';
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';
@Injectable()
@ -49,6 +54,7 @@ export class SymbolProfileService {
select: { date: true },
take: 1
},
tags: true,
SymbolProfileOverrides: true
},
where: {
@ -72,7 +78,8 @@ export class SymbolProfileService {
_count: {
select: { Order: true }
},
SymbolProfileOverrides: true
SymbolProfileOverrides: true,
tags: true
},
where: {
id: {
@ -91,6 +98,7 @@ export class SymbolProfileService {
comment,
dataSource,
name,
tags,
scraperConfiguration,
symbol,
symbolMapping
@ -101,6 +109,7 @@ export class SymbolProfileService {
assetSubClass,
comment,
name,
tags,
scraperConfiguration,
symbolMapping
},
@ -114,6 +123,7 @@ export class SymbolProfileService {
Order?: {
date: Date;
}[];
tags?: Tag[];
SymbolProfileOverrides: SymbolProfileOverrides;
})[]
): EnhancedSymbolProfile[] {
@ -127,7 +137,8 @@ export class SymbolProfileService {
dateOfFirstActivity: <Date>undefined,
scraperConfiguration: this.getScraperConfiguration(symbolProfile),
sectors: this.getSectors(symbolProfile),
symbolMapping: this.getSymbolMapping(symbolProfile)
symbolMapping: this.getSymbolMapping(symbolProfile),
tags: symbolProfile?.tags
};
item.activitiesCount = symbolProfile._count.Order;

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

@ -19,11 +19,20 @@ export class TagService {
name: 'asc'
},
where: {
OR: [
{
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,
ChangeDetectorRef,
Component,
ElementRef,
Inject,
OnDestroy,
OnInit
OnInit,
ViewChild
} from '@angular/core';
import { FormBuilder, FormControl, Validators } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
@ -22,7 +24,8 @@ import {
AssetClass,
AssetSubClass,
MarketData,
SymbolProfile
SymbolProfile,
Tag
} from '@prisma/client';
import { format } from 'date-fns';
import { parse as csvToJson } from 'papaparse';
@ -30,6 +33,8 @@ import { EMPTY, Subject } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators';
import { AssetProfileDialogParams } from './interfaces/interfaces';
import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import { COMMA, ENTER } from '@angular/cdk/keycodes';
@Component({
host: { class: 'd-flex flex-column h-100' },
@ -39,6 +44,8 @@ import { AssetProfileDialogParams } from './interfaces/interfaces';
styleUrls: ['./asset-profile-dialog.component.scss']
})
export class AssetProfileDialog implements OnDestroy, OnInit {
@ViewChild('tagInput') tagInput: ElementRef<HTMLInputElement>;
public separatorKeysCodes: number[] = [ENTER, COMMA];
public assetProfileClass: string;
public assetClasses = Object.keys(AssetClass).map((assetClass) => {
return { id: assetClass, label: translate(assetClass) };
@ -55,6 +62,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
csvString: ''
}),
name: ['', Validators.required],
tags: new FormControl<Tag[]>(undefined),
scraperConfiguration: '',
symbolMapping: ''
});
@ -69,6 +77,8 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
[name: string]: { name: string; value: number };
};
public HoldingTags: { id: string; name: string }[];
private static readonly HISTORICAL_DATA_TEMPLATE = `date;marketPrice\n${format(
new Date(),
DATE_FORMAT
@ -92,6 +102,18 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
}
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
.fetchAdminMarketDataBySymbol({
dataSource: this.data.dataSource,
@ -132,6 +154,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
assetClass: this.assetProfile.assetClass ?? null,
assetSubClass: this.assetProfile.assetSubClass ?? null,
comment: this.assetProfile?.comment ?? '',
tags: this.assetProfile?.tags,
historicalData: {
csvString: AssetProfileDialog.HISTORICAL_DATA_TEMPLATE
},
@ -249,6 +272,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
assetSubClass: this.assetProfileForm.controls['assetSubClass'].value,
comment: this.assetProfileForm.controls['comment'].value ?? null,
name: this.assetProfileForm.controls['name'].value,
tags: this.assetProfileForm.controls['tags'].value,
scraperConfiguration,
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() {
this.unsubscribeSubject.next();
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" />
</mat-form-field>
</div>
<div *ngIf="assetProfile?.dataSource === 'MANUAL'" class="mt-3">
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Asset Class</mat-label>
<mat-select formControlName="assetClass">
@ -196,7 +196,7 @@
</mat-select>
</mat-form-field>
</div>
<div *ngIf="assetProfile?.dataSource === 'MANUAL'" class="mt-3">
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Asset Sub Class</mat-label>
<mat-select formControlName="assetSubClass">
@ -209,6 +209,37 @@
</mat-select>
</mat-form-field>
</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="w-50">
<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 { AssetProfileDialog } from './asset-profile-dialog.component';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatChipsModule } from '@angular/material/chips';
@NgModule({
declarations: [AssetProfileDialog],
@ -22,6 +24,8 @@ import { AssetProfileDialog } from './asset-profile-dialog.component';
FormsModule,
GfAdminMarketDataDetailModule,
GfPortfolioProportionChartModule,
MatAutocompleteModule,
MatChipsModule,
GfValueModule,
MatButtonModule,
MatCheckboxModule,

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

@ -47,6 +47,19 @@
{{ element.activityCount }}
</td>
</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>
<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 deviceType: string;
public displayedColumns = ['name', 'activities', 'actions'];
public displayedColumns = ['name', 'activities', 'holdings', 'actions'];
public tags: Tag[];
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 dividendInBaseCurrency: number;
public stakeRewards: number;
public feeInBaseCurrency: number;
public firstBuyDate: string;
public grossPerformance: number;
@ -54,6 +55,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
public orders: OrderWithAccount[];
public quantity: number;
public quantityPrecision = 2;
public stakePrecision = 2;
public reportDataGlitchMail: string;
public sectors: {
[name: string]: { name: string; value: number };
@ -84,6 +86,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
averagePrice,
dataProviderInfo,
dividendInBaseCurrency,
stakeRewards,
feeInBaseCurrency,
firstBuyDate,
grossPerformance,
@ -107,6 +110,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
this.countries = {};
this.dataProviderInfo = dataProviderInfo;
this.dividendInBaseCurrency = dividendInBaseCurrency;
this.stakeRewards = stakeRewards;
this.feeInBaseCurrency = feeInBaseCurrency;
this.firstBuyDate = firstBuyDate;
this.grossPerformance = grossPerformance;
@ -226,6 +230,13 @@ export class PositionDetailDialog 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;
@ -234,6 +245,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
} else if (this.quantity > 10000000) {
this.quantityPrecision = 0;
}
this.stakePrecision = this.quantityPrecision;
}
this.changeDetectorRef.markForCheck();

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

@ -126,7 +126,10 @@
>Investment</gf-value
>
</div>
<div class="col-6 mb-3">
<div
*ngIf="dividendInBaseCurrency > 0 || !stakeRewards"
class="col-6 mb-3"
>
<gf-value
i18n
size="medium"
@ -137,6 +140,20 @@
>Dividend</gf-value
>
</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">
<gf-value
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 {
public static DEFAULT_DATE_RANGE_OPTIONS: ToggleOption[] = [
{ 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`1Y`, value: '1y' },
{ 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['unitPrice'].value +
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 {
this.total =
this.activityForm.controls['quantity'].value *
@ -265,7 +269,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
if (this.activityForm.controls['searchSymbol'].invalid) {
this.data.activity.SymbolProfile = null;
} else if (
['BUY', 'DIVIDEND', 'SELL'].includes(
['BUY', 'DIVIDEND', 'SELL', 'STAKE'].includes(
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-select formControlName="type">
<mat-select-trigger
>{{ typesTranslationMap[activityForm.controls['type'].value]
}}</mat-select-trigger
>
>{{ typesTranslationMap[activityForm.controls['type'].value] }}
</mat-select-trigger>
<mat-option value="BUY">
<span><b>{{ typesTranslationMap['BUY'] }}</b></span>
<small class="d-block line-height-1 text-muted text-nowrap" i18n
@ -39,6 +38,13 @@
>Revenue for lending out money</small
>
</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">
<span><b>{{ typesTranslationMap['LIABILITY'] }}</b></span>
<small class="d-block line-height-1 text-muted text-nowrap" i18n
@ -158,7 +164,10 @@
class="mb-3"
[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-label
><ng-container [ngSwitch]="activityForm.controls['type']?.value">

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

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

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

@ -347,6 +347,8 @@ export class ImportActivitiesService {
return Type.LIABILITY;
case 'sell':
return Type.SELL;
case 'stake':
return Type.STAKE;
default:
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 {
count: number;
@ -16,4 +16,5 @@ export interface AdminMarketDataItem {
name: string;
sectorsCount: number;
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 { ScraperConfiguration } from './scraper-configuration.interface';
@ -23,4 +23,5 @@ export interface EnhancedSymbolProfile {
symbolMapping?: { [key: string]: string };
updatedAt: Date;
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';
:host {
display: block;
.activities {
overflow-x: auto;
.mat-mdc-table {
th {
::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',
item: activityType === 'ITEM',
liability: activityType === 'LIABILITY',
sell: activityType === 'SELL'
sell: activityType === 'SELL',
stake: activityType === 'STAKE'
}"
>
<ion-icon
@ -15,7 +16,11 @@
name="arrow-up-circle-outline"
></ion-icon>
<ion-icon
*ngIf="activityType === 'DIVIDEND' || activityType === 'INTEREST'"
*ngIf="
activityType === 'DIVIDEND' ||
activityType === 'INTEREST' ||
activityType === 'STAKE'
"
name="add-circle-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`,
LIABILITY: $localize`Liability`,
SELL: $localize`Sell`,
STAKE: $localize`Stake`,
// enum AssetClass
CASH: $localize`Cash`,

1
package.json

@ -86,6 +86,7 @@
"@simplewebauthn/server": "8.3.2",
"@stripe/stripe-js": "1.47.0",
"alphavantage": "2.2.0",
"await-lock": "^2.2.2",
"big.js": "6.2.1",
"body-parser": "1.20.1",
"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?
url String?
Order Order[]
tags Tag[]
SymbolProfileOverrides SymbolProfileOverrides?
@@unique([dataSource, symbol])
@ -175,6 +176,7 @@ model Tag {
id String @id @default(uuid())
name String @unique
orders Order[]
symbolProfile SymbolProfile[]
}
model User {
@ -256,6 +258,7 @@ enum Type {
ITEM
LIABILITY
SELL
STAKE
}
enum ViewMode {

11
yarn.lock

@ -8,9 +8,9 @@
integrity sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==
"@adobe/css-tools@^4.0.1":
version "4.3.1"
resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.3.1.tgz#abfccb8ca78075a2b6187345c26243c1a0842f28"
integrity sha512-/62yikz7NLScCGAAST5SHdnjaDJQBDq0M2muyRTpf2VQhw6StBg2ALiu73zSJQ4fMVLA+0uBhBHAle7Wg+2kSg==
version "4.3.2"
resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.3.2.tgz#a6abc715fb6884851fca9dad37fc34739a04fd11"
integrity sha512-DA5a1C0gD/pLOvhv33YMrbf2FK3oUzwNl9oOJqE4XVjuEtt6XIakRcsd7eLiOSPkp1kTRQGICTA8cKra/vFbjw==
"@ampproject/remapping@2.2.1", "@ampproject/remapping@^2.2.0":
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"
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:
version "0.7.0"
resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8"

Loading…
Cancel
Save