diff --git a/CHANGELOG.md b/CHANGELOG.md index 853dd9773..9f99a5e02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added the subscription type to the users table of the admin control panel +- Introduced the sub classification of assets + +### Todo + +- Apply data migration (`yarn database:push`) ## 1.41.0 - 21.08.2021 diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 19d553b0c..5f77ef58d 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -218,6 +218,7 @@ export class PortfolioService { allocationCurrent: value.div(totalValue).toNumber(), allocationInvestment: item.investment.div(totalInvestment).toNumber(), assetClass: symbolProfile.assetClass, + assetSubClass: symbolProfile.assetSubClass, countries: symbolProfile.countries, currency: item.currency, exchange: dataProviderResponse.exchange, diff --git a/apps/api/src/services/data-gathering.service.ts b/apps/api/src/services/data-gathering.service.ts index eff9d3c21..fcf9c4eba 100644 --- a/apps/api/src/services/data-gathering.service.ts +++ b/apps/api/src/services/data-gathering.service.ts @@ -38,7 +38,7 @@ export class DataGatheringService { if (isDataGatheringNeeded) { console.log('7d data gathering has been started.'); - console.time('7d-data-gathering'); + console.time('data-gathering-7d'); await this.prismaService.property.create({ data: { @@ -71,7 +71,7 @@ export class DataGatheringService { }); console.log('7d data gathering has been completed.'); - console.timeEnd('7d-data-gathering'); + console.timeEnd('data-gathering-7d'); } } @@ -82,7 +82,7 @@ export class DataGatheringService { if (!isDataGatheringLocked) { console.log('Max data gathering has been started.'); - console.time('max-data-gathering'); + console.time('data-gathering-max'); await this.prismaService.property.create({ data: { @@ -115,13 +115,13 @@ export class DataGatheringService { }); console.log('Max data gathering has been completed.'); - console.timeEnd('max-data-gathering'); + console.timeEnd('data-gathering-max'); } } public async gatherProfileData(aSymbols?: string[]) { console.log('Profile data gathering has been started.'); - console.time('profile-data-gathering'); + console.time('data-gathering-profile'); let symbols = aSymbols; @@ -136,12 +136,13 @@ export class DataGatheringService { for (const [ symbol, - { assetClass, currency, dataSource, name } + { assetClass, assetSubClass, currency, dataSource, name } ] of Object.entries(currentData)) { try { await this.prismaService.symbolProfile.upsert({ create: { assetClass, + assetSubClass, currency, dataSource, name, @@ -149,6 +150,7 @@ export class DataGatheringService { }, update: { assetClass, + assetSubClass, currency, name }, @@ -165,7 +167,7 @@ export class DataGatheringService { } console.log('Profile data gathering has been completed.'); - console.timeEnd('profile-data-gathering'); + console.timeEnd('data-gathering-profile'); } public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) { diff --git a/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts b/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts index 9b744468b..3b59271aa 100644 --- a/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts +++ b/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts @@ -8,7 +8,12 @@ import { } from '@ghostfolio/common/helper'; import { Granularity } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; -import { AssetClass, Currency, DataSource } from '@prisma/client'; +import { + AssetClass, + AssetSubClass, + Currency, + DataSource +} from '@prisma/client'; import * as bent from 'bent'; import Big from 'big.js'; import { format } from 'date-fns'; @@ -22,6 +27,7 @@ import { } from '../../interfaces/interfaces'; import { IYahooFinanceHistoricalResponse, + IYahooFinancePrice, IYahooFinanceQuoteResponse } from './interfaces/interfaces'; @@ -60,8 +66,11 @@ export class YahooFinanceService implements DataProviderInterface { // Convert symbols back const symbol = convertFromYahooSymbol(yahooSymbol); + const { assetClass, assetSubClass } = this.parseAssetClass(value.price); + response[symbol] = { - assetClass: this.parseAssetClass(value.price?.quoteType), + assetClass, + assetSubClass, currency: parseCurrency(value.price?.currency), dataSource: DataSource.YAHOO, exchange: this.parseExchange(value.price?.exchangeName), @@ -229,20 +238,29 @@ export class YahooFinanceService implements DataProviderInterface { return aSymbol; } - private parseAssetClass(aString: string): AssetClass { + private parseAssetClass(aPrice: IYahooFinancePrice): { + assetClass: AssetClass; + assetSubClass: AssetSubClass; + } { let assetClass: AssetClass; + let assetSubClass: AssetSubClass; - switch (aString?.toLowerCase()) { + switch (aPrice?.quoteType?.toLowerCase()) { case 'cryptocurrency': assetClass = AssetClass.CASH; + assetSubClass = AssetSubClass.CRYPTOCURRENCY; break; case 'equity': + assetClass = AssetClass.EQUITY; + assetSubClass = AssetSubClass.STOCK; + break; case 'etf': assetClass = AssetClass.EQUITY; + assetSubClass = AssetSubClass.ETF; break; } - return assetClass; + return { assetClass, assetSubClass }; } private parseExchange(aString: string): string { diff --git a/apps/api/src/services/interfaces/interfaces.ts b/apps/api/src/services/interfaces/interfaces.ts index ba1f4a50d..fce1e94d1 100644 --- a/apps/api/src/services/interfaces/interfaces.ts +++ b/apps/api/src/services/interfaces/interfaces.ts @@ -1,6 +1,7 @@ import { Account, AssetClass, + AssetSubClass, Currency, DataSource, SymbolProfile @@ -35,6 +36,7 @@ export interface IDataProviderHistoricalResponse { export interface IDataProviderResponse { assetClass?: AssetClass; + assetSubClass?: AssetSubClass; currency: Currency; dataSource: DataSource; exchange?: string; diff --git a/apps/api/src/services/interfaces/symbol-profile.interface.ts b/apps/api/src/services/interfaces/symbol-profile.interface.ts index 304774d5c..43585c3a9 100644 --- a/apps/api/src/services/interfaces/symbol-profile.interface.ts +++ b/apps/api/src/services/interfaces/symbol-profile.interface.ts @@ -1,9 +1,15 @@ import { Country } from '@ghostfolio/common/interfaces/country.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; -import { AssetClass, Currency, DataSource } from '@prisma/client'; +import { + AssetClass, + AssetSubClass, + Currency, + DataSource +} from '@prisma/client'; export interface EnhancedSymbolProfile { assetClass: AssetClass; + assetSubClass: AssetSubClass; createdAt: Date; currency: Currency | null; dataSource: DataSource; diff --git a/apps/client/src/app/components/portfolio-proportion-chart/portfolio-proportion-chart.component.ts b/apps/client/src/app/components/portfolio-proportion-chart/portfolio-proportion-chart.component.ts index b3d9b2125..dd50d67c8 100644 --- a/apps/client/src/app/components/portfolio-proportion-chart/portfolio-proportion-chart.component.ts +++ b/apps/client/src/app/components/portfolio-proportion-chart/portfolio-proportion-chart.component.ts @@ -16,6 +16,7 @@ import { LinearScale } from 'chart.js'; import { ArcElement } from 'chart.js'; import { DoughnutController } from 'chart.js'; import { Chart } from 'chart.js'; +import * as Color from 'color'; @Component({ selector: 'gf-portfolio-proportion-chart', @@ -28,7 +29,7 @@ export class PortfolioProportionChartComponent { @Input() baseCurrency: Currency; @Input() isInPercent: boolean; - @Input() key: string; + @Input() keys: string[]; @Input() locale: string; @Input() maxItems?: number; @Input() positions: { @@ -65,24 +66,54 @@ export class PortfolioProportionChartComponent private initialize() { this.isLoading = true; const chartData: { - [symbol: string]: { color?: string; value: number }; + [symbol: string]: { + color?: string; + subCategory: { [symbol: string]: { value: number } }; + value: number; + }; } = {}; Object.keys(this.positions).forEach((symbol) => { - if (this.positions[symbol][this.key]) { - if (chartData[this.positions[symbol][this.key]]) { - chartData[this.positions[symbol][this.key]].value += + if (this.positions[symbol][this.keys[0]]) { + if (chartData[this.positions[symbol][this.keys[0]]]) { + chartData[this.positions[symbol][this.keys[0]]].value += this.positions[symbol].value; + + if ( + chartData[this.positions[symbol][this.keys[0]]].subCategory[ + this.positions[symbol][this.keys[1]] + ] + ) { + chartData[this.positions[symbol][this.keys[0]]].subCategory[ + this.positions[symbol][this.keys[1]] + ].value += this.positions[symbol].value; + } else { + chartData[this.positions[symbol][this.keys[0]]].subCategory[ + this.positions[symbol][this.keys[1]] ?? UNKNOWN_KEY + ] = { value: this.positions[symbol].value }; + } } else { - chartData[this.positions[symbol][this.key]] = { + chartData[this.positions[symbol][this.keys[0]]] = { + subCategory: {}, value: this.positions[symbol].value }; + + if (this.positions[symbol][this.keys[1]]) { + chartData[this.positions[symbol][this.keys[0]]].subCategory = { + [this.positions[symbol][this.keys[1]]]: { + value: this.positions[symbol].value + } + }; + } } } else { if (chartData[UNKNOWN_KEY]) { chartData[UNKNOWN_KEY].value += this.positions[symbol].value; } else { chartData[UNKNOWN_KEY] = { + subCategory: this.keys[1] + ? { [this.keys[1]]: { value: 0 } } + : undefined, value: this.positions[symbol].value }; } @@ -107,13 +138,17 @@ export class PortfolioProportionChartComponent }); if (!unknownItem) { - const index = chartDataSorted.push([UNKNOWN_KEY, { value: 0 }]); + const index = chartDataSorted.push([ + UNKNOWN_KEY, + { subCategory: {}, value: 0 } + ]); unknownItem = chartDataSorted[index]; } rest.forEach((restItem) => { if (unknownItem?.[1]) { unknownItem[1] = { + subCategory: {}, value: unknownItem[1].value + restItem[1].value }; } @@ -141,21 +176,53 @@ export class PortfolioProportionChartComponent } }); + const backgroundColorSubCategory: string[] = []; + const dataSubCategory: number[] = []; + const labelSubCategory: string[] = []; + + chartDataSorted.forEach(([, item]) => { + let lightnessRatio = 0.2; + + Object.keys(item.subCategory).forEach((subCategory) => { + backgroundColorSubCategory.push( + Color(item.color).lighten(lightnessRatio).hex() + ); + dataSubCategory.push(item.subCategory[subCategory].value); + labelSubCategory.push(subCategory); + + lightnessRatio += 0.1; + }); + }); + + const datasets = [ + { + backgroundColor: chartDataSorted.map(([, item]) => { + return item.color; + }), + borderWidth: 0, + data: chartDataSorted.map(([, item]) => { + return item.value; + }) + } + ]; + + let labels = chartDataSorted.map(([label]) => { + return label; + }); + + if (this.keys[1]) { + datasets.unshift({ + backgroundColor: backgroundColorSubCategory, + borderWidth: 0, + data: dataSubCategory + }); + + labels = labelSubCategory.concat(labels); + } + const data = { - datasets: [ - { - backgroundColor: chartDataSorted.map(([, item]) => { - return item.color; - }), - borderWidth: 0, - data: chartDataSorted.map(([, item]) => { - return item.value; - }) - } - ], - labels: chartDataSorted.map(([label]) => { - return label; - }) + datasets, + labels }; if (this.chartCanvas) { @@ -166,13 +233,16 @@ export class PortfolioProportionChartComponent this.chart = new Chart(this.chartCanvas.nativeElement, { data, options: { + cutout: '70%', plugins: { legend: { display: false }, tooltip: { callbacks: { label: (context) => { - const label = - context.label === UNKNOWN_KEY ? 'Other' : context.label; + const labelIndex = + (data.datasets[context.datasetIndex - 1]?.data?.length ?? + 0) + context.dataIndex; + const label = context.chart.data.labels[labelIndex]; if (this.isInPercent) { const value = 100 * context.raw; diff --git a/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts b/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts index b44822832..f1dd34f87 100644 --- a/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts +++ b/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts @@ -3,7 +3,7 @@ import { ToggleOption } from '@ghostfolio/client/components/toggle/interfaces/to import { DataService } from '@ghostfolio/client/services/data.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; -import { UNKNOWN_KEY, ghostfolioCashSymbol } from '@ghostfolio/common/config'; +import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { PortfolioDetails, PortfolioPosition, @@ -129,6 +129,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit { )) { this.positions[symbol] = { assetClass: position.assetClass, + assetSubClass: position.assetSubClass, currency: position.currency, exchange: position.exchange, value: diff --git a/apps/client/src/app/pages/portfolio/allocations/allocations-page.html b/apps/client/src/app/pages/portfolio/allocations/allocations-page.html index 9e160b2d5..f9fd44f08 100644 --- a/apps/client/src/app/pages/portfolio/allocations/allocations-page.html +++ b/apps/client/src/app/pages/portfolio/allocations/allocations-page.html @@ -18,9 +18,9 @@ @@ -40,9 +40,9 @@ @@ -62,9 +62,9 @@ @@ -84,9 +84,9 @@ @@ -129,7 +129,7 @@