Browse Source

Merge branch 'main' into feature/include-dividends-in-portfolio-performance

pull/7961/head
Thomas Kaul 2 weeks ago
committed by GitHub
parent
commit
73091c1ae8
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 6
      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
  8. 437
      libs/common/src/lib/personal-finance-tools.ts
  9. 28
      libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts
  10. 2
      libs/ui/src/lib/value/value.component.html
  11. 13
      libs/ui/src/lib/value/value.component.ts
  12. 8
      package-lock.json
  13. 2
      package.json

6
CHANGELOG.md

@ -10,6 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Changed the performance calculation to include the dividends (total return)
- Improved the get quotes functionality of the _Manual_ service
- 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

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 { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { resetHours } from '@ghostfolio/common/helper';
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 { DateQuery } from './interfaces/date-query.interface';
@ -25,6 +28,9 @@ jest.mock('@ghostfolio/api/services/market-data/market-data.service', () => {
state: 'CLOSE'
});
},
getLatest: () => {
return Promise.resolve<MarketData>(null);
},
getRange: ({
assetProfileIdentifiers,
dateQuery
@ -97,12 +103,19 @@ jest.mock('@ghostfolio/api/services/property/property.service', () => {
});
describe('CurrentRateService', () => {
let activitiesService: ActivitiesService;
let currentRateService: CurrentRateService;
let dataProviderService: DataProviderService;
let marketDataService: MarketDataService;
let propertyService: PropertyService;
beforeAll(async () => {
activitiesService = {
getLatestActivity: () => {
return Promise.resolve<Order>(null);
}
} as unknown as ActivitiesService;
propertyService = new PropertyService(null);
dataProviderService = new DataProviderService(
@ -117,7 +130,7 @@ describe('CurrentRateService', () => {
marketDataService = new MarketDataService(null);
currentRateService = new CurrentRateService(
null,
activitiesService,
dataProviderService,
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) {
// Fallback to unit price of latest activity
const latestActivity =
await this.activitiesService.getLatestActivity({
dataSource,
symbol
});
const latestMarketData = await this.marketDataService.getLatest({
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 = {
dataSource,
marketPrice,
symbol,
date: today,
marketPrice: latestActivity?.unitPrice ?? 0
date: today
};
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 { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
@ -33,6 +34,7 @@ describe('PortfolioService', () => {
let accountService: AccountService;
let activitiesService: ActivitiesService;
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let dataProviderService: DataProviderService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
@ -82,6 +84,12 @@ describe('PortfolioService', () => {
null
);
currentRateService = new CurrentRateService(
activitiesService,
dataProviderService,
null
);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
null,
@ -110,6 +118,7 @@ describe('PortfolioService', () => {
activitiesService,
null,
portfolioCalculatorFactory,
currentRateService,
dataProviderService,
exchangeRateDataService,
null,
@ -901,15 +910,19 @@ describe('PortfolioService', () => {
count: 4
});
const getQuotes = jest
.spyOn(dataProviderService, 'getQuotes')
const getValues = jest
.spyOn(currentRateService, 'getValues')
.mockResolvedValue({
'YAHOO-AAPL': {
currency: 'CHF',
dataSource: DataSource.YAHOO,
marketPrice: 200,
marketState: 'open'
}
dataProviderInfos: [],
errors: [],
values: [
{
dataSource: DataSource.YAHOO,
date: new Date(),
marketPrice: 200,
symbol: 'AAPL'
}
]
});
const summary = await getSummary({
@ -921,10 +934,10 @@ describe('PortfolioService', () => {
userId: userDummyData.id
});
// The closed holding (MSFT) does not need a quote
expect(getQuotes).toHaveBeenCalledWith(
// The closed holding (MSFT) does not need a market price
expect(getValues).toHaveBeenCalledWith(
expect.objectContaining({
items: [{ dataSource: DataSource.YAHOO, symbol: 'AAPL' }]
dataGatheringItems: [{ dataSource: DataSource.YAHOO, symbol: 'AAPL' }]
})
);
@ -961,13 +974,17 @@ describe('PortfolioService', () => {
count: 2
});
jest.spyOn(dataProviderService, 'getQuotes').mockResolvedValue({
'YAHOO-AAPL': {
currency: 'USD',
dataSource: DataSource.YAHOO,
marketPrice: 200,
marketState: 'open'
}
jest.spyOn(currentRateService, 'getValues').mockResolvedValue({
dataProviderInfos: [],
errors: [],
values: [
{
dataSource: DataSource.YAHOO,
date: new Date(),
marketPrice: 200,
symbol: 'AAPL'
}
]
});
jest
@ -1018,7 +1035,11 @@ describe('PortfolioService', () => {
count: 2
});
jest.spyOn(dataProviderService, 'getQuotes').mockResolvedValue({});
jest.spyOn(currentRateService, 'getValues').mockResolvedValue({
dataProviderInfos: [],
errors: [],
values: []
});
const summary = await getSummary({
balanceInBaseCurrency: 1000,

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

@ -94,18 +94,21 @@ import {
import { Big } from 'big.js';
import {
differenceInDays,
endOfDay,
format,
isAfter,
isBefore,
isSameMonth,
isSameYear,
parseISO,
set
set,
startOfDay
} from 'date-fns';
import { groupBy } from 'lodash';
import { PortfolioCalculator } from './calculator/portfolio-calculator';
import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory';
import { CurrentRateService } from './current-rate.service';
import { RulesService } from './rules.service';
const Fuse = require('fuse.js');
@ -125,6 +128,7 @@ export class PortfolioService {
private readonly activitiesService: ActivitiesService,
private readonly benchmarkService: BenchmarkService,
private readonly calculatorFactory: PortfolioCalculatorFactory,
private readonly currentRateService: CurrentRateService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly i18nService: I18nService,
@ -2543,11 +2547,15 @@ export class PortfolioService {
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
? await this.dataProviderService.getQuotes({
? await this.currentRateService.getValues({
subscriptionType,
items: openHoldings.map(
dataGatheringItems: openHoldings.map(
({
latestActivity: {
assetProfile: { dataSource, symbol }
@ -2555,16 +2563,24 @@ export class PortfolioService {
}) => {
return { dataSource, symbol };
}
)
),
dateQuery: {
gte: startOfDay(now),
lt: endOfDay(now)
}
})
: {};
: { values: [] };
return getSum(
openHoldings.map(({ latestActivity, quantity }) => {
const { assetProfile, currency, unitPrice } = latestActivity;
const marketPrice =
quotes[getAssetProfileIdentifier(assetProfile)]?.marketPrice;
const marketPrice = values.find((value) => {
return (
getAssetProfileIdentifier(value) ===
getAssetProfileIdentifier(assetProfile)
);
})?.marketPrice;
if (!marketPrice) {
// 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({
distinct: ['symbol'],
orderBy: {
date: 'desc'
},
take: symbols.length,
where: {
symbol: {
in: symbols
}
}
});
// Query the latest market data per symbol because distinct loads all
// the market data of the symbols into memory
const marketData = await Promise.all(
symbols.map((symbol) => {
return this.prismaService.marketData.findFirst({
orderBy: {
date: 'desc'
},
where: {
symbol,
dataSource: this.getName()
}
});
})
);
const symbolProfilesToScrape = symbolProfiles.filter(
({ scraperConfiguration }) => {
@ -202,7 +205,7 @@ export class ManualService implements DataProviderInterface {
marketPrice =
marketPrice ??
marketData.find((marketDataItem) => {
return marketDataItem.symbol === symbol;
return marketDataItem?.symbol === symbol;
})?.marketPrice ??
0;

437
libs/common/src/lib/personal-finance-tools.ts

@ -2,6 +2,21 @@ import { Product } from '@ghostfolio/common/interfaces';
export const personalFinanceTools: Product[] = (
[
{
categories: [
'FAMILY_OFFICE',
'FINANCIAL_ADVISOR',
'NET_WORTH_TRACKING',
'WEALTH_MANAGEMENT'
],
key: '1fs-wealth',
name: '1fs Wealth',
origin: 'GB',
platforms: ['WEB'],
slogan: 'A single platform for all of your wealth.',
updatedAt: '2026-09-24',
url: 'https://www.1fs.co'
},
{
categories: [
'FAMILY_OFFICE',
@ -44,7 +59,6 @@ export const personalFinanceTools: Product[] = (
url: 'https://addepar.com'
},
{
alias: 'APX',
categories: ['FAMILY_OFFICE', 'FINANCIAL_ADVISOR', 'WEALTH_MANAGEMENT'],
hasSelfHostingAbility: true,
key: 'advent-portfolio-exchange',
@ -77,6 +91,22 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-07-09',
url: 'https://akkuro.com'
},
{
categories: [
'FAMILY_OFFICE',
'FINANCIAL_ADVISOR',
'NET_WORTH_TRACKING',
'WEALTH_MANAGEMENT'
],
hasSelfHostingAbility: false,
key: 'aleta',
name: 'Aleta',
origin: 'US',
platforms: ['ANDROID', 'IOS', 'WEB'],
slogan: 'Family Office Software for Forward-Thinking Families',
updatedAt: '2026-09-24',
url: 'https://aleta.io'
},
{
categories: ['ETF_TRACKING', 'STOCK_TRACKING'],
founded: 2023,
@ -174,6 +204,26 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-07-05',
url: 'https://altruist.com'
},
{
categories: ['BUDGETING', 'FINANCIAL_ADVISOR', 'WEALTH_MANAGEMENT'],
hasFreePlan: true,
key: 'alwy',
languages: [
'Dansk',
'Deutsch',
'English',
'Nederlands',
'Norsk',
'Suomi',
'Svenska'
],
name: 'Alwy',
origin: 'SE',
platforms: ['IOS', 'WEB'],
slogan: 'Future-proof your business',
updatedAt: '2026-09-24',
url: 'https://alwy.se'
},
{
categories: ['INVESTMENT_RESEARCH', 'STOCK_TRACKING'],
founded: 2023,
@ -215,6 +265,21 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-07-05',
url: 'https://anlage.app'
},
{
categories: [
'FAMILY_OFFICE',
'FINANCIAL_ADVISOR',
'TAX_REPORTING',
'WEALTH_MANAGEMENT'
],
key: 'arch',
name: 'Arch',
origin: 'US',
platforms: ['WEB'],
slogan: 'Private Markets. Simplified.',
updatedAt: '2026-09-24',
url: 'https://arch.com'
},
{
categories: ['FAMILY_OFFICE', 'WEALTH_MANAGEMENT'],
founded: 2002,
@ -240,6 +305,39 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-09-23',
url: 'https://asignal.io'
},
{
categories: ['FAMILY_OFFICE', 'NET_WORTH_TRACKING', 'WEALTH_MANAGEMENT'],
founded: 2021,
hasFreePlan: false,
hasSelfHostingAbility: false,
key: 'asora',
name: 'Asora',
origin: 'IE',
platforms: ['ANDROID', 'IOS', 'WEB'],
pricingPerYear: '$10800',
slogan: 'Software for the modern family office',
updatedAt: '2026-09-24',
url: 'https://www.asora.com'
},
{
categories: [
'FAMILY_OFFICE',
'FINANCIAL_ADVISOR',
'NET_WORTH_TRACKING',
'WEALTH_MANAGEMENT'
],
founded: 2011,
hasFreePlan: false,
hasSelfHostingAbility: false,
key: 'asset-vantage',
name: 'Asset Vantage',
platforms: ['ANDROID', 'IOS', 'WEB'],
pricingPerYear: '$30000',
slogan:
'The Financial Operating System for Modern Family Offices and Their Advisors',
updatedAt: '2026-09-24',
url: 'https://www.assetvantage.com'
},
{
categories: ['FAMILY_OFFICE'],
founded: 2022,
@ -253,6 +351,33 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-07-05',
url: 'https://www.asseta.ai'
},
{
categories: ['FAMILY_OFFICE', 'WEALTH_MANAGEMENT'],
key: 'atalaya',
languages: ['English', 'Español'],
name: 'Atalaya',
origin: 'ES',
slogan: 'Software for Family Offices',
updatedAt: '2026-09-24',
url: 'https://www.atalaya.ai'
},
{
categories: [
'FAMILY_OFFICE',
'FINANCIAL_ADVISOR',
'NET_WORTH_TRACKING',
'TAX_REPORTING',
'WEALTH_MANAGEMENT'
],
hasSelfHostingAbility: false,
key: 'atlasfive',
languages: ['English'],
name: 'AtlasFive®',
origin: 'US',
slogan: 'Experience Wealth Intelligence',
updatedAt: '2026-09-24',
url: 'https://eton-solutions.com'
},
{
categories: ['WEALTH_MANAGEMENT'],
founded: 2016,
@ -263,6 +388,20 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-07-05',
url: 'https://www.atominvest.co'
},
{
categories: [
'FAMILY_OFFICE',
'FINANCIAL_ADVISOR',
'NET_WORTH_TRACKING',
'WEALTH_MANAGEMENT'
],
key: 'auria',
name: 'Auria',
platforms: ['WEB'],
slogan: 'Simplify the complexities of wealth with a unified solution',
updatedAt: '2026-09-24',
url: 'https://auriaplatform.com'
},
{
categories: ['BUDGETING'],
founded: 2020,
@ -506,6 +645,41 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-07-09',
url: 'https://www.buxfer.com'
},
{
categories: [
'FAMILY_OFFICE',
'FINANCIAL_ADVISOR',
'NET_WORTH_TRACKING',
'WEALTH_MANAGEMENT'
],
founded: 2017,
hasSelfHostingAbility: false,
key: 'canopy',
languages: [
'Bahasa Indonesia',
'Bahasa Melayu',
'Deutsch',
'English',
'Español',
'Français',
'Italiano',
'Português',
'Tiếng Việt',
'Русский',
'العربية',
'ภาษาไทย',
'日本語',
'简体中文',
'繁體中文',
'한국어'
],
name: 'Canopy',
origin: 'SG',
platforms: ['ANDROID', 'IOS', 'WEB'],
slogan: 'All your wealth, finally understood.',
updatedAt: '2026-09-24',
url: 'https://www.canopy.cloud'
},
{
categories: ['NET_WORTH_TRACKING'],
hasFreePlan: true,
@ -557,6 +731,19 @@ export const personalFinanceTools: Product[] = (
slogan: 'Toma el control total de tus finanzas.',
updatedAt: '2025-07-27'
},
{
categories: ['FAMILY_OFFICE', 'FINANCIAL_ADVISOR', 'WEALTH_MANAGEMENT'],
founded: 2016,
hasFreePlan: false,
key: 'clockwork',
name: 'Clockwork',
origin: 'US',
platforms: ['WEB'],
slogan:
'Manage your alternative investments, and everything else, with Clockwork.',
updatedAt: '2026-09-24',
url: 'https://www.clockwork.app'
},
{
categories: ['WEALTH_MANAGEMENT'],
founded: 2011,
@ -882,6 +1069,22 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-08-16',
url: 'https://www.donkycapital.com'
},
{
categories: [
'FAMILY_OFFICE',
'NET_WORTH_TRACKING',
'TAX_REPORTING',
'WEALTH_MANAGEMENT'
],
founded: 2006,
key: 'elysys',
name: 'Elysys',
origin: 'MC',
slogan:
'Complete front-to-back investment accounting and portfolio management on Business Central',
updatedAt: '2026-09-24',
url: 'https://www.elysys.com'
},
{
categories: ['BUDGETING', 'FINANCIAL_ADVISOR', 'FINANCIAL_PLANNING'],
hasSelfHostingAbility: false,
@ -1116,6 +1319,17 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-08-16',
url: 'https://extraetf.com'
},
{
categories: ['FAMILY_OFFICE', 'FINANCIAL_ADVISOR', 'WEALTH_MANAGEMENT'],
hasSelfHostingAbility: false,
key: 'fa-platform',
languages: ['English'],
name: 'FA Platform',
origin: 'FI',
slogan: 'Empowering Investment Management with Innovation',
updatedAt: '2026-09-24',
url: 'https://fasolutions.com'
},
{
categories: ['INVESTMENT_RESEARCH'],
key: 'factorstoday',
@ -1442,6 +1656,20 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-07-05',
url: 'https://www.fundmanagersoftware.com'
},
{
categories: ['FAMILY_OFFICE', 'TAX_REPORTING', 'WEALTH_MANAGEMENT'],
founded: 1999,
hasFreePlan: false,
hasSelfHostingAbility: true,
key: 'fundcount',
name: 'FundCount',
origin: 'BB',
platforms: ['LINUX', 'MACOS', 'WEB', 'WINDOWS'],
pricingPerYear: '$14450',
slogan: 'One source of truth for every entity, fund, and asset class.',
updatedAt: '2026-09-24',
url: 'https://fundcount.com'
},
{
categories: ['BUDGETING'],
hasFreePlan: true,
@ -1626,6 +1854,16 @@ export const personalFinanceTools: Product[] = (
pricingPerYear: '$120',
updatedAt: '2025-08-27'
},
{
categories: ['FAMILY_OFFICE', 'FINANCIAL_ADVISOR', 'WEALTH_MANAGEMENT'],
key: 'infront',
name: 'Infront',
origin: 'NO',
platforms: ['WEB'],
slogan: 'Built for Complex Wealth. Made for Family Offices',
updatedAt: '2026-09-24',
url: 'https://www.infront.co/global/en/your-industry/wealth/family-offices.html'
},
{
categories: ['INVESTMENT_RESEARCH'],
hasFreePlan: true,
@ -1760,6 +1998,18 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-09-23',
url: 'https://istfin.com'
},
{
categories: ['FAMILY_OFFICE', 'FINANCIAL_ADVISOR', 'WEALTH_MANAGEMENT'],
hasSelfHostingAbility: false,
key: 'j-ray',
languages: ['English'],
name: 'J-Ray',
origin: 'FI',
platforms: ['WEB'],
slogan: 'The Guiding Light for Complex Wealth',
updatedAt: '2026-09-24',
url: 'https://jaysolutions.com'
},
{
categories: ['ETF_TRACKING'],
founded: 2011,
@ -1774,6 +2024,18 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-07-05',
url: 'https://www.justetf.com'
},
{
categories: ['FAMILY_OFFICE', 'FINANCIAL_ADVISOR', 'WEALTH_MANAGEMENT'],
founded: 2004,
hasSelfHostingAbility: true,
key: 'keesense',
languages: ['English', 'Français'],
name: 'KeeSense',
origin: 'MC',
slogan: 'Augmented Wealth Management',
updatedAt: '2026-09-24',
url: 'https://www.keesystem.com'
},
{
categories: ['CRYPTOCURRENCY', 'TAX_REPORTING'],
founded: 2018,
@ -2233,6 +2495,23 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-07-05',
url: 'https://myfinancetools.io'
},
{
categories: [
'FAMILY_OFFICE',
'FINANCIAL_ADVISOR',
'NET_WORTH_TRACKING',
'WEALTH_MANAGEMENT'
],
hasSelfHostingAbility: false,
key: 'myfo',
languages: ['English'],
name: 'MYFO',
origin: 'CA',
platforms: ['WEB'],
slogan: 'The First Full Family Office Operating System',
updatedAt: '2026-09-24',
url: 'https://www.myfotech.com'
},
{
categories: ['CRYPTOCURRENCY', 'NET_WORTH_TRACKING', 'STOCK_TRACKING'],
hasFreePlan: true,
@ -2499,6 +2778,19 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-07-05',
url: 'https://www.getpennies.com'
},
{
categories: ['FAMILY_OFFICE', 'FINANCIAL_ADVISOR', 'WEALTH_MANAGEMENT'],
founded: 2020,
hasSelfHostingAbility: false,
key: 'performativ',
languages: ['Deutsch', 'English', 'Nederlands'],
name: 'Performativ',
origin: 'DK',
platforms: ['WEB'],
slogan: 'The Operating System Powering Tomorrow’s Wealth Management',
updatedAt: '2026-09-24',
url: 'https://www.performativ.com'
},
{
categories: ['FAMILY_OFFICE'],
hasFreePlan: true,
@ -2552,6 +2844,22 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-07-05',
url: 'https://www.plannix.co'
},
{
categories: [
'CRYPTOCURRENCY',
'FAMILY_OFFICE',
'FINANCIAL_ADVISOR',
'WEALTH_MANAGEMENT'
],
founded: 2012,
hasSelfHostingAbility: true,
key: 'pmplus',
name: 'PMplus®',
origin: 'CH',
platforms: ['WEB'],
updatedAt: '2026-09-24',
url: 'https://www.suissetechpartners.com'
},
{
categories: ['BUDGETING'],
founded: 2015,
@ -2582,6 +2890,17 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-07-05',
url: 'https://www.pocketsmith.com'
},
{
categories: ['FAMILY_OFFICE', 'FINANCIAL_ADVISOR', 'WEALTH_MANAGEMENT'],
founded: 2017,
key: 'point',
languages: ['English'],
name: 'Point',
origin: 'GB',
slogan: 'AI-Ready Investment Data',
updatedAt: '2026-09-24',
url: 'https://pointgroup.io'
},
{
categories: ['FINANCIAL_PLANNING', 'WEALTH_MANAGEMENT'],
hasFreePlan: false,
@ -2775,6 +3094,16 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-07-05',
url: 'https://www.portseido.com'
},
{
categories: ['FAMILY_OFFICE', 'FINANCIAL_ADVISOR', 'WEALTH_MANAGEMENT'],
key: 'private-wealth-systems',
name: 'Private Wealth Systems',
origin: 'US',
platforms: ['WEB'],
slogan: 'Portfolio Management & Reporting Software',
updatedAt: '2026-09-24',
url: 'https://privatewealthsystems.com'
},
{
categories: ['FINANCIAL_PLANNING'],
founded: 2021,
@ -2815,6 +3144,23 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-09-23',
url: 'https://www.assetbook.com'
},
{
categories: [
'FAMILY_OFFICE',
'FINANCIAL_ADVISOR',
'TAX_REPORTING',
'WEALTH_MANAGEMENT'
],
founded: 2012,
key: 'qplix',
languages: ['Deutsch', 'English'],
name: 'QPLIX',
origin: 'DE',
platforms: ['IOS', 'WEB'],
slogan: 'Your Software to Master Wealth Management.',
updatedAt: '2026-09-24',
url: 'https://www.qplix.com'
},
{
categories: ['NET_WORTH_TRACKING'],
hasSelfHostingAbility: false,
@ -2963,6 +3309,20 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-07-05',
url: 'https://www.segmio.com'
},
{
categories: ['FAMILY_OFFICE', 'FINANCIAL_ADVISOR', 'WEALTH_MANAGEMENT'],
founded: 2019,
hasFreePlan: false,
hasSelfHostingAbility: false,
key: 'sesame-one',
name: 'Sesame One',
origin: 'GB',
platforms: ['WEB'],
pricingPerYear: '£20000',
slogan: 'Spend more time building investment value, not spreadsheets',
updatedAt: '2026-09-24',
url: 'https://www.landytech.com'
},
{
categories: ['INVESTMENT_RESEARCH', 'STOCK_TRACKING'],
key: 'sharescope',
@ -3360,6 +3720,30 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-07-05',
url: 'https://www.sumio.app'
},
{
categories: ['FAMILY_OFFICE'],
hasFreePlan: false,
hasSelfHostingAbility: false,
key: 'sumit',
languages: ['English'],
name: 'SumIt',
platforms: ['WEB'],
slogan: 'Powerfully simple family office accounting.',
updatedAt: '2026-09-24',
url: 'https://getsumit.com'
},
{
categories: ['FAMILY_OFFICE', 'WEALTH_MANAGEMENT'],
hasSelfHostingAbility: true,
key: 'swimbird',
languages: ['English'],
name: 'Swimbird',
origin: 'SE',
platforms: ['ANDROID', 'IOS'],
slogan: 'Wealth Management Software',
updatedAt: '2026-09-24',
url: 'https://www.swimbird.com'
},
{
categories: ['INVESTMENT_RESEARCH'],
key: 'tc2000',
@ -3408,6 +3792,22 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-07-05',
url: 'https://tiller.com'
},
{
categories: [
'FINANCIAL_ADVISOR',
'INVESTMENT_RESEARCH',
'WEALTH_MANAGEMENT'
],
founded: 2010,
hasFreePlan: false,
key: 'tindeco',
languages: ['English'],
name: 'Tindeco',
origin: 'CH',
slogan: 'Scalable Investing Made Simple',
updatedAt: '2026-09-24',
url: 'https://tindecofs.com'
},
{
categories: ['CRYPTOCURRENCY', 'DIVIDEND_TRACKING', 'STOCK_TRACKING'],
hasFreePlan: true,
@ -3858,6 +4258,17 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-07-05',
url: 'https://www.wallstreetzen.com'
},
{
categories: ['FINANCIAL_ADVISOR', 'FINANCIAL_PLANNING'],
key: 'wealth-wizards',
languages: ['English'],
name: 'Wealth Wizards',
origin: 'GB',
regions: ['GB'],
slogan: 'Get new regime ready.',
updatedAt: '2026-09-24',
url: 'https://www.wealthwizards.com'
},
{
categories: ['FAMILY_OFFICE', 'TAX_REPORTING'],
founded: 2001,
@ -3940,6 +4351,18 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-07-05',
url: 'https://www.wealthposition.com'
},
{
categories: ['FAMILY_OFFICE', 'FINANCIAL_ADVISOR', 'WEALTH_MANAGEMENT'],
hasSelfHostingAbility: true,
key: 'wealthspectrum',
languages: ['English'],
name: 'WealthSpectrum',
origin: 'IN',
platforms: ['WEB'],
slogan: 'Powering smart investment operations',
updatedAt: '2026-09-24',
url: 'https://www.wealthspectrum.com'
},
{
categories: ['STOCK_TRACKING'],
founded: 2018,
@ -3970,6 +4393,18 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-08-28',
url: 'https://www.wisesheets.io'
},
{
categories: ['FAMILY_OFFICE', 'FINANCIAL_ADVISOR', 'WEALTH_MANAGEMENT'],
founded: 2010,
hasSelfHostingAbility: true,
key: 'wize',
languages: ['English'],
name: 'WIZE',
origin: 'CH',
slogan: 'The all-in-one Wealth & Asset management IT platform',
updatedAt: '2026-09-24',
url: 'https://www.wize.net'
},
{
categories: [
'FINANCIAL_ADVISOR',

28
libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts

@ -1,5 +1,5 @@
import { getTooltipOptions } from '@ghostfolio/common/chart-helper';
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { DEFAULT_COLOR_SCHEME, UNKNOWN_KEY } from '@ghostfolio/common/config';
import { getLocale, getSum, getTextColor } from '@ghostfolio/common/helper';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { ColorScheme } from '@ghostfolio/common/types';
@ -60,20 +60,20 @@ const {
export class GfPortfolioProportionChartComponent
implements AfterViewInit, OnChanges, OnDestroy
{
@Input() baseCurrency: string;
@Input() colorScheme: ColorScheme;
@Input() baseCurrency?: string;
@Input() colorScheme?: ColorScheme;
@Input() cursor: string;
@Input() data: {
[symbol: string]: Pick<PortfolioPosition, 'type'> & {
dataSource?: DataSource;
isClickable?: boolean;
name: string;
name?: string | null;
value: number;
};
} = {};
@Input() isInPercentage = false;
@Input() isInPercentage?: boolean = false;
@Input() keys: string[] = [];
@Input() locale = getLocale();
@Input() locale?: string = getLocale();
@Input() maxItems?: number;
@Input() showLabels = false;
@ -114,6 +114,7 @@ export class GfPortfolioProportionChartComponent
private initialize() {
this.isLoading = true;
const chartData: {
[symbol: string]: {
color?: string;
@ -122,9 +123,12 @@ export class GfPortfolioProportionChartComponent
value: Big;
};
} = {};
const textColor = getTextColor(this.colorScheme ?? DEFAULT_COLOR_SCHEME);
this.colorMap = {
[this.OTHER_KEY]: `rgba(${getTextColor(this.colorScheme)}, 0.24)`,
[UNKNOWN_KEY]: `rgba(${getTextColor(this.colorScheme)}, 0.12)`
[this.OTHER_KEY]: `rgba(${textColor}, 0.24)`,
[UNKNOWN_KEY]: `rgba(${textColor}, 0.12)`
};
if (this.keys.length > 0) {
@ -178,7 +182,7 @@ export class GfPortfolioProportionChartComponent
);
} else {
chartData[UNKNOWN_KEY] = {
name: this.data[symbol].name,
name: this.data[symbol].name ?? '',
subCategory: secondaryKey
? { [secondaryKey]: { value: new Big(0) } }
: undefined,
@ -190,7 +194,7 @@ export class GfPortfolioProportionChartComponent
} else {
Object.keys(this.data).forEach((symbol) => {
chartData[symbol] = {
name: this.data[symbol].name,
name: this.data[symbol].name ?? '',
value: new Big(this.data[symbol].value || 0)
};
});
@ -444,7 +448,7 @@ export class GfPortfolioProportionChartComponent
): Partial<TooltipOptions<'doughnut'>> {
return {
...getTooltipOptions({
colorScheme: this.colorScheme,
colorScheme: this.colorScheme ?? DEFAULT_COLOR_SCHEME,
currency: this.baseCurrency,
locale: this.locale
}),
@ -486,7 +490,7 @@ export class GfPortfolioProportionChartComponent
`${value.toLocaleString(this.locale, {
maximumFractionDigits: 2,
minimumFractionDigits: 2
})} ${this.baseCurrency} (${percentage.toFixed(2)}%)`
})} ${this.baseCurrency ?? ''} (${percentage.toFixed(2)}%)`
];
}
},

2
libs/ui/src/lib/value/value.component.html

@ -46,7 +46,7 @@
[class.justify-content-end]="position === 'end'"
>
@if (isNumber || value === null) {
@if (colorizeSign && !useAbsoluteValue) {
@if (colorizeSign && !useAbsoluteValue && value !== null) {
@if (+value > 0) {
<div class="mr-1 text-success">+</div>
}

13
libs/ui/src/lib/value/value.component.ts

@ -42,12 +42,12 @@ export class GfValueComponent implements AfterViewInit, OnChanges, OnDestroy {
@Input() isCurrency = false;
@Input() isDate = false;
@Input() isPercent = false;
@Input() locale: string;
@Input() locale?: string;
@Input() position = '';
@Input() size: 'large' | 'medium' | 'small' = 'small';
@Input() subLabel = '';
@Input() unit = '';
@Input() value: number | string = '';
@Input() subLabel?: string = '';
@Input() unit?: string | null = '';
@Input() value?: number | string | null = '';
@ViewChild('labelContent', { static: false })
labelContent!: ElementRef<HTMLSpanElement>;
@ -219,11 +219,14 @@ export class GfValueComponent implements AfterViewInit, OnChanges, OnDestroy {
this.isCopied = false;
this.isNumber = false;
this.isString = false;
this.locale = this.locale || getLocale();
this.useAbsoluteValue = false;
if (this.copyToClipboardTimeout) {
clearTimeout(this.copyToClipboardTimeout);
}
if (this.locale === undefined || this.locale === '') {
this.locale = getLocale();
}
}
}

8
package-lock.json

@ -59,7 +59,7 @@
"bull": "4.16.5",
"chart.js": "4.5.1",
"chartjs-adapter-date-fns": "3.0.0",
"chartjs-chart-treemap": "4.2.0",
"chartjs-chart-treemap": "4.2.2",
"chartjs-plugin-annotation": "3.1.0",
"chartjs-plugin-datalabels": "2.2.0",
"cheerio": "1.2.0",
@ -18249,9 +18249,9 @@
}
},
"node_modules/chartjs-chart-treemap": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/chartjs-chart-treemap/-/chartjs-chart-treemap-4.2.0.tgz",
"integrity": "sha512-2ghdKHbknLYEzqD2fRTbACOzEhuH/f7zI5t6dbQ3cAOS+ric4mLjll6Sxsaugy/HFU0KUftJEszYOvtay3wFSQ==",
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/chartjs-chart-treemap/-/chartjs-chart-treemap-4.2.2.tgz",
"integrity": "sha512-gmOVr5bokdhs0RsHqrnj1oQu9ybaRjdlXubzLhmRB9DsRnpY51WmyqxUd8JCCKbnFVru1yjdeOTXyqeMIFhxlg==",
"license": "MIT",
"peerDependencies": {
"chart.js": ">=3.0.0"

2
package.json

@ -103,7 +103,7 @@
"bull": "4.16.5",
"chart.js": "4.5.1",
"chartjs-adapter-date-fns": "3.0.0",
"chartjs-chart-treemap": "4.2.0",
"chartjs-chart-treemap": "4.2.2",
"chartjs-plugin-annotation": "3.1.0",
"chartjs-plugin-datalabels": "2.2.0",
"cheerio": "1.2.0",

Loading…
Cancel
Save