From 6355fdb3bad8f78060492eb304fb68cbba4d4574 Mon Sep 17 00:00:00 2001 From: Thomas <4159106+dtslvr@users.noreply.github.com> Date: Sun, 27 Jun 2021 22:46:06 +0200 Subject: [PATCH] Initial implementation --- .../app/experimental/experimental.service.ts | 1 + apps/api/src/app/order/order.controller.ts | 8 +- .../src/app/portfolio/portfolio.service.ts | 58 ++-- apps/api/src/models/order.ts | 6 + apps/api/src/models/portfolio.ts | 255 ++++++++++-------- .../src/services/data-gathering.service.ts | 10 +- .../api/src/services/interfaces/interfaces.ts | 1 + .../investment-chart.component.ts | 26 +- .../transactions-table.component.html | 15 +- apps/client/src/styles/bootstrap.scss | 2 +- .../migration.sql | 2 + prisma/schema.prisma | 1 + 12 files changed, 242 insertions(+), 143 deletions(-) create mode 100644 prisma/migrations/20210627204133_added_is_draft_to_orders/migration.sql diff --git a/apps/api/src/app/experimental/experimental.service.ts b/apps/api/src/app/experimental/experimental.service.ts index b0d41b867..a7bf50bce 100644 --- a/apps/api/src/app/experimental/experimental.service.ts +++ b/apps/api/src/app/experimental/experimental.service.ts @@ -43,6 +43,7 @@ export class ExperimentalService { date: parseISO(order.date), fee: 0, id: undefined, + isDraft: false, platformId: undefined, symbolProfileId: undefined, type: Type.BUY, diff --git a/apps/api/src/app/order/order.controller.ts b/apps/api/src/app/order/order.controller.ts index a386898a2..ea23e6d5b 100644 --- a/apps/api/src/app/order/order.controller.ts +++ b/apps/api/src/app/order/order.controller.ts @@ -22,7 +22,7 @@ import { import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; import { Order as OrderModel } from '@prisma/client'; -import { parseISO } from 'date-fns'; +import { endOfToday, isAfter, parseISO } from 'date-fns'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { CreateOrderDto } from './create-order.dto'; @@ -129,6 +129,8 @@ export class OrderController { const accountId = data.accountId; delete data.accountId; + const isDraft = isAfter(date, endOfToday()); + return this.orderService.createOrder( { ...data, @@ -138,6 +140,7 @@ export class OrderController { } }, date, + isDraft, SymbolProfile: { connectOrCreate: { where: { @@ -192,11 +195,14 @@ export class OrderController { const accountId = data.accountId; delete data.accountId; + const isDraft = isAfter(date, endOfToday()); + return this.orderService.updateOrder( { data: { ...data, date, + isDraft, Account: { connect: { id_userId: { id: accountId, userId: this.request.user.id } diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index ac7c6bf32..02762c48e 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -14,11 +14,13 @@ import { REQUEST } from '@nestjs/core'; import { DataSource } from '@prisma/client'; import { add, + endOfToday, format, getDate, getMonth, getYear, isAfter, + isBefore, isSameDay, parse, parseISO, @@ -26,6 +28,7 @@ import { setMonth, sub } from 'date-fns'; +import { port } from 'envalid'; import { isEmpty } from 'lodash'; import * as roundTo from 'round-to'; @@ -52,7 +55,7 @@ export class PortfolioService { public async createPortfolio(aUserId: string): Promise { let portfolio: Portfolio; - let stringifiedPortfolio = await this.redisCacheService.get( + const stringifiedPortfolio = await this.redisCacheService.get( `${aUserId}.portfolio` ); @@ -63,9 +66,8 @@ export class PortfolioService { const { orders, portfolioItems - }: { orders: IOrder[]; portfolioItems: PortfolioItem[] } = JSON.parse( - stringifiedPortfolio - ); + }: { orders: IOrder[]; portfolioItems: PortfolioItem[] } = + JSON.parse(stringifiedPortfolio); portfolio = new Portfolio( this.dataProviderService, @@ -104,15 +106,21 @@ export class PortfolioService { } // Enrich portfolio with current data - return await portfolio.addCurrentPortfolioItems(); + await portfolio.addCurrentPortfolioItems(); + + // Enrich portfolio with future data + await portfolio.addFuturePortfolioItems(); + + return portfolio; } public async findAll(aImpersonationId: string): Promise { try { - const impersonationUserId = await this.impersonationService.validateImpersonationId( - aImpersonationId, - this.request.user.id - ); + const impersonationUserId = + await this.impersonationService.validateImpersonationId( + aImpersonationId, + this.request.user.id + ); const portfolio = await this.createPortfolio( impersonationUserId || this.request.user.id @@ -127,10 +135,11 @@ export class PortfolioService { aImpersonationId: string, aDateRange: DateRange = 'max' ): Promise { - const impersonationUserId = await this.impersonationService.validateImpersonationId( - aImpersonationId, - this.request.user.id - ); + const impersonationUserId = + await this.impersonationService.validateImpersonationId( + aImpersonationId, + this.request.user.id + ); const portfolio = await this.createPortfolio( impersonationUserId || this.request.user.id @@ -148,6 +157,11 @@ export class PortfolioService { return portfolio .get() .filter((portfolioItem) => { + if (isAfter(parseISO(portfolioItem.date), endOfToday())) { + // Filter out future dates + return false; + } + if (dateRangeDate === undefined) { return true; } @@ -170,10 +184,11 @@ export class PortfolioService { public async getOverview( aImpersonationId: string ): Promise { - const impersonationUserId = await this.impersonationService.validateImpersonationId( - aImpersonationId, - this.request.user.id - ); + const impersonationUserId = + await this.impersonationService.validateImpersonationId( + aImpersonationId, + this.request.user.id + ); const portfolio = await this.createPortfolio( impersonationUserId || this.request.user.id @@ -195,10 +210,11 @@ export class PortfolioService { aImpersonationId: string, aSymbol: string ): Promise { - const impersonationUserId = await this.impersonationService.validateImpersonationId( - aImpersonationId, - this.request.user.id - ); + const impersonationUserId = + await this.impersonationService.validateImpersonationId( + aImpersonationId, + this.request.user.id + ); const portfolio = await this.createPortfolio( impersonationUserId || this.request.user.id diff --git a/apps/api/src/models/order.ts b/apps/api/src/models/order.ts index 0a741ddc6..c08febcd4 100644 --- a/apps/api/src/models/order.ts +++ b/apps/api/src/models/order.ts @@ -10,6 +10,7 @@ export class Order { private fee: number; private date: string; private id: string; + private isDraft: boolean; private quantity: number; private symbol: string; private symbolProfile: SymbolProfile; @@ -23,6 +24,7 @@ export class Order { this.fee = data.fee; this.date = data.date; this.id = data.id || uuidv4(); + this.isDraft = data.isDraft ?? false; this.quantity = data.quantity; this.symbol = data.symbol; this.symbolProfile = data.symbolProfile; @@ -52,6 +54,10 @@ export class Order { return this.id; } + public getIsDraft() { + return this.isDraft; + } + public getQuantity() { return this.quantity; } diff --git a/apps/api/src/models/portfolio.ts b/apps/api/src/models/portfolio.ts index d081fd2c5..7a4e450df 100644 --- a/apps/api/src/models/portfolio.ts +++ b/apps/api/src/models/portfolio.ts @@ -73,7 +73,7 @@ export class Portfolio implements PortfolioInterface { const [portfolioItemsYesterday] = this.get(yesterday); - let positions: { [symbol: string]: Position } = {}; + const positions: { [symbol: string]: Position } = {}; this.getSymbols().forEach((symbol) => { positions[symbol] = { @@ -105,14 +105,49 @@ export class Portfolio implements PortfolioInterface { ); // Set value after pushing today's portfolio items - this.portfolioItems[portfolioItemsLength - 1].value = this.getValue( - today - ); + this.portfolioItems[portfolioItemsLength - 1].value = + this.getValue(today); } return this; } + public async addFuturePortfolioItems() { + let investment = this.getInvestment(new Date()); + + this.getOrders() + .filter((order) => order.getIsDraft() === true) + .forEach((order) => { + const portfolioItem = this.portfolioItems.find((item) => { + return item.date === order.getDate(); + }); + + if (portfolioItem) { + portfolioItem.investment += this.exchangeRateDataService.toCurrency( + order.getTotal(), + order.getCurrency(), + this.user.Settings.currency + ); + } else { + investment += this.exchangeRateDataService.toCurrency( + order.getTotal(), + order.getCurrency(), + this.user.Settings.currency + ); + + this.portfolioItems.push({ + investment, + date: order.getDate(), + grossPerformancePercent: 0, + positions: {}, + value: 0 + }); + } + }); + + return this; + } + public createFromData({ orders, portfolioItems, @@ -129,6 +164,7 @@ export class Portfolio implements PortfolioInterface { fee, date, id, + isDraft, quantity, symbol, symbolProfile, @@ -142,6 +178,7 @@ export class Portfolio implements PortfolioInterface { fee, date, id, + isDraft, quantity, symbol, symbolProfile, @@ -178,9 +215,12 @@ export class Portfolio implements PortfolioInterface { if (filteredPortfolio) { return [cloneDeep(filteredPortfolio)]; } + + return []; } return cloneDeep(this.portfolioItems); + // return []; } public getCommittedFunds() { @@ -239,12 +279,10 @@ export class Portfolio implements PortfolioInterface { if ( accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY]?.current ) { - accounts[ - orderOfSymbol.getAccount()?.name || UNKNOWN_KEY - ].current += currentValueOfSymbol; - accounts[ - orderOfSymbol.getAccount()?.name || UNKNOWN_KEY - ].original += originalValueOfSymbol; + accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY].current += + currentValueOfSymbol; + accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY].original += + originalValueOfSymbol; } else { accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY] = { current: currentValueOfSymbol, @@ -365,7 +403,11 @@ export class Portfolio implements PortfolioInterface { } public getMinDate() { - if (this.orders.length > 0) { + const orders = this.getOrders().filter( + (order) => order.getIsDraft() === false + ); + + if (orders.length > 0) { return new Date(this.orders[0].getDate()); } @@ -492,9 +534,11 @@ export class Portfolio implements PortfolioInterface { } } } else { - symbols = this.orders.map((order) => { - return order.getSymbol(); - }); + symbols = this.orders + .filter((order) => order.getIsDraft() === false) + .map((order) => { + return order.getSymbol(); + }); } // unique values @@ -503,7 +547,9 @@ export class Portfolio implements PortfolioInterface { public getTotalBuy() { return this.orders - .filter((order) => order.getType() === 'BUY') + .filter( + (order) => order.getIsDraft() === false && order.getType() === 'BUY' + ) .map((order) => { return this.exchangeRateDataService.toCurrency( order.getTotal(), @@ -516,7 +562,9 @@ export class Portfolio implements PortfolioInterface { public getTotalSell() { return this.orders - .filter((order) => order.getType() === 'SELL') + .filter( + (order) => order.getIsDraft() === false && order.getType() === 'SELL' + ) .map((order) => { return this.exchangeRateDataService.toCurrency( order.getTotal(), @@ -583,6 +631,7 @@ export class Portfolio implements PortfolioInterface { currency: order.currency, date: order.date.toISOString(), fee: order.fee, + isDraft: order.isDraft, quantity: order.quantity, symbol: order.symbol, symbolProfile: order.SymbolProfile, @@ -686,10 +735,10 @@ export class Portfolio implements PortfolioInterface { this.portfolioItems.push( cloneDeep({ + positions, date: yesterday.toISOString(), grossPerformancePercent: 0, investment: 0, - positions: positions, value: 0 }) ); @@ -746,8 +795,6 @@ export class Portfolio implements PortfolioInterface { } private updatePortfolioItems() { - // console.time('update-portfolio-items'); - let currentDate = new Date(); const year = getYear(currentDate); @@ -771,107 +818,99 @@ export class Portfolio implements PortfolioInterface { } this.orders.forEach((order) => { - let index = this.portfolioItems.findIndex((item) => { - const dateOfOrder = setDate(parseISO(order.getDate()), 1); - return isSameDay(parseISO(item.date), dateOfOrder); - }); - - if (index === -1) { - // if not found, we only have one order, which means we do not loop below - index = 0; - } - - for (let i = index; i < this.portfolioItems.length; i++) { - // Set currency - this.portfolioItems[i].positions[ - order.getSymbol() - ].currency = order.getCurrency(); + if (order.getIsDraft() === false) { + let index = this.portfolioItems.findIndex((item) => { + const dateOfOrder = setDate(parseISO(order.getDate()), 1); + return isSameDay(parseISO(item.date), dateOfOrder); + }); - this.portfolioItems[i].positions[ - order.getSymbol() - ].transactionCount += 1; + if (index === -1) { + // if not found, we only have one order, which means we do not loop below + index = 0; + } - if (order.getType() === 'BUY') { - if ( - !this.portfolioItems[i].positions[order.getSymbol()].firstBuyDate - ) { - this.portfolioItems[i].positions[ - order.getSymbol() - ].firstBuyDate = resetHours( - parseISO(order.getDate()) - ).toISOString(); - } + for (let i = index; i < this.portfolioItems.length; i++) { + // Set currency + this.portfolioItems[i].positions[order.getSymbol()].currency = + order.getCurrency(); this.portfolioItems[i].positions[ order.getSymbol() - ].quantity += order.getQuantity(); - this.portfolioItems[i].positions[ - order.getSymbol() - ].investment += this.exchangeRateDataService.toCurrency( - order.getTotal(), - order.getCurrency(), - this.user.Settings.currency - ); - this.portfolioItems[i].positions[ - order.getSymbol() - ].investmentInOriginalCurrency += order.getTotal(); - - this.portfolioItems[ - i - ].investment += this.exchangeRateDataService.toCurrency( - order.getTotal(), - order.getCurrency(), - this.user.Settings.currency - ); - } else if (order.getType() === 'SELL') { - this.portfolioItems[i].positions[ - order.getSymbol() - ].quantity -= order.getQuantity(); - - if ( - this.portfolioItems[i].positions[order.getSymbol()].quantity === 0 - ) { - this.portfolioItems[i].positions[order.getSymbol()].investment = 0; - this.portfolioItems[i].positions[ - order.getSymbol() - ].investmentInOriginalCurrency = 0; - } else { + ].transactionCount += 1; + + if (order.getType() === 'BUY') { + if ( + !this.portfolioItems[i].positions[order.getSymbol()].firstBuyDate + ) { + this.portfolioItems[i].positions[order.getSymbol()].firstBuyDate = + resetHours(parseISO(order.getDate())).toISOString(); + } + + this.portfolioItems[i].positions[order.getSymbol()].quantity += + order.getQuantity(); + this.portfolioItems[i].positions[order.getSymbol()].investment += + this.exchangeRateDataService.toCurrency( + order.getTotal(), + order.getCurrency(), + this.user.Settings.currency + ); this.portfolioItems[i].positions[ order.getSymbol() - ].investment -= this.exchangeRateDataService.toCurrency( - order.getTotal(), - order.getCurrency(), - this.user.Settings.currency - ); - this.portfolioItems[i].positions[ - order.getSymbol() - ].investmentInOriginalCurrency -= order.getTotal(); + ].investmentInOriginalCurrency += order.getTotal(); + + this.portfolioItems[i].investment += + this.exchangeRateDataService.toCurrency( + order.getTotal(), + order.getCurrency(), + this.user.Settings.currency + ); + } else if (order.getType() === 'SELL') { + this.portfolioItems[i].positions[order.getSymbol()].quantity -= + order.getQuantity(); + + if ( + this.portfolioItems[i].positions[order.getSymbol()].quantity === 0 + ) { + this.portfolioItems[i].positions[ + order.getSymbol() + ].investment = 0; + this.portfolioItems[i].positions[ + order.getSymbol() + ].investmentInOriginalCurrency = 0; + } else { + this.portfolioItems[i].positions[order.getSymbol()].investment -= + this.exchangeRateDataService.toCurrency( + order.getTotal(), + order.getCurrency(), + this.user.Settings.currency + ); + this.portfolioItems[i].positions[ + order.getSymbol() + ].investmentInOriginalCurrency -= order.getTotal(); + } + + this.portfolioItems[i].investment -= + this.exchangeRateDataService.toCurrency( + order.getTotal(), + order.getCurrency(), + this.user.Settings.currency + ); } - this.portfolioItems[ - i - ].investment -= this.exchangeRateDataService.toCurrency( - order.getTotal(), - order.getCurrency(), - this.user.Settings.currency - ); - } - - this.portfolioItems[i].positions[order.getSymbol()].averagePrice = - this.portfolioItems[i].positions[order.getSymbol()] - .investmentInOriginalCurrency / - this.portfolioItems[i].positions[order.getSymbol()].quantity; + this.portfolioItems[i].positions[order.getSymbol()].averagePrice = + this.portfolioItems[i].positions[order.getSymbol()] + .investmentInOriginalCurrency / + this.portfolioItems[i].positions[order.getSymbol()].quantity; - const currentValue = this.getValue( - parseISO(this.portfolioItems[i].date) - ); + const currentValue = this.getValue( + parseISO(this.portfolioItems[i].date) + ); - this.portfolioItems[i].grossPerformancePercent = - currentValue / this.portfolioItems[i].investment - 1 || 0; - this.portfolioItems[i].value = currentValue; + this.portfolioItems[i].grossPerformancePercent = + currentValue / this.portfolioItems[i].investment - 1 || 0; + this.portfolioItems[i].value = currentValue; + } } }); - - // console.timeEnd('update-portfolio-items'); } } diff --git a/apps/api/src/services/data-gathering.service.ts b/apps/api/src/services/data-gathering.service.ts index 96ba41735..9486268fb 100644 --- a/apps/api/src/services/data-gathering.service.ts +++ b/apps/api/src/services/data-gathering.service.ts @@ -224,7 +224,10 @@ export class DataGatheringService { const distinctOrders = await this.prisma.order.findMany({ distinct: ['symbol'], orderBy: [{ symbol: 'asc' }], - select: { dataSource: true, symbol: true } + select: { dataSource: true, symbol: true }, + where: { + isDraft: false + } }); const distinctOrdersWithDate: IDataGatheringItem[] = distinctOrders @@ -280,7 +283,10 @@ export class DataGatheringService { const distinctOrders = await this.prisma.order.findMany({ distinct: ['symbol'], orderBy: [{ date: 'asc' }], - select: { dataSource: true, date: true, symbol: true } + select: { dataSource: true, date: true, symbol: true }, + where: { + isDraft: false + } }); return [ diff --git a/apps/api/src/services/interfaces/interfaces.ts b/apps/api/src/services/interfaces/interfaces.ts index b909c7e78..0cd3f1034 100644 --- a/apps/api/src/services/interfaces/interfaces.ts +++ b/apps/api/src/services/interfaces/interfaces.ts @@ -22,6 +22,7 @@ export interface IOrder { date: string; fee: number; id?: string; + isDraft: boolean; quantity: number; symbol: string; symbolProfile: SymbolProfile; diff --git a/apps/client/src/app/components/investment-chart/investment-chart.component.ts b/apps/client/src/app/components/investment-chart/investment-chart.component.ts index 38b787147..0dec440b5 100644 --- a/apps/client/src/app/components/investment-chart/investment-chart.component.ts +++ b/apps/client/src/app/components/investment-chart/investment-chart.component.ts @@ -19,6 +19,7 @@ import { TimeScale } from 'chart.js'; import { Chart } from 'chart.js'; +import { addMonths, parseISO, subMonths } from 'date-fns'; @Component({ selector: 'gf-investment-chart', @@ -52,9 +53,30 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit { } } + public ngOnDestroy() { + this.chart?.destroy(); + } + private initialize() { this.isLoading = true; + if (this.portfolioItems?.length > 0) { + // Extend chart by three months (before) + const firstItem = this.portfolioItems[0]; + this.portfolioItems.unshift({ + ...firstItem, + date: subMonths(parseISO(firstItem.date), 3).toISOString(), + investment: 0 + }); + + // Extend chart by three months (after) + const lastItem = this.portfolioItems[this.portfolioItems.length - 1]; + this.portfolioItems.push({ + ...lastItem, + date: addMonths(parseISO(lastItem.date), 3).toISOString() + }); + } + const data = { labels: this.portfolioItems.map((position) => { return position.date; @@ -122,8 +144,4 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit { } } } - - public ngOnDestroy() { - this.chart?.destroy(); - } } diff --git a/apps/client/src/app/components/transactions-table/transactions-table.component.html b/apps/client/src/app/components/transactions-table/transactions-table.component.html index e8da18858..455555648 100644 --- a/apps/client/src/app/components/transactions-table/transactions-table.component.html +++ b/apps/client/src/app/components/transactions-table/transactions-table.component.html @@ -100,24 +100,27 @@ Symbol - {{ element.symbol | gfSymbol }} +
+ {{ element.symbol | gfSymbol }} + Draft +
Currency -
- {{ element.currency }} -
+ {{ element.currency }}
diff --git a/apps/client/src/styles/bootstrap.scss b/apps/client/src/styles/bootstrap.scss index f2792d01a..3835c6b62 100644 --- a/apps/client/src/styles/bootstrap.scss +++ b/apps/client/src/styles/bootstrap.scss @@ -27,7 +27,7 @@ // @import '~bootstrap/scss/card'; // @import '~bootstrap/scss/breadcrumb'; // @import '~bootstrap/scss/pagination'; -// @import '~bootstrap/scss/badge'; +@import '~bootstrap/scss/badge'; // @import '~bootstrap/scss/jumbotron'; // @import '~bootstrap/scss/alert'; // @import '~bootstrap/scss/progress'; diff --git a/prisma/migrations/20210627204133_added_is_draft_to_orders/migration.sql b/prisma/migrations/20210627204133_added_is_draft_to_orders/migration.sql new file mode 100644 index 000000000..51970ec3c --- /dev/null +++ b/prisma/migrations/20210627204133_added_is_draft_to_orders/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Order" ADD COLUMN "isDraft" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 85b89c75e..88e023f69 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -79,6 +79,7 @@ model Order { date DateTime fee Float id String @default(uuid()) + isDraft Boolean @default(false) quantity Float symbol String SymbolProfile SymbolProfile? @relation(fields: [symbolProfileId], references: [id])