Browse Source

Merge branch 'main' into task/improve-performance-of-last-market-price-in-market-data-table-of-admin-control

pull/8060/head
Thomas Kaul 1 week ago
committed by GitHub
parent
commit
640780eb2a
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 9
      CHANGELOG.md
  2. 10
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  3. 53
      apps/api/src/app/portfolio/current-rate.service.spec.ts
  4. 28
      apps/api/src/app/portfolio/current-rate.service.ts
  5. 2
      apps/api/src/app/portfolio/interfaces/get-values-params.interface.ts
  6. 3
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  7. 21
      apps/api/src/app/portfolio/portfolio.service.ts
  8. 2
      apps/api/src/services/market-data/market-data.service.ts
  9. 2
      apps/api/src/services/queues/data-gathering/data-gathering.service.ts
  10. 4
      package-lock.json
  11. 2
      package.json
  12. 8
      prisma/migrations/20261005000000_updated_indexes_of_market_data/migration.sql
  13. 3
      prisma/schema.prisma

9
CHANGELOG.md

@ -7,13 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Changed
- Improved the performance of the historical market data table of the admin control panel by loading only the latest market price per asset profile
## 3.80.0 - 2026-10-05
### Added ### Added
- Exposed the `ENABLE_FEATURE_SECURITY_HEADERS` environment variable to enable HTTP security headers (experimental) - Exposed the `ENABLE_FEATURE_SECURITY_HEADERS` environment variable to enable HTTP security headers (experimental)
### Changed ### Changed
- Improved the performance of the historical market data table of the admin control panel by loading only the latest market price per asset profile - Improved the portfolio snapshot calculation to get the quotes of active holdings only
- Improved the performance of getting the latest market data by optimizing the indexes of the market data table
- Deprecated `SymbolProfile` in favor of `assetProfile` in the endpoint `POST api/v1/activities` - Deprecated `SymbolProfile` in favor of `assetProfile` in the endpoint `POST api/v1/activities`
- Upgraded `Nx` from version `23.1.1` to `23.2.1` - Upgraded `Nx` from version `23.1.1` to `23.2.1`

10
apps/api/src/app/portfolio/calculator/portfolio-calculator.ts

@ -268,6 +268,7 @@ export abstract class PortfolioCalculator {
}; };
} }
const assetProfileIdentifiersWithQuotes: AssetProfileIdentifier[] = [];
const cashAssetProfileIdentifiers = new Set<string>(); const cashAssetProfileIdentifiers = new Set<string>();
const currencies: { [assetProfileIdentifier: string]: string } = {}; const currencies: { [assetProfileIdentifier: string]: string } = {};
const dataGatheringItems: DataGatheringItem[] = []; const dataGatheringItems: DataGatheringItem[] = [];
@ -279,6 +280,7 @@ export abstract class PortfolioCalculator {
assetSubClass, assetSubClass,
currency, currency,
dataSource, dataSource,
quantity,
symbol symbol
} of holdingBalancesByDate.at(-1).holdings) { } of holdingBalancesByDate.at(-1).holdings) {
// Gather data for all assets except CASH // Gather data for all assets except CASH
@ -287,6 +289,13 @@ export abstract class PortfolioCalculator {
dataSource, dataSource,
symbol symbol
}); });
if (!quantity.eq(0)) {
assetProfileIdentifiersWithQuotes.push({
dataSource,
symbol
});
}
} }
currencies[getAssetProfileIdentifier({ dataSource, symbol })] = currency; currencies[getAssetProfileIdentifier({ dataSource, symbol })] = currency;
@ -305,6 +314,7 @@ export abstract class PortfolioCalculator {
errors: currentRateErrors, errors: currentRateErrors,
values: marketSymbols values: marketSymbols
} = await this.currentRateService.getValues({ } = await this.currentRateService.getValues({
assetProfileIdentifiersWithQuotes,
dataGatheringItems, dataGatheringItems,
dateQuery: { dateQuery: {
gte: this.startDate, gte: this.startDate,

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

@ -144,6 +144,9 @@ describe('CurrentRateService', () => {
it('getValues', async () => { it('getValues', async () => {
expect( expect(
await currentRateService.getValues({ await currentRateService.getValues({
assetProfileIdentifiersWithQuotes: [
{ dataSource: DataSource.YAHOO, symbol: 'AMZN' }
],
dataGatheringItems: [{ dataSource: DataSource.YAHOO, symbol: 'AMZN' }], dataGatheringItems: [{ dataSource: DataSource.YAHOO, symbol: 'AMZN' }],
dateQuery: { dateQuery: {
lt: new Date(Date.UTC(2020, 0, 2, 0, 0, 0)), lt: new Date(Date.UTC(2020, 0, 2, 0, 0, 0)),
@ -210,7 +213,8 @@ describe('CurrentRateService', () => {
const { errors, values } = await currentRateService.getValues({ const { errors, values } = await currentRateService.getValues({
dataGatheringItems, dataGatheringItems,
dateQuery dateQuery,
assetProfileIdentifiersWithQuotes: dataGatheringItems
}); });
expect(getLatestActivity).not.toHaveBeenCalled(); expect(getLatestActivity).not.toHaveBeenCalled();
@ -234,7 +238,8 @@ describe('CurrentRateService', () => {
const { errors, values } = await currentRateService.getValues({ const { errors, values } = await currentRateService.getValues({
dataGatheringItems, dataGatheringItems,
dateQuery dateQuery,
assetProfileIdentifiersWithQuotes: dataGatheringItems
}); });
expect(getLatestActivity).toHaveBeenCalledWith({ expect(getLatestActivity).toHaveBeenCalledWith({
@ -252,5 +257,49 @@ describe('CurrentRateService', () => {
} }
]); ]);
}); });
it('should carry the latest market price forward without a quote request', async () => {
const getQuotes = jest.spyOn(dataProviderService, 'getQuotes');
jest.spyOn(marketDataService, 'getRangeCount').mockResolvedValue(1);
jest.spyOn(marketDataService, 'getRange').mockResolvedValue([
{
createdAt: yesterday,
dataSource: DataSource.YAHOO,
date: yesterday,
id: 'd51d4e0b-9d1f-4d2e-8a0c-9a0f5c6b1d22',
isCarriedForward: false,
marketPrice: 1841.823902,
state: 'CLOSE',
symbol: 'AMZN'
}
]);
const { errors, values } = await currentRateService.getValues({
dataGatheringItems,
dateQuery,
assetProfileIdentifiersWithQuotes: []
});
expect(getQuotes).toHaveBeenCalledWith(
expect.objectContaining({ items: [] })
);
expect(errors).toEqual([]);
expect(values).toEqual([
{
dataSource: DataSource.YAHOO,
date: yesterday,
marketPrice: 1841.823902,
symbol: 'AMZN'
},
{
dataSource: DataSource.YAHOO,
date: today,
marketPrice: 1841.823902,
symbol: 'AMZN'
}
]);
});
}); });
}); });

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

@ -16,7 +16,7 @@ import { utc } from '@date-fns/utc';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Type as ActivityType } from '@prisma/client'; import { Type as ActivityType } from '@prisma/client';
import { isBefore, isSameDay } from 'date-fns'; import { isBefore, isSameDay } from 'date-fns';
import { isEmpty, uniqBy } from 'lodash-es'; import { uniqBy } from 'lodash-es';
import { GetValueObject } from './interfaces/get-value-object.interface'; import { GetValueObject } from './interfaces/get-value-object.interface';
import { GetValuesObject } from './interfaces/get-values-object.interface'; import { GetValuesObject } from './interfaces/get-values-object.interface';
@ -34,6 +34,7 @@ export class CurrentRateService {
@LogPerformance @LogPerformance
public async getValues({ public async getValues({
assetProfileIdentifiersWithQuotes,
dataGatheringItems, dataGatheringItems,
dateQuery, dateQuery,
subscriptionType subscriptionType
@ -52,10 +53,10 @@ export class CurrentRateService {
if (includesToday) { if (includesToday) {
const quotes = await this.dataProviderService.getQuotes({ const quotes = await this.dataProviderService.getQuotes({
subscriptionType, subscriptionType,
items: dataGatheringItems items: assetProfileIdentifiersWithQuotes
}); });
for (const { dataSource, symbol } of dataGatheringItems) { for (const { dataSource, symbol } of assetProfileIdentifiersWithQuotes) {
const quote = quotes[getAssetProfileIdentifier({ dataSource, symbol })]; const quote = quotes[getAssetProfileIdentifier({ dataSource, symbol })];
if (quote?.dataProviderInfo) { if (quote?.dataProviderInfo) {
@ -121,8 +122,25 @@ export class CurrentRateService {
}) })
}; };
if (!isEmpty(quoteErrors)) { if (includesToday) {
for (const { dataSource, symbol } of quoteErrors) { const assetProfileIdentifiersWithoutQuotes = [
...quoteErrors,
...dataGatheringItems.filter(({ dataSource, symbol }) => {
return !assetProfileIdentifiersWithQuotes.some(
(assetProfileIdentifier) => {
return (
assetProfileIdentifier.dataSource === dataSource &&
assetProfileIdentifier.symbol === symbol
);
}
);
})
];
for (const {
dataSource,
symbol
} of assetProfileIdentifiersWithoutQuotes) {
try { try {
// If missing quote, fallback to the latest available historical market price // If missing quote, fallback to the latest available historical market price
let value: GetValueObject = response.values.find((currentValue) => { let value: GetValueObject = response.values.find((currentValue) => {

2
apps/api/src/app/portfolio/interfaces/get-values-params.interface.ts

@ -1,9 +1,11 @@
import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { SubscriptionType } from '@ghostfolio/common/enums'; import { SubscriptionType } from '@ghostfolio/common/enums';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { DateQuery } from './date-query.interface'; import { DateQuery } from './date-query.interface';
export interface GetValuesParams { export interface GetValuesParams {
assetProfileIdentifiersWithQuotes: AssetProfileIdentifier[];
dataGatheringItems: DataGatheringItem[]; dataGatheringItems: DataGatheringItem[];
dateQuery: DateQuery; dateQuery: DateQuery;
subscriptionType?: SubscriptionType; subscriptionType?: SubscriptionType;

3
apps/api/src/app/portfolio/portfolio.service.spec.ts

@ -997,6 +997,9 @@ describe('PortfolioService', () => {
// The closed holding (MSFT) does not need a market price // The closed holding (MSFT) does not need a market price
expect(getValues).toHaveBeenCalledWith( expect(getValues).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
assetProfileIdentifiersWithQuotes: [
{ dataSource: DataSource.YAHOO, symbol: 'AAPL' }
],
dataGatheringItems: [{ dataSource: DataSource.YAHOO, symbol: 'AAPL' }] dataGatheringItems: [{ dataSource: DataSource.YAHOO, symbol: 'AAPL' }]
}) })
); );

21
apps/api/src/app/portfolio/portfolio.service.ts

@ -2545,6 +2545,16 @@ export class PortfolioService {
return !quantity.eq(0); return !quantity.eq(0);
}); });
const assetProfileIdentifiers: AssetProfileIdentifier[] = openHoldings.map(
({
latestActivity: {
assetProfile: { dataSource, symbol }
}
}) => {
return { dataSource, symbol };
}
);
const now = new Date(); const now = new Date();
// Get the market prices of today with the same fallback as the portfolio // Get the market prices of today with the same fallback as the portfolio
@ -2553,15 +2563,8 @@ export class PortfolioService {
openHoldings.length > 0 openHoldings.length > 0
? await this.currentRateService.getValues({ ? await this.currentRateService.getValues({
subscriptionType, subscriptionType,
dataGatheringItems: openHoldings.map( assetProfileIdentifiersWithQuotes: assetProfileIdentifiers,
({ dataGatheringItems: assetProfileIdentifiers,
latestActivity: {
assetProfile: { dataSource, symbol }
}
}) => {
return { dataSource, symbol };
}
),
dateQuery: { dateQuery: {
gte: startOfDay(now), gte: startOfDay(now),
lt: endOfDay(now) lt: endOfDay(now)

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

@ -248,7 +248,7 @@ export class MarketDataService {
state: state as MarketDataState state: state as MarketDataState
}, },
where: { where: {
dataSource_date_symbol: { dataSource_symbol_date: {
dataSource: dataSource as DataSource, dataSource: dataSource as DataSource,
date: date as Date, date: date as Date,
symbol: symbol as string symbol: symbol as string

2
apps/api/src/services/queues/data-gathering/data-gathering.service.ts

@ -348,7 +348,7 @@ export class DataGatheringService {
}, },
update: { marketPrice, isCarriedForward: false }, update: { marketPrice, isCarriedForward: false },
where: { where: {
dataSource_date_symbol: { dataSource_symbol_date: {
dataSource, dataSource,
symbol, symbol,
date: startOfUtcDate date: startOfUtcDate

4
package-lock.json

@ -1,12 +1,12 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.79.0", "version": "3.80.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.79.0", "version": "3.80.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.79.0", "version": "3.80.0",
"homepage": "https://ghostfol.io", "homepage": "https://ghostfol.io",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio", "repository": "https://github.com/ghostfolio/ghostfolio",

8
prisma/migrations/20261005000000_updated_indexes_of_market_data/migration.sql

@ -0,0 +1,8 @@
-- CreateIndex
CREATE UNIQUE INDEX "MarketData_dataSource_symbol_date_key" ON "MarketData"("dataSource", "symbol", "date");
-- DropIndex
DROP INDEX "MarketData_dataSource_symbol_idx";
-- DropIndex
DROP INDEX "MarketData_dataSource_date_symbol_key";

3
prisma/schema.prisma

@ -173,9 +173,8 @@ model MarketData {
state MarketDataState @default(CLOSE) state MarketDataState @default(CLOSE)
symbol String symbol String
@@unique([dataSource, date, symbol]) @@unique([dataSource, symbol, date])
@@index([dataSource]) @@index([dataSource])
@@index([dataSource, symbol])
@@index([date]) @@index([date])
@@index([state]) @@index([state])
@@index([symbol]) @@index([symbol])

Loading…
Cancel
Save