Feature/add symbol profile model (#148)

* Add symbol profile model and positions by country chart

* Add positions by continent chart

* Fix tests

* Extend seed

* Update changelog
pull/149/head
Thomas 3 years ago committed by GitHub
parent 21504573b4
commit 6a03120225
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -5,6 +5,13 @@ 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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Added
- Added a symbol profile model with additional data
- Added new pie charts: Positions by continent and country
## 1.11.0 - 05.06.2021
### Added

@ -44,6 +44,7 @@ export class ExperimentalService {
fee: 0,
id: undefined,
platformId: undefined,
symbolProfileId: undefined,
type: Type.BUY,
updatedAt: undefined,
userId: undefined

@ -76,7 +76,8 @@ export class PortfolioService {
// Get portfolio from database
const orders = await this.orderService.orders({
include: {
Account: true
Account: true,
SymbolProfile: true
},
orderBy: { date: 'asc' },
where: { userId: aUserId }

@ -1,4 +1,4 @@
import { Account, Currency, Platform } from '@prisma/client';
import { Account, Currency, Platform, SymbolProfile } from '@prisma/client';
import { v4 as uuidv4 } from 'uuid';
import { IOrder } from '../services/interfaces/interfaces';
@ -12,6 +12,7 @@ export class Order {
private id: string;
private quantity: number;
private symbol: string;
private symbolProfile: SymbolProfile;
private total: number;
private type: OrderType;
private unitPrice: number;
@ -24,6 +25,7 @@ export class Order {
this.id = data.id || uuidv4();
this.quantity = data.quantity;
this.symbol = data.symbol;
this.symbolProfile = data.symbolProfile;
this.type = data.type;
this.unitPrice = data.unitPrice;
@ -58,6 +60,10 @@ export class Order {
return this.symbol;
}
getSymbolProfile() {
return this.symbolProfile;
}
public getTotal() {
return this.total;
}

@ -189,6 +189,7 @@ describe('Portfolio', () => {
id: '8d999347-dee2-46ee-88e1-26b344e71fcc',
quantity: 1,
symbol: 'BTCUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 49631.24,
updatedAt: null,
@ -223,6 +224,7 @@ describe('Portfolio', () => {
},
allocationCurrent: 1,
allocationInvestment: 1,
countries: [],
currency: Currency.USD,
exchange: UNKNOWN_KEY,
grossPerformance: 0,
@ -290,6 +292,7 @@ describe('Portfolio', () => {
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
quantity: 0.2,
symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 991.49,
updatedAt: null,
@ -324,6 +327,7 @@ describe('Portfolio', () => {
},
// allocationCurrent: 1,
allocationInvestment: 1,
countries: [],
currency: Currency.USD,
exchange: UNKNOWN_KEY,
// grossPerformance: 0,
@ -385,6 +389,7 @@ describe('Portfolio', () => {
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
quantity: 0.2,
symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 991.49,
updatedAt: null,
@ -401,6 +406,7 @@ describe('Portfolio', () => {
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
quantity: 0.3,
symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 1050,
updatedAt: null,
@ -461,6 +467,7 @@ describe('Portfolio', () => {
id: 'd96795b2-6ae6-420e-aa21-fabe5e45d475',
quantity: 0.05614682,
symbol: 'BTCUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 3562.089535970158,
updatedAt: null,
@ -477,6 +484,7 @@ describe('Portfolio', () => {
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
quantity: 0.2,
symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 991.49,
updatedAt: null,
@ -550,6 +558,7 @@ describe('Portfolio', () => {
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
quantity: 0.2,
symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 991.49,
updatedAt: null,
@ -566,6 +575,7 @@ describe('Portfolio', () => {
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
quantity: 0.1,
symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.SELL,
unitPrice: 1050,
updatedAt: null,
@ -582,6 +592,7 @@ describe('Portfolio', () => {
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
quantity: 0.2,
symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 1050,
updatedAt: null,

@ -8,7 +8,10 @@ import {
Position,
UserWithSettings
} from '@ghostfolio/common/interfaces';
import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { DateRange, OrderWithAccount } from '@ghostfolio/common/types';
import { Prisma } from '@prisma/client';
import { continents, countries } from 'countries-list';
import {
add,
format,
@ -127,6 +130,7 @@ export class Portfolio implements PortfolioInterface {
id,
quantity,
symbol,
symbolProfile,
type,
unitPrice
}) => {
@ -139,6 +143,7 @@ export class Portfolio implements PortfolioInterface {
id,
quantity,
symbol,
symbolProfile,
type,
unitPrice
})
@ -204,6 +209,7 @@ export class Portfolio implements PortfolioInterface {
symbols.forEach((symbol) => {
const accounts: PortfolioPosition['accounts'] = {};
let countriesOfSymbol: Country[];
const [portfolioItem] = portfolioItems;
const ordersBySymbol = this.getOrders().filter((order) => {
@ -243,6 +249,21 @@ export class Portfolio implements PortfolioInterface {
original: originalValueOfSymbol
};
}
countriesOfSymbol = (
(orderOfSymbol.getSymbolProfile()?.countries as Prisma.JsonArray) ??
[]
).map((country) => {
const { code, weight } = country as Prisma.JsonObject;
return {
code: code as string,
continent:
continents[countries[code as string]?.continent] ?? UNKNOWN_KEY,
name: countries[code as string]?.name ?? UNKNOWN_KEY,
weight: weight as number
};
});
});
let now = portfolioItemsNow.positions[symbol].marketPrice;
@ -289,6 +310,7 @@ export class Portfolio implements PortfolioInterface {
) / value,
allocationInvestment:
portfolioItem.positions[symbol].investment / investment,
countries: countriesOfSymbol,
grossPerformance: roundTo(
portfolioItemsNow.positions[symbol].quantity * (now - before),
2
@ -296,7 +318,12 @@ export class Portfolio implements PortfolioInterface {
grossPerformancePercent: roundTo((now - before) / before, 4),
investment: portfolioItem.positions[symbol].investment,
quantity: portfolioItem.positions[symbol].quantity,
transactionCount: portfolioItem.positions[symbol].transactionCount
transactionCount: portfolioItem.positions[symbol].transactionCount,
value: this.exchangeRateDataService.toCurrency(
portfolioItem.positions[symbol].quantity * now,
data[symbol]?.currency,
this.user.Settings.currency
)
};
});
@ -544,6 +571,7 @@ export class Portfolio implements PortfolioInterface {
fee: order.fee,
quantity: order.quantity,
symbol: order.symbol,
symbolProfile: order.SymbolProfile,
type: <OrderType>order.type,
unitPrice: order.unitPrice
})

@ -1,5 +1,5 @@
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { Account, Currency, DataSource } from '@prisma/client';
import { Account, Currency, DataSource, SymbolProfile } from '@prisma/client';
import { OrderType } from '../../models/order-type';
@ -41,6 +41,7 @@ export interface IOrder {
id?: string;
quantity: number;
symbol: string;
symbolProfile: SymbolProfile;
type: OrderType;
unitPrice: number;
}

@ -3,6 +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 } from '@ghostfolio/common/config';
import {
PortfolioItem,
PortfolioPosition,
@ -21,6 +22,12 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
public accounts: {
[symbol: string]: Pick<PortfolioPosition, 'name'> & { value: number };
};
public continents: {
[code: string]: { name: string; value: number };
};
public countries: {
[code: string]: { name: string; value: number };
};
public deviceType: string;
public period = 'current';
public periodOptions: ToggleOption[] = [
@ -97,6 +104,18 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
aPeriod: string
) {
this.accounts = {};
this.continents = {
[UNKNOWN_KEY]: {
name: UNKNOWN_KEY,
value: 0
}
};
this.countries = {
[UNKNOWN_KEY]: {
name: UNKNOWN_KEY,
value: 0
}
};
this.positions = {};
this.positionsArray = [];
@ -122,11 +141,53 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
aPeriod === 'original' ? original : current;
} else {
this.accounts[account] = {
value: aPeriod === 'original' ? original : current,
name: account
name: account,
value: aPeriod === 'original' ? original : current
};
}
}
if (position.countries.length > 0) {
for (const country of position.countries) {
const { code, continent, name, weight } = country;
if (this.continents[continent]?.value) {
this.continents[continent].value += weight * position.value;
} else {
this.continents[continent] = {
name: continent,
value:
weight *
(aPeriod === 'original'
? this.portfolioPositions[symbol].investment
: this.portfolioPositions[symbol].value)
};
}
if (this.countries[code]?.value) {
this.countries[code].value += weight * position.value;
} else {
this.countries[code] = {
name,
value:
weight *
(aPeriod === 'original'
? this.portfolioPositions[symbol].investment
: this.portfolioPositions[symbol].value)
};
}
}
} else {
this.continents[UNKNOWN_KEY].value +=
aPeriod === 'original'
? this.portfolioPositions[symbol].investment
: this.portfolioPositions[symbol].value;
this.countries[UNKNOWN_KEY].value +=
aPeriod === 'original'
? this.portfolioPositions[symbol].investment
: this.portfolioPositions[symbol].value;
}
}
}

@ -102,6 +102,50 @@
</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 Continent</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="name"
[baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="false"
[locale]="user?.settings?.locale"
[positions]="continents"
></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 Country</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="name"
[baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="false"
[locale]="user?.settings?.locale"
[positions]="countries"
></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">

@ -0,0 +1,6 @@
export interface Country {
code: string;
continent: string;
name: string;
weight: number;
}

@ -1,12 +1,15 @@
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
import { Currency } from '@prisma/client';
import { Country } from './country.interface';
export interface PortfolioPosition {
accounts: {
[name: string]: { current: number; original: number };
};
allocationCurrent: number;
allocationInvestment: number;
countries: Country[];
currency: Currency;
exchange?: string;
grossPerformance: number;
@ -24,4 +27,5 @@ export interface PortfolioPosition {
symbol: string;
type?: string;
url?: string;
value: number;
}

@ -1,5 +1,8 @@
import { Account, Order, Platform } from '@prisma/client';
import { Account, Order, Platform, SymbolProfile } from '@prisma/client';
type AccountWithPlatform = Account & { Platform?: Platform };
export type OrderWithAccount = Order & { Account?: AccountWithPlatform };
export type OrderWithAccount = Order & {
Account?: AccountWithPlatform;
SymbolProfile?: SymbolProfile;
};

@ -79,6 +79,7 @@
"cheerio": "1.0.0-rc.6",
"class-transformer": "0.3.2",
"class-validator": "0.13.1",
"countries-list": "2.6.1",
"countup.js": "2.0.7",
"cryptocurrencies": "7.0.0",
"date-fns": "2.19.0",

@ -0,0 +1,21 @@
-- AlterTable
ALTER TABLE "Order" ADD COLUMN "symbolProfileId" TEXT;
-- CreateTable
CREATE TABLE "SymbolProfile" (
"countries" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"dataSource" "DataSource" NOT NULL,
"id" TEXT NOT NULL,
"name" TEXT,
"updatedAt" TIMESTAMP(3) NOT NULL,
"symbol" TEXT NOT NULL,
PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "SymbolProfile.dataSource_symbol_unique" ON "SymbolProfile"("dataSource", "symbol");
-- AddForeignKey
ALTER TABLE "Order" ADD FOREIGN KEY ("symbolProfileId") REFERENCES "SymbolProfile"("id") ON DELETE SET NULL ON UPDATE CASCADE;

@ -59,22 +59,24 @@ model MarketData {
}
model Order {
Account Account? @relation(fields: [accountId, accountUserId], references: [id, userId])
accountId String?
accountUserId String?
createdAt DateTime @default(now())
currency Currency
dataSource DataSource @default(YAHOO)
date DateTime
fee Float
id String @default(uuid())
quantity Float
symbol String
type Type
unitPrice Float
updatedAt DateTime @updatedAt
User User @relation(fields: [userId], references: [id])
userId String
Account Account? @relation(fields: [accountId, accountUserId], references: [id, userId])
accountId String?
accountUserId String?
createdAt DateTime @default(now())
currency Currency
dataSource DataSource @default(YAHOO)
date DateTime
fee Float
id String @default(uuid())
quantity Float
symbol String
SymbolProfile SymbolProfile? @relation(fields: [symbolProfileId], references: [id])
symbolProfileId String?
type Type
unitPrice Float
updatedAt DateTime @updatedAt
User User @relation(fields: [userId], references: [id])
userId String
@@id([id, userId])
}
@ -99,6 +101,19 @@ model Settings {
userId String @id
}
model SymbolProfile {
countries Json?
createdAt DateTime @default(now())
dataSource DataSource
id String @id @default(uuid())
name String?
Order Order[]
updatedAt DateTime @updatedAt
symbol String
@@unique([dataSource, symbol])
}
model Subscription {
createdAt DateTime @default(now())
expiresAt DateTime

@ -1,6 +1,7 @@
import {
AccountType,
Currency,
DataSource,
PrismaClient,
Role,
Type
@ -135,17 +136,47 @@ async function main() {
where: { id: '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f' }
});
await prisma.symbolProfile.createMany({
data: [
{
countries: [{ code: 'US', weight: 1 }],
dataSource: DataSource.YAHOO,
id: '2bd26362-136e-411c-b578-334084b4cdcc',
symbol: 'AMZN'
},
{
countries: [{ code: 'US', weight: 1 }],
dataSource: DataSource.YAHOO,
id: 'd1ee9681-fb21-4f99-a3b7-afd4fc04df2e',
symbol: 'TSLA'
},
{
countries: [
{ code: 'US', weight: 0.9886789999999981 },
{ code: 'NL', weight: 0.000203 },
{ code: 'CA', weight: 0.000362 }
],
dataSource: DataSource.YAHOO,
id: '7d9c8540-061e-4e7e-b019-0d0f4a84e796',
symbol: 'VTI'
}
],
skipDuplicates: true
});
await prisma.order.createMany({
data: [
{
accountId: '65cfb79d-b6c7-4591-9d46-73426bc62094',
accountUserId: userDemo.id,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
date: new Date(Date.UTC(2017, 0, 3, 0, 0, 0)),
fee: 30,
id: 'cf7c0418-8535-4089-ae3d-5dbfa0aec2e1',
quantity: 50,
symbol: 'TSLA',
symbolProfileId: 'd1ee9681-fb21-4f99-a3b7-afd4fc04df2e',
type: Type.BUY,
unitPrice: 42.97,
userId: userDemo.id
@ -154,6 +185,7 @@ async function main() {
accountId: 'd804de69-0429-42dc-b6ca-b308fd7dd926',
accountUserId: userDemo.id,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
date: new Date(Date.UTC(2017, 7, 16, 0, 0, 0)),
fee: 29.9,
id: 'a1c5d73a-8631-44e5-ac44-356827a5212c',
@ -167,11 +199,13 @@ async function main() {
accountId: '480269ce-e12a-4fd1-ac88-c4b0ff3f899c',
accountUserId: userDemo.id,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
date: new Date(Date.UTC(2018, 9, 1, 0, 0, 0)),
fee: 80.79,
id: '71c08e2a-4a86-44ae-a890-c337de5d5f9b',
quantity: 5,
symbol: 'AMZN',
symbolProfileId: '2bd26362-136e-411c-b578-334084b4cdcc',
type: Type.BUY,
unitPrice: 2021.99,
userId: userDemo.id
@ -180,11 +214,13 @@ async function main() {
accountId: '480269ce-e12a-4fd1-ac88-c4b0ff3f899c',
accountUserId: userDemo.id,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
date: new Date(Date.UTC(2019, 2, 1, 0, 0, 0)),
fee: 19.9,
id: '385f2c2c-d53e-4937-b0e5-e92ef6020d4e',
quantity: 10,
symbol: 'VTI',
symbolProfileId: '7d9c8540-061e-4e7e-b019-0d0f4a84e796',
type: Type.BUY,
unitPrice: 144.38,
userId: userDemo.id
@ -193,11 +229,13 @@ async function main() {
accountId: '480269ce-e12a-4fd1-ac88-c4b0ff3f899c',
accountUserId: userDemo.id,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
date: new Date(Date.UTC(2019, 8, 3, 0, 0, 0)),
fee: 19.9,
id: '185f2c2c-d53e-4937-b0e5-a93ef6020d4e',
quantity: 10,
symbol: 'VTI',
symbolProfileId: '7d9c8540-061e-4e7e-b019-0d0f4a84e796',
type: Type.BUY,
unitPrice: 147.99,
userId: userDemo.id
@ -206,11 +244,13 @@ async function main() {
accountId: '480269ce-e12a-4fd1-ac88-c4b0ff3f899c',
accountUserId: userDemo.id,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
date: new Date(Date.UTC(2020, 2, 2, 0, 0, 0)),
fee: 19.9,
id: '347b0430-a84f-4031-a0f9-390399066ad6',
quantity: 10,
symbol: 'VTI',
symbolProfileId: '7d9c8540-061e-4e7e-b019-0d0f4a84e796',
type: Type.BUY,
unitPrice: 151.41,
userId: userDemo.id
@ -219,11 +259,13 @@ async function main() {
accountId: '480269ce-e12a-4fd1-ac88-c4b0ff3f899c',
accountUserId: userDemo.id,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
date: new Date(Date.UTC(2020, 8, 1, 0, 0, 0)),
fee: 19.9,
id: '67ec3f47-3189-4b63-ba05-60d3a06b302f',
quantity: 10,
symbol: 'VTI',
symbolProfileId: '7d9c8540-061e-4e7e-b019-0d0f4a84e796',
type: Type.BUY,
unitPrice: 177.69,
userId: userDemo.id
@ -232,11 +274,13 @@ async function main() {
accountId: '480269ce-e12a-4fd1-ac88-c4b0ff3f899c',
accountUserId: userDemo.id,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
date: new Date(Date.UTC(2020, 2, 1, 0, 0, 0)),
fee: 19.9,
id: 'd01c6fbc-fa8d-47e6-8e80-66f882d2bfd2',
quantity: 10,
symbol: 'VTI',
symbolProfileId: '7d9c8540-061e-4e7e-b019-0d0f4a84e796',
type: Type.BUY,
unitPrice: 203.15,
userId: userDemo.id

@ -4698,6 +4698,11 @@ cosmiconfig@^7.0.0:
path-type "^4.0.0"
yaml "^1.10.0"
countries-list@2.6.1:
version "2.6.1"
resolved "https://registry.yarnpkg.com/countries-list/-/countries-list-2.6.1.tgz#d479757ac873b1e596ccea0a925962d20396c0cb"
integrity sha512-jXM1Nv3U56dPQ1DsUSsEaGmLHburo4fnB7m+1yhWDUVvx5gXCd1ok/y3gXCjXzhqyawG+igcPYcAl4qjkvopaQ==
countup.js@2.0.7:
version "2.0.7"
resolved "https://registry.yarnpkg.com/countup.js/-/countup.js-2.0.7.tgz#56b72a87fc0ee3cadb38356c246ccac88fb0a8cc"

Loading…
Cancel
Save