import { PortfolioItem, Position } from 'apps/api/src/app/portfolio/interfaces/portfolio-item.interface'; import { add, format, getDate, getMonth, getYear, isAfter, isBefore, isSameDay, isToday, isYesterday, parseISO, setDate, setMonth, sub } from 'date-fns'; import { getToday, getYesterday, resetHours } from 'libs/helper/src'; import { cloneDeep, isEmpty } from 'lodash'; import * as roundTo from 'round-to'; import { UserWithSettings } from '../app/interfaces/user-with-settings'; import { OrderWithPlatform } from '../app/order/interfaces/order-with-platform.type'; import { DateRange } from '../app/portfolio/interfaces/date-range.type'; import { PortfolioPerformance } from '../app/portfolio/interfaces/portfolio-performance.interface'; import { PortfolioPosition } from '../app/portfolio/interfaces/portfolio-position.interface'; import { PortfolioReport } from '../app/portfolio/interfaces/portfolio-report.interface'; import { DataProviderService } from '../services/data-provider.service'; import { ExchangeRateDataService } from '../services/exchange-rate-data.service'; import { IOrder } from '../services/interfaces/interfaces'; import { RulesService } from '../services/rules.service'; import { PortfolioInterface } from './interfaces/portfolio.interface'; import { Order } from './order'; import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from './rules/currency-cluster-risk/base-currency-current-investment'; import { CurrencyClusterRiskBaseCurrencyInitialInvestment } from './rules/currency-cluster-risk/base-currency-initial-investment'; import { CurrencyClusterRiskCurrentInvestment } from './rules/currency-cluster-risk/current-investment'; import { CurrencyClusterRiskInitialInvestment } from './rules/currency-cluster-risk/initial-investment'; import { FeeRatioInitialInvestment } from './rules/fees/fee-ratio-initial-investment'; import { PlatformClusterRiskCurrentInvestment } from './rules/platform-cluster-risk/current-investment'; import { PlatformClusterRiskInitialInvestment } from './rules/platform-cluster-risk/initial-investment'; import { PlatformClusterRiskSinglePlatform } from './rules/platform-cluster-risk/single-platform'; export class Portfolio implements PortfolioInterface { private orders: Order[] = []; private portfolioItems: PortfolioItem[] = []; private user: UserWithSettings; public constructor( private dataProviderService: DataProviderService, private exchangeRateDataService: ExchangeRateDataService, private rulesService: RulesService ) {} public async addCurrentPortfolioItems() { const currentData = await this.dataProviderService.get(this.getSymbols()); let currentDate = new Date(); const year = getYear(currentDate); const month = getMonth(currentDate); const day = getDate(currentDate); const today = new Date(Date.UTC(year, month, day)); const yesterday = getYesterday(); const [portfolioItemsYesterday] = this.get(yesterday); let positions: { [symbol: string]: Position } = {}; this.getSymbols().forEach((symbol) => { positions[symbol] = { averagePrice: portfolioItemsYesterday?.positions[symbol]?.averagePrice, currency: portfolioItemsYesterday?.positions[symbol]?.currency, firstBuyDate: portfolioItemsYesterday?.positions[symbol]?.firstBuyDate, investment: portfolioItemsYesterday?.positions[symbol]?.investment, investmentInOriginalCurrency: portfolioItemsYesterday?.positions[symbol] ?.investmentInOriginalCurrency, marketPrice: currentData[symbol]?.marketPrice, quantity: portfolioItemsYesterday?.positions[symbol]?.quantity }; }); if (portfolioItemsYesterday?.investment) { const portfolioItemsLength = this.portfolioItems.push( cloneDeep({ date: today.toISOString(), grossPerformancePercent: 0, investment: portfolioItemsYesterday?.investment, positions: positions, value: 0 }) ); // Set value after pushing today's portfolio items this.portfolioItems[portfolioItemsLength - 1].value = this.getValue( today ); } return this; } public createFromData({ orders, portfolioItems, user }: { orders: IOrder[]; portfolioItems: PortfolioItem[]; user: UserWithSettings; }): Portfolio { orders.forEach( ({ currency, fee, date, id, platform, quantity, symbol, type, unitPrice }) => { this.orders.push( new Order({ currency, fee, date, id, platform, quantity, symbol, type, unitPrice }) ); } ); portfolioItems.forEach( ({ date, grossPerformancePercent, investment, positions, value }) => { this.portfolioItems.push({ date, grossPerformancePercent, investment, positions, value }); } ); this.setUser(user); return this; } private convertDateRangeToDate(aDateRange: DateRange, aMinDate: Date) { let currentDate = new Date(); const normalizedMinDate = getDate(aMinDate) === 1 ? aMinDate : add(setDate(aMinDate, 1), { months: 1 }); const year = getYear(currentDate); const month = getMonth(currentDate); const day = getDate(currentDate); currentDate = new Date(Date.UTC(year, month, day, 0)); switch (aDateRange) { case '1d': return sub(currentDate, { days: 1 }); case 'ytd': currentDate = setDate(currentDate, 1); currentDate = setMonth(currentDate, 0); return isAfter(currentDate, normalizedMinDate) ? currentDate : undefined; case '1y': currentDate = setDate(currentDate, 1); currentDate = sub(currentDate, { years: 1 }); return isAfter(currentDate, normalizedMinDate) ? currentDate : undefined; case '5y': currentDate = setDate(currentDate, 1); currentDate = sub(currentDate, { years: 5 }); return isAfter(currentDate, normalizedMinDate) ? currentDate : undefined; default: // Gets handled as all data return undefined; } } public get(aDate?: Date): PortfolioItem[] { if (aDate) { const filteredPortfolio = this.portfolioItems.find((item) => { return isSameDay(aDate, new Date(item.date)); }); if (filteredPortfolio) { return [cloneDeep(filteredPortfolio)]; } } return cloneDeep(this.portfolioItems); } public getCommittedFunds() { return this.getTotalBuy() - this.getTotalSell(); } public async getDetails( aDateRange: DateRange = 'max' ): Promise<{ [symbol: string]: PortfolioPosition }> { const dateRangeDate = this.convertDateRangeToDate( aDateRange, this.getMinDate() ); const [portfolioItemsBefore] = this.get(dateRangeDate); const [portfolioItemsNow] = await this.get(new Date()); const investment = this.getInvestment(new Date()); const portfolioItems = this.get(new Date()); const symbols = this.getSymbols(new Date()); const value = this.getValue(); const details: { [symbol: string]: PortfolioPosition } = {}; const data = await this.dataProviderService.get(symbols); symbols.forEach((symbol) => { const platforms: PortfolioPosition['platforms'] = {}; const [portfolioItem] = portfolioItems; const ordersBySymbol = this.getOrders().filter((order) => { return order.getSymbol() === symbol; }); ordersBySymbol.forEach((orderOfSymbol) => { let currentValueOfSymbol = this.exchangeRateDataService.toCurrency( orderOfSymbol.getQuantity() * portfolioItemsNow.positions[symbol].marketPrice, orderOfSymbol.getCurrency(), this.user.Settings.currency ); let originalValueOfSymbol = this.exchangeRateDataService.toCurrency( orderOfSymbol.getQuantity() * orderOfSymbol.getUnitPrice(), orderOfSymbol.getCurrency(), this.user.Settings.currency ); if (orderOfSymbol.getType() === 'SELL') { currentValueOfSymbol *= -1; originalValueOfSymbol *= -1; } if (platforms[orderOfSymbol.getPlatform()?.name || 'Other']?.current) { platforms[ orderOfSymbol.getPlatform()?.name || 'Other' ].current += currentValueOfSymbol; platforms[ orderOfSymbol.getPlatform()?.name || 'Other' ].original += originalValueOfSymbol; } else { platforms[orderOfSymbol.getPlatform()?.name || 'Other'] = { current: currentValueOfSymbol, original: originalValueOfSymbol }; } }); let now = portfolioItemsNow.positions[symbol].marketPrice; // 1d let before = portfolioItemsBefore.positions[symbol].marketPrice; if (aDateRange === 'ytd') { before = portfolioItemsBefore.positions[symbol].marketPrice || portfolioItemsNow.positions[symbol].averagePrice; } else if ( aDateRange === '1y' || aDateRange === '5y' || aDateRange === 'max' ) { before = portfolioItemsNow.positions[symbol].averagePrice; } if ( !isBefore( parseISO(portfolioItemsNow.positions[symbol].firstBuyDate), parseISO(portfolioItemsBefore.date) ) ) { // Trade was not before the date of portfolioItemsBefore, then override it with average price // (e.g. on same day) before = portfolioItemsNow.positions[symbol].averagePrice; } if (isToday(parseISO(portfolioItemsNow.positions[symbol].firstBuyDate))) { now = portfolioItemsNow.positions[symbol].averagePrice; } details[symbol] = { ...data[symbol], platforms, symbol, grossPerformance: roundTo( portfolioItemsNow.positions[symbol].quantity * (now - before), 2 ), grossPerformancePercent: roundTo((now - before) / before, 4), investment: portfolioItem.positions[symbol].investment, quantity: portfolioItem.positions[symbol].quantity, shareCurrent: this.exchangeRateDataService.toCurrency( portfolioItem.positions[symbol].quantity * now, data[symbol]?.currency, this.user.Settings.currency ) / value, shareInvestment: portfolioItem.positions[symbol].investment / investment }; }); return details; } public getFees(aDate = new Date(0)) { return this.orders .filter((order) => { // Filter out all orders before given date return isBefore(aDate, new Date(order.getDate())); }) .map((order) => { return this.exchangeRateDataService.toCurrency( order.getFee(), order.getCurrency(), this.user.Settings.currency ); }) .reduce((previous, current) => previous + current, 0); } public getInvestment(aDate: Date): number { return this.get(aDate)[0]?.investment || 0; } public getMinDate() { if (this.orders.length > 0) { return new Date(this.orders[0].getDate()); } return null; } public async getPerformance( aDateRange: DateRange = 'max' ): Promise { const dateRangeDate = this.convertDateRangeToDate( aDateRange, this.getMinDate() ); const currentInvestment = this.getInvestment(new Date()); const currentValue = await this.getValue(); let originalInvestment = currentInvestment; let originalValue = this.getCommittedFunds(); if (dateRangeDate) { originalInvestment = this.getInvestment(dateRangeDate); originalValue = (await this.getValue(dateRangeDate)) || originalValue; } const fees = this.getFees(dateRangeDate); const currentGrossPerformance = currentValue - currentInvestment - (originalValue - originalInvestment); // https://www.skillsyouneed.com/num/percent-change.html const currentGrossPerformancePercent = currentGrossPerformance / originalInvestment || 0; const currentNetPerformance = currentGrossPerformance - fees; // https://www.skillsyouneed.com/num/percent-change.html const currentNetPerformancePercent = currentNetPerformance / originalInvestment || 0; return { currentGrossPerformance, currentGrossPerformancePercent, currentNetPerformance, currentNetPerformancePercent, currentValue }; } public getPositions(aDate: Date) { const [portfolioItem] = this.get(aDate); if (portfolioItem) { return portfolioItem.positions; } return {}; } public getPortfolioItems() { return this.portfolioItems; } public async getReport(): Promise { const details = await this.getDetails(); if (isEmpty(details)) { return { rules: {} }; } return { rules: { currencyClusterRisk: await this.rulesService.evaluate( this, [ new CurrencyClusterRiskBaseCurrencyInitialInvestment( this.exchangeRateDataService ), new CurrencyClusterRiskBaseCurrencyCurrentInvestment( this.exchangeRateDataService ), new CurrencyClusterRiskInitialInvestment( this.exchangeRateDataService ), new CurrencyClusterRiskCurrentInvestment( this.exchangeRateDataService ) ], { baseCurrency: this.user.Settings.currency } ), platformClusterRisk: await this.rulesService.evaluate( this, [ new PlatformClusterRiskSinglePlatform(this.exchangeRateDataService), new PlatformClusterRiskInitialInvestment( this.exchangeRateDataService ), new PlatformClusterRiskCurrentInvestment( this.exchangeRateDataService ) ], { baseCurrency: this.user.Settings.currency } ), fees: await this.rulesService.evaluate( this, [new FeeRatioInitialInvestment(this.exchangeRateDataService)], { baseCurrency: this.user.Settings.currency } ) } }; } public getSymbols(aDate?: Date) { let symbols: string[] = []; if (aDate) { const positions = this.getPositions(aDate); for (const symbol in positions) { if (positions[symbol].quantity > 0) { symbols.push(symbol); } } } else { symbols = this.orders.map((order) => { return order.getSymbol(); }); } // unique values return Array.from(new Set(symbols)); } public getTotalBuy() { return this.orders .filter((order) => order.getType() === 'BUY') .map((order) => { return this.exchangeRateDataService.toCurrency( order.getTotal(), order.getCurrency(), this.user.Settings.currency ); }) .reduce((previous, current) => previous + current, 0); } public getTotalSell() { return this.orders .filter((order) => order.getType() === 'SELL') .map((order) => { return this.exchangeRateDataService.toCurrency( order.getTotal(), order.getCurrency(), this.user.Settings.currency ); }) .reduce((previous, current) => previous + current, 0); } public getOrders() { return this.orders; } private getOrdersByType(aFilter: string[]) { return this.orders.filter((order) => { return aFilter.includes(order.getType()); }); } public getValue(aDate = getToday()) { const positions = this.getPositions(aDate); let value = 0; const [portfolioItem] = this.get(aDate); for (const symbol in positions) { if (portfolioItem.positions[symbol]?.quantity > 0) { if ( isBefore( aDate, parseISO(portfolioItem.positions[symbol]?.firstBuyDate) ) || portfolioItem.positions[symbol]?.marketPrice === 0 ) { value += this.exchangeRateDataService.toCurrency( portfolioItem.positions[symbol]?.quantity * portfolioItem.positions[symbol]?.averagePrice, portfolioItem.positions[symbol]?.currency, this.user.Settings.currency ); } else { value += this.exchangeRateDataService.toCurrency( portfolioItem.positions[symbol]?.quantity * portfolioItem.positions[symbol]?.marketPrice, portfolioItem.positions[symbol]?.currency, this.user.Settings.currency ); } } } return isFinite(value) ? value : null; } public async setOrders(aOrders: OrderWithPlatform[]) { this.orders = []; // Map data aOrders.forEach((order) => { this.orders.push( new Order({ currency: order.currency, date: order.date.toISOString(), fee: order.fee, platform: order.Platform, quantity: order.quantity, symbol: order.symbol, type: order.type, unitPrice: order.unitPrice }) ); }); await this.update(); return this; } public setUser(aUser: UserWithSettings) { this.user = aUser; return this; } /** * TODO: Refactor */ private async update() { this.portfolioItems = []; let currentDate = this.getMinDate(); if (!currentDate) { return; } // Set current date to first of month currentDate = setDate(currentDate, 1); const historicalData = await this.dataProviderService.getHistorical( this.getSymbols(), 'month', currentDate, new Date() ); while (isBefore(currentDate, Date.now())) { const positions: { [symbol: string]: Position } = {}; this.getSymbols().forEach((symbol) => { positions[symbol] = { averagePrice: 0, currency: undefined, firstBuyDate: null, investment: 0, investmentInOriginalCurrency: 0, marketPrice: historicalData[symbol]?.[format(currentDate, 'yyyy-MM-dd')] ?.marketPrice || 0, quantity: 0 }; }); if (!isYesterday(currentDate) && !isToday(currentDate)) { // Add to portfolio (ignore yesterday and today because they are added later) this.portfolioItems.push( cloneDeep({ date: currentDate.toISOString(), grossPerformancePercent: 0, investment: 0, positions: positions, value: 0 }) ); } const year = getYear(currentDate); const month = getMonth(currentDate); const day = getDate(currentDate); // Count month one up for iteration currentDate = new Date(Date.UTC(year, month + 1, day, 0)); } const yesterday = getYesterday(); let positions: { [symbol: string]: Position } = {}; if (isAfter(yesterday, this.getMinDate())) { // Add yesterday this.getSymbols().forEach((symbol) => { positions[symbol] = { averagePrice: 0, currency: undefined, firstBuyDate: null, investment: 0, investmentInOriginalCurrency: 0, marketPrice: historicalData[symbol]?.[format(yesterday, 'yyyy-MM-dd')] ?.marketPrice || 0, quantity: 0 }; }); this.portfolioItems.push( cloneDeep({ date: yesterday.toISOString(), grossPerformancePercent: 0, investment: 0, positions: positions, value: 0 }) ); } this.updatePortfolioItems(); } private updatePortfolioItems() { // console.time('update-portfolio-items'); let currentDate = new Date(); const year = getYear(currentDate); const month = getMonth(currentDate); const day = getDate(currentDate); currentDate = new Date(Date.UTC(year, month, day, 0)); if (this.portfolioItems?.length === 1) { // At least one portfolio items is needed, keep it but change the date to today. // This happens if there are only orders from today this.portfolioItems[0].date = currentDate.toISOString(); } else { // Only keep entries which are not before first buy date this.portfolioItems = this.portfolioItems.filter((portfolioItem) => { return ( isSameDay(parseISO(portfolioItem.date), this.getMinDate()) || isAfter(parseISO(portfolioItem.date), this.getMinDate()) ); }); } 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.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() ].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].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) ); this.portfolioItems[i].grossPerformancePercent = currentValue / this.portfolioItems[i].investment - 1 || 0; this.portfolioItems[i].value = currentValue; } }); // console.timeEnd('update-portfolio-items'); } }