Browse Source

Merge branch 'main' into feature/simplified-mode-in-holdings-table-component

pull/7830/head^2
Thomas Kaul 2 days ago
committed by GitHub
parent
commit
4b7041bd7d
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      CHANGELOG.md
  2. 20
      apps/api/src/app/activities/activities.service.spec.ts
  3. 7
      apps/api/src/app/activities/activities.service.ts
  4. 6
      apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts
  5. 10
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  6. 6
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts
  7. 6
      apps/api/src/app/portfolio/portfolio.controller.ts
  8. 146
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  9. 53
      apps/api/src/app/portfolio/portfolio.service.ts
  10. 4
      apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts
  11. 2
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  12. 21
      libs/ui/src/lib/activities-table/activities-table.component.ts

4
CHANGELOG.md

@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added a simplified mode to the holdings table component
### Changed
- Made the details of holdings excluded from analysis accessible via the activities table
## 3.67.1 - 2026-09-05
### Added

20
apps/api/src/app/activities/activities.service.spec.ts

@ -216,6 +216,26 @@ describe('ActivitiesService', () => {
});
});
it('includes excluded accounts and activities when requested', async () => {
jest.spyOn(activitiesService, 'getActivities').mockResolvedValue({
activities: [],
count: 0
});
await activitiesService.getActivitiesForPortfolioCalculator({
userCurrency: 'USD',
userId: 'user-id',
withExcludedAccountsAndActivities: true
});
expect(activitiesService.getActivities).toHaveBeenCalledWith({
filters: undefined,
userCurrency: 'USD',
userId: 'user-id',
withExcludedAccountsAndActivities: true
});
});
it('does not adjust synthetic cash activities', async () => {
const activity = createActivity({ symbol: 'AAPL' });
const cashActivity = createActivity({

7
apps/api/src/app/activities/activities.service.ts

@ -759,7 +759,8 @@ export class ActivitiesService {
filters,
userCurrency,
userId,
withCash = false
withCash = false,
withExcludedAccountsAndActivities = false
}: {
/** Optional filters to apply to the activities. */
filters?: Filter[];
@ -769,13 +770,15 @@ export class ActivitiesService {
userId: string;
/** Whether to include cash activities in the result. */
withCash?: boolean;
/** Whether to include activities that are excluded from analysis. */
withExcludedAccountsAndActivities?: boolean;
}) {
const [activities, splits] = await Promise.all([
this.getActivities({
filters,
userCurrency,
userId,
withExcludedAccountsAndActivities: false // TODO
withExcludedAccountsAndActivities
}),
this.assetProfileSplitService.getSplitsByUserId({ userId })
]);

6
apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts

@ -34,6 +34,7 @@ export class PortfolioCalculatorFactory {
calculationType,
currency,
filters = [],
usePortfolioSnapshotCache = true,
userId
}: {
accountBalanceItems?: HistoricalDataItem[];
@ -41,6 +42,7 @@ export class PortfolioCalculatorFactory {
calculationType: PerformanceCalculationType;
currency: string;
filters?: Filter[];
usePortfolioSnapshotCache?: boolean;
userId: string;
}): PortfolioCalculator {
switch (calculationType) {
@ -50,6 +52,7 @@ export class PortfolioCalculatorFactory {
activities,
currency,
filters,
usePortfolioSnapshotCache,
userId,
configurationService: this.configurationService,
currentRateService: this.currentRateService,
@ -64,6 +67,7 @@ export class PortfolioCalculatorFactory {
activities,
currency,
filters,
usePortfolioSnapshotCache,
userId,
configurationService: this.configurationService,
currentRateService: this.currentRateService,
@ -78,6 +82,7 @@ export class PortfolioCalculatorFactory {
activities,
currency,
filters,
usePortfolioSnapshotCache,
userId,
configurationService: this.configurationService,
currentRateService: this.currentRateService,
@ -92,6 +97,7 @@ export class PortfolioCalculatorFactory {
activities,
currency,
filters,
usePortfolioSnapshotCache,
userId,
configurationService: this.configurationService,
currentRateService: this.currentRateService,

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

@ -90,6 +90,7 @@ export abstract class PortfolioCalculator {
private snapshotPromise: Promise<void>;
private startDate: Date;
private transactionPoints: TransactionPoint[];
private usePortfolioSnapshotCache: boolean;
private userId: string;
public constructor({
@ -102,6 +103,7 @@ export abstract class PortfolioCalculator {
filters,
portfolioSnapshotService,
redisCacheService,
usePortfolioSnapshotCache = true,
userId
}: {
accountBalanceItems: HistoricalDataItem[];
@ -113,6 +115,7 @@ export abstract class PortfolioCalculator {
filters: Filter[];
portfolioSnapshotService: PortfolioSnapshotService;
redisCacheService: RedisCacheService;
usePortfolioSnapshotCache?: boolean;
userId: string;
}) {
this.accountBalanceItems = accountBalanceItems;
@ -175,6 +178,7 @@ export abstract class PortfolioCalculator {
this.portfolioSnapshotService = portfolioSnapshotService;
this.redisCacheService = redisCacheService;
this.usePortfolioSnapshotCache = usePortfolioSnapshotCache;
this.userId = userId;
const { endDate, startDate } = getIntervalFromDateRange({
@ -187,7 +191,11 @@ export abstract class PortfolioCalculator {
this.computeTransactionPoints();
this.snapshotPromise = this.initialize();
this.snapshotPromise = this.usePortfolioSnapshotCache
? this.initialize()
: this.computeSnapshot().then((snapshot) => {
this.snapshot = snapshot;
});
// Mark the rejection as handled to prevent an unhandled promise rejection
// in case the snapshot promise is never awaited. Consumers awaiting it

6
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts

@ -108,10 +108,11 @@ describe('PortfolioCalculator', () => {
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'USD',
usePortfolioSnapshotCache: false,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('500000'),
@ -169,6 +170,9 @@ describe('PortfolioCalculator', () => {
totalInvestmentValueWithCurrencyEffect: 500000
})
);
expect(PortfolioSnapshotServiceMock.jobsStore.size).toBe(0);
expect(RedisCacheServiceMock.cache.size).toBe(0);
});
});
});

6
apps/api/src/app/portfolio/portfolio.controller.ts

@ -357,7 +357,8 @@ export class PortfolioController {
const holding = await this.portfolioService.getHolding({
dataSource,
symbol,
userId
userId,
withExcludedActivities: true
});
if (!holding) {
@ -631,7 +632,8 @@ export class PortfolioController {
const holding = await this.portfolioService.getHolding({
dataSource,
symbol,
userId
userId,
withExcludedActivities: true
});
if (!holding) {

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

@ -9,9 +9,14 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con
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 { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { TAG_ID_EMERGENCY_FUND, UNKNOWN_KEY } from '@ghostfolio/common/config';
import {
TAG_ID_EMERGENCY_FUND,
TAG_ID_EXCLUDE_FROM_ANALYSIS,
UNKNOWN_KEY
} from '@ghostfolio/common/config';
import { parseDate } from '@ghostfolio/common/helper';
import {
Activity,
AssetProfileIdentifier,
Filter,
PortfolioSummary
@ -490,6 +495,145 @@ describe('PortfolioService', () => {
});
});
describe('getHolding', () => {
const dataSource = DataSource.YAHOO;
const symbol = 'AAPL';
const includedActivity = {
assetProfile: { dataSource, symbol },
tags: []
} as Activity;
beforeEach(() => {
jest.spyOn(userService, 'user').mockResolvedValue({
id: userDummyData.id,
settings: { settings: { baseCurrency: 'USD' } }
} as unknown as Awaited<ReturnType<typeof userService.user>>);
jest
.spyOn(symbolProfileService, 'getSymbolProfiles')
.mockResolvedValue([]);
jest
.spyOn(portfolioCalculatorFactory, 'createCalculator')
.mockReturnValue({
getSnapshot: jest.fn().mockResolvedValue({ positions: [] }),
getTransactionPoints: jest.fn().mockReturnValue([])
} as unknown as PortfolioCalculator);
});
it('keeps the cached path when the holding has included and excluded activities', async () => {
const excludedActivity = {
...includedActivity,
tags: [{ id: TAG_ID_EXCLUDE_FROM_ANALYSIS }]
} as Activity;
const getActivities = jest
.spyOn(activitiesService, 'getActivitiesForPortfolioCalculator')
.mockResolvedValueOnce({ activities: [includedActivity], count: 1 })
.mockResolvedValue({
activities: [includedActivity, excludedActivity],
count: 2
});
await portfolioService.getHolding({
dataSource,
symbol,
userId: userDummyData.id,
withExcludedActivities: true
});
expect(getActivities).toHaveBeenCalledTimes(1);
expect(getActivities).toHaveBeenCalledWith({
userCurrency: 'USD',
userId: userDummyData.id
});
expect(portfolioCalculatorFactory.createCalculator).toHaveBeenCalledWith(
expect.objectContaining({
activities: [includedActivity],
filters: undefined,
usePortfolioSnapshotCache: true
})
);
});
it.each([
{
account: { tags: [{ id: TAG_ID_EXCLUDE_FROM_ANALYSIS }] },
name: 'account',
tags: []
},
{
account: { tags: [] },
name: 'activity tag',
tags: [{ id: TAG_ID_EXCLUDE_FROM_ANALYSIS }]
}
])(
'uses the direct path for a holding excluded by its $name',
async ({ account, tags }) => {
const excludedActivity = {
...includedActivity,
account,
tags
} as Activity;
const getActivities = jest
.spyOn(activitiesService, 'getActivitiesForPortfolioCalculator')
.mockResolvedValueOnce({ activities: [], count: 0 })
.mockResolvedValueOnce({
activities: [excludedActivity],
count: 1
});
await portfolioService.getHolding({
dataSource,
symbol,
userId: userDummyData.id,
withExcludedActivities: true
});
expect(getActivities).toHaveBeenCalledTimes(2);
expect(getActivities).toHaveBeenLastCalledWith({
filters: [
{ id: dataSource, type: 'DATA_SOURCE' },
{ id: symbol, type: 'SYMBOL' }
],
userCurrency: 'USD',
userId: userDummyData.id,
withExcludedAccountsAndActivities: true
});
expect(
portfolioCalculatorFactory.createCalculator
).toHaveBeenCalledWith(
expect.objectContaining({
activities: [excludedActivity],
filters: [
{ id: dataSource, type: 'DATA_SOURCE' },
{ id: symbol, type: 'SYMBOL' }
],
usePortfolioSnapshotCache: false
})
);
}
);
it('does not load excluded activities by default', async () => {
const getActivities = jest
.spyOn(activitiesService, 'getActivitiesForPortfolioCalculator')
.mockResolvedValue({ activities: [], count: 0 });
const holding = await portfolioService.getHolding({
dataSource,
symbol,
userId: userDummyData.id
});
expect(holding).toBeUndefined();
expect(getActivities).toHaveBeenCalledTimes(1);
expect(
portfolioCalculatorFactory.createCalculator
).not.toHaveBeenCalled();
});
});
describe('getSummary', () => {
const getSummary = (args: object) => {
return (

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

@ -888,20 +888,47 @@ export class PortfolioService {
public async getHolding({
dataSource,
symbol,
userId
userId,
withExcludedActivities = false
}: {
userId: string;
withExcludedActivities?: boolean;
} & AssetProfileIdentifier): Promise<PortfolioHoldingResponse> {
const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user);
const { activities } =
const holdingFilters: Filter[] = [
{ id: dataSource, type: 'DATA_SOURCE' },
{ id: symbol, type: 'SYMBOL' }
];
let { activities } =
await this.activitiesService.getActivitiesForPortfolioCalculator({
userCurrency,
userId
});
if (activities.length === 0) {
let hasActivitiesOfHolding = activities.some(({ assetProfile }) => {
return (
assetProfile.dataSource === dataSource && assetProfile.symbol === symbol
);
});
const isExcludedHolding = withExcludedActivities && !hasActivitiesOfHolding;
if (isExcludedHolding) {
({ activities } =
await this.activitiesService.getActivitiesForPortfolioCalculator({
userCurrency,
userId,
filters: holdingFilters,
withExcludedAccountsAndActivities: true
}));
hasActivitiesOfHolding = activities.length > 0;
}
if (!hasActivitiesOfHolding) {
return undefined;
}
@ -927,7 +954,9 @@ export class PortfolioService {
activities,
userId,
calculationType: this.getUserPerformanceCalculationType(user),
currency: userCurrency
currency: userCurrency,
filters: isExcludedHolding ? holdingFilters : undefined,
usePortfolioSnapshotCache: !isExcludedHolding
});
const transactionPoints = portfolioCalculator.getTransactionPoints();
@ -2033,12 +2062,7 @@ export class PortfolioService {
const nonExcludedActivities: Activity[] = [];
for (const activity of activities) {
if (
(activity.account && isAccountExcluded(activity.account)) ||
activity.tags?.some(({ id }) => {
return id === TAG_ID_EXCLUDE_FROM_ANALYSIS;
})
) {
if (this.isExcludedFromAnalysis(activity)) {
excludedActivities.push(activity);
} else {
nonExcludedActivities.push(activity);
@ -2418,4 +2442,13 @@ export class PortfolioService {
return { accounts, platforms };
}
private isExcludedFromAnalysis(activity: Activity) {
return (
isAccountExcluded(activity.account) ||
activity.tags?.some(({ id }) => {
return id === TAG_ID_EXCLUDE_FROM_ANALYSIS;
}) === true
);
}
}

4
apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts

@ -70,6 +70,8 @@ export class TransformDataSourceInResponseInterceptor<
}
}
data.dataProviderInfo = undefined;
if (Object.keys(valueMap).length === 0) {
return data;
}
@ -94,8 +96,6 @@ export class TransformDataSourceInResponseInterceptor<
'watchlist[*].dataSource'
]
});
data.dataProviderInfo = undefined;
}
return data;

2
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html

@ -358,7 +358,7 @@
</form>
</div>
@if (dataProviderInfo) {
@if (dataProviderInfo?.name) {
<div class="col-md-12 mb-3 text-center">
<hr />
<gf-data-provider-credits

21
libs/ui/src/lib/activities-table/activities-table.component.ts

@ -1,13 +1,6 @@
import {
DEFAULT_PAGE_SIZE,
TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config';
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import {
getLocale,
isAccountExcluded,
isDraftActivity
} from '@ghostfolio/common/helper';
import { getLocale, isDraftActivity } from '@ghostfolio/common/helper';
import {
Activity,
AssetProfileIdentifier
@ -286,7 +279,6 @@ export class GfActivitiesTableComponent implements AfterViewInit, OnInit {
public canClickActivity(activity: Activity) {
return (
this.hasPermissionToOpenDetails &&
this.isExcludedFromAnalysis(activity) === false &&
isDraftActivity(activity) === false &&
['BUY', 'DIVIDEND', 'SELL'].includes(activity.type)
);
@ -306,15 +298,6 @@ export class GfActivitiesTableComponent implements AfterViewInit, OnInit {
);
}
public isExcludedFromAnalysis(activity: Activity) {
return (
isAccountExcluded(activity.account) ||
activity.tags?.some(({ id }) => {
return id === TAG_ID_EXCLUDE_FROM_ANALYSIS;
}) === true
);
}
public onChangePage(page: PageEvent) {
this.pageChanged.emit(page);
}

Loading…
Cancel
Save