From d23addb673700bb9d6da406d6dbfdf4aa400d807 Mon Sep 17 00:00:00 2001
From: Valentin Zickner <github@zickner.ch>
Date: Sat, 31 Jul 2021 20:45:12 +0200
Subject: [PATCH] change getDetails to portfolio-calculator.ts

Co-authored-by: Thomas <dotsilver@gmail.com>
---
 .../src/app/core/portfolio-calculator.spec.ts |   7 +
 apps/api/src/app/core/portfolio-calculator.ts |   7 +-
 .../src/app/portfolio/portfolio.controller.ts |   9 +-
 .../api/src/app/portfolio/portfolio.module.ts |   4 +-
 .../src/app/portfolio/portfolio.service.ts    | 191 +++++++++++++++---
 .../interfaces/symbol-profile.interface.ts    |  15 ++
 .../src/services/symbol-profile.service.ts    |  64 ++++++
 7 files changed, 263 insertions(+), 34 deletions(-)
 create mode 100644 apps/api/src/services/interfaces/symbol-profile.interface.ts
 create mode 100644 apps/api/src/services/symbol-profile.service.ts

diff --git a/apps/api/src/app/core/portfolio-calculator.spec.ts b/apps/api/src/app/core/portfolio-calculator.spec.ts
index e2fd3f2a..3ca9e08e 100644
--- a/apps/api/src/app/core/portfolio-calculator.spec.ts
+++ b/apps/api/src/app/core/portfolio-calculator.spec.ts
@@ -637,6 +637,7 @@ describe('PortfolioCalculator', () => {
         currentValue: new Big('657.62'),
         grossPerformance: new Big('-61.84'),
         grossPerformancePercentage: new Big('-0.08595335390431712673'),
+        totalInvestment: new Big('719.46'),
         positions: [
           {
             averagePrice: new Big('719.46'),
@@ -675,6 +676,7 @@ describe('PortfolioCalculator', () => {
         currentValue: new Big('657.62'),
         grossPerformance: new Big('-61.84'),
         grossPerformancePercentage: new Big('-0.08595335390431712673'),
+        totalInvestment: new Big('719.46'),
         positions: [
           {
             averagePrice: new Big('719.46'),
@@ -713,6 +715,7 @@ describe('PortfolioCalculator', () => {
         currentValue: new Big('657.62'),
         grossPerformance: new Big('-9.04'),
         grossPerformancePercentage: new Big('-0.01356013560135601356'),
+        totalInvestment: new Big('719.46'),
         positions: [
           {
             averagePrice: new Big('719.46'),
@@ -751,6 +754,7 @@ describe('PortfolioCalculator', () => {
         currentValue: new Big('4871.5'),
         grossPerformance: new Big('240.4'),
         grossPerformancePercentage: new Big('0.08839407904876477102'),
+        totalInvestment: new Big('4460.95'),
         positions: [
           {
             averagePrice: new Big('178.438'),
@@ -831,6 +835,7 @@ describe('PortfolioCalculator', () => {
         currentValue: new Big('3897.2'),
         grossPerformance: new Big('303.2'),
         grossPerformancePercentage: new Big('0.27537838148272398344'),
+        totalInvestment: new Big('2923.7'),
         positions: [
           {
             averagePrice: new Big('146.185'),
@@ -904,6 +909,7 @@ describe('PortfolioCalculator', () => {
         currentValue: new Big('1192327.999656600298238721'),
         grossPerformance: new Big('92327.999656600898394721'),
         grossPerformancePercentage: new Big('0.09788498099999947809'),
+        totalInvestment: new Big('1100000'),
         positions: [
           {
             averagePrice: new Big('1.01287018290924923237'), // 1'100'000 / 1'086'022.689344542
@@ -992,6 +998,7 @@ describe('PortfolioCalculator', () => {
         currentValue: new Big('517'),
         grossPerformance: new Big('17'), // 517 - 500
         grossPerformancePercentage: new Big('0.034'), // ((200 * 0.025) + (300 * 0.04)) / (200 + 300) = 3.4%
+        totalInvestment: new Big('500'),
         hasErrors: false,
         positions: [
           {
diff --git a/apps/api/src/app/core/portfolio-calculator.ts b/apps/api/src/app/core/portfolio-calculator.ts
index 375819be..438f8639 100644
--- a/apps/api/src/app/core/portfolio-calculator.ts
+++ b/apps/api/src/app/core/portfolio-calculator.ts
@@ -117,6 +117,7 @@ export class PortfolioCalculator {
     grossPerformance: Big;
     grossPerformancePercentage: Big;
     currentValue: Big;
+    totalInvestment: Big;
   }> {
     if (!this.transactionPoints?.length) {
       return {
@@ -124,7 +125,8 @@ export class PortfolioCalculator {
         positions: [],
         grossPerformance: new Big(0),
         grossPerformancePercentage: new Big(0),
-        currentValue: new Big(0)
+        currentValue: new Big(0),
+        totalInvestment: new Big(0)
       };
     }
 
@@ -377,6 +379,7 @@ export class PortfolioCalculator {
   ) {
     let hasErrors = false;
     let currentValue = new Big(0);
+    let totalInvestment = new Big(0);
     let grossPerformance = new Big(0);
     let grossPerformancePercentage = new Big(0);
     let completeInitialValue = new Big(0);
@@ -384,6 +387,7 @@ export class PortfolioCalculator {
       currentValue = currentValue.add(
         new Big(currentPosition.marketPrice).mul(currentPosition.quantity)
       );
+      totalInvestment = totalInvestment.add(currentPosition.investment);
       if (currentPosition.grossPerformance) {
         grossPerformance = grossPerformance.plus(
           currentPosition.grossPerformance
@@ -411,6 +415,7 @@ export class PortfolioCalculator {
     }
     return {
       currentValue,
+      totalInvestment,
       grossPerformance,
       grossPerformancePercentage:
         grossPerformancePercentage.div(completeInitialValue),
diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts
index 729b5213..bbef3b44 100644
--- a/apps/api/src/app/portfolio/portfolio.controller.ts
+++ b/apps/api/src/app/portfolio/portfolio.controller.ts
@@ -149,12 +149,11 @@ export class PortfolioController {
         this.request.user.id
       );
 
-    const portfolio = await this.portfolioService.createPortfolio(
-      impersonationUserId || this.request.user.id
-    );
-
     try {
-      details = await portfolio.getDetails(range);
+      details = await this.portfolioService.getDetails(
+        impersonationUserId,
+        range
+      );
     } catch (error) {
       console.error(error);
 
diff --git a/apps/api/src/app/portfolio/portfolio.module.ts b/apps/api/src/app/portfolio/portfolio.module.ts
index 50bcbd2d..b3bb2509 100644
--- a/apps/api/src/app/portfolio/portfolio.module.ts
+++ b/apps/api/src/app/portfolio/portfolio.module.ts
@@ -20,6 +20,7 @@ import { Module } from '@nestjs/common';
 
 import { PortfolioController } from './portfolio.controller';
 import { PortfolioService } from './portfolio.service';
+import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
 
 @Module({
   imports: [RedisCacheModule],
@@ -35,12 +36,13 @@ import { PortfolioService } from './portfolio.service';
     ExchangeRateDataService,
     GhostfolioScraperApiService,
     ImpersonationService,
+    MarketDataService,
     OrderService,
     PortfolioService,
     PrismaService,
     RakutenRapidApiService,
     RulesService,
-    MarketDataService,
+    SymbolProfileService,
     UserService,
     YahooFinanceService
   ]
diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts
index 280d313a..d8a5c887 100644
--- a/apps/api/src/app/portfolio/portfolio.service.ts
+++ b/apps/api/src/app/portfolio/portfolio.service.ts
@@ -11,17 +11,22 @@ import { Portfolio } from '@ghostfolio/api/models/portfolio';
 import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
 import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
 import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
-import { IOrder } from '@ghostfolio/api/services/interfaces/interfaces';
-import { Type } from '@ghostfolio/api/services/interfaces/interfaces';
+import { IOrder, Type } from '@ghostfolio/api/services/interfaces/interfaces';
 import { RulesService } from '@ghostfolio/api/services/rules.service';
 import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
 import {
   PortfolioItem,
   PortfolioOverview,
   PortfolioPerformance,
-  Position
+  PortfolioPosition,
+  Position,
+  TimelinePosition
 } from '@ghostfolio/common/interfaces';
-import { DateRange, RequestWithUser } from '@ghostfolio/common/types';
+import {
+  DateRange,
+  OrderWithAccount,
+  RequestWithUser
+} from '@ghostfolio/common/types';
 import { Inject, Injectable } from '@nestjs/common';
 import { REQUEST } from '@nestjs/core';
 import { DataSource } from '@prisma/client';
@@ -52,6 +57,10 @@ import {
   HistoricalDataItem,
   PortfolioPositionDetail
 } from './interfaces/portfolio-position-detail.interface';
+import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
+import { UNKNOWN_KEY } from '@ghostfolio/common/config';
+import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
+import { TransactionPoint } from '@ghostfolio/api/app/core/interfaces/transaction-point.interface';
 
 @Injectable()
 export class PortfolioService {
@@ -65,7 +74,8 @@ export class PortfolioService {
     private readonly redisCacheService: RedisCacheService,
     @Inject(REQUEST) private readonly request: RequestWithUser,
     private readonly rulesService: RulesService,
-    private readonly userService: UserService
+    private readonly userService: UserService,
+    private readonly symbolProfileService: SymbolProfileService
   ) {}
 
   public async createPortfolio(aUserId: string): Promise<Portfolio> {
@@ -158,7 +168,7 @@ export class PortfolioService {
       this.request.user.Settings.currency
     );
 
-    const transactionPoints = await this.getTransactionPoints(userId);
+    const { transactionPoints } = await this.getTransactionPoints(userId);
     portfolioCalculator.setTransactionPoints(transactionPoints);
     if (transactionPoints.length === 0) {
       return [];
@@ -221,19 +231,98 @@ export class PortfolioService {
     };
   }
 
+  public async getDetails(
+    aImpersonationId: string,
+    aDateRange: DateRange = 'max'
+  ): Promise<{ [symbol: string]: PortfolioPosition }> {
+    const userId = await this.getUserId(aImpersonationId);
+
+    const userCurrency = this.request.user.Settings.currency;
+    const portfolioCalculator = new PortfolioCalculator(
+      this.currentRateService,
+      userCurrency
+    );
+
+    const { transactionPoints, orders } = await this.getTransactionPoints(
+      userId
+    );
+
+    if (transactionPoints?.length <= 0) {
+      return {};
+    }
+
+    portfolioCalculator.setTransactionPoints(transactionPoints);
+
+    const portfolioStart = parseDate(transactionPoints[0].date);
+    const startDate = this.getStartDate(aDateRange, portfolioStart);
+    const currentPositions = await portfolioCalculator.getCurrentPositions(
+      startDate
+    );
+
+    if (currentPositions.hasErrors) {
+      throw new Error('Missing information');
+    }
+
+    const result: { [symbol: string]: PortfolioPosition } = {};
+    const totalValue = currentPositions.currentValue;
+
+    const symbols = currentPositions.positions.map(
+      (position) => position.symbol
+    );
+
+    const [dataProviderResponses, symbolProfiles] = await Promise.all([
+      this.dataProviderService.get(symbols),
+      this.symbolProfileService.getSymbolProfiles(symbols)
+    ]);
+
+    const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
+    for (const symbolProfile of symbolProfiles) {
+      symbolProfileMap[symbolProfile.symbol] = symbolProfile;
+    }
+
+    const portfolioItemsNow: { [symbol: string]: TimelinePosition } = {};
+    for (const position of currentPositions.positions) {
+      portfolioItemsNow[position.symbol] = position;
+    }
+    const accounts = this.getAccounts(orders, portfolioItemsNow, userCurrency);
+
+    for (const item of currentPositions.positions) {
+      const value = item.quantity.mul(item.marketPrice);
+      const symbolProfile = symbolProfileMap[item.symbol];
+      const dataProviderResponse = dataProviderResponses[item.symbol];
+      result[item.symbol] = {
+        accounts,
+        allocationCurrent: value.div(totalValue).toNumber(),
+        allocationInvestment: item.investment
+          .div(currentPositions.totalInvestment)
+          .toNumber(),
+        countries: symbolProfile.countries,
+        currency: item.currency,
+        exchange: dataProviderResponse.exchange,
+        grossPerformance: item.grossPerformance.toNumber(),
+        grossPerformancePercent: item.grossPerformancePercentage.toNumber(),
+        investment: item.investment.toNumber(),
+        marketPrice: item.marketPrice,
+        marketState: dataProviderResponse.marketState,
+        name: item.name,
+        quantity: item.quantity.toNumber(),
+        sectors: symbolProfile.sectors,
+        symbol: item.symbol,
+        transactionCount: item.transactionCount,
+        type: dataProviderResponse.type,
+        value: value.toNumber()
+      };
+    }
+
+    return result;
+  }
+
   public async getPosition(
     aImpersonationId: string,
     aSymbol: string
   ): Promise<PortfolioPositionDetail> {
-    const impersonationUserId =
-      await this.impersonationService.validateImpersonationId(
-        aImpersonationId,
-        this.request.user.id
-      );
-
-    const portfolio = await this.createPortfolio(
-      impersonationUserId || this.request.user.id
-    );
+    const userId = await this.getUserId(aImpersonationId);
+    const portfolio = await this.createPortfolio(userId);
 
     const position = portfolio.getPositions(new Date())[aSymbol];
 
@@ -396,20 +485,14 @@ export class PortfolioService {
     aImpersonationId: string,
     aDateRange: DateRange = 'max'
   ): Promise<{ hasErrors: boolean; positions: Position[] }> {
-    const impersonationUserId =
-      await this.impersonationService.validateImpersonationId(
-        aImpersonationId,
-        this.request.user.id
-      );
-
-    const userId = impersonationUserId || this.request.user.id;
+    const userId = await this.getUserId(aImpersonationId);
 
     const portfolioCalculator = new PortfolioCalculator(
       this.currentRateService,
       this.request.user.Settings.currency
     );
 
-    const transactionPoints = await this.getTransactionPoints(userId);
+    const { transactionPoints } = await this.getTransactionPoints(userId);
 
     if (transactionPoints?.length <= 0) {
       return {
@@ -461,7 +544,7 @@ export class PortfolioService {
       this.request.user.Settings.currency
     );
 
-    const transactionPoints = await this.getTransactionPoints(userId);
+    const { transactionPoints } = await this.getTransactionPoints(userId);
 
     if (transactionPoints?.length <= 0) {
       return {
@@ -521,11 +604,14 @@ export class PortfolioService {
     return portfolioStart;
   }
 
-  private async getTransactionPoints(userId: string) {
+  private async getTransactionPoints(userId: string): Promise<{
+    transactionPoints: TransactionPoint[];
+    orders: OrderWithAccount[];
+  }> {
     const orders = await this.getOrders(userId);
 
     if (orders.length <= 0) {
-      return [];
+      return { transactionPoints: [], orders: [] };
     }
 
     const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
@@ -543,7 +629,10 @@ export class PortfolioService {
       this.request.user.Settings.currency
     );
     portfolioCalculator.computeTransactionPoints(portfolioOrders);
-    return portfolioCalculator.getTransactionPoints();
+    return {
+      transactionPoints: portfolioCalculator.getTransactionPoints(),
+      orders
+    };
   }
 
   private convertDateRangeToDate(aDateRange: DateRange, aMinDate: Date) {
@@ -593,6 +682,44 @@ export class PortfolioService {
     }
   }
 
+  private getAccounts(
+    orders: OrderWithAccount[],
+    portfolioItemsNow: { [p: string]: TimelinePosition },
+    userCurrency
+  ) {
+    const accounts: PortfolioPosition['accounts'] = {};
+    for (const order of orders) {
+      let currentValueOfSymbol = this.exchangeRateDataService.toCurrency(
+        order.quantity * portfolioItemsNow[order.symbol].marketPrice,
+        order.currency,
+        userCurrency
+      );
+      let originalValueOfSymbol = this.exchangeRateDataService.toCurrency(
+        order.quantity * order.unitPrice,
+        order.currency,
+        userCurrency
+      );
+
+      if (order.type === 'SELL') {
+        currentValueOfSymbol *= -1;
+        originalValueOfSymbol *= -1;
+      }
+
+      if (accounts[order.Account?.name || UNKNOWN_KEY]?.current) {
+        accounts[order.Account?.name || UNKNOWN_KEY].current +=
+          currentValueOfSymbol;
+        accounts[order.Account?.name || UNKNOWN_KEY].original +=
+          originalValueOfSymbol;
+      } else {
+        accounts[order.Account?.name || UNKNOWN_KEY] = {
+          current: currentValueOfSymbol,
+          original: originalValueOfSymbol
+        };
+      }
+    }
+    return accounts;
+  }
+
   private getOrders(aUserId: string) {
     return this.orderService.orders({
       include: {
@@ -605,4 +732,14 @@ export class PortfolioService {
       where: { userId: aUserId }
     });
   }
+
+  private async getUserId(aImpersonationId: string) {
+    const impersonationUserId =
+      await this.impersonationService.validateImpersonationId(
+        aImpersonationId,
+        this.request.user.id
+      );
+
+    return impersonationUserId || this.request.user.id;
+  }
 }
diff --git a/apps/api/src/services/interfaces/symbol-profile.interface.ts b/apps/api/src/services/interfaces/symbol-profile.interface.ts
new file mode 100644
index 00000000..f6f83c3c
--- /dev/null
+++ b/apps/api/src/services/interfaces/symbol-profile.interface.ts
@@ -0,0 +1,15 @@
+import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
+import { Country } from '@ghostfolio/common/interfaces/country.interface';
+import { Currency, DataSource } from '@prisma/client';
+
+export interface EnhancedSymbolProfile {
+  createdAt: Date;
+  currency: Currency | null;
+  dataSource: DataSource;
+  id: string;
+  name: string | null;
+  updatedAt: Date;
+  symbol: string;
+  countries: Country[];
+  sectors: Sector[];
+}
diff --git a/apps/api/src/services/symbol-profile.service.ts b/apps/api/src/services/symbol-profile.service.ts
new file mode 100644
index 00000000..4af066d7
--- /dev/null
+++ b/apps/api/src/services/symbol-profile.service.ts
@@ -0,0 +1,64 @@
+import { PrismaService } from '@ghostfolio/api/services/prisma.service';
+import { Injectable } from '@nestjs/common';
+import { Prisma, SymbolProfile } from '@prisma/client';
+import { continents, countries } from 'countries-list';
+import { UNKNOWN_KEY } from '@ghostfolio/common/config';
+import { Country } from '@ghostfolio/common/interfaces/country.interface';
+import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
+import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
+
+@Injectable()
+export class SymbolProfileService {
+  constructor(private prisma: PrismaService) {}
+
+  public async getSymbolProfiles(
+    symbols: string[]
+  ): Promise<EnhancedSymbolProfile[]> {
+    return this.prisma.symbolProfile
+      .findMany({
+        where: {
+          symbol: {
+            in: symbols
+          }
+        }
+      })
+      .then((symbolProfiles) => this.getSymbols(symbolProfiles));
+  }
+
+  private getSymbols(symbolProfiles: SymbolProfile[]): EnhancedSymbolProfile[] {
+    return symbolProfiles.map((symbolProfile) => ({
+      ...symbolProfile,
+      countries: this.getCountries(symbolProfile),
+      sectors: this.getSectors(symbolProfile)
+    }));
+  }
+
+  private getCountries(symbolProfile: SymbolProfile): Country[] {
+    return ((symbolProfile?.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
+        };
+      }
+    );
+  }
+
+  private getSectors(symbolProfile: SymbolProfile): Sector[] {
+    return ((symbolProfile?.sectors as Prisma.JsonArray) ?? []).map(
+      (sector) => {
+        const { name, weight } = sector as Prisma.JsonObject;
+
+        return {
+          name: (name as string) ?? UNKNOWN_KEY,
+          weight: weight as number
+        };
+      }
+    );
+  }
+}