mirror of https://github.com/ghostfolio/ghostfolio
Valentin Zickner
4 years ago
committed by
Thomas
6 changed files with 720 additions and 0 deletions
@ -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); |
|||
}); |
|||
|
|||
}); |
@ -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<number> { |
|||
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; |
|||
} |
@ -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 |
|||
} |
|||
]; |
@ -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; |
|||
} |
Loading…
Reference in new issue