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.yml b/.github/workflows/docker-image.yml index 26ccb9b8d..bf533d836 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -6,7 +6,7 @@ on: - '*.*.*' pull_request: branches: - - 'main' + - 'dockerpush' jobs: build_and_push: @@ -19,7 +19,7 @@ jobs: id: meta uses: docker/metadata-action@v4 with: - images: ghostfolio/ghostfolio + images: dandevaud/ghostfolio tags: | type=semver,pattern={{version}} 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 cf6d1b156..7d6db5a82 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(), @@ -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 diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 93af43edb..d8884aff0 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -111,21 +111,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; @@ -135,6 +152,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 564056d1c..b0722f2f7 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -525,11 +525,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; @@ -555,59 +553,13 @@ 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(); + this.calculateMarketsAllocation( + symbolProfile, + markets, + marketsAdvanced, + value + ); - marketsAdvanced[UNKNOWN_KEY] = new Big(marketsAdvanced[UNKNOWN_KEY]) - .plus(value) - .toNumber(); - } holdings[item.symbol] = { markets, @@ -640,6 +592,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'; }); @@ -655,16 +669,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 && @@ -699,30 +723,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( @@ -755,6 +828,7 @@ export class PortfolioService { averagePrice: undefined, dataProviderInfo: undefined, dividendInBaseCurrency: undefined, + stakeRewards: undefined, feeInBaseCurrency: undefined, firstBuyDate: undefined, grossPerformance: undefined, @@ -783,7 +857,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, @@ -840,6 +918,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(), @@ -934,6 +1022,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, @@ -997,6 +1086,7 @@ export class PortfolioService { averagePrice: 0, dataProviderInfo: undefined, dividendInBaseCurrency: 0, + stakeRewards: 0, feeInBaseCurrency: 0, firstBuyDate: undefined, grossPerformance: undefined, @@ -1644,41 +1734,63 @@ 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 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; + } + } + } - const dividend = this.getDividend({ - activities, - userCurrency - }).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 items = this.getItems(activities).toNumber(); - const liabilities = this.getLiabilities({ - activities, - userCurrency - }).toNumber(); - const totalBuy = this.getTotalByType(activities, userCurrency, 'BUY'); - const totalSell = this.getTotalByType(activities, userCurrency, 'SELL'); + const firstOrderDate = activities[0]?.date; const cash = new Big(balanceInBaseCurrency) .minus(emergencyFund) @@ -1780,7 +1892,7 @@ export class PortfolioService { userCurrency, userId, withExcludedAccounts, - types: ['BUY', 'SELL'] + types: ['BUY', 'SELL', 'STAKE'] }); if (orders.length <= 0) { 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 379250fcd..18aca3f76 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 -