Browse Source

Feature/improve investments by sector (#172)

* Improve investments analysis by sector

* Update changelog
pull/173/head
Thomas 4 years ago
committed by GitHub
parent
commit
0264b592b9
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      CHANGELOG.md
  2. 14
      apps/api/src/models/portfolio.ts
  3. 63
      apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts
  4. 23
      apps/api/src/services/interfaces/interfaces.ts
  5. 36
      apps/client/src/app/pages/tools/analysis/analysis-page.component.ts
  6. 41
      apps/client/src/app/pages/tools/analysis/analysis-page.html
  7. 4
      libs/common/src/lib/interfaces/portfolio-position.interface.ts
  8. 4
      libs/common/src/lib/interfaces/sector.interface.ts
  9. 2
      prisma/migrations/20210616075245_added_sectors_to_symbol_profile/migration.sql
  10. 1
      prisma/schema.prisma
  11. 18
      prisma/seed.ts

6
CHANGELOG.md

@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Changed
- Improved the pie chart: Investments by sector
## 1.17.0 - 15.06.2021 ## 1.17.0 - 15.06.2021
### Changed ### Changed

14
apps/api/src/models/portfolio.ts

@ -9,6 +9,7 @@ import {
UserWithSettings UserWithSettings
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Country } from '@ghostfolio/common/interfaces/country.interface'; import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { DateRange, OrderWithAccount } from '@ghostfolio/common/types'; import { DateRange, OrderWithAccount } from '@ghostfolio/common/types';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { continents, countries } from 'countries-list'; import { continents, countries } from 'countries-list';
@ -210,6 +211,7 @@ export class Portfolio implements PortfolioInterface {
symbols.forEach((symbol) => { symbols.forEach((symbol) => {
const accounts: PortfolioPosition['accounts'] = {}; const accounts: PortfolioPosition['accounts'] = {};
let countriesOfSymbol: Country[]; let countriesOfSymbol: Country[];
let sectorsOfSymbol: Sector[];
const [portfolioItem] = portfolioItems; const [portfolioItem] = portfolioItems;
const ordersBySymbol = this.getOrders().filter((order) => { const ordersBySymbol = this.getOrders().filter((order) => {
@ -264,6 +266,17 @@ export class Portfolio implements PortfolioInterface {
weight: weight as number weight: weight as number
}; };
}); });
sectorsOfSymbol = (
(orderOfSymbol.getSymbolProfile()?.sectors as Prisma.JsonArray) ?? []
).map((sector) => {
const { name, weight } = sector as Prisma.JsonObject;
return {
name: (name as string) ?? UNKNOWN_KEY,
weight: weight as number
};
});
}); });
let now = portfolioItemsNow.positions[symbol].marketPrice; let now = portfolioItemsNow.positions[symbol].marketPrice;
@ -318,6 +331,7 @@ export class Portfolio implements PortfolioInterface {
grossPerformancePercent: roundTo((now - before) / before, 4), grossPerformancePercent: roundTo((now - before) / before, 4),
investment: portfolioItem.positions[symbol].investment, investment: portfolioItem.positions[symbol].investment,
quantity: portfolioItem.positions[symbol].quantity, quantity: portfolioItem.positions[symbol].quantity,
sectors: sectorsOfSymbol,
transactionCount: portfolioItem.positions[symbol].transactionCount, transactionCount: portfolioItem.positions[symbol].transactionCount,
value: this.exchangeRateDataService.toCurrency( value: this.exchangeRateDataService.toCurrency(
portfolioItem.positions[symbol].quantity * now, portfolioItem.positions[symbol].quantity * now,

63
apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts

@ -12,9 +12,7 @@ import { DataProviderInterface } from '../../interfaces/data-provider.interface'
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse, IDataProviderResponse,
Industry,
MarketState, MarketState,
Sector,
Type Type
} from '../../interfaces/interfaces'; } from '../../interfaces/interfaces';
import { import {
@ -70,16 +68,6 @@ export class YahooFinanceService implements DataProviderInterface {
type: this.parseType(this.getType(symbol, value)) type: this.parseType(this.getType(symbol, value))
}; };
const industry = this.parseIndustry(value.summaryProfile?.industry);
if (industry) {
response[symbol].industry = industry;
}
const sector = this.parseSector(value.summaryProfile?.sector);
if (sector) {
response[symbol].sector = sector;
}
const url = value.summaryProfile?.website; const url = value.summaryProfile?.website;
if (url) { if (url) {
response[symbol].url = url; response[symbol].url = url;
@ -228,55 +216,6 @@ export class YahooFinanceService implements DataProviderInterface {
return aString; return aString;
} }
private parseIndustry(aString: string): Industry {
if (aString === undefined) {
return undefined;
}
if (aString?.toLowerCase() === 'auto manufacturers') {
return Industry.Automotive;
} else if (aString?.toLowerCase() === 'biotechnology') {
return Industry.Biotechnology;
} else if (
aString?.toLowerCase() === 'drug manufacturers—specialty & generic'
) {
return Industry.Pharmaceutical;
} else if (
aString?.toLowerCase() === 'internet content & information' ||
aString?.toLowerCase() === 'internet retail'
) {
return Industry.Internet;
} else if (aString?.toLowerCase() === 'packaged foods') {
return Industry.Food;
} else if (aString?.toLowerCase() === 'software—application') {
return Industry.Software;
}
return Industry.Unknown;
}
private parseSector(aString: string): Sector {
if (aString === undefined) {
return undefined;
}
if (
aString?.toLowerCase() === 'consumer cyclical' ||
aString?.toLowerCase() === 'consumer defensive'
) {
return Sector.Consumer;
} else if (aString?.toLowerCase() === 'healthcare') {
return Sector.Healthcare;
} else if (
aString?.toLowerCase() === 'communication services' ||
aString?.toLowerCase() === 'technology'
) {
return Sector.Technology;
}
return Sector.Unknown;
}
private parseType(aString: string): Type { private parseType(aString: string): Type {
if (aString?.toLowerCase() === 'cryptocurrency') { if (aString?.toLowerCase() === 'cryptocurrency') {
return Type.Cryptocurrency; return Type.Cryptocurrency;
@ -291,6 +230,6 @@ export class YahooFinanceService implements DataProviderInterface {
} }
export const convertFromYahooSymbol = (aSymbol: string) => { export const convertFromYahooSymbol = (aSymbol: string) => {
let symbol = aSymbol.replace('-', ''); const symbol = aSymbol.replace('-', '');
return symbol.replace('=X', ''); return symbol.replace('=X', '');
}; };

23
apps/api/src/services/interfaces/interfaces.ts

@ -3,29 +3,12 @@ import { Account, Currency, DataSource, SymbolProfile } from '@prisma/client';
import { OrderType } from '../../models/order-type'; import { OrderType } from '../../models/order-type';
export const Industry = {
Automotive: 'Automotive',
Biotechnology: 'Biotechnology',
Food: 'Food',
Internet: 'Internet',
Pharmaceutical: 'Pharmaceutical',
Software: 'Software',
Unknown: UNKNOWN_KEY
};
export const MarketState = { export const MarketState = {
closed: 'closed', closed: 'closed',
delayed: 'delayed', delayed: 'delayed',
open: 'open' open: 'open'
}; };
export const Sector = {
Consumer: 'Consumer',
Healthcare: 'Healthcare',
Technology: 'Technology',
Unknown: UNKNOWN_KEY
};
export const Type = { export const Type = {
Cryptocurrency: 'Cryptocurrency', Cryptocurrency: 'Cryptocurrency',
ETF: 'ETF', ETF: 'ETF',
@ -55,13 +38,11 @@ export interface IDataProviderResponse {
currency: Currency; currency: Currency;
dataSource: DataSource; dataSource: DataSource;
exchange?: string; exchange?: string;
industry?: Industry;
marketChange?: number; marketChange?: number;
marketChangePercent?: number; marketChangePercent?: number;
marketPrice: number; marketPrice: number;
marketState: MarketState; marketState: MarketState;
name: string; name: string;
sector?: Sector;
type?: Type; type?: Type;
url?: string; url?: string;
} }
@ -72,10 +53,6 @@ export interface IDataGatheringItem {
symbol: string; symbol: string;
} }
export type Industry = typeof Industry[keyof typeof Industry];
export type MarketState = typeof MarketState[keyof typeof MarketState]; export type MarketState = typeof MarketState[keyof typeof MarketState];
export type Sector = typeof Sector[keyof typeof Sector];
export type Type = typeof Type[keyof typeof Type]; export type Type = typeof Type[keyof typeof Type];

36
apps/client/src/app/pages/tools/analysis/analysis-page.component.ts

@ -9,6 +9,7 @@ import {
PortfolioPosition, PortfolioPosition,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -39,6 +40,9 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
public portfolioPositions: { [symbol: string]: PortfolioPosition }; public portfolioPositions: { [symbol: string]: PortfolioPosition };
public positions: { [symbol: string]: any }; public positions: { [symbol: string]: any };
public positionsArray: PortfolioPosition[]; public positionsArray: PortfolioPosition[];
public sectors: {
[name: string]: { name: string; value: number };
};
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -118,13 +122,17 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
}; };
this.positions = {}; this.positions = {};
this.positionsArray = []; this.positionsArray = [];
this.sectors = {
[UNKNOWN_KEY]: {
name: UNKNOWN_KEY,
value: 0
}
};
for (const [symbol, position] of Object.entries(aPortfolioPositions)) { for (const [symbol, position] of Object.entries(aPortfolioPositions)) {
this.positions[symbol] = { this.positions[symbol] = {
currency: position.currency, currency: position.currency,
exchange: position.exchange, exchange: position.exchange,
industry: position.industry,
sector: position.sector,
type: position.type, type: position.type,
value: value:
aPeriod === 'original' aPeriod === 'original'
@ -188,6 +196,30 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
? this.portfolioPositions[symbol].investment ? this.portfolioPositions[symbol].investment
: this.portfolioPositions[symbol].value; : this.portfolioPositions[symbol].value;
} }
if (position.sectors.length > 0) {
for (const sector of position.sectors) {
const { name, weight } = sector;
if (this.sectors[name]?.value) {
this.sectors[name].value += weight * position.value;
} else {
this.sectors[name] = {
name,
value:
weight *
(aPeriod === 'original'
? this.portfolioPositions[symbol].investment
: this.portfolioPositions[symbol].value)
};
}
}
} else {
this.sectors[UNKNOWN_KEY].value +=
aPeriod === 'original'
? this.portfolioPositions[symbol].investment
: this.portfolioPositions[symbol].value;
}
} }
} }

41
apps/client/src/app/pages/tools/analysis/analysis-page.html

@ -61,30 +61,7 @@
<div class="col-md-6"> <div class="col-md-6">
<mat-card class="mb-3"> <mat-card class="mb-3">
<mat-card-header class="w-100"> <mat-card-header class="w-100">
<mat-card-title i18n>By Sector</mat-card-title> <mat-card-title i18n>By Currency</mat-card-title>
<gf-toggle
[defaultValue]="period"
[isLoading]="false"
[options]="periodOptions"
(change)="onChangePeriod($event.value)"
></gf-toggle>
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
key="sector"
[baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="true"
[locale]="user?.settings?.locale"
[maxItems]="10"
[positions]="positions"
></gf-portfolio-proportion-chart>
</mat-card-content>
</mat-card>
</div>
<div class="col-md-6">
<mat-card class="mb-3">
<mat-card-header class="w-100">
<mat-card-title i18n>By Industry</mat-card-title>
<gf-toggle <gf-toggle
[defaultValue]="period" [defaultValue]="period"
[isLoading]="false" [isLoading]="false"
@ -94,11 +71,10 @@
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
key="industry" key="currency"
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="true" [isInPercent]="true"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[maxItems]="10"
[positions]="positions" [positions]="positions"
></gf-portfolio-proportion-chart> ></gf-portfolio-proportion-chart>
</mat-card-content> </mat-card-content>
@ -107,7 +83,7 @@
<div class="col-md-6"> <div class="col-md-6">
<mat-card class="mb-3"> <mat-card class="mb-3">
<mat-card-header class="w-100"> <mat-card-header class="w-100">
<mat-card-title i18n>By Currency</mat-card-title> <mat-card-title i18n>By Exchange</mat-card-title>
<gf-toggle <gf-toggle
[defaultValue]="period" [defaultValue]="period"
[isLoading]="false" [isLoading]="false"
@ -117,7 +93,7 @@
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
key="currency" key="exchange"
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="true" [isInPercent]="true"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
@ -129,7 +105,7 @@
<div class="col-md-6"> <div class="col-md-6">
<mat-card class="mb-3"> <mat-card class="mb-3">
<mat-card-header class="w-100"> <mat-card-header class="w-100">
<mat-card-title i18n>By Exchange</mat-card-title> <mat-card-title i18n>By Sector</mat-card-title>
<gf-toggle <gf-toggle
[defaultValue]="period" [defaultValue]="period"
[isLoading]="false" [isLoading]="false"
@ -139,11 +115,12 @@
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
key="exchange" key="name"
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="true" [isInPercent]="false"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[positions]="positions" [maxItems]="10"
[positions]="sectors"
></gf-portfolio-proportion-chart> ></gf-portfolio-proportion-chart>
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>

4
libs/common/src/lib/interfaces/portfolio-position.interface.ts

@ -2,6 +2,7 @@ import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';
import { Country } from './country.interface'; import { Country } from './country.interface';
import { Sector } from './sector.interface';
export interface PortfolioPosition { export interface PortfolioPosition {
accounts: { accounts: {
@ -14,7 +15,6 @@ export interface PortfolioPosition {
exchange?: string; exchange?: string;
grossPerformance: number; grossPerformance: number;
grossPerformancePercent: number; grossPerformancePercent: number;
industry?: string;
investment: number; investment: number;
marketChange?: number; marketChange?: number;
marketChangePercent?: number; marketChangePercent?: number;
@ -22,7 +22,7 @@ export interface PortfolioPosition {
marketState: MarketState; marketState: MarketState;
name: string; name: string;
quantity: number; quantity: number;
sector?: string; sectors: Sector[];
transactionCount: number; transactionCount: number;
symbol: string; symbol: string;
type?: string; type?: string;

4
libs/common/src/lib/interfaces/sector.interface.ts

@ -0,0 +1,4 @@
export interface Sector {
name: string;
weight: number;
}

2
prisma/migrations/20210616075245_added_sectors_to_symbol_profile/migration.sql

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "SymbolProfile" ADD COLUMN "sectors" JSONB;

1
prisma/schema.prisma

@ -120,6 +120,7 @@ model SymbolProfile {
name String? name String?
Order Order[] Order Order[]
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
sectors Json?
symbol String symbol String
@@unique([dataSource, symbol]) @@unique([dataSource, symbol])

18
prisma/seed.ts

@ -142,18 +142,21 @@ async function main() {
countries: [{ code: 'US', weight: 1 }], countries: [{ code: 'US', weight: 1 }],
dataSource: DataSource.YAHOO, dataSource: DataSource.YAHOO,
id: '2bd26362-136e-411c-b578-334084b4cdcc', id: '2bd26362-136e-411c-b578-334084b4cdcc',
sectors: [{ name: 'Consumer Cyclical', weight: 1 }],
symbol: 'AMZN' symbol: 'AMZN'
}, },
{ {
countries: null, countries: null,
dataSource: DataSource.YAHOO, dataSource: DataSource.YAHOO,
id: 'fdc42ea6-1321-44f5-9fb0-d7f1f2cf9b1e', id: 'fdc42ea6-1321-44f5-9fb0-d7f1f2cf9b1e',
sectors: null,
symbol: 'BTCUSD' symbol: 'BTCUSD'
}, },
{ {
countries: [{ code: 'US', weight: 1 }], countries: [{ code: 'US', weight: 1 }],
dataSource: DataSource.YAHOO, dataSource: DataSource.YAHOO,
id: 'd1ee9681-fb21-4f99-a3b7-afd4fc04df2e', id: 'd1ee9681-fb21-4f99-a3b7-afd4fc04df2e',
sectors: [{ name: 'Consumer Cyclical', weight: 1 }],
symbol: 'TSLA' symbol: 'TSLA'
}, },
{ {
@ -164,6 +167,21 @@ async function main() {
], ],
dataSource: DataSource.YAHOO, dataSource: DataSource.YAHOO,
id: '7d9c8540-061e-4e7e-b019-0d0f4a84e796', id: '7d9c8540-061e-4e7e-b019-0d0f4a84e796',
sectors: [
{ name: 'Technology', weight: 0.31393799999999955 },
{ name: 'Consumer Cyclical', weight: 0.149224 },
{ name: 'Financials', weight: 0.11716100000000002 },
{ name: 'Healthcare', weight: 0.13285199999999994 },
{ name: 'Consumer Staples', weight: 0.053919000000000016 },
{ name: 'Energy', weight: 0.025529999999999997 },
{ name: 'Telecommunications', weight: 0.012579 },
{ name: 'Industrials', weight: 0.09526399999999995 },
{ name: 'Utilities', weight: 0.024791999999999988 },
{ name: 'Materials', weight: 0.027664 },
{ name: 'Real Estate', weight: 0.03239999999999998 },
{ name: 'Communication', weight: 0.0036139999999999996 },
{ name: 'Other', weight: 0.000218 }
],
symbol: 'VTI' symbol: 'VTI'
} }
], ],

Loading…
Cancel
Save