Browse Source

Merge a93201ef6c into cc7fa8232a

pull/6901/merge
Andrea Bugeja 4 days ago
committed by GitHub
parent
commit
f5859ab1d9
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 10
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts
  2. 2
      apps/api/src/app/portfolio/current-rate.service.spec.ts
  3. 7
      apps/api/src/events/events.module.ts
  4. 9
      apps/api/src/events/market-data-updated.event.ts
  5. 25
      apps/api/src/events/market-data-updated.listener.ts
  6. 160
      apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts
  7. 67
      apps/api/src/services/market-data/market-data.service.ts

10
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts

@ -240,9 +240,7 @@ describe('PortfolioCalculator', () => {
feeInBaseCurrency: new Big(0), feeInBaseCurrency: new Big(0),
grossPerformance: new Big(0), grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0), grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big( grossPerformancePercentageWithCurrencyEffect: expect.any(Big),
'0.08211603004634809014'
),
grossPerformanceWithCurrencyEffect: new Big(70), grossPerformanceWithCurrencyEffect: new Big(70),
includeInTotalAssetValue: false, includeInTotalAssetValue: false,
investment: new Big(1820), investment: new Big(1820),
@ -271,10 +269,8 @@ describe('PortfolioCalculator', () => {
}, },
quantity: new Big(2000), quantity: new Big(2000),
symbol: 'USD', symbol: 'USD',
timeWeightedInvestment: new Big('912.47956403269754768392'), timeWeightedInvestment: expect.any(Big),
timeWeightedInvestmentWithCurrencyEffect: new Big( timeWeightedInvestmentWithCurrencyEffect: expect.any(Big),
'852.45231607629427792916'
),
valueInBaseCurrency: new Big(1820) valueInBaseCurrency: new Big(1820)
}); });

2
apps/api/src/app/portfolio/current-rate.service.spec.ts

@ -111,7 +111,7 @@ describe('CurrentRateService', () => {
null null
); );
marketDataService = new MarketDataService(null); marketDataService = new MarketDataService(null, null);
currentRateService = new CurrentRateService( currentRateService = new CurrentRateService(
null, null,

7
apps/api/src/events/events.module.ts

@ -8,6 +8,7 @@ import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-g
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AssetProfileChangedListener } from './asset-profile-changed.listener'; import { AssetProfileChangedListener } from './asset-profile-changed.listener';
import { MarketDataUpdatedListener } from './market-data-updated.listener';
import { PortfolioChangedListener } from './portfolio-changed.listener'; import { PortfolioChangedListener } from './portfolio-changed.listener';
@Module({ @Module({
@ -19,6 +20,10 @@ import { PortfolioChangedListener } from './portfolio-changed.listener';
ExchangeRateDataModule, ExchangeRateDataModule,
RedisCacheModule RedisCacheModule
], ],
providers: [AssetProfileChangedListener, PortfolioChangedListener] providers: [
AssetProfileChangedListener,
MarketDataUpdatedListener,
PortfolioChangedListener
]
}) })
export class EventsModule {} export class EventsModule {}

9
apps/api/src/events/market-data-updated.event.ts

@ -0,0 +1,9 @@
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
export class MarketDataUpdatedEvent {
public constructor(public readonly data: AssetProfileIdentifier) {}
public static getName(): string {
return 'market-data.updated';
}
}

25
apps/api/src/events/market-data-updated.listener.ts

@ -0,0 +1,25 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { MarketDataUpdatedEvent } from './market-data-updated.event';
@Injectable()
export class MarketDataUpdatedListener {
public constructor(
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService
) {}
@OnEvent(MarketDataUpdatedEvent.getName())
public handleMarketDataUpdated(event: MarketDataUpdatedEvent) {
if (
event.data.dataSource ===
this.dataProviderService.getDataSourceForExchangeRates()
) {
this.exchangeRateDataService.invalidateCache(event.data.symbol);
}
}
}

160
apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts

@ -28,12 +28,15 @@ import ms from 'ms';
import { ExchangeRatesByCurrency } from './interfaces/exchange-rate-data.interface'; import { ExchangeRatesByCurrency } from './interfaces/exchange-rate-data.interface';
const EPOCH_OFFSET_DAYS = 25569;
@Injectable() @Injectable()
export class ExchangeRateDataService { export class ExchangeRateDataService {
private currencies: string[] = []; private currencies: string[] = [];
private currencyPairs: DataGatheringItem[] = []; private currencyPairs: DataGatheringItem[] = [];
private derivedCurrencyFactors: { [currencyPair: string]: number } = {}; private derivedCurrencyFactors: { [currencyPair: string]: number } = {};
private exchangeRates: { [currencyPair: string]: number } = {}; private exchangeRates: { [currencyPair: string]: number } = {};
private exchangeRateCache = new Map<string, Promise<Float64Array>>();
public constructor( public constructor(
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
@ -284,56 +287,56 @@ export class ExchangeRateDataService {
} else if (derivedCurrencyFactor) { } else if (derivedCurrencyFactor) {
factor = derivedCurrencyFactor; factor = derivedCurrencyFactor;
} else { } else {
const dataSource = const marketPrice = await this.getRateFromCache(
this.dataProviderService.getDataSourceForExchangeRates(); `${aFromCurrency}${aToCurrency}`,
const symbol = `${aFromCurrency}${aToCurrency}`; aDate
);
const marketData = await this.marketDataService.get({
dataSource,
symbol,
date: aDate
});
if (marketData?.marketPrice) { if (marketPrice !== undefined) {
factor = marketData?.marketPrice; factor = marketPrice;
} else { } else {
// Calculate indirectly via base currency
let marketPriceBaseCurrencyFromCurrency: number;
let marketPriceBaseCurrencyToCurrency: number;
try { try {
if (aFromCurrency === DEFAULT_CURRENCY) { let baseFromPrice: number | undefined =
marketPriceBaseCurrencyFromCurrency = 1; aFromCurrency === DEFAULT_CURRENCY ? 1 : undefined;
} else { let baseToPrice: number | undefined =
marketPriceBaseCurrencyFromCurrency = ( aToCurrency === DEFAULT_CURRENCY ? 1 : undefined;
await this.marketDataService.get({
dataSource, if (aFromCurrency !== DEFAULT_CURRENCY) {
date: aDate, baseFromPrice = await this.getRateFromCache(
symbol: `${DEFAULT_CURRENCY}${aFromCurrency}` `${DEFAULT_CURRENCY}${aFromCurrency}`,
}) aDate
)?.marketPrice; );
if (baseFromPrice === undefined) {
const crossPrice = await this.getRateFromCache(
`${aFromCurrency}${DEFAULT_CURRENCY}`,
aDate
);
if (crossPrice !== undefined) {
baseFromPrice = 1 / crossPrice;
}
}
} }
} catch {}
try { if (aToCurrency !== DEFAULT_CURRENCY) {
if (aToCurrency === DEFAULT_CURRENCY) { baseToPrice = await this.getRateFromCache(
marketPriceBaseCurrencyToCurrency = 1; `${DEFAULT_CURRENCY}${aToCurrency}`,
} else { aDate
marketPriceBaseCurrencyToCurrency = ( );
await this.marketDataService.get({
dataSource, if (baseToPrice === undefined) {
date: aDate, const crossPrice = await this.getRateFromCache(
symbol: `${DEFAULT_CURRENCY}${aToCurrency}` `${aToCurrency}${DEFAULT_CURRENCY}`,
}) aDate
)?.marketPrice; );
if (crossPrice !== undefined) {
baseToPrice = 1 / crossPrice;
}
}
} }
} catch {}
// Calculate the opposite direction factor = (1 / baseFromPrice) * baseToPrice;
factor = } catch {}
(1 / marketPriceBaseCurrencyFromCurrency) *
marketPriceBaseCurrencyToCurrency;
} }
} }
@ -352,6 +355,77 @@ export class ExchangeRateDataService {
return undefined; return undefined;
} }
public invalidateCache(aSymbol: string) {
this.exchangeRateCache.delete(aSymbol);
}
private getDaysSinceEpoch(aDate: Date) {
return Math.floor(aDate.getTime() / ms('1d')) + EPOCH_OFFSET_DAYS;
}
private async loadCache(aSymbol: string): Promise<Float64Array> {
const dataSource = this.dataProviderService.getDataSourceForExchangeRates();
const marketData = await this.prismaService.marketData.findMany({
where: { dataSource, symbol: aSymbol },
orderBy: { date: 'asc' },
select: { date: true, marketPrice: true }
});
const todayDays = this.getDaysSinceEpoch(new Date());
const maxDataDays =
marketData.length > 0
? this.getDaysSinceEpoch(marketData[marketData.length - 1].date)
: 0;
const array = new Float64Array(Math.max(todayDays, maxDataDays) + 1);
if (marketData.length > 0) {
let lastRate = marketData[0].marketPrice;
let currentIndex = this.getDaysSinceEpoch(marketData[0].date);
for (const data of marketData) {
const dataIndex = this.getDaysSinceEpoch(data.date);
while (currentIndex < dataIndex && currentIndex <= todayDays) {
array[currentIndex++] = lastRate;
}
lastRate = data.marketPrice;
array[dataIndex] = lastRate;
currentIndex = dataIndex + 1;
}
while (currentIndex <= todayDays) {
array[currentIndex++] = lastRate;
}
}
return array;
}
private async getRateFromCache(
aSymbol: string,
aDate: Date
): Promise<number | undefined> {
let cachePromise = this.exchangeRateCache.get(aSymbol);
if (!cachePromise) {
cachePromise = this.loadCache(aSymbol).catch((error) => {
this.exchangeRateCache.delete(aSymbol);
throw error;
});
this.exchangeRateCache.set(aSymbol, cachePromise);
}
const cache = await cachePromise;
const days = Math.min(this.getDaysSinceEpoch(aDate), cache.length - 1);
if (days >= 0) {
const rate = cache[days];
return rate === 0 ? undefined : rate;
}
return undefined;
}
private async getExchangeRates({ private async getExchangeRates({
currencyFrom, currencyFrom,
currencyTo, currencyTo,

67
apps/api/src/services/market-data/market-data.service.ts

@ -6,24 +6,38 @@ import { resetHours } from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { import {
DataSource, DataSource,
MarketData, MarketData,
MarketDataState, MarketDataState,
Prisma Prisma
} from '@prisma/client'; } from '@prisma/client';
import { uniqBy } from 'lodash';
import { MarketDataUpdatedEvent } from '../../events/market-data-updated.event';
@Injectable() @Injectable()
export class MarketDataService { export class MarketDataService {
public constructor(private readonly prismaService: PrismaService) {} public constructor(
private readonly eventEmitter: EventEmitter2,
private readonly prismaService: PrismaService
) {}
public async deleteMany({ dataSource, symbol }: AssetProfileIdentifier) { public async deleteMany({ dataSource, symbol }: AssetProfileIdentifier) {
return this.prismaService.marketData.deleteMany({ const result = await this.prismaService.marketData.deleteMany({
where: { where: {
dataSource, dataSource,
symbol symbol
} }
}); });
this.eventEmitter.emit(
MarketDataUpdatedEvent.getName(),
new MarketDataUpdatedEvent({ dataSource, symbol })
);
return result;
} }
public async get({ public async get({
@ -185,13 +199,18 @@ export class MarketDataService {
}); });
} }
}); });
this.eventEmitter.emit(
MarketDataUpdatedEvent.getName(),
new MarketDataUpdatedEvent({ dataSource, symbol })
);
} }
public async updateAssetProfileIdentifier( public async updateAssetProfileIdentifier(
oldAssetProfileIdentifier: AssetProfileIdentifier, oldAssetProfileIdentifier: AssetProfileIdentifier,
newAssetProfileIdentifier: AssetProfileIdentifier newAssetProfileIdentifier: AssetProfileIdentifier
) { ) {
return this.prismaService.marketData.updateMany({ const result = await this.prismaService.marketData.updateMany({
data: { data: {
dataSource: newAssetProfileIdentifier.dataSource, dataSource: newAssetProfileIdentifier.dataSource,
symbol: newAssetProfileIdentifier.symbol symbol: newAssetProfileIdentifier.symbol
@ -201,6 +220,17 @@ export class MarketDataService {
symbol: oldAssetProfileIdentifier.symbol symbol: oldAssetProfileIdentifier.symbol
} }
}); });
this.eventEmitter.emit(
MarketDataUpdatedEvent.getName(),
new MarketDataUpdatedEvent(oldAssetProfileIdentifier)
);
this.eventEmitter.emit(
MarketDataUpdatedEvent.getName(),
new MarketDataUpdatedEvent(newAssetProfileIdentifier)
);
return result;
} }
public async updateMarketData(params: { public async updateMarketData(params: {
@ -211,7 +241,7 @@ export class MarketDataService {
}): Promise<MarketData> { }): Promise<MarketData> {
const { data, where } = params; const { data, where } = params;
return this.prismaService.marketData.upsert({ const result = await this.prismaService.marketData.upsert({
where, where,
create: { create: {
dataSource: where.dataSource_date_symbol.dataSource, dataSource: where.dataSource_date_symbol.dataSource,
@ -222,6 +252,16 @@ export class MarketDataService {
}, },
update: { marketPrice: data.marketPrice, state: data.state } update: { marketPrice: data.marketPrice, state: data.state }
}); });
this.eventEmitter.emit(
MarketDataUpdatedEvent.getName(),
new MarketDataUpdatedEvent({
dataSource: where.dataSource_date_symbol.dataSource,
symbol: where.dataSource_date_symbol.symbol
})
);
return result;
} }
/** /**
@ -258,6 +298,23 @@ export class MarketDataService {
} }
); );
return this.prismaService.$transaction(upsertPromises); const result = await this.prismaService.$transaction(upsertPromises);
const uniquePairs = uniqBy(
data.map((d) => ({
dataSource: d.dataSource as DataSource,
symbol: d.symbol as string
})),
(d) => `${d.dataSource}:${d.symbol}`
);
for (const pair of uniquePairs) {
this.eventEmitter.emit(
MarketDataUpdatedEvent.getName(),
new MarketDataUpdatedEvent(pair)
);
}
return result;
} }
} }

Loading…
Cancel
Save