Browse Source

Task/include closed holdings by default in holdings endpoint (#7843)

* Return active and closed holdings in holdings endpoint

* Update changelog
pull/7844/head^2
Thomas Kaul 23 hours ago
committed by GitHub
parent
commit
f7c0a1b9fd
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      CHANGELOG.md
  2. 136
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  3. 37
      apps/api/src/app/portfolio/portfolio.service.ts
  4. 8
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  5. 4
      apps/client/src/app/components/home-holdings/home-holdings.component.ts
  6. 4
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
  7. 4
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  8. 4
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts
  9. 5
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  10. 4
      libs/ui/src/lib/assistant/assistant.component.ts

1
CHANGELOG.md

@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Improved the loading state of the activity count in the portfolio summary
- Changed the holdings endpoint to return active and closed holdings by default and reuse a single snapshot for both types
- Upgraded `zod` from version `4.4.3` to `4.5.4`
## 3.68.0 - 2026-09-06

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

@ -355,8 +355,13 @@ describe('PortfolioService', () => {
describe('getDetails', () => {
const setUpCashOnlyPortfolio = ({
baseCurrency = 'CHF',
emergencyFund
}: { baseCurrency?: string; emergencyFund?: number } = {}) => {
emergencyFund,
quantity = 2000
}: {
baseCurrency?: string;
emergencyFund?: number;
quantity?: number;
} = {}) => {
const cashAccount: AccountWithBalance = {
balance: 2000,
comment: null,
@ -421,7 +426,7 @@ describe('PortfolioService', () => {
netPerformancePercentage: new Big(0),
netPerformancePercentageWithCurrencyEffectMap: {},
netPerformanceWithCurrencyEffectMap: {},
quantity: new Big(2000),
quantity: new Big(quantity),
symbol: 'USD',
tags: [],
timeWeightedInvestment: new Big(0),
@ -493,6 +498,131 @@ describe('PortfolioService', () => {
expect(holdings[0].assetProfile.symbol).toBe('USD');
expect(holdings[0].valueInBaseCurrency).toBe(1000);
});
it('should include closed holdings when all holdings are requested', async () => {
setUpCashOnlyPortfolio({ quantity: 0 });
const { holdings } = await portfolioService.getDetails({
filters: [],
includeAllHoldings: true,
userId: userDummyData.id
});
expect(holdings).toHaveLength(1);
expect(holdings[0].quantity).toBe(0);
});
it.each([
{ holdingType: 'ACTIVE', quantity: 2000 },
{ holdingType: 'CLOSED', quantity: 0 }
])(
'should return $holdingType holdings when the holding type is specified',
async ({ holdingType, quantity }) => {
setUpCashOnlyPortfolio({ quantity });
const { holdings } = await portfolioService.getDetails({
filters: [{ id: holdingType, type: 'HOLDING_TYPE' }],
userId: userDummyData.id
});
expect(holdings).toHaveLength(1);
expect(holdings[0].quantity).toBe(quantity);
}
);
it('should remove the holding type only from the snapshot filters', async () => {
setUpCashOnlyPortfolio({ quantity: 0 });
await portfolioService.getDetails({
filters: [
{ id: AssetClass.EQUITY, type: 'ASSET_CLASS' },
{ id: 'CLOSED', type: 'HOLDING_TYPE' }
],
userId: userDummyData.id
});
expect(portfolioCalculatorFactory.createCalculator).toHaveBeenCalledWith(
expect.objectContaining({
filters: [{ id: AssetClass.EQUITY, type: 'ASSET_CLASS' }]
})
);
expect(
activitiesService.getActivitiesForPortfolioCalculator
).toHaveBeenCalledWith({
filters: [{ id: AssetClass.EQUITY, type: 'ASSET_CLASS' }],
userCurrency: 'CHF',
userId: userDummyData.id
});
});
});
describe('getHoldings', () => {
const activeHolding = {
assetProfile: {
isin: 'US0378331005',
name: 'Apple',
symbol: 'AAPL'
},
quantity: 1
};
const closedHolding = {
assetProfile: {
isin: 'US5949181045',
name: 'Microsoft',
symbol: 'MSFT'
},
quantity: 0
};
beforeEach(() => {
jest.spyOn(portfolioService, 'getDetails').mockResolvedValue({
holdings: [activeHolding, closedHolding]
} as unknown as Awaited<ReturnType<typeof portfolioService.getDetails>>);
});
it('should request all holdings when the holding type is not specified', async () => {
const holdings = await portfolioService.getHoldings({
dateRange: 'max',
userId: userDummyData.id
});
expect(holdings).toEqual([activeHolding, closedHolding]);
expect(portfolioService.getDetails).toHaveBeenCalledWith({
dateRange: 'max',
filters: undefined,
includeAllHoldings: true,
userId: userDummyData.id
});
});
it('should find a closed holding when the holding type is not specified', async () => {
const holdings = await portfolioService.getHoldings({
dateRange: 'max',
filters: [{ id: 'Microsoft', type: 'SEARCH_QUERY' }],
userId: userDummyData.id
});
expect(holdings).toEqual([closedHolding]);
});
it.each(['ACTIVE', 'CLOSED'])(
'should not request all holdings when the holding type is %s',
async (holdingType) => {
await portfolioService.getHoldings({
dateRange: 'max',
filters: [{ id: holdingType, type: 'HOLDING_TYPE' }],
userId: userDummyData.id
});
expect(portfolioService.getDetails).toHaveBeenCalledWith({
dateRange: 'max',
filters: [{ id: holdingType, type: 'HOLDING_TYPE' }],
includeAllHoldings: false,
userId: userDummyData.id
});
}
);
});
describe('getHolding', () => {

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

@ -477,12 +477,12 @@ export class PortfolioService {
filters?: Filter[];
userId: string;
}) {
const { SEARCH_QUERY: [filterBySearchQuery] = [] } = groupBy(
filters,
({ type }) => {
return type;
}
);
const {
HOLDING_TYPE: [filterByHoldingType] = [],
SEARCH_QUERY: [filterBySearchQuery] = []
} = groupBy(filters, ({ type }) => {
return type;
});
const filtersWithoutSearchQueryFilter = filters?.filter(({ type }) => {
return type !== 'SEARCH_QUERY';
@ -491,7 +491,8 @@ export class PortfolioService {
let { holdings } = await this.getDetails({
dateRange,
userId,
filters: filtersWithoutSearchQueryFilter
filters: filtersWithoutSearchQueryFilter,
includeAllHoldings: !filterByHoldingType
});
if (filterBySearchQuery) {
@ -592,6 +593,7 @@ export class PortfolioService {
public async getDetails({
dateRange = DEFAULT_DATE_RANGE,
filters,
includeAllHoldings = false,
user: userFromCaller,
userId,
withExcludedAccounts = false,
@ -600,6 +602,7 @@ export class PortfolioService {
}: {
dateRange?: DateRange;
filters?: Filter[];
includeAllHoldings?: boolean;
user?: UserWithSettings;
userId: string;
withExcludedAccounts?: boolean;
@ -614,19 +617,23 @@ export class PortfolioService {
(user.settings?.settings as UserSettings)?.emergencyFund ?? 0
);
const portfolioSnapshotFilters = filters?.filter(({ type }) => {
return type !== 'HOLDING_TYPE';
});
const { activities } =
await this.activitiesService.getActivitiesForPortfolioCalculator({
filters,
userCurrency,
userId
userId,
filters: portfolioSnapshotFilters
});
const portfolioCalculator = this.calculatorFactory.createCalculator({
activities,
filters,
userId,
calculationType: this.getUserPerformanceCalculationType(user),
currency: userCurrency
currency: userCurrency,
filters: portfolioSnapshotFilters
});
const { createdAt, currentValueInBaseCurrency, hasErrors, positions } =
@ -706,13 +713,13 @@ export class PortfolioService {
tags,
valueInBaseCurrency
} of positions) {
if (isFilteredByClosedHoldings === true) {
if (!quantity.eq(0)) {
if (!includeAllHoldings) {
if (isFilteredByClosedHoldings && !quantity.eq(0)) {
// Ignore positions with a quantity
continue;
}
} else {
if (quantity.eq(0)) {
if (!isFilteredByClosedHoldings && quantity.eq(0)) {
// Ignore positions without any quantity
continue;
}

8
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts

@ -393,8 +393,12 @@ export class GfAccountDetailDialogComponent implements OnInit {
.fetchPortfolioHoldings({
filters: [
{
type: 'ACCOUNT',
id: this.data.accountId
id: this.data.accountId,
type: 'ACCOUNT'
},
{
id: 'ACTIVE',
type: 'HOLDING_TYPE'
}
]
})

4
apps/client/src/app/components/home-holdings/home-holdings.component.ts

@ -153,9 +153,7 @@ export class GfHomeHoldingsComponent implements OnInit {
private fetchHoldings() {
const filters = this.userService.getFilters();
if (this.holdingType === 'CLOSED') {
filters.push({ id: 'CLOSED', type: 'HOLDING_TYPE' });
}
filters.push({ id: this.holdingType, type: 'HOLDING_TYPE' });
return this.dataService.fetchPortfolioHoldings({
filters,

4
apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts

@ -367,7 +367,9 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
private loadHoldings() {
this.dataService
.fetchPortfolioHoldings()
.fetchPortfolioHoldings({
filters: [{ id: 'ACTIVE', type: 'HOLDING_TYPE' }]
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ holdings }) => {
this.holdings = getHoldingsForFilter(holdings);

4
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts

@ -153,7 +153,9 @@ export class GfCreateOrUpdateActivityDialogComponent {
this.defaultDateFormat = getDateFormatString(this.locale);
this.dataService
.fetchPortfolioHoldings()
.fetchPortfolioHoldings({
filters: [{ id: 'ACTIVE', type: 'HOLDING_TYPE' }]
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ holdings }) => {
this.defaultLookupItems = holdings

4
apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts

@ -144,6 +144,10 @@ export class GfImportActivitiesDialogComponent {
{
id: AssetClass.FIXED_INCOME,
type: 'ASSET_CLASS'
},
{
id: 'ACTIVE',
type: 'HOLDING_TYPE'
}
],
range: DEFAULT_DATE_RANGE

5
apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts

@ -377,7 +377,10 @@ export class GfAnalysisPageComponent implements OnInit {
this.dataService
.fetchPortfolioHoldings({
filters: this.userService.getFilters(),
filters: [
...this.userService.getFilters(),
{ id: 'ACTIVE', type: 'HOLDING_TYPE' }
],
range: this.user?.settings?.dateRange
})
.pipe(takeUntilDestroyed(this.destroyRef))

4
libs/ui/src/lib/assistant/assistant.component.ts

@ -479,7 +479,9 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
this.setIsOpen(true);
this.dataService
.fetchPortfolioHoldings()
.fetchPortfolioHoldings({
filters: [{ id: 'ACTIVE', type: 'HOLDING_TYPE' }]
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ holdings }) => {
this.holdings = getHoldingsForFilter(holdings);

Loading…
Cancel
Save