diff --git a/apps/api/src/app/core/current-rate.service.spec.ts b/apps/api/src/app/core/current-rate.service.spec.ts new file mode 100644 index 000000000..905701c6d --- /dev/null +++ b/apps/api/src/app/core/current-rate.service.spec.ts @@ -0,0 +1,60 @@ +import { CurrentRateService } from '@ghostfolio/api/app/core/current-rate.service'; +import { Currency } from '@prisma/client'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; +import { PrismaService } from '@ghostfolio/api/services/prisma.service'; + +jest.mock('../../services/exchange-rate-data.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + exchangeRateDataService: jest.fn().mockImplementation(() => { + return { + toCurrency: (aValue: number, + aFromCurrency: Currency, + aToCurrency: Currency) => { + return 1 * aValue; + } + } + }) + }; +}); + +// https://jestjs.io/docs/manual-mocks#mocking-node-modules +// jest.mock('?', () => { +// return { +// // eslint-disable-next-line @typescript-eslint/naming-convention +// prismaService: jest.fn().mockImplementation(() => { +// return { +// marketData: { +// findFirst: (data: any) => { +// return { +// marketPrice: 100 +// }; +// } +// } +// }; +// }) +// }; +// }); + +xdescribe('CurrentRateService', () => { + + let exchangeRateDataService: ExchangeRateDataService; + let prismaService: PrismaService; + + beforeEach(() => { + exchangeRateDataService = new ExchangeRateDataService(undefined); + prismaService = new PrismaService(); + }); + + it('getValue', () => { + const currentRateService = new CurrentRateService(exchangeRateDataService, prismaService); + + expect(currentRateService.getValue({ + date: new Date(), + symbol: 'AIA', + currency: Currency.USD, + userCurrency: Currency.CHF + })).toEqual(0); + }); + +}); diff --git a/apps/api/src/app/core/current-rate.service.ts b/apps/api/src/app/core/current-rate.service.ts new file mode 100644 index 000000000..fe1c470a6 --- /dev/null +++ b/apps/api/src/app/core/current-rate.service.ts @@ -0,0 +1,37 @@ +import { Currency } from '@prisma/client'; +import { PrismaService } from '@ghostfolio/api/services/prisma.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; + +export class CurrentRateService { + + public constructor( + private readonly exchangeRateDataService: ExchangeRateDataService, + private prisma: PrismaService + ) {} + + /** + * TODO: @dtslvr + */ + public async getValue({date, symbol, currency, userCurrency}: GetValueParams): Promise { + const marketData = await this.prisma.marketData.findFirst({ + select: { date: true, marketPrice: true }, + where: { date: date, symbol: symbol } + }); + + console.log(marketData); // { date: Date, marketPrice: number } + + return this.exchangeRateDataService.toCurrency( + marketData.marketPrice, + currency, + userCurrency + ); + } + +} + +export interface GetValueParams { + date: Date; + symbol: string; + currency: Currency; + userCurrency: Currency; +} diff --git a/apps/api/src/app/core/portfolio-calculator.spec.ts b/apps/api/src/app/core/portfolio-calculator.spec.ts new file mode 100644 index 000000000..30b9fd693 --- /dev/null +++ b/apps/api/src/app/core/portfolio-calculator.spec.ts @@ -0,0 +1,459 @@ +import { PortfolioCalculator, PortfolioOrder } from '@ghostfolio/api/app/core/portfolio-calculator'; +import { CurrentRateService } from '@ghostfolio/api/app/core/current-rate.service'; +import { Currency } from '@prisma/client'; +import { OrderType } from '@ghostfolio/api/models/order-type'; +import Big from 'big.js'; + +jest.mock('./current-rate.service.ts', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + CurrentRateService: jest.fn().mockImplementation(() => { + return { + getValue: (date: Date, symbol: string, currency: Currency) => { + return 4; + } + }; + }) + }; +}); + +describe('PortfolioCalculator', () => { + + let currentRateService: CurrentRateService; + beforeEach(() => { + currentRateService = new CurrentRateService(null, null); + }); + + describe('calculate transaction points', () => { + it('with orders of only one symbol', () => { + const portfolioCalculator = new PortfolioCalculator(currentRateService, Currency.USD, ordersVTI); + const portfolioItemsAtTransactionPoints = portfolioCalculator.getPortfolioItemsAtTransactionPoints(); + + expect(portfolioItemsAtTransactionPoints).toEqual([ + { + date: '2019-02-01', + items: [{ + quantity: new Big('10'), + symbol: 'VTI', + investment: new Big('1443.8'), + currency: Currency.USD + }] + }, + { + date: '2019-08-03', + items: [{ + quantity: new Big('20'), + symbol: 'VTI', + investment: new Big('2923.7'), + currency: Currency.USD + }] + }, + { + date: '2020-02-02', + items: [{ + quantity: new Big('5'), + symbol: 'VTI', + investment: new Big('652.55'), + currency: Currency.USD + }] + }, + { + date: '2021-02-01', + items: [{ + quantity: new Big('15'), + symbol: 'VTI', + investment: new Big('2684.05'), + currency: Currency.USD + }] + }, + { + date: '2021-08-01', + items: [{ + quantity: new Big('25'), + symbol: 'VTI', + investment: new Big('4460.95'), + currency: Currency.USD + }] + } + ]); + }); + + it('with two orders at the same day of the same type', () => { + const orders = [ + ...ordersVTI, + { + date: '2021-02-01', + quantity: new Big('20'), + symbol: 'VTI', + type: OrderType.Buy, + unitPrice: new Big('197.15'), + currency: Currency.USD + } + ]; + const portfolioCalculator = new PortfolioCalculator(currentRateService, Currency.USD, orders); + const portfolioItemsAtTransactionPoints = portfolioCalculator.getPortfolioItemsAtTransactionPoints(); + + expect(portfolioItemsAtTransactionPoints).toEqual([ + { + date: '2019-02-01', + items: [{ + quantity: new Big('10'), + symbol: 'VTI', + investment: new Big('1443.8'), + currency: Currency.USD + }] + }, + { + date: '2019-08-03', + items: [{ + quantity: new Big('20'), + symbol: 'VTI', + investment: new Big('2923.7'), + currency: Currency.USD + }] + }, + { + date: '2020-02-02', + items: [{ + quantity: new Big('5'), + symbol: 'VTI', + investment: new Big('652.55'), + currency: Currency.USD + }] + }, + { + date: '2021-02-01', + items: [{ + quantity: new Big('35'), + symbol: 'VTI', + investment: new Big('6627.05'), + currency: Currency.USD + }] + }, + { + date: '2021-08-01', + items: [{ + quantity: new Big('45'), + symbol: 'VTI', + investment: new Big('8403.95'), + currency: Currency.USD + }] + } + ]); + }); + + it('with additional order', () => { + const orders = [ + ...ordersVTI, + { + date: '2019-09-01', + quantity: new Big('5'), + symbol: 'AMZN', + type: OrderType.Buy, + unitPrice: new Big('2021.99'), + currency: Currency.USD + } + ]; + const portfolioCalculator = new PortfolioCalculator(currentRateService, Currency.USD, orders); + const portfolioItemsAtTransactionPoints = portfolioCalculator.getPortfolioItemsAtTransactionPoints(); + + expect(portfolioItemsAtTransactionPoints).toEqual([ + { + date: '2019-02-01', + items: [{ + quantity: new Big('10'), + symbol: 'VTI', + investment: new Big('1443.8'), + currency: Currency.USD + }] + }, + { + date: '2019-08-03', + items: [{ + quantity: new Big('20'), + symbol: 'VTI', + investment: new Big('2923.7'), + currency: Currency.USD + }] + }, + { + date: '2019-09-01', + items: [{ + quantity: new Big('5'), + symbol: 'AMZN', + investment: new Big('10109.95'), + currency: Currency.USD + }, { + quantity: new Big('20'), + symbol: 'VTI', + investment: new Big('2923.7'), + currency: Currency.USD + }] + }, + { + date: '2020-02-02', + items: [{ + quantity: new Big('5'), + symbol: 'AMZN', + investment: new Big('10109.95'), + currency: Currency.USD + }, { + quantity: new Big('5'), + symbol: 'VTI', + investment: new Big('652.55'), + currency: Currency.USD + }] + }, + { + date: '2021-02-01', + items: [{ + quantity: new Big('5'), + symbol: 'AMZN', + investment: new Big('10109.95'), + currency: Currency.USD + }, { + quantity: new Big('15'), + symbol: 'VTI', + investment: new Big('2684.05'), + currency: Currency.USD + }] + }, + { + date: '2021-08-01', + items: [{ + quantity: new Big('5'), + symbol: 'AMZN', + investment: new Big('10109.95'), + currency: Currency.USD + }, { + quantity: new Big('25'), + symbol: 'VTI', + investment: new Big('4460.95'), + currency: Currency.USD + }] + } + ]); + }); + + it('with additional buy & sell', () => { + const orders = [ + ...ordersVTI, + { + date: '2019-09-01', + quantity: new Big('5'), + symbol: 'AMZN', + type: OrderType.Buy, + unitPrice: new Big('2021.99'), + currency: Currency.USD + }, + { + date: '2020-08-02', + quantity: new Big('5'), + symbol: 'AMZN', + type: OrderType.Sell, + unitPrice: new Big('2412.23'), + currency: Currency.USD + } + ]; + const portfolioCalculator = new PortfolioCalculator(currentRateService, Currency.USD, orders); + const portfolioItemsAtTransactionPoints = portfolioCalculator.getPortfolioItemsAtTransactionPoints(); + + expect(portfolioItemsAtTransactionPoints).toEqual([ + { + date: '2019-02-01', + items: [{ + quantity: new Big('10'), + symbol: 'VTI', + investment: new Big('1443.8'), + currency: Currency.USD + }] + }, + { + date: '2019-08-03', + items: [{ + quantity: new Big('20'), + symbol: 'VTI', + investment: new Big('2923.7'), + currency: Currency.USD + }] + }, + { + date: '2019-09-01', + items: [{ + quantity: new Big('5'), + symbol: 'AMZN', + investment: new Big('10109.95'), + currency: Currency.USD + }, { + quantity: new Big('20'), + symbol: 'VTI', + investment: new Big('2923.7'), + currency: Currency.USD + }] + }, + { + date: '2020-02-02', + items: [{ + quantity: new Big('5'), + symbol: 'AMZN', + investment: new Big('10109.95'), + currency: Currency.USD + }, { + quantity: new Big('5'), + symbol: 'VTI', + investment: new Big('652.55'), + currency: Currency.USD + }] + }, + { + date: '2020-08-02', + items: [{ + quantity: new Big('5'), + symbol: 'VTI', + investment: new Big('652.55'), + currency: Currency.USD + }] + }, + { + date: '2021-02-01', + items: [{ + quantity: new Big('15'), + symbol: 'VTI', + investment: new Big('2684.05'), + currency: Currency.USD + }] + }, + { + date: '2021-08-01', + items: [{ + quantity: new Big('25'), + symbol: 'VTI', + investment: new Big('4460.95'), + currency: Currency.USD + }] + } + ]); + }); + + it('with mixed symbols', () => { + const portfolioCalculator = new PortfolioCalculator(currentRateService, Currency.USD, ordersMixedSymbols); + const portfolioItemsAtTransactionPoints = portfolioCalculator.getPortfolioItemsAtTransactionPoints(); + + expect(portfolioItemsAtTransactionPoints).toEqual([ + { + date: '2017-01-03', + items: [{ + quantity: new Big('50'), + symbol: 'TSLA', + investment: new Big('2148.5'), + currency: Currency.USD + }] + }, + { + date: '2017-07-01', + items: [{ + quantity: new Big('0.5614682'), + symbol: 'BTCUSD', + investment: new Big('1999.9999999999998659756'), + currency: Currency.USD + }, { + quantity: new Big('50'), + symbol: 'TSLA', + investment: new Big('2148.5'), + currency: Currency.USD + }] + }, + { + date: '2018-09-01', + items: [{ + quantity: new Big('5'), + symbol: 'AMZN', + investment: new Big('10109.95'), + currency: Currency.USD + }, { + quantity: new Big('0.5614682'), + symbol: 'BTCUSD', + investment: new Big('1999.9999999999998659756'), + currency: Currency.USD + }, { + quantity: new Big('50'), + symbol: 'TSLA', + investment: new Big('2148.5'), + currency: Currency.USD + }] + } + ]); + }); + }); + +}); + +const ordersMixedSymbols: PortfolioOrder[] = [ + { + date: '2017-01-03', + quantity: new Big('50'), + symbol: 'TSLA', + type: OrderType.Buy, + unitPrice: new Big('42.97'), + currency: Currency.USD + }, + { + date: '2017-07-01', + quantity: new Big('0.5614682'), + symbol: 'BTCUSD', + type: OrderType.Buy, + unitPrice: new Big('3562.089535970158'), + currency: Currency.USD + }, + { + date: '2018-09-01', + quantity: new Big('5'), + symbol: 'AMZN', + type: OrderType.Buy, + unitPrice: new Big('2021.99'), + currency: Currency.USD + } +]; + +const ordersVTI: PortfolioOrder[] = [ + { + date: '2019-02-01', + quantity: new Big('10'), + symbol: 'VTI', + type: OrderType.Buy, + unitPrice: new Big('144.38'), + currency: Currency.USD + }, + { + date: '2019-08-03', + quantity: new Big('10'), + symbol: 'VTI', + type: OrderType.Buy, + unitPrice: new Big('147.99'), + currency: Currency.USD + }, + { + date: '2020-02-02', + quantity: new Big('15'), + symbol: 'VTI', + type: OrderType.Sell, + unitPrice: new Big('151.41'), + currency: Currency.USD + }, + { + date: '2021-08-01', + quantity: new Big('10'), + symbol: 'VTI', + type: OrderType.Buy, + unitPrice: new Big('177.69'), + currency: Currency.USD + }, + { + date: '2021-02-01', + quantity: new Big('10'), + symbol: 'VTI', + type: OrderType.Buy, + unitPrice: new Big('203.15'), + currency: Currency.USD + } +]; diff --git a/apps/api/src/app/core/portfolio-calculator.ts b/apps/api/src/app/core/portfolio-calculator.ts new file mode 100644 index 000000000..4a10860e1 --- /dev/null +++ b/apps/api/src/app/core/portfolio-calculator.ts @@ -0,0 +1,154 @@ +import { Currency } from '@prisma/client'; +import { CurrentRateService } from '@ghostfolio/api/app/core/current-rate.service'; +import { OrderType } from '@ghostfolio/api/models/order-type'; +import Big from 'big.js'; + +export class PortfolioCalculator { + + private transactionPoints: TransactionPoint[]; + + constructor( + private currentRateService: CurrentRateService, + private currency: Currency, + orders: PortfolioOrder[] + ) { + this.computeTransactionPoints(orders); + } + + addOrder(order: PortfolioOrder): void { + + } + + deleteOrder(order: PortfolioOrder): void { + + } + + getPortfolioItemsAtTransactionPoints(): TransactionPoint[] { + return this.transactionPoints; + } + + getCurrentPositions(): { [symbol: string]: TimelinePosition } { + return {}; + } + + calculateTimeline(timelineSpecification: TimelineSpecification[], endDate: Date): TimelinePeriod[] { + return null; + } + + private computeTransactionPoints(orders: PortfolioOrder[]) { + orders.sort((a, b) => a.date.localeCompare(b.date)); + + this.transactionPoints = []; + const symbols: { [symbol: string]: TransactionPointSymbol } = {}; + + let lastDate: string = null; + let lastTransactionPoint: TransactionPoint = null; + for (const order of orders) { + const currentDate = order.date; + + let currentTransactionPointItem: TransactionPointSymbol; + const oldAccumulatedSymbol = symbols[order.symbol]; + + const factor = this.getFactor(order.type); + const unitPrice = new Big(order.unitPrice); + if (oldAccumulatedSymbol) { + currentTransactionPointItem = { + quantity: order.quantity.mul(factor).plus(oldAccumulatedSymbol.quantity), + symbol: order.symbol, + investment: unitPrice.mul(order.quantity).mul(factor).add(oldAccumulatedSymbol.investment), + currency: order.currency + }; + } else { + currentTransactionPointItem = { + quantity: order.quantity.mul(factor), + symbol: order.symbol, + investment: unitPrice.mul(order.quantity).mul(factor), + currency: order.currency + }; + } + + symbols[order.symbol] = currentTransactionPointItem; + + const items = lastTransactionPoint?.items ?? []; + const newItems = items.filter(transactionPointItem => transactionPointItem.symbol !== order.symbol); + if (!currentTransactionPointItem.quantity.eq(0)) { + newItems.push(currentTransactionPointItem); + } else { + delete symbols[order.symbol]; + } + newItems.sort((a, b) => a.symbol.localeCompare(b.symbol)); + if (lastDate !== currentDate || lastTransactionPoint === null) { + lastTransactionPoint = { + date: currentDate, + items: newItems + }; + this.transactionPoints.push(lastTransactionPoint); + } else { + lastTransactionPoint.items = newItems; + } + lastDate = currentDate; + } + } + + private getFactor(type: OrderType) { + let factor: number; + switch (type) { + case OrderType.Buy: + factor = 1; + break; + case OrderType.Sell: + factor = -1; + break; + default: + factor = 0; + break; + } + return factor; + } + +} + +interface TransactionPoint { + date: string; + items: TransactionPointSymbol[]; +} + +interface TransactionPointSymbol { + quantity: Big; + symbol: string; + investment: Big; + currency: Currency; +} + +interface TimelinePosition { + averagePrice: Big; + firstBuyDate: string; + quantity: Big; + symbol: string; + investment: Big; + marketPrice: number; + transactionCount: number; +} + +type Accuracy = 'year' | 'month' | 'day'; + +interface TimelineSpecification { + start: Date; + accuracy: Accuracy +} + +interface TimelinePeriod { + date: Date; + grossPerformance: number; + investment: number; + value: number; +} + +export interface PortfolioOrder { + date: string; + quantity: Big; + symbol: string; + type: OrderType; + unitPrice: Big; + currency: Currency; +} diff --git a/package.json b/package.json index 521bf25c5..c33634c9e 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "alphavantage": "2.2.0", "angular-material-css-vars": "2.1.0", "bent": "7.3.12", + "big.js": "^6.1.1", "bootstrap": "4.6.0", "cache-manager": "3.4.3", "cache-manager-redis-store": "2.0.0", @@ -123,6 +124,7 @@ "@nrwl/node": "12.5.4", "@nrwl/tao": "12.5.4", "@nrwl/workspace": "12.5.4", + "@types/big.js": "^6.1.1", "@types/cache-manager": "3.4.0", "@types/jest": "26.0.20", "@types/lodash": "4.14.168", diff --git a/yarn.lock b/yarn.lock index cf58770a9..edf8a2d9b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2549,6 +2549,10 @@ dependencies: "@babel/types" "^7.3.0" +"@types/big.js@^6.1.1": + version "6.1.1" + integrity sha1-wr5egeDPDBwxcE47EvdQcS9kdBQ= + "@types/body-parser@*": version "1.19.0" resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.0.tgz#0685b3c47eb3006ffed117cdd55164b61f80538f" @@ -3943,6 +3947,10 @@ big.js@^5.2.2: resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ== +big.js@^6.1.1: + version "6.1.1" + integrity sha1-Y7NbGdyXdclJke5dt2lIgGVdVTc= + bignumber.js@^9.0.1: version "9.0.1" resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.0.1.tgz#8d7ba124c882bfd8e43260c67475518d0689e4e5"