diff --git a/CHANGELOG.md b/CHANGELOG.md index cc679c1edc..4586622cc3 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/apps/api/src/app/portfolio/current-rate.service.spec.ts b/apps/api/src/app/portfolio/current-rate.service.spec.ts index 4bb3a1b8cf..b2ec6cd2d8 100644 --- a/apps/api/src/app/portfolio/current-rate.service.spec.ts +++ b/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(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(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' + } + ]); + }); + }); }); diff --git a/apps/api/src/app/portfolio/current-rate.service.ts b/apps/api/src/app/portfolio/current-rate.service.ts index 66119a3c63..79363c6344 100644 --- a/apps/api/src/app/portfolio/current-rate.service.ts +++ b/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); diff --git a/apps/api/src/app/portfolio/portfolio.service.spec.ts b/apps/api/src/app/portfolio/portfolio.service.spec.ts index 223a8964c1..ccd26e0345 100644 --- a/apps/api/src/app/portfolio/portfolio.service.spec.ts +++ b/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, diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 8e362846f7..61d243e686 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/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 diff --git a/apps/api/src/services/data-provider/manual/manual.service.spec.ts b/apps/api/src/services/data-provider/manual/manual.service.spec.ts new file mode 100644 index 0000000000..2fc69b8b2d --- /dev/null +++ b/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' + } + }); + }); + }); +}); diff --git a/apps/api/src/services/data-provider/manual/manual.service.ts b/apps/api/src/services/data-provider/manual/manual.service.ts index a187335a0e..30168cdef8 100644 --- a/apps/api/src/services/data-provider/manual/manual.service.ts +++ b/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; diff --git a/libs/common/src/lib/personal-finance-tools.ts b/libs/common/src/lib/personal-finance-tools.ts index f713871dc8..9f891d3818 100644 --- a/libs/common/src/lib/personal-finance-tools.ts +++ b/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', diff --git a/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts b/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts index 9a215ee5b3..bf2f595c26 100644 --- a/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts +++ b/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 & { 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> { 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)}%)` ]; } }, diff --git a/libs/ui/src/lib/value/value.component.html b/libs/ui/src/lib/value/value.component.html index 3ab711f7e6..c5ebe4e2cf 100644 --- a/libs/ui/src/lib/value/value.component.html +++ b/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) {
+
} diff --git a/libs/ui/src/lib/value/value.component.ts b/libs/ui/src/lib/value/value.component.ts index cc08e5a248..c2c7d4621d 100644 --- a/libs/ui/src/lib/value/value.component.ts +++ b/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; @@ -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(); + } } } diff --git a/package-lock.json b/package-lock.json index 62a865e1b3..0bccf99a2b 100644 --- a/package-lock.json +++ b/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" diff --git a/package.json b/package.json index 91907a9f18..bb1c66a955 100644 --- a/package.json +++ b/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",