Browse Source

Merge 42630fbaa6 into cf0bdba6eb

pull/7948/merge
Thomas Kaul 2 hours ago
committed by GitHub
parent
commit
50dc0fb92c
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 5
      CHANGELOG.md
  2. 96
      apps/api/src/app/portfolio/current-rate.service.spec.ts
  3. 27
      apps/api/src/app/portfolio/current-rate.service.ts
  4. 59
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  5. 32
      apps/api/src/app/portfolio/portfolio.service.ts
  6. 96
      apps/api/src/services/data-provider/manual/manual.service.spec.ts
  7. 29
      apps/api/src/services/data-provider/manual/manual.service.ts

5
CHANGELOG.md

@ -9,8 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Improved the get quotes functionality of the _Manual_ service
- Upgraded `chartjs-chart-treemap` from version `4.2.0` to `4.2.2` - Upgraded `chartjs-chart-treemap` from version `4.2.0` to `4.2.2`
### Fixed
- Fixed an issue where holdings without a quote have been valued at the unit price of the latest activity instead of the latest market price
## 3.74.0 - 2026-09-27 ## 3.74.0 - 2026-09-27
### Added ### Added

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

@ -1,9 +1,12 @@
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { resetHours } from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { DataSource, MarketData } from '@prisma/client'; import { DataSource, MarketData, Order } from '@prisma/client';
import { addDays, subDays } from 'date-fns';
import { CurrentRateService } from './current-rate.service'; import { CurrentRateService } from './current-rate.service';
import { DateQuery } from './interfaces/date-query.interface'; import { DateQuery } from './interfaces/date-query.interface';
@ -25,6 +28,9 @@ jest.mock('@ghostfolio/api/services/market-data/market-data.service', () => {
state: 'CLOSE' state: 'CLOSE'
}); });
}, },
getLatest: () => {
return Promise.resolve<MarketData>(null);
},
getRange: ({ getRange: ({
assetProfileIdentifiers, assetProfileIdentifiers,
dateQuery dateQuery
@ -97,12 +103,19 @@ jest.mock('@ghostfolio/api/services/property/property.service', () => {
}); });
describe('CurrentRateService', () => { describe('CurrentRateService', () => {
let activitiesService: ActivitiesService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
let dataProviderService: DataProviderService; let dataProviderService: DataProviderService;
let marketDataService: MarketDataService; let marketDataService: MarketDataService;
let propertyService: PropertyService; let propertyService: PropertyService;
beforeAll(async () => { beforeAll(async () => {
activitiesService = {
getLatestActivity: () => {
return Promise.resolve<Order>(null);
}
} as unknown as ActivitiesService;
propertyService = new PropertyService(null); propertyService = new PropertyService(null);
dataProviderService = new DataProviderService( dataProviderService = new DataProviderService(
@ -117,7 +130,7 @@ describe('CurrentRateService', () => {
marketDataService = new MarketDataService(null); marketDataService = new MarketDataService(null);
currentRateService = new CurrentRateService( currentRateService = new CurrentRateService(
null, activitiesService,
dataProviderService, dataProviderService,
marketDataService marketDataService
); );
@ -151,4 +164,83 @@ describe('CurrentRateService', () => {
] ]
}); });
}); });
describe('getValues without a quote', () => {
const today = resetHours(new Date());
const yesterday = subDays(today, 1);
const dataGatheringItems: AssetProfileIdentifier[] = [
{ dataSource: DataSource.YAHOO, symbol: 'AMZN' }
];
const dateQuery: DateQuery = {
gte: yesterday,
lt: addDays(today, 1)
};
beforeEach(() => {
jest.spyOn(dataProviderService, 'getQuotes').mockResolvedValue({});
jest.spyOn(marketDataService, 'getRangeCount').mockResolvedValue(0);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should fall back to the latest market price', async () => {
const getLatestActivity = jest
.spyOn(activitiesService, 'getLatestActivity')
.mockResolvedValue({ unitPrice: 1000 } as Order);
jest.spyOn(marketDataService, 'getLatest').mockResolvedValue({
createdAt: yesterday,
dataSource: DataSource.YAHOO,
date: yesterday,
id: '4f2b8a1e-7c3d-4e5f-9a6b-1c2d3e4f5a6b',
isCarriedForward: false,
marketPrice: 1847.839966,
state: 'CLOSE',
symbol: 'AMZN'
});
const { errors, values } = await currentRateService.getValues({
dataGatheringItems,
dateQuery
});
expect(getLatestActivity).not.toHaveBeenCalled();
expect(errors).toEqual(dataGatheringItems);
expect(values).toEqual([
{
dataSource: DataSource.YAHOO,
date: today,
marketPrice: 1847.839966,
symbol: 'AMZN'
}
]);
});
it('should fall back to the unit price of the latest activity without market data', async () => {
jest
.spyOn(activitiesService, 'getLatestActivity')
.mockResolvedValue({ unitPrice: 1000 } as Order);
jest.spyOn(marketDataService, 'getLatest').mockResolvedValue(null);
const { errors, values } = await currentRateService.getValues({
dataGatheringItems,
dateQuery
});
expect(errors).toEqual(dataGatheringItems);
expect(values).toEqual([
{
dataSource: DataSource.YAHOO,
date: today,
marketPrice: 1000,
symbol: 'AMZN'
}
]);
});
});
}); });

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

@ -132,18 +132,29 @@ export class CurrentRateService {
}); });
if (!value) { if (!value) {
// Fallback to unit price of latest activity const latestMarketData = await this.marketDataService.getLatest({
const latestActivity = dataSource,
await this.activitiesService.getLatestActivity({ symbol
dataSource, });
symbol
}); let marketPrice = latestMarketData?.marketPrice;
if (!marketPrice) {
// Fallback to unit price of latest activity
const latestActivity =
await this.activitiesService.getLatestActivity({
dataSource,
symbol
});
marketPrice = latestActivity?.unitPrice ?? 0;
}
value = { value = {
dataSource, dataSource,
marketPrice,
symbol, symbol,
date: today, date: today
marketPrice: latestActivity?.unitPrice ?? 0
}; };
response.values.push(value); response.values.push(value);

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

@ -4,6 +4,7 @@ import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.ser
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
@ -33,6 +34,7 @@ describe('PortfolioService', () => {
let accountService: AccountService; let accountService: AccountService;
let activitiesService: ActivitiesService; let activitiesService: ActivitiesService;
let configurationService: ConfigurationService; let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let dataProviderService: DataProviderService; let dataProviderService: DataProviderService;
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory; let portfolioCalculatorFactory: PortfolioCalculatorFactory;
@ -82,6 +84,12 @@ describe('PortfolioService', () => {
null null
); );
currentRateService = new CurrentRateService(
activitiesService,
dataProviderService,
null
);
portfolioCalculatorFactory = new PortfolioCalculatorFactory( portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService, configurationService,
null, null,
@ -110,6 +118,7 @@ describe('PortfolioService', () => {
activitiesService, activitiesService,
null, null,
portfolioCalculatorFactory, portfolioCalculatorFactory,
currentRateService,
dataProviderService, dataProviderService,
exchangeRateDataService, exchangeRateDataService,
null, null,
@ -901,15 +910,19 @@ describe('PortfolioService', () => {
count: 4 count: 4
}); });
const getQuotes = jest const getValues = jest
.spyOn(dataProviderService, 'getQuotes') .spyOn(currentRateService, 'getValues')
.mockResolvedValue({ .mockResolvedValue({
'YAHOO-AAPL': { dataProviderInfos: [],
currency: 'CHF', errors: [],
dataSource: DataSource.YAHOO, values: [
marketPrice: 200, {
marketState: 'open' dataSource: DataSource.YAHOO,
} date: new Date(),
marketPrice: 200,
symbol: 'AAPL'
}
]
}); });
const summary = await getSummary({ const summary = await getSummary({
@ -921,10 +934,10 @@ describe('PortfolioService', () => {
userId: userDummyData.id userId: userDummyData.id
}); });
// The closed holding (MSFT) does not need a quote // The closed holding (MSFT) does not need a market price
expect(getQuotes).toHaveBeenCalledWith( expect(getValues).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
items: [{ dataSource: DataSource.YAHOO, symbol: 'AAPL' }] dataGatheringItems: [{ dataSource: DataSource.YAHOO, symbol: 'AAPL' }]
}) })
); );
@ -961,13 +974,17 @@ describe('PortfolioService', () => {
count: 2 count: 2
}); });
jest.spyOn(dataProviderService, 'getQuotes').mockResolvedValue({ jest.spyOn(currentRateService, 'getValues').mockResolvedValue({
'YAHOO-AAPL': { dataProviderInfos: [],
currency: 'USD', errors: [],
dataSource: DataSource.YAHOO, values: [
marketPrice: 200, {
marketState: 'open' dataSource: DataSource.YAHOO,
} date: new Date(),
marketPrice: 200,
symbol: 'AAPL'
}
]
}); });
jest jest
@ -1018,7 +1035,11 @@ describe('PortfolioService', () => {
count: 2 count: 2
}); });
jest.spyOn(dataProviderService, 'getQuotes').mockResolvedValue({}); jest.spyOn(currentRateService, 'getValues').mockResolvedValue({
dataProviderInfos: [],
errors: [],
values: []
});
const summary = await getSummary({ const summary = await getSummary({
balanceInBaseCurrency: 1000, balanceInBaseCurrency: 1000,

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

@ -94,18 +94,21 @@ import {
import { Big } from 'big.js'; import { Big } from 'big.js';
import { import {
differenceInDays, differenceInDays,
endOfDay,
format, format,
isAfter, isAfter,
isBefore, isBefore,
isSameMonth, isSameMonth,
isSameYear, isSameYear,
parseISO, parseISO,
set set,
startOfDay
} from 'date-fns'; } from 'date-fns';
import { groupBy } from 'lodash'; import { groupBy } from 'lodash';
import { PortfolioCalculator } from './calculator/portfolio-calculator'; import { PortfolioCalculator } from './calculator/portfolio-calculator';
import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory';
import { CurrentRateService } from './current-rate.service';
import { RulesService } from './rules.service'; import { RulesService } from './rules.service';
const Fuse = require('fuse.js'); const Fuse = require('fuse.js');
@ -125,6 +128,7 @@ export class PortfolioService {
private readonly activitiesService: ActivitiesService, private readonly activitiesService: ActivitiesService,
private readonly benchmarkService: BenchmarkService, private readonly benchmarkService: BenchmarkService,
private readonly calculatorFactory: PortfolioCalculatorFactory, private readonly calculatorFactory: PortfolioCalculatorFactory,
private readonly currentRateService: CurrentRateService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly i18nService: I18nService, private readonly i18nService: I18nService,
@ -2539,11 +2543,15 @@ export class PortfolioService {
return !quantity.eq(0); return !quantity.eq(0);
}); });
const quotes = const now = new Date();
// Get the market prices of today with the same fallback as the portfolio
// calculator
const { values } =
openHoldings.length > 0 openHoldings.length > 0
? await this.dataProviderService.getQuotes({ ? await this.currentRateService.getValues({
subscriptionType, subscriptionType,
items: openHoldings.map( dataGatheringItems: openHoldings.map(
({ ({
latestActivity: { latestActivity: {
assetProfile: { dataSource, symbol } assetProfile: { dataSource, symbol }
@ -2551,16 +2559,24 @@ export class PortfolioService {
}) => { }) => {
return { dataSource, symbol }; return { dataSource, symbol };
} }
) ),
dateQuery: {
gte: startOfDay(now),
lt: endOfDay(now)
}
}) })
: {}; : { values: [] };
return getSum( return getSum(
openHoldings.map(({ latestActivity, quantity }) => { openHoldings.map(({ latestActivity, quantity }) => {
const { assetProfile, currency, unitPrice } = latestActivity; const { assetProfile, currency, unitPrice } = latestActivity;
const marketPrice = const marketPrice = values.find((value) => {
quotes[getAssetProfileIdentifier(assetProfile)]?.marketPrice; return (
getAssetProfileIdentifier(value) ===
getAssetProfileIdentifier(assetProfile)
);
})?.marketPrice;
if (!marketPrice) { if (!marketPrice) {
// Fall back to the unit price of the latest activity without a // Fall back to the unit price of the latest activity without a

96
apps/api/src/services/data-provider/manual/manual.service.spec.ts

@ -0,0 +1,96 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { EnhancedAssetProfile } from '@ghostfolio/common/interfaces';
import { DataSource } from '@prisma/client';
import { ManualService } from './manual.service';
jest.mock('@ghostfolio/api/services/fetch/fetch.service', () => {
return {
FetchService: jest.fn()
};
});
describe('ManualService', () => {
let manualService: ManualService;
let prismaService: {
marketData: {
findFirst: jest.Mock;
};
};
let symbolProfileService: SymbolProfileService;
beforeEach(() => {
prismaService = {
marketData: {
findFirst: jest.fn()
}
};
symbolProfileService = new SymbolProfileService(null);
manualService = new ManualService(
null,
null,
prismaService as unknown as PrismaService,
symbolProfileService
);
});
describe('getQuotes', () => {
it('should use the latest market price of each symbol of the manual data source', async () => {
const latestMarketPrices: { [symbol: string]: number } = {
A: 13,
B: 22
};
prismaService.marketData.findFirst.mockImplementation(
({ where: { symbol } }) => {
return Promise.resolve(
latestMarketPrices[symbol]
? { symbol, marketPrice: latestMarketPrices[symbol] }
: null
);
}
);
jest.spyOn(symbolProfileService, 'getSymbolProfiles').mockResolvedValue(
['A', 'B', 'C'].map((symbol) => {
return { symbol, currency: 'USD' };
}) as EnhancedAssetProfile[]
);
const quotes = await manualService.getQuotes({
symbols: ['A', 'B', 'C']
});
expect(prismaService.marketData.findFirst).toHaveBeenCalledWith({
orderBy: { date: 'desc' },
where: { dataSource: DataSource.MANUAL, symbol: 'A' }
});
expect(quotes).toEqual({
A: {
currency: 'USD',
dataSource: DataSource.MANUAL,
marketPrice: 13,
marketState: 'delayed'
},
B: {
currency: 'USD',
dataSource: DataSource.MANUAL,
marketPrice: 22,
marketState: 'delayed'
},
// Without market data
C: {
currency: 'USD',
dataSource: DataSource.MANUAL,
marketPrice: 0,
marketState: 'delayed'
}
});
});
});
});

29
apps/api/src/services/data-provider/manual/manual.service.ts

@ -150,18 +150,21 @@ export class ManualService implements DataProviderInterface {
}) })
); );
const marketData = await this.prismaService.marketData.findMany({ // Query the latest market data per symbol because distinct loads all
distinct: ['symbol'], // the market data of the symbols into memory
orderBy: { const marketData = await Promise.all(
date: 'desc' symbols.map((symbol) => {
}, return this.prismaService.marketData.findFirst({
take: symbols.length, orderBy: {
where: { date: 'desc'
symbol: { },
in: symbols where: {
} symbol,
} dataSource: this.getName()
}); }
});
})
);
const symbolProfilesToScrape = symbolProfiles.filter( const symbolProfilesToScrape = symbolProfiles.filter(
({ scraperConfiguration }) => { ({ scraperConfiguration }) => {
@ -202,7 +205,7 @@ export class ManualService implements DataProviderInterface {
marketPrice = marketPrice =
marketPrice ?? marketPrice ??
marketData.find((marketDataItem) => { marketData.find((marketDataItem) => {
return marketDataItem.symbol === symbol; return marketDataItem?.symbol === symbol;
})?.marketPrice ?? })?.marketPrice ??
0; 0;

Loading…
Cancel
Save