From 1c2ca5b96b2158accc410052e7df7c831ab07faf Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Mon, 22 Nov 2021 21:28:32 +0100 Subject: [PATCH] Feature/accentuate all time high and low (#428) * Accentuate all time high and all time low * Update changelog Co-authored-by: Valentin Zickner --- CHANGELOG.md | 6 ++ .../portfolio-position-detail.interface.ts | 6 ++ .../interfaces/timeline-info.interface.ts | 8 +++ .../portfolio/portfolio-calculator.spec.ts | 63 ++++++++-------- .../src/app/portfolio/portfolio-calculator.ts | 72 ++++++++++++++++--- .../src/app/portfolio/portfolio.controller.ts | 18 +++-- .../src/app/portfolio/portfolio.service.ts | 61 ++++++++++++++-- .../portfolio-performance.component.html | 10 ++- .../portfolio-performance.component.ts | 2 + .../src/app/pages/home/home-page.component.ts | 6 +- apps/client/src/app/pages/home/home-page.html | 2 + .../src/app/pages/zen/zen-page.component.ts | 6 +- apps/client/src/app/pages/zen/zen-page.html | 2 + apps/client/src/app/services/data.service.ts | 16 ++--- libs/common/src/lib/interfaces/index.ts | 2 + .../interfaces/portfolio-chart.interface.ts | 7 ++ .../portfolio-performance.interface.ts | 2 + 17 files changed, 221 insertions(+), 68 deletions(-) create mode 100644 apps/api/src/app/portfolio/interfaces/timeline-info.interface.ts create mode 100644 libs/common/src/lib/interfaces/portfolio-chart.interface.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b55d4624f..d3e5669e9 100644 --- a/CHANGELOG.md +++ b/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/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +- Accentuated the all time high and the all time low + ## 1.79.0 - 21.11.2021 ### Added diff --git a/apps/api/src/app/portfolio/interfaces/portfolio-position-detail.interface.ts b/apps/api/src/app/portfolio/interfaces/portfolio-position-detail.interface.ts index c3824802c..336e5a2d4 100644 --- a/apps/api/src/app/portfolio/interfaces/portfolio-position-detail.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/portfolio-position-detail.interface.ts @@ -21,6 +21,12 @@ export interface PortfolioPositionDetail { transactionCount: number; } +export interface HistoricalDataContainer { + isAllTimeHigh: boolean; + isAllTimeLow: boolean; + items: HistoricalDataItem[]; +} + export interface HistoricalDataItem { averagePrice?: number; date: string; diff --git a/apps/api/src/app/portfolio/interfaces/timeline-info.interface.ts b/apps/api/src/app/portfolio/interfaces/timeline-info.interface.ts new file mode 100644 index 000000000..7fb7db06a --- /dev/null +++ b/apps/api/src/app/portfolio/interfaces/timeline-info.interface.ts @@ -0,0 +1,8 @@ +import { TimelinePeriod } from '@ghostfolio/api/app/portfolio/interfaces/timeline-period.interface'; +import Big from 'big.js'; + +export interface TimelineInfoInterface { + maxNetPerformance: Big; + minNetPerformance: Big; + timelinePeriods: TimelinePeriod[]; +} diff --git a/apps/api/src/app/portfolio/portfolio-calculator.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator.spec.ts index 5dd8bb62a..1e3d6b576 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator.spec.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator.spec.ts @@ -1502,11 +1502,11 @@ describe('PortfolioCalculator', () => { accuracy: 'year' } ]; - const timeline: TimelinePeriod[] = - await portfolioCalculator.calculateTimeline( - timelineSpecification, - '2021-06-30' - ); + const timelineInfo = await portfolioCalculator.calculateTimeline( + timelineSpecification, + '2021-06-30' + ); + const timeline: TimelinePeriod[] = timelineInfo.timelinePeriods; expect(timeline).toEqual([ { @@ -1622,11 +1622,11 @@ describe('PortfolioCalculator', () => { accuracy: 'year' } ]; - const timeline: TimelinePeriod[] = - await portfolioCalculator.calculateTimeline( - timelineSpecification, - '2021-06-30' - ); + const timelineInfo = await portfolioCalculator.calculateTimeline( + timelineSpecification, + '2021-06-30' + ); + const timeline: TimelinePeriod[] = timelineInfo.timelinePeriods; expect(timeline).toEqual([ { @@ -1665,11 +1665,11 @@ describe('PortfolioCalculator', () => { accuracy: 'month' } ]; - const timeline: TimelinePeriod[] = - await portfolioCalculator.calculateTimeline( - timelineSpecification, - '2021-06-30' - ); + const timelineInfo = await portfolioCalculator.calculateTimeline( + timelineSpecification, + '2021-06-30' + ); + const timeline: TimelinePeriod[] = timelineInfo.timelinePeriods; expect(timeline).toEqual([ { @@ -1883,6 +1883,9 @@ describe('PortfolioCalculator', () => { value: new Big('3186.9') // 15 * (144.38 + days=851 * 0.08) } ]); + + expect(timelineInfo.maxNetPerformance).toEqual(new Big('547.9')); + expect(timelineInfo.minNetPerformance).toEqual(new Big('0')); }); it('with yearly and monthly mixed', async () => { @@ -1901,11 +1904,11 @@ describe('PortfolioCalculator', () => { accuracy: 'month' } ]; - const timeline: TimelinePeriod[] = - await portfolioCalculator.calculateTimeline( - timelineSpecification, - '2021-06-30' - ); + const timelineInfo = await portfolioCalculator.calculateTimeline( + timelineSpecification, + '2021-06-30' + ); + const timeline: TimelinePeriod[] = timelineInfo.timelinePeriods; expect(timeline).toEqual([ { @@ -1987,11 +1990,11 @@ describe('PortfolioCalculator', () => { accuracy: 'day' } ]; - const timeline: TimelinePeriod[] = - await portfolioCalculator.calculateTimeline( - timelineSpecification, - '2021-06-30' - ); + const timelineInfo = await portfolioCalculator.calculateTimeline( + timelineSpecification, + '2021-06-30' + ); + const timeline: TimelinePeriod[] = timelineInfo.timelinePeriods; expect(timeline).toEqual( expect.objectContaining([ @@ -2296,11 +2299,11 @@ describe('PortfolioCalculator', () => { accuracy: 'year' } ]; - const timeline: TimelinePeriod[] = - await portfolioCalculator.calculateTimeline( - timelineSpecification, - '2020-01-01' - ); + const timelineInfo = await portfolioCalculator.calculateTimeline( + timelineSpecification, + '2020-01-01' + ); + const timeline: TimelinePeriod[] = timelineInfo.timelinePeriods; expect(timeline).toEqual([ { diff --git a/apps/api/src/app/portfolio/portfolio-calculator.ts b/apps/api/src/app/portfolio/portfolio-calculator.ts index cc2f1c4ed..2dc328532 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator.ts @@ -1,3 +1,4 @@ +import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface'; import { OrderType } from '@ghostfolio/api/models/order-type'; import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper'; @@ -365,16 +366,20 @@ export class PortfolioCalculator { public async calculateTimeline( timelineSpecification: TimelineSpecification[], endDate: string - ): Promise { + ): Promise { if (timelineSpecification.length === 0) { - return []; + return { + maxNetPerformance: new Big(0), + minNetPerformance: new Big(0), + timelinePeriods: [] + }; } const startDate = timelineSpecification[0].start; const start = parseDate(startDate); const end = parseDate(endDate); - const timelinePeriodPromises: Promise[] = []; + const timelinePeriodPromises: Promise[] = []; let i = 0; let j = -1; for ( @@ -417,11 +422,40 @@ export class PortfolioCalculator { } } - const timelinePeriods: TimelinePeriod[][] = await Promise.all( + const timelineInfoInterfaces: TimelineInfoInterface[] = await Promise.all( timelinePeriodPromises ); + const minNetPerformance = timelineInfoInterfaces + .map((timelineInfo) => timelineInfo.minNetPerformance) + .filter((performance) => performance !== null) + .reduce((minPerformance, current) => { + if (minPerformance.lt(current)) { + return minPerformance; + } else { + return current; + } + }); - return flatten(timelinePeriods); + const maxNetPerformance = timelineInfoInterfaces + .map((timelineInfo) => timelineInfo.maxNetPerformance) + .filter((performance) => performance !== null) + .reduce((maxPerformance, current) => { + if (maxPerformance.gt(current)) { + return maxPerformance; + } else { + return current; + } + }); + + const timelinePeriods = timelineInfoInterfaces.map( + (timelineInfo) => timelineInfo.timelinePeriods + ); + + return { + maxNetPerformance, + minNetPerformance, + timelinePeriods: flatten(timelinePeriods) + }; } private calculateOverallPerformance( @@ -513,7 +547,7 @@ export class PortfolioCalculator { j: number, startDate: Date, endDate: Date - ): Promise { + ): Promise { let investment: Big = new Big(0); let fees: Big = new Big(0); @@ -569,6 +603,8 @@ export class PortfolioCalculator { } const results: TimelinePeriod[] = []; + let maxNetPerformance: Big = null; + let minNetPerformance: Big = null; for ( let currentDate = startDate; isBefore(currentDate, endDate); @@ -592,18 +628,36 @@ export class PortfolioCalculator { } if (!invalid) { const grossPerformance = value.minus(investment); + const netPerformance = grossPerformance.minus(fees); + if ( + minNetPerformance === null || + minNetPerformance.gt(netPerformance) + ) { + minNetPerformance = netPerformance; + } + if ( + maxNetPerformance === null || + maxNetPerformance.lt(netPerformance) + ) { + maxNetPerformance = netPerformance; + } + const result = { grossPerformance, investment, + netPerformance, value, - date: currentDateAsString, - netPerformance: grossPerformance.minus(fees) + date: currentDateAsString }; results.push(result); } } - return results; + return { + maxNetPerformance, + minNetPerformance, + timelinePeriods: results + }; } private getFactor(type: OrderType) { diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 1c540b961..362badb8e 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -8,6 +8,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { baseCurrency } from '@ghostfolio/common/config'; import { + PortfolioChart, PortfolioDetails, PortfolioPerformance, PortfolioPublicDetails, @@ -32,10 +33,7 @@ import { AuthGuard } from '@nestjs/passport'; import { Response } from 'express'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; -import { - HistoricalDataItem, - PortfolioPositionDetail -} from './interfaces/portfolio-position-detail.interface'; +import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface'; import { PortfolioPositions } from './interfaces/portfolio-positions.interface'; import { PortfolioService } from './portfolio.service'; @@ -92,12 +90,14 @@ export class PortfolioController { @Headers('impersonation-id') impersonationId, @Query('range') range, @Res() res: Response - ): Promise { - let chartData = await this.portfolioService.getChart( + ): Promise { + const historicalDataContainer = await this.portfolioService.getChart( impersonationId, range ); + let chartData = historicalDataContainer.items; + let hasNullValue = false; chartData.forEach((chartDataItem) => { @@ -130,7 +130,11 @@ export class PortfolioController { }); } - return res.json(chartData); + return res.json({ + chart: chartData, + isAllTimeHigh: historicalDataContainer.isAllTimeHigh, + isAllTimeLow: historicalDataContainer.isAllTimeLow + }); } @Get('details') diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 4f182b4c2..e6279d1f4 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -56,12 +56,14 @@ import { parse, parseISO, setDayOfYear, + startOfDay, subDays, subYears } from 'date-fns'; import { isEmpty } from 'lodash'; import { + HistoricalDataContainer, HistoricalDataItem, PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface'; @@ -164,7 +166,7 @@ export class PortfolioService { public async getChart( aImpersonationId: string, aDateRange: DateRange = 'max' - ): Promise { + ): Promise { const userId = await this.getUserId(aImpersonationId, this.request.user.id); const portfolioCalculator = new PortfolioCalculator( @@ -175,14 +177,21 @@ export class PortfolioService { const { transactionPoints } = await this.getTransactionPoints({ userId }); portfolioCalculator.setTransactionPoints(transactionPoints); if (transactionPoints.length === 0) { - return []; + return { + isAllTimeHigh: false, + isAllTimeLow: false, + items: [] + }; } let portfolioStart = parse( transactionPoints[0].date, DATE_FORMAT, new Date() ); - portfolioStart = this.getStartDate(aDateRange, portfolioStart); + + // Get start date for the full portfolio because of because of the + // min and max calculation + portfolioStart = this.getStartDate('max', portfolioStart); const timelineSpecification: TimelineSpecification[] = [ { @@ -191,18 +200,52 @@ export class PortfolioService { } ]; - const timeline = await portfolioCalculator.calculateTimeline( + const timelineInfo = await portfolioCalculator.calculateTimeline( timelineSpecification, format(new Date(), DATE_FORMAT) ); - return timeline + const timeline = timelineInfo.timelinePeriods; + + const items = timeline .filter((timelineItem) => timelineItem !== null) .map((timelineItem) => ({ date: timelineItem.date, marketPrice: timelineItem.value, value: timelineItem.netPerformance.toNumber() })); + + let lastItem = null; + if (timeline.length > 0) { + lastItem = timeline[timeline.length - 1]; + } + + let isAllTimeHigh = timelineInfo.maxNetPerformance?.eq( + lastItem?.netPerformance + ); + let isAllTimeLow = timelineInfo.minNetPerformance?.eq( + lastItem?.netPerformance + ); + if (isAllTimeHigh && isAllTimeLow) { + isAllTimeHigh = false; + isAllTimeLow = false; + } + + portfolioStart = startOfDay( + this.getStartDate( + aDateRange, + parse(transactionPoints[0].date, DATE_FORMAT, new Date()) + ) + ); + + return { + isAllTimeHigh, + isAllTimeLow, + items: items.filter((item) => { + // Filter items of date range + return !isAfter(portfolioStart, parseDate(item.date)); + }) + }; } public async getDetails( @@ -639,7 +682,9 @@ export class PortfolioService { currentGrossPerformancePercent: 0, currentNetPerformance: 0, currentNetPerformancePercent: 0, - currentValue: 0 + currentValue: 0, + isAllTimeHigh: false, + isAllTimeLow: false } }; } @@ -672,7 +717,9 @@ export class PortfolioService { currentGrossPerformancePercent, currentNetPerformance, currentNetPerformancePercent, - currentValue + currentValue, + isAllTimeHigh: true, // TODO + isAllTimeLow: false // TODO } }; } diff --git a/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html b/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html index 42aa5a683..b2e653029 100644 --- a/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html +++ b/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html @@ -1,5 +1,11 @@
-
+
diff --git a/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts b/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts index 697941b70..8c69eea1d 100644 --- a/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts +++ b/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts @@ -19,6 +19,8 @@ import { isNumber } from 'lodash'; }) export class PortfolioPerformanceComponent implements OnChanges, OnInit { @Input() baseCurrency: string; + @Input() isAllTimeHigh: boolean; + @Input() isAllTimeLow: boolean; @Input() isLoading: boolean; @Input() locale: string; @Input() performance: PortfolioPerformance; diff --git a/apps/client/src/app/pages/home/home-page.component.ts b/apps/client/src/app/pages/home/home-page.component.ts index 8a908a915..cbf98470b 100644 --- a/apps/client/src/app/pages/home/home-page.component.ts +++ b/apps/client/src/app/pages/home/home-page.component.ts @@ -59,6 +59,8 @@ export class HomePageComponent implements OnDestroy, OnInit { public hasPermissionToAccessFearAndGreedIndex: boolean; public hasPermissionToCreateOrder: boolean; public historicalDataItems: LineChartItem[]; + public isAllTimeHigh: boolean; + public isAllTimeLow: boolean; public isLoadingPerformance = true; public isLoadingSummary = true; public performance: PortfolioPerformance; @@ -166,12 +168,14 @@ export class HomePageComponent implements OnDestroy, OnInit { .fetchChart({ range: this.dateRange }) .pipe(takeUntil(this.unsubscribeSubject)) .subscribe((chartData) => { - this.historicalDataItems = chartData.map((chartDataItem) => { + this.historicalDataItems = chartData.chart.map((chartDataItem) => { return { date: chartDataItem.date, value: chartDataItem.value }; }); + this.isAllTimeHigh = chartData.isAllTimeHigh; + this.isAllTimeLow = chartData.isAllTimeLow; this.changeDetectorRef.markForCheck(); }); diff --git a/apps/client/src/app/pages/home/home-page.html b/apps/client/src/app/pages/home/home-page.html index e37e0c4b1..b21fbb3f6 100644 --- a/apps/client/src/app/pages/home/home-page.html +++ b/apps/client/src/app/pages/home/home-page.html @@ -54,6 +54,8 @@ { - this.historicalDataItems = chartData.map((chartDataItem) => { + this.historicalDataItems = chartData.chart.map((chartDataItem) => { return { date: chartDataItem.date, value: chartDataItem.value }; }); + this.isAllTimeHigh = chartData.isAllTimeHigh; + this.isAllTimeLow = chartData.isAllTimeLow; this.changeDetectorRef.markForCheck(); }); diff --git a/apps/client/src/app/pages/zen/zen-page.html b/apps/client/src/app/pages/zen/zen-page.html index b7773a360..c45a53af0 100644 --- a/apps/client/src/app/pages/zen/zen-page.html +++ b/apps/client/src/app/pages/zen/zen-page.html @@ -47,6 +47,8 @@ ('/api/portfolio/chart', { + return this.http.get('/api/portfolio/chart', { params: { range } }); } diff --git a/libs/common/src/lib/interfaces/index.ts b/libs/common/src/lib/interfaces/index.ts index b2c973be6..af065936b 100644 --- a/libs/common/src/lib/interfaces/index.ts +++ b/libs/common/src/lib/interfaces/index.ts @@ -3,6 +3,7 @@ import { Accounts } from './accounts.interface'; import { AdminData } from './admin-data.interface'; import { Export } from './export.interface'; import { InfoItem } from './info-item.interface'; +import { PortfolioChart } from './portfolio-chart.interface'; import { PortfolioDetails } from './portfolio-details.interface'; import { PortfolioItem } from './portfolio-item.interface'; import { PortfolioOverview } from './portfolio-overview.interface'; @@ -24,6 +25,7 @@ export { AdminData, Export, InfoItem, + PortfolioChart, PortfolioDetails, PortfolioItem, PortfolioOverview, diff --git a/libs/common/src/lib/interfaces/portfolio-chart.interface.ts b/libs/common/src/lib/interfaces/portfolio-chart.interface.ts new file mode 100644 index 000000000..d9946746d --- /dev/null +++ b/libs/common/src/lib/interfaces/portfolio-chart.interface.ts @@ -0,0 +1,7 @@ +import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface'; + +export interface PortfolioChart { + isAllTimeHigh: boolean; + isAllTimeLow: boolean; + chart: HistoricalDataItem[]; +} diff --git a/libs/common/src/lib/interfaces/portfolio-performance.interface.ts b/libs/common/src/lib/interfaces/portfolio-performance.interface.ts index 3a2770786..bd09c288b 100644 --- a/libs/common/src/lib/interfaces/portfolio-performance.interface.ts +++ b/libs/common/src/lib/interfaces/portfolio-performance.interface.ts @@ -5,4 +5,6 @@ export interface PortfolioPerformance { currentNetPerformance: number; currentNetPerformancePercent: number; currentValue: number; + isAllTimeHigh: boolean; + isAllTimeLow: boolean; }