diff --git a/.admin.cred b/.admin.cred new file mode 100644 index 000000000..53cce50db --- /dev/null +++ b/.admin.cred @@ -0,0 +1 @@ +14d4daf73eefed7da7c32ec19bc37e678be0244fb46c8f4965bfe9ece7384706ed58222ad9b96323893c1d845bc33a308e7524c2c79636062cbb095e0780cb51 \ No newline at end of file diff --git a/.github/workflows/docker-image-dev.yml b/.github/workflows/docker-image-dev.yml new file mode 100644 index 000000000..01e4d9231 --- /dev/null +++ b/.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 diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 47943977f..66638f680 100644 --- a/.github/workflows/docker-image.yml +++ b/.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}} diff --git a/apps/api/src/app/admin/admin.controller.ts b/apps/api/src/app/admin/admin.controller.ts index e277e77e4..205a99993 100644 --- a/apps/api/src/app/admin/admin.controller.ts +++ b/apps/api/src/app/admin/admin.controller.ts @@ -448,11 +448,45 @@ export class AdminController { ); } - return this.adminService.patchAssetProfileData({ - ...assetProfileData, - dataSource, - symbol - }); + if (dataSource === 'MANUAL') { + await this.adminService.patchAssetProfileData({ + dataSource, + 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') diff --git a/apps/api/src/app/admin/admin.module.ts b/apps/api/src/app/admin/admin.module.ts index 079af87fa..c018d8615 100644 --- a/apps/api/src/app/admin/admin.module.ts +++ b/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], diff --git a/apps/api/src/app/admin/admin.service.ts b/apps/api/src/app/admin/admin.service.ts index b46a0a13a..a87f2ad10 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/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) { - await this.symbolProfileService.updateSymbolProfile({ - assetClass, - assetSubClass, - comment, - dataSource, - name, - scraperConfiguration, - symbol, - symbolMapping - }); + 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: [] }; }); diff --git a/apps/api/src/app/admin/update-asset-profile.dto.ts b/apps/api/src/app/admin/update-asset-profile.dto.ts index a39f8db81..ee801cab5 100644 --- a/apps/api/src/app/admin/update-asset-profile.dto.ts +++ b/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; diff --git a/apps/api/src/app/order/order.service.ts b/apps/api/src/app/order/order.service.ts index 10515018c..3c228bc79 100644 --- a/apps/api/src/app/order/order.service.ts +++ b/apps/api/src/app/order/order.service.ts @@ -298,13 +298,34 @@ export class OrderService { } if (filtersByTag?.length > 0) { - where.tags = { - some: { - OR: filtersByTag.map(({ id }) => { - return { id }; - }) + 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' } diff --git a/apps/api/src/app/portfolio/current-rate.service.ts b/apps/api/src/app/portfolio/current-rate.service.ts index 718ec6095..3aeedff9a 100644 --- a/apps/api/src/app/portfolio/current-rate.service.ts +++ b/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[] = []; 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 { diff --git a/apps/api/src/app/portfolio/interfaces/portfolio-position-detail.interface.ts b/apps/api/src/app/portfolio/interfaces/portfolio-position-detail.interface.ts index 827aa25fe..df4760bb0 100644 --- a/apps/api/src/app/portfolio/interfaces/portfolio-position-detail.interface.ts +++ b/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; diff --git a/apps/api/src/app/portfolio/portfolio-calculator.ts b/apps/api/src/app/portfolio/portfolio-calculator.ts index c11e514e4..16b41136c 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator.ts +++ b/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) + ); } } diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index dd013989f..d5da0a333 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/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( - portfolioPosition.quantity * portfolioPosition.marketPrice, - portfolioPosition.currency, - this.request.user.Settings.settings.baseCurrency - ); - }) - .reduce((a, b) => a + b, 0); + 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 + ); + } + 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)) { diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 85e914287..9bc585397 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/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({ + const ordersRaw = await this.orderService.getOrders({ userCurrency, - userId - }); - - const excludedActivities = ( - await this.orderService.getOrders({ - userCurrency, - userId, - withExcludedAccounts: true - }) - ).filter(({ Account: account }) => { - return account?.isExcluded ?? false; + userId, + withExcludedAccounts: true }); + 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) { diff --git a/apps/api/src/app/tag/tag.service.ts b/apps/api/src/app/tag/tag.service.ts index 9da7cc475..674f6aa92 100644 --- a/apps/api/src/app/tag/tag.service.ts +++ b/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 }; }); } diff --git a/apps/api/src/app/user/update-user-setting.dto.ts b/apps/api/src/app/user/update-user-setting.dto.ts index e510880ed..b1967faba 100644 --- a/apps/api/src/app/user/update-user-setting.dto.ts +++ b/apps/api/src/app/user/update-user-setting.dto.ts @@ -29,7 +29,7 @@ export class UpdateUserSettingDto { @IsOptional() colorScheme?: ColorScheme; - @IsIn(['1d', '1y', '5y', 'max', 'ytd']) + @IsIn(['1d', '1w', '1m', '3m', '1y', '5y', 'max', 'ytd']) @IsOptional() dateRange?: DateRange; diff --git a/apps/api/src/helper/dateQueryHelper.ts b/apps/api/src/helper/dateQueryHelper.ts new file mode 100644 index 000000000..2016de74a --- /dev/null +++ b/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 }; + } +} diff --git a/apps/api/src/services/data-gathering/data-gathering.service.ts b/apps/api/src/services/data-gathering/data-gathering.service.ts index 78531b745..213f91692 100644 --- a/apps/api/src/services/data-gathering/data-gathering.service.ts +++ b/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,16 +103,21 @@ export class DataGatheringService { historicalData[symbol][format(date, DATE_FORMAT)].marketPrice; if (marketPrice) { - return await this.prismaService.marketData.upsert({ - create: { - dataSource, - date, - marketPrice, - symbol - }, - update: { marketPrice }, - where: { dataSource_date_symbol: { dataSource, date, symbol } } - }); + await this.lock.acquireAsync(); + try { + return await this.prismaService.marketData.upsert({ + create: { + dataSource, + date, + marketPrice, + symbol + }, + update: { marketPrice }, + where: { dataSource_date_symbol: { dataSource, date, symbol } } + }); + } finally { + this.lock.release(); + } } } catch (error) { Logger.error(error, 'DataGatheringService'); diff --git a/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts b/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts index 4de08fcef..6678721c7 100644 --- a/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts +++ b/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts @@ -29,9 +29,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { response: Partial; symbol: string; }): Promise> { - 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; } diff --git a/apps/api/src/services/data-provider/manual/manual.service.ts b/apps/api/src/services/data-provider/manual/manual.service.ts index 1464a526d..5482b52ed 100644 --- a/apps/api/src/services/data-provider/manual/manual.service.ts +++ b/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,18 +154,25 @@ export class ManualService implements DataProviderInterface { }) ); - const marketData = await this.prismaService.marketData.findMany({ - distinct: ['symbol'], - orderBy: { - date: 'desc' - }, - take: symbols.length, - where: { - symbol: { - in: symbols - } - } - }); + const batch = new BatchPrismaClient(this.prismaService); + + const marketData = await batch + .over(symbols) + .with((prisma, _symbols) => + prisma.marketData.findMany({ + distinct: ['symbol'], + orderBy: { + date: 'desc' + }, + take: symbols.length, + where: { + symbol: { + in: _symbols + } + } + }) + ) + .then((_result) => _result.flat()); for (const symbolProfile of symbolProfiles) { response[symbolProfile.symbol] = { diff --git a/apps/api/src/services/market-data/market-data.service.ts b/apps/api/src/services/market-data/market-data.service.ts index 05172dfe1..42bd046e8 100644 --- a/apps/api/src/services/market-data/market-data.service.ts +++ b/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,18 +123,22 @@ export class MarketDataService { where: Prisma.MarketDataWhereUniqueInput; }): Promise { const { data, where } = params; - - return this.prismaService.marketData.upsert({ - where, - create: { - dataSource: where.dataSource_date_symbol.dataSource, - date: where.dataSource_date_symbol.date, - marketPrice: data.marketPrice, - state: data.state, - symbol: where.dataSource_date_symbol.symbol - }, - update: { marketPrice: data.marketPrice, state: data.state } - }); + await this.lock.acquireAsync(); + try { + return this.prismaService.marketData.upsert({ + where, + create: { + dataSource: where.dataSource_date_symbol.dataSource, + date: where.dataSource_date_symbol.date, + marketPrice: data.marketPrice, + state: data.state, + symbol: where.dataSource_date_symbol.symbol + }, + update: { marketPrice: data.marketPrice, state: data.state } + }); + } finally { + this.lock.release(); + } } /** @@ -140,30 +151,34 @@ export class MarketDataService { data: Prisma.MarketDataUpdateInput[]; }): Promise { const upsertPromises = data.map( - ({ dataSource, date, marketPrice, symbol, state }) => { - return this.prismaService.marketData.upsert({ - create: { - dataSource: dataSource, - date: date, - marketPrice: marketPrice, - state: state, - symbol: symbol - }, - update: { - marketPrice: marketPrice, - state: state - }, - where: { - dataSource_date_symbol: { + async ({ dataSource, date, marketPrice, symbol, state }) => { + await this.lock.acquireAsync(); + try { + return this.prismaService.marketData.upsert({ + create: { dataSource: dataSource, date: date, + marketPrice: marketPrice, + state: state, symbol: symbol + }, + update: { + marketPrice: marketPrice, + state: state + }, + where: { + dataSource_date_symbol: { + dataSource: dataSource, + date: date, + symbol: symbol + } } - } - }); + }); + } finally { + this.lock.release(); + } } ); - - return this.prismaService.$transaction(upsertPromises); + return await Promise.all(upsertPromises); } } diff --git a/apps/api/src/services/symbol-profile/symbol-profile-overwrite.module.ts b/apps/api/src/services/symbol-profile/symbol-profile-overwrite.module.ts new file mode 100644 index 000000000..3015b268c --- /dev/null +++ b/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 {} diff --git a/apps/api/src/services/symbol-profile/symbol-profile-overwrite.service.ts b/apps/api/src/services/symbol-profile/symbol-profile-overwrite.service.ts new file mode 100644 index 000000000..1b1326849 --- /dev/null +++ b/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 { + 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 { + 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; + } +} diff --git a/apps/api/src/services/symbol-profile/symbol-profile.service.ts b/apps/api/src/services/symbol-profile/symbol-profile.service.ts index 46a6991cb..3747b07f3 100644 --- a/apps/api/src/services/symbol-profile/symbol-profile.service.ts +++ b/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: 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; diff --git a/apps/api/src/services/tag/tag.service.ts b/apps/api/src/services/tag/tag.service.ts index c02345784..a47a528bc 100644 --- a/apps/api/src/services/tag/tag.service.ts +++ b/apps/api/src/services/tag/tag.service.ts @@ -19,11 +19,20 @@ export class TagService { name: 'asc' }, where: { - orders: { - some: { - userId + OR: [ + { + orders: { + some: { + userId + } + } + }, + { + symbolProfile: { + some: {} + } } - } + ] } }); } diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts index 03dc717d5..470354080 100644 --- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts +++ b/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; + 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(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(); diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html index 72d673776..675e5b3b1 100644 --- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html @@ -183,7 +183,7 @@ -
+
Asset Class @@ -196,7 +196,7 @@
-
+
Asset Sub Class @@ -209,6 +209,37 @@
+
+ + Tags + + + {{ tag.name }} + + + + + + + {{ tag.name }} + + + +
+ + + Holdings + + + {{ element.holdingCount }} + + = new MatTableDataSource(); public deviceType: string; - public displayedColumns = ['name', 'activities', 'actions']; + public displayedColumns = ['name', 'activities', 'holdings', 'actions']; public tags: Tag[]; private unsubscribeSubject = new Subject(); diff --git a/apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.component.ts b/apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.component.ts index bc68cf231..bd7d6c46d 100644 --- a/apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.component.ts +++ b/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(); diff --git a/apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.html b/apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.html index 8d0f62ed9..65d653d76 100644 --- a/apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.html +++ b/apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.html @@ -126,7 +126,10 @@ >Investment
-
+
Dividend
+ +
+ Stake Rewards + +
Type {{ typesTranslationMap[activityForm.controls['type'].value] - }} + >{{ typesTranslationMap[activityForm.controls['type'].value] }} + {{ typesTranslationMap['BUY'] }} Revenue for lending out money + + {{ typesTranslationMap['STAKE'] }}
+ Stake rewards, stock dividends, free/gifted stocks +
{{ typesTranslationMap['LIABILITY'] }} -
+
diff --git a/apps/client/src/app/services/admin.service.ts b/apps/client/src/app/services/admin.service.ts index 042914ee4..b8ea0c70d 100644 --- a/apps/client/src/app/services/admin.service.ts +++ b/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( `/api/v1/admin/profile-data/${dataSource}/${symbol}`, @@ -219,7 +220,8 @@ export class AdminService { comment, name, scraperConfiguration, - symbolMapping + symbolMapping, + tags } ); } diff --git a/apps/client/src/app/services/import-activities.service.ts b/apps/client/src/app/services/import-activities.service.ts index af7e8e6c9..3bbc35e3c 100644 --- a/apps/client/src/app/services/import-activities.service.ts +++ b/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; } diff --git a/libs/common/src/lib/chunkhelper.ts b/libs/common/src/lib/chunkhelper.ts new file mode 100644 index 000000000..5f2929055 --- /dev/null +++ b/libs/common/src/lib/chunkhelper.ts @@ -0,0 +1,46 @@ +import { Prisma, PrismaClient } from '@prisma/client'; + +class Chunk implements Iterable { + 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(mapper: (items?: T[]) => U): U[] { + return Array.from(this).map((items) => mapper(items)); + } + + static of(values: readonly U[]) { + return { + by: (size: number) => new Chunk(values, size) + }; + } +} + +export type Queryable = ( + p: PrismaClient, + vs?: T[] +) => Prisma.PrismaPromise; +export class BatchPrismaClient { + constructor( + private readonly prisma: PrismaClient, + private readonly size = 32_000 + ) {} + + over(values: readonly T[]) { + return { + with: (queryable: Queryable) => + this.prisma.$transaction( + Chunk.of(values) + .by(this.size) + .map((vs) => queryable(this.prisma, vs)) + ) + }; + } +} diff --git a/libs/common/src/lib/interfaces/admin-market-data.interface.ts b/libs/common/src/lib/interfaces/admin-market-data.interface.ts index 08838d4bc..791530b38 100644 --- a/libs/common/src/lib/interfaces/admin-market-data.interface.ts +++ b/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[]; } diff --git a/libs/common/src/lib/interfaces/enhanced-symbol-profile.interface.ts b/libs/common/src/lib/interfaces/enhanced-symbol-profile.interface.ts index 3bf914eaa..f53d41354 100644 --- a/libs/common/src/lib/interfaces/enhanced-symbol-profile.interface.ts +++ b/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[]; } diff --git a/libs/common/src/lib/types/date-range.type.ts b/libs/common/src/lib/types/date-range.type.ts index afee7b100..5b7d1fbf6 100644 --- a/libs/common/src/lib/types/date-range.type.ts +++ b/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'; diff --git a/libs/ui/src/lib/activities-table/activities-table.component.scss b/libs/ui/src/lib/activities-table/activities-table.component.scss index ea3dad292..36559be7a 100644 --- a/libs/ui/src/lib/activities-table/activities-table.component.scss +++ b/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; } } } diff --git a/libs/ui/src/lib/activity-type/activity-type.component.html b/libs/ui/src/lib/activity-type/activity-type.component.html index b5c0ad060..a0ad19437 100644 --- a/libs/ui/src/lib/activity-type/activity-type.component.html +++ b/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' }" > diff --git a/libs/ui/src/lib/i18n.ts b/libs/ui/src/lib/i18n.ts index 53c2d3a77..9bc965bfd 100644 --- a/libs/ui/src/lib/i18n.ts +++ b/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`, diff --git a/package.json b/package.json index bccedc68f..cd0ec309e 100644 --- a/package.json +++ b/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", diff --git a/prisma/migrations/20231108082445_added_tags_to_holding/migration.sql b/prisma/migrations/20231108082445_added_tags_to_holding/migration.sql new file mode 100644 index 000000000..b598b9d23 --- /dev/null +++ b/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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a7db0ceee..70d186be4 100644 --- a/prisma/schema.prisma +++ b/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 { diff --git a/yarn.lock b/yarn.lock index c19ae8175..e7a032612 100644 --- a/yarn.lock +++ b/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"