Compare commits

...

47 Commits

Author SHA1 Message Date
90dc34380e Release 1.296.0 (#2199) 2023-08-01 09:12:26 +02:00
286e41eb21 Feature/optimize import validation by reducing to unique asset profiles (#2198)
* Optimize activities validation

* Optimize data gathering in import

* Update changelog
2023-08-01 09:10:13 +02:00
4973d0261d Release 1.295.0 (#2197) 2023-07-30 19:41:22 +02:00
c4a62dfd68 Bugfix/remove stay signed in setting from local storage on sign in with fingerprint activation (#2196)
* Remove staySignedIn from local storage

* Update changelog
2023-07-30 19:36:06 +02:00
4d6be0a507 Exclude open-source-alternative-to-markets.sh (#2195) 2023-07-30 19:35:49 +02:00
b259ab7b0c Feature/add step by step introduction for new users (#2191)
* Add introduction for new users

* Update changelog
2023-07-30 18:49:38 +02:00
e1ac5245c7 Release 1.294.0 (#2192) 2023-07-29 20:33:31 +02:00
d4fea075af Feature/include unavailable data in allocations by market chart (#2190)
* Include unavailable data in allocations by market chart

* Update changelog
2023-07-28 20:20:08 +02:00
cef7fa79de Fix total account value calculation for liabilities (#2184)
* Fix calculation

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-07-28 19:42:57 +02:00
ca05397dcd Extend Community Projects section (#2188) 2023-07-27 17:39:29 +02:00
2a11977001 Release 1.293.0 (#2186) 2023-07-26 21:32:35 +02:00
fb1a5c93ef Bugfix/fix no such file or directory error caused by missing favicon.ico (#2185)
* Add instructions to copy favicon.ico

* Update changelog
2023-07-26 21:26:05 +02:00
77e9791e03 Feature/set lastmod dates of sitemap.xml dynamically (#2170)
* Setup template with interpolation for sitemap.xml

* Update changelog
2023-07-26 21:08:38 +02:00
efd9e7a5c7 Fix RedisClient import (#2183) 2023-07-26 20:36:34 +02:00
d9ced885e1 Feature/add error handling for redis connections (#2179)
* Add error handling

* Update changelog
2023-07-26 20:30:32 +02:00
5fe07cb85f Bugfix/fix value in holdings table (#2182)
* Fix missing value

* Update changelog
2023-07-26 20:05:26 +02:00
af008aa74f Release 1.292.0 (#2175) 2023-07-24 20:17:46 +02:00
ca7bf27c20 Feature/upgrade yahoo finance2 to version 2.4.3 (#2174)
* Upgrade yahoo-finance2 to version 2.4.3

* Update changelog
2023-07-24 20:16:14 +02:00
0866587cab Increase frequency (#2169) 2023-07-24 20:12:07 +02:00
622bb8b0cf Feature/add allocations by market chart (#2171)
* Add allocations by (advanced) market

* Fix public page

* Update changelog
2023-07-24 20:04:34 +02:00
16b9fbe00e Release 1.291.0 (#2168) 2023-07-23 16:06:45 +02:00
c9353d0a39 Support account balance time series (#2166)
* Initial setup

* Support account balance in export

* Handle account balance update

* Add schema migration

* Update changelog
2023-07-23 15:55:58 +02:00
ea101dd3bd Refactor value to valueInBaseCurrency (#2167)
* Revert value to valueInBaseCurrency refactoring
2023-07-23 14:13:02 +02:00
cd67ce82fa Feature/rename queries to presets in market data table of admin control (#2165)
* Rename queries to presets

* Update changelog
2023-07-21 11:40:49 +02:00
d5b3c52602 Refactor value to valueInBaseCurrency (#2164) 2023-07-20 20:28:56 +02:00
bdf72164b1 Feature/break down emergency fund by cash and assets (#2159)
* Break down emergency fund in cash and assets

* Update changelog
2023-07-19 11:30:48 +02:00
455a2d2e92 Refactor value to valueInBaseCurrency (#2160) 2023-07-18 21:29:08 +02:00
9c0f46b587 Add markets.sh (#2161) 2023-07-18 21:28:44 +02:00
8533606177 Release 1.290.0 (#2158) 2023-07-16 08:01:31 +02:00
6728e04ff7 Improve http response interceptor (#2157)
Do not show snack bar for login endpoint
2023-07-15 22:17:07 +02:00
2bf4f1237a Feature/Improve login dialog (#2124)
* Improve login dialog

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-07-15 22:09:12 +02:00
4857b2e620 Update locales (#2154) 2023-07-15 19:50:11 +02:00
68a9a7f6f9 Feature/add queries to market data table in admin control (#2153)
* Add queries

* ETF_WITHOUT_COUNTRIES
* ETF_WITHOUT_SECTORS

* Update changelog
2023-07-15 17:54:16 +02:00
81ef95e13e Setup permissions (#2151) 2023-07-15 12:32:59 +02:00
b633132757 Feature/upgrade prisma to version 4.16.2 (#2109)
* Upgrade prisma to version 4.16.2

* Update changelog
2023-07-15 12:32:43 +02:00
2b0f961370 Feature/improve faq page (#2152)
* Extend content

* Update changelog
2023-07-15 12:16:19 +02:00
30f1a3514a Feature/add hints to activity types in create or edit activity dialog (#2150)
* Add hints

* Update changelog
2023-07-15 11:31:05 +02:00
ed735e0b29 Feature/disable caching in health check endpoints for data providers (#2147)
* Disable caching in health check endpoint

* Update changelog
2023-07-15 10:54:19 +02:00
b89ccd2dde Release 1.289.0 (#2149) 2023-07-14 10:26:27 +02:00
df6d39377f Upgrade @types/lodash to version 4.14.195 (#2125) 2023-07-14 07:53:39 +02:00
d5d14497d6 Release 1.288.0 (#2146) 2023-07-12 19:56:16 +02:00
09c300661a Feature/improve language localization for german 20230711 (#2144)
* Improve i18n

* Update changelog
2023-07-12 19:53:55 +02:00
92382e0b4d Feature/improve loading state during filtering on allocations page (#2141)
* Improve loading state

* Update changelog
2023-07-11 21:41:12 +02:00
c25f532487 Improve product pages (#2143) 2023-07-11 21:40:45 +02:00
5d26d94586 Sort imports (#2142) 2023-07-11 20:27:54 +02:00
73b6784e9f Feature/beautify ampersand in asset profile names (#2138)
* Beautify ampersand

* Update changelog
2023-07-10 20:16:38 +02:00
6159f48a62 Feature/setup personal finance tools pages 2 (#2140) 2023-07-10 20:16:20 +02:00
94 changed files with 23657 additions and 1586 deletions

View File

@ -5,6 +5,101 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 1.296.0 - 2023-08-01
### Changed
- Optimized the validation in the activities import by reducing the list to unique asset profiles
- Optimized the data gathering in the activities import
## 1.295.0 - 2023-07-30
### Added
- Added a step by step introduction for new users
### Fixed
- Removed the _Stay signed in_ setting on _Sign in with fingerprint_ activation
## 1.294.0 - 2023-07-29
### Changed
- Extended the allocations by market chart on the allocations page by unavailable data
### Fixed
- Considered liabilities in the total account value calculation
## 1.293.0 - 2023-07-26
### Added
- Added error handling for the _Redis_ connections to keep the app running if the connection fails
### Changed
- Set the `lastmod` dates of `sitemap.xml` dynamically
### Fixed
- Fixed the missing values in the holdings table
- Fixed the `no such file or directory` error caused by the missing `favicon.ico` file
## 1.292.0 - 2023-07-24
### Added
- Introduced the allocations by market chart on the allocations page
### Changed
- Upgraded `yahoo-finance2` from version `2.4.2` to `2.4.3`
### Fixed
- Fixed an issue in the public page
## 1.291.0 - 2023-07-23
### Added
- Broken down the emergency fund by cash and assets
- Added support for account balance time series
### Changed
- Renamed queries to presets in the historical market data table of the admin control panel
## 1.290.0 - 2023-07-16
### Added
- Added hints to the activity types in the create or edit activity dialog
- Added queries to the historical market data table of the admin control panel
### Changed
- Improved the usability of the login dialog
- Disabled the caching in the health check endpoints for data providers
- Improved the content of the Frequently Asked Questions (FAQ) page
- Upgraded `prisma` from version `4.15.0` to `4.16.2`
## 1.289.0 - 2023-07-14
### Changed
- Upgraded `yahoo-finance2` from version `2.4.1` to `2.4.2`
## 1.288.0 - 2023-07-12
### Changed
- Improved the loading state during filtering on the allocations page
- Beautified the names with ampersand (`&amp;`) in the asset profile
- Improved the language localization for German (`de`)
## 1.287.0 - 2023-07-09
### Changed

View File

@ -263,7 +263,9 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
## Community Projects
- [ghostfolio-cli](https://github.com/DerAndereJohannes/ghostfolio-cli): Command-line interface to access your portfolio
Discover a variety of community projects for Ghostfolio: https://github.com/topics/ghostfolio
Are you building your own project? Add the `ghostfolio` topic to your _GitHub_ repository to get listed as well. [Learn more →](https://docs.github.com/en/articles/classifying-your-repository-with-topics)
## Contributing

View File

@ -1,6 +1,7 @@
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { AccountBalanceModule } from '@ghostfolio/api/services/account-balance/account-balance.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
@ -15,6 +16,7 @@ import { AccountService } from './account.service';
controllers: [AccountController],
exports: [AccountService],
imports: [
AccountBalanceModule,
ConfigurationModule,
DataProviderModule,
ExchangeRateDataModule,

View File

@ -1,3 +1,4 @@
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Filter } from '@ghostfolio/common/interfaces';
@ -11,16 +12,21 @@ import { CashDetails } from './interfaces/cash-details.interface';
@Injectable()
export class AccountService {
public constructor(
private readonly accountBalanceService: AccountBalanceService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService
) {}
public async account(
accountWhereUniqueInput: Prisma.AccountWhereUniqueInput
): Promise<Account | null> {
return this.prismaService.account.findUnique({
where: accountWhereUniqueInput
public async account({
id_userId
}: Prisma.AccountWhereUniqueInput): Promise<Account | null> {
const { id, userId } = id_userId;
const [account] = await this.accounts({
where: { id, userId }
});
return account;
}
public async accountWithOrders(
@ -50,9 +56,11 @@ export class AccountService {
Platform?: Platform;
})[]
> {
const { include, skip, take, cursor, where, orderBy } = params;
const { include = {}, skip, take, cursor, where, orderBy } = params;
return this.prismaService.account.findMany({
include.balances = { orderBy: { date: 'desc' }, take: 1 };
const accounts = await this.prismaService.account.findMany({
cursor,
include,
orderBy,
@ -60,15 +68,36 @@ export class AccountService {
take,
where
});
return accounts.map((account) => {
account = { ...account, balance: account.balances[0]?.value ?? 0 };
delete account.balances;
return account;
});
}
public async createAccount(
data: Prisma.AccountCreateInput,
aUserId: string
): Promise<Account> {
return this.prismaService.account.create({
const account = await this.prismaService.account.create({
data
});
await this.prismaService.accountBalance.create({
data: {
Account: {
connect: {
id_userId: { id: account.id, userId: aUserId }
}
},
value: data.balance
}
});
return account;
}
public async deleteAccount(
@ -167,6 +196,18 @@ export class AccountService {
aUserId: string
): Promise<Account> {
const { data, where } = params;
await this.prismaService.accountBalance.create({
data: {
Account: {
connect: {
id_userId: where.id_userId
}
},
value: <number>data.balance
}
});
return this.prismaService.account.update({
data,
where
@ -202,16 +243,17 @@ export class AccountService {
);
if (amountInCurrencyOfAccount) {
await this.prismaService.account.update({
data: {
balance: new Big(balance).plus(amountInCurrencyOfAccount).toNumber()
},
where: {
id_userId: {
userId,
id: accountId
await this.accountBalanceService.createAccountBalance({
date,
Account: {
connect: {
id_userId: {
userId,
id: accountId
}
}
}
},
value: new Big(balance).plus(amountInCurrencyOfAccount).toNumber()
});
}
}

View File

@ -7,6 +7,7 @@ import {
GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import {
AdminData,
AdminMarketData,
@ -15,7 +16,10 @@ import {
Filter
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import type {
MarketDataPreset,
RequestWithUser
} from '@ghostfolio/common/types';
import {
Body,
Controller,
@ -113,7 +117,7 @@ export class AdminController {
name: GATHER_ASSET_PROFILE_PROCESS,
opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: `${dataSource}-${symbol}`
jobId: getAssetProfileIdentifier({ dataSource, symbol })
}
};
})
@ -149,7 +153,7 @@ export class AdminController {
name: GATHER_ASSET_PROFILE_PROCESS,
opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: `${dataSource}-${symbol}`
jobId: getAssetProfileIdentifier({ dataSource, symbol })
}
};
})
@ -182,7 +186,7 @@ export class AdminController {
name: GATHER_ASSET_PROFILE_PROCESS,
opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: `${dataSource}-${symbol}`
jobId: getAssetProfileIdentifier({ dataSource, symbol })
}
});
}
@ -249,6 +253,7 @@ export class AdminController {
@UseGuards(AuthGuard('jwt'))
public async getMarketData(
@Query('assetSubClasses') filterByAssetSubClasses?: string,
@Query('presetId') presetId?: MarketDataPreset,
@Query('skip') skip?: number,
@Query('sortColumn') sortColumn?: string,
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
@ -279,6 +284,7 @@ export class AdminController {
return this.adminService.getMarketData({
filters,
presetId,
sortColumn,
sortDirection,
skip: isNaN(skip) ? undefined : skip,

View File

@ -17,6 +17,7 @@ import {
Filter,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { MarketDataPreset } from '@ghostfolio/common/types';
import { BadRequestException, Injectable } from '@nestjs/common';
import { AssetSubClass, Prisma, Property, SymbolProfile } from '@prisma/client';
import { differenceInDays } from 'date-fns';
@ -103,12 +104,14 @@ export class AdminService {
public async getMarketData({
filters,
presetId,
sortColumn,
sortDirection,
skip,
take = DEFAULT_PAGE_SIZE
take = Number.MAX_SAFE_INTEGER
}: {
filters?: Filter[];
presetId?: MarketDataPreset;
skip?: number;
sortColumn?: string;
sortDirection?: Prisma.SortOrder;
@ -118,6 +121,13 @@ export class AdminService {
[{ symbol: 'asc' }];
const where: Prisma.SymbolProfileWhereInput = {};
if (
presetId === 'ETF_WITHOUT_COUNTRIES' ||
presetId === 'ETF_WITHOUT_SECTORS'
) {
filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }];
}
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
filters,
(filter) => {
@ -146,7 +156,7 @@ export class AdminService {
}
}
const [assetProfiles, count] = await Promise.all([
let [assetProfiles, count] = await Promise.all([
this.prismaService.symbolProfile.findMany({
orderBy,
skip,
@ -174,44 +184,60 @@ export class AdminService {
this.prismaService.symbolProfile.count({ where })
]);
return {
count,
marketData: assetProfiles.map(
({
_count,
let marketData = assetProfiles.map(
({
_count,
assetClass,
assetSubClass,
comment,
countries,
dataSource,
Order,
sectors,
symbol
}) => {
const countriesCount = countries ? Object.keys(countries).length : 0;
const marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
const sectorsCount = sectors ? Object.keys(sectors).length : 0;
return {
assetClass,
assetSubClass,
comment,
countries,
countriesCount,
dataSource,
Order,
sectors,
symbol
}) => {
const countriesCount = countries ? Object.keys(countries).length : 0;
const marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
const sectorsCount = sectors ? Object.keys(sectors).length : 0;
symbol,
marketDataItemCount,
sectorsCount,
activitiesCount: _count.Order,
date: Order?.[0]?.date
};
}
);
return {
assetClass,
assetSubClass,
comment,
countriesCount,
dataSource,
symbol,
marketDataItemCount,
sectorsCount,
activitiesCount: _count.Order,
date: Order?.[0]?.date
};
}
)
if (presetId) {
if (presetId === 'ETF_WITHOUT_COUNTRIES') {
marketData = marketData.filter(({ countriesCount }) => {
return countriesCount === 0;
});
} else if (presetId === 'ETF_WITHOUT_SECTORS') {
marketData = marketData.filter(({ sectorsCount }) => {
return sectorsCount === 0;
});
}
count = marketData.length;
}
return {
count,
marketData
};
}

View File

@ -66,11 +66,11 @@ export class BenchmarkService {
const promises: Promise<number>[] = [];
const quotes = await this.dataProviderService.getQuotes(
benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
const quotes = await this.dataProviderService.getQuotes({
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
})
);
});
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
promises.push(this.marketDataService.getMax({ dataSource, symbol }));

View File

@ -1,8 +1,9 @@
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common';
import { ExportController } from './export.controller';
@ -10,10 +11,11 @@ import { ExportService } from './export.service';
@Module({
imports: [
AccountModule,
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
PrismaModule,
OrderModule,
RedisCacheModule
],
controllers: [ExportController],

View File

@ -1,11 +1,15 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { environment } from '@ghostfolio/api/environments/environment';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Export } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
@Injectable()
export class ExportService {
public constructor(private readonly prismaService: PrismaService) {}
public constructor(
private readonly accountService: AccountService,
private readonly orderService: OrderService
) {}
public async export({
activityIds,
@ -14,36 +18,40 @@ export class ExportService {
activityIds?: string[];
userId: string;
}): Promise<Export> {
const accounts = await this.prismaService.account.findMany({
orderBy: {
name: 'asc'
},
select: {
accountType: true,
balance: true,
comment: true,
currency: true,
id: true,
isExcluded: true,
name: true,
platformId: true
},
where: { userId }
});
const accounts = (
await this.accountService.accounts({
orderBy: {
name: 'asc'
},
where: { userId }
})
).map(
({
accountType,
balance,
comment,
currency,
id,
isExcluded,
name,
platformId
}) => {
return {
accountType,
balance,
comment,
currency,
id,
isExcluded,
name,
platformId
};
}
);
let activities = await this.prismaService.order.findMany({
let activities = await this.orderService.orders({
include: { SymbolProfile: true },
orderBy: { date: 'desc' },
select: {
accountId: true,
comment: true,
date: true,
fee: true,
id: true,
quantity: true,
SymbolProfile: true,
type: true,
unitPrice: true
},
where: { userId }
});

View File

@ -4,7 +4,7 @@ import * as path from 'path';
import { environment } from '@ghostfolio/api/environments/environment';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Injectable, NestMiddleware } from '@nestjs/common';
import { format } from 'date-fns';
import { NextFunction, Request, Response } from 'express';
@ -18,6 +18,7 @@ export class FrontendMiddleware implements NestMiddleware {
public indexHtmlIt = '';
public indexHtmlNl = '';
public indexHtmlPt = '';
public sitemapXml = '';
private static readonly DEFAULT_DESCRIPTION =
'Ghostfolio is a personal finance dashboard to keep track of your assets like stocks, ETFs or cryptocurrencies across multiple platforms.';
@ -54,6 +55,10 @@ export class FrontendMiddleware implements NestMiddleware {
this.getPathOfIndexHtmlFile('pt'),
'utf8'
);
this.sitemapXml = fs.readFileSync(
path.join(__dirname, 'assets', 'sitemap.xml'),
'utf8'
);
} catch {}
}
@ -118,6 +123,13 @@ export class FrontendMiddleware implements NestMiddleware {
) {
// Skip
next();
} else if (request.path === '/sitemap.xml') {
response.setHeader('content-type', 'application/xml');
response.send(
this.interpolate(this.sitemapXml, {
currentDate: format(getYesterday(), DATE_FORMAT)
})
);
} else if (request.path === '/de' || request.path.startsWith('/de/')) {
response.send(
this.interpolate(this.indexHtmlDe, {
@ -228,7 +240,13 @@ export class FrontendMiddleware implements NestMiddleware {
private isFileRequest(filename: string) {
if (filename === '/assets/LICENSE') {
return true;
} else if (filename.includes('auth/ey')) {
} else if (
filename === '/sitemap.xml' ||
filename.includes('auth/ey') ||
filename.includes(
'personal-finance-tools/open-source-alternative-to-markets.sh'
)
) {
return false;
}

View File

@ -8,10 +8,14 @@ import {
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
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 { parseDate } from '@ghostfolio/common/helper';
import {
getAssetProfileIdentifier,
parseDate
} from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import {
AccountWithPlatform,
@ -21,12 +25,14 @@ import { Injectable } from '@nestjs/common';
import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
import Big from 'big.js';
import { endOfToday, isAfter, isSameDay, parseISO } from 'date-fns';
import { uniqBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid';
@Injectable()
export class ImportService {
public constructor(
private readonly accountService: AccountService,
private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly orderService: OrderService,
@ -220,8 +226,7 @@ export class ImportService {
const assetProfiles = await this.validateActivities({
activitiesDto,
maxActivitiesToImport,
userId
maxActivitiesToImport
});
const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({
@ -250,10 +255,37 @@ export class ImportService {
error,
fee,
quantity,
SymbolProfile: assetProfile,
SymbolProfile,
type,
unitPrice
} of activitiesExtendedWithErrors) {
const assetProfile = assetProfiles[
getAssetProfileIdentifier({
dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol
})
] ?? {
currency: SymbolProfile.currency,
dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol
};
const {
assetClass,
assetSubClass,
countries,
createdAt,
currency,
dataSource,
id,
isin,
name,
scraperConfiguration,
sectors,
symbol,
symbolMapping,
url,
updatedAt
} = assetProfile;
const validatedAccount = accounts.find(({ id }) => {
return id === accountId;
});
@ -279,23 +311,22 @@ export class ImportService {
id: uuidv4(),
isDraft: isAfter(date, endOfToday()),
SymbolProfile: {
assetClass: assetProfile.assetClass,
assetSubClass: assetProfile.assetSubClass,
comment: assetProfile.comment,
countries: assetProfile.countries,
createdAt: assetProfile.createdAt,
currency: assetProfile.currency,
dataSource: assetProfile.dataSource,
id: assetProfile.id,
isin: assetProfile.isin,
name: assetProfile.name,
scraperConfiguration: assetProfile.scraperConfiguration,
sectors: assetProfile.sectors,
symbol: assetProfile.currency,
symbolMapping: assetProfile.symbolMapping,
updatedAt: assetProfile.updatedAt,
url: assetProfile.url,
...assetProfiles[assetProfile.symbol]
assetClass,
assetSubClass,
countries,
createdAt,
currency,
dataSource,
id,
isin,
name,
scraperConfiguration,
sectors,
symbol,
symbolMapping,
updatedAt,
url,
comment: assetProfile.comment
},
Account: validatedAccount,
symbolProfileId: undefined,
@ -318,14 +349,14 @@ export class ImportService {
SymbolProfile: {
connectOrCreate: {
create: {
currency: assetProfile.currency,
dataSource: assetProfile.dataSource,
symbol: assetProfile.symbol
currency,
dataSource,
symbol
},
where: {
dataSource_symbol: {
dataSource: assetProfile.dataSource,
symbol: assetProfile.symbol
dataSource,
symbol
}
}
}
@ -337,24 +368,49 @@ export class ImportService {
const value = new Big(quantity).mul(unitPrice).toNumber();
//@ts-ignore
activities.push({
...order,
error,
value,
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
fee,
assetProfile.currency,
currency,
userCurrency
),
//@ts-ignore
SymbolProfile: assetProfile,
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value,
assetProfile.currency,
currency,
userCurrency
)
});
}
activities.sort((activity1, activity2) => {
return Number(activity1.date) - Number(activity2.date);
});
if (!isDryRun) {
// Gather symbol data in the background, if not dry run
const uniqueActivities = uniqBy(activities, ({ SymbolProfile }) => {
return getAssetProfileIdentifier({
dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol
});
});
this.dataGatheringService.gatherSymbols(
uniqueActivities.map(({ date, SymbolProfile }) => {
return {
date,
dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol
};
})
);
}
return activities;
}
@ -446,25 +502,30 @@ export class ImportService {
private async validateActivities({
activitiesDto,
maxActivitiesToImport,
userId
maxActivitiesToImport
}: {
activitiesDto: Partial<CreateOrderDto>[];
maxActivitiesToImport: number;
userId: string;
}) {
if (activitiesDto?.length > maxActivitiesToImport) {
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
}
const assetProfiles: {
[symbol: string]: Partial<SymbolProfile>;
[assetProfileIdentifier: string]: Partial<SymbolProfile>;
} = {};
const uniqueActivitiesDto = uniqBy(
activitiesDto,
({ dataSource, symbol }) => {
return getAssetProfileIdentifier({ dataSource, symbol });
}
);
for (const [
index,
{ currency, dataSource, symbol }
] of activitiesDto.entries()) {
] of uniqueActivitiesDto.entries()) {
if (dataSource !== 'MANUAL') {
const assetProfile = (
await this.dataProviderService.getAssetProfiles([
@ -484,7 +545,8 @@ export class ImportService {
);
}
assetProfiles[symbol] = assetProfile;
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] =
assetProfile;
}
}

View File

@ -2,6 +2,7 @@ import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -36,6 +37,7 @@ import { UpdateOrderDto } from './update-order.dto';
export class OrderController {
public constructor(
private readonly apiService: ApiService,
private readonly dataGatheringService: DataGatheringService,
private readonly impersonationService: ImpersonationService,
private readonly orderService: OrderService,
@Inject(REQUEST) private readonly request: RequestWithUser
@ -123,7 +125,7 @@ export class OrderController {
);
}
return this.orderService.createOrder({
const order = await this.orderService.createOrder({
...data,
date: parseISO(data.date),
SymbolProfile: {
@ -144,6 +146,19 @@ export class OrderController {
User: { connect: { id: this.request.user.id } },
userId: this.request.user.id
});
if (!order.isDraft) {
// Gather symbol data in the background, if not draft
this.dataGatheringService.gatherSymbols([
{
dataSource: data.dataSource,
date: order.date,
symbol: data.symbol
}
]);
}
return order;
}
@Put(':id')

View File

@ -2,6 +2,7 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
@ -31,6 +32,6 @@ import { OrderService } from './order.service';
SymbolProfileModule,
UserModule
],
providers: [AccountService, OrderService]
providers: [AccountBalanceService, AccountService, OrderService]
})
export class OrderModule {}

View File

@ -7,6 +7,7 @@ import {
GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { Filter } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
@ -117,7 +118,7 @@ export class OrderService {
};
}
await this.dataGatheringService.addJobToQueue({
this.dataGatheringService.addJobToQueue({
data: {
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
symbol: data.SymbolProfile.connectOrCreate.create.symbol
@ -125,26 +126,13 @@ export class OrderService {
name: GATHER_ASSET_PROFILE_PROCESS,
opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: `${data.SymbolProfile.connectOrCreate.create.dataSource}-${data.SymbolProfile.connectOrCreate.create.symbol}`
jobId: getAssetProfileIdentifier({
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
symbol: data.SymbolProfile.connectOrCreate.create.symbol
})
}
});
const isDraft =
data.type === 'LIABILITY'
? false
: isAfter(data.date as Date, endOfToday());
if (!isDraft) {
// Gather symbol data of order in the background, if not draft
this.dataGatheringService.gatherSymbols([
{
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
date: <Date>data.date,
symbol: data.SymbolProfile.connectOrCreate.create.symbol
}
]);
}
delete data.accountId;
delete data.assetClass;
delete data.assetSubClass;
@ -162,6 +150,11 @@ export class OrderService {
const orderData: Prisma.OrderCreateInput = data;
const isDraft =
data.type === 'LIABILITY'
? false
: isAfter(data.date as Date, endOfToday());
const order = await this.prismaService.order.create({
data: {
...orderData,

View File

@ -38,7 +38,7 @@ export class CurrentRateService {
if (includeToday) {
promises.push(
this.dataProviderService
.getQuotes(dataGatheringItems)
.getQuotes({ items: dataGatheringItems })
.then((dataResultProvider) => {
const result: GetValueObject[] = [];
for (const dataGatheringItem of dataGatheringItems) {

View File

@ -1,4 +1,4 @@
import { DataSource, Type as TypeOfOrder } from '@prisma/client';
import { DataSource, Tag, Type as TypeOfOrder } from '@prisma/client';
import Big from 'big.js';
export interface PortfolioOrder {
@ -9,6 +9,7 @@ export interface PortfolioOrder {
name: string;
quantity: Big;
symbol: string;
tags?: Tag[];
type: TypeOfOrder;
unitPrice: Big;
}

View File

@ -1,4 +1,4 @@
import { DataSource } from '@prisma/client';
import { DataSource, Tag } from '@prisma/client';
import Big from 'big.js';
export interface TransactionPointSymbol {
@ -9,5 +9,6 @@ export interface TransactionPointSymbol {
investment: Big;
quantity: Big;
symbol: string;
tags?: Tag[];
transactionCount: number;
}

View File

@ -114,6 +114,7 @@ export class PortfolioCalculator {
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
quantity: newQuantity,
symbol: order.symbol,
tags: order.tags,
transactionCount: oldAccumulatedSymbol.transactionCount + 1
};
} else {
@ -125,6 +126,7 @@ export class PortfolioCalculator {
investment: unitPrice.mul(order.quantity).mul(factor),
quantity: order.quantity.mul(factor),
symbol: order.symbol,
tags: order.tags,
transactionCount: 1
};
}
@ -492,6 +494,7 @@ export class PortfolioCalculator {
: null,
quantity: item.quantity,
symbol: item.symbol,
tags: item.tags,
transactionCount: item.transactionCount
});

View File

@ -134,7 +134,7 @@ export class PortfolioController {
portfolioPosition.netPerformance = null;
portfolioPosition.quantity = null;
portfolioPosition.valueInPercentage =
portfolioPosition.value / totalValue;
portfolioPosition.valueInBaseCurrency / totalValue;
}
for (const [name, { valueInBaseCurrency }] of Object.entries(accounts)) {
@ -161,10 +161,12 @@ export class PortfolioController {
'emergencyFund',
'excludedAccountsAndActivities',
'fees',
'fireWealth',
'items',
'liabilities',
'netWorth',
'totalBuy',
'totalInvestment',
'totalSell'
]);
}
@ -177,6 +179,9 @@ export class PortfolioController {
countries: hasDetails ? portfolioPosition.countries : [],
currency: hasDetails ? portfolioPosition.currency : undefined,
markets: hasDetails ? portfolioPosition.markets : undefined,
marketsAdvanced: hasDetails
? portfolioPosition.marketsAdvanced
: undefined,
sectors: hasDetails ? portfolioPosition.sectors : []
};
}
@ -445,7 +450,8 @@ export class PortfolioController {
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
portfolioPublicDetails.holdings[symbol] = {
allocationInPercentage: portfolioPosition.value / totalValue,
allocationInPercentage:
portfolioPosition.valueInBaseCurrency / totalValue,
countries: hasDetails ? portfolioPosition.countries : [],
currency: hasDetails ? portfolioPosition.currency : undefined,
dataSource: portfolioPosition.dataSource,
@ -456,7 +462,7 @@ export class PortfolioController {
sectors: hasDetails ? portfolioPosition.sectors : [],
symbol: portfolioPosition.symbol,
url: portfolioPosition.url,
valueInPercentage: portfolioPosition.value / totalValue
valueInPercentage: portfolioPosition.valueInBaseCurrency / totalValue
};
}

View File

@ -2,6 +2,7 @@ import { AccessModule } from '@ghostfolio/api/app/access/access.module';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
@ -36,6 +37,7 @@ import { RulesService } from './rules.service';
UserModule
],
providers: [
AccountBalanceService,
AccountService,
CurrentRateService,
PortfolioService,

View File

@ -42,7 +42,6 @@ import type {
AccountWithValue,
DateRange,
GroupBy,
Market,
OrderWithAccount,
RequestWithUser,
UserWithSettings
@ -84,8 +83,10 @@ import {
import { PortfolioCalculator } from './portfolio-calculator';
import { RulesService } from './rules.service';
const asiaPacificMarkets = require('../../assets/countries/asia-pacific-markets.json');
const developedMarkets = require('../../assets/countries/developed-markets.json');
const emergingMarkets = require('../../assets/countries/emerging-markets.json');
const europeMarkets = require('../../assets/countries/europe-markets.json');
@Injectable()
export class PortfolioService {
@ -504,15 +505,17 @@ export class PortfolioService {
);
}
const dataGatheringItems = currentPositions.positions.map((position) => {
return {
dataSource: position.dataSource,
symbol: position.symbol
};
});
const dataGatheringItems = currentPositions.positions.map(
({ dataSource, symbol }) => {
return {
dataSource,
symbol
};
}
);
const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.getQuotes(dataGatheringItems),
this.dataProviderService.getQuotes({ items: dataGatheringItems }),
this.symbolProfileService.getSymbolProfiles(dataGatheringItems)
]);
@ -536,30 +539,79 @@ export class PortfolioService {
const symbolProfile = symbolProfileMap[item.symbol];
const dataProviderResponse = dataProviderResponses[item.symbol];
const markets: { [key in Market]: number } = {
const markets: PortfolioPosition['markets'] = {
[UNKNOWN_KEY]: 0,
developedMarkets: 0,
emergingMarkets: 0,
otherMarkets: 0
};
const marketsAdvanced: PortfolioPosition['marketsAdvanced'] = {
[UNKNOWN_KEY]: 0,
asiaPacific: 0,
emergingMarkets: 0,
europe: 0,
japan: 0,
northAmerica: 0,
otherMarkets: 0
};
for (const country of symbolProfile.countries) {
if (developedMarkets.includes(country.code)) {
markets.developedMarkets = new Big(markets.developedMarkets)
.plus(country.weight)
.toNumber();
} else if (emergingMarkets.includes(country.code)) {
markets.emergingMarkets = new Big(markets.emergingMarkets)
.plus(country.weight)
.toNumber();
} else {
markets.otherMarkets = new Big(markets.otherMarkets)
.plus(country.weight)
.toNumber();
if (symbolProfile.countries.length > 0) {
for (const country of symbolProfile.countries) {
if (developedMarkets.includes(country.code)) {
markets.developedMarkets = new Big(markets.developedMarkets)
.plus(country.weight)
.toNumber();
} else if (emergingMarkets.includes(country.code)) {
markets.emergingMarkets = new Big(markets.emergingMarkets)
.plus(country.weight)
.toNumber();
} else {
markets.otherMarkets = new Big(markets.otherMarkets)
.plus(country.weight)
.toNumber();
}
if (country.code === 'JP') {
marketsAdvanced.japan = new Big(marketsAdvanced.japan)
.plus(country.weight)
.toNumber();
} else if (country.code === 'CA' || country.code === 'US') {
marketsAdvanced.northAmerica = new Big(marketsAdvanced.northAmerica)
.plus(country.weight)
.toNumber();
} else if (asiaPacificMarkets.includes(country.code)) {
marketsAdvanced.asiaPacific = new Big(marketsAdvanced.asiaPacific)
.plus(country.weight)
.toNumber();
} else if (emergingMarkets.includes(country.code)) {
marketsAdvanced.emergingMarkets = new Big(
marketsAdvanced.emergingMarkets
)
.plus(country.weight)
.toNumber();
} else if (europeMarkets.includes(country.code)) {
marketsAdvanced.europe = new Big(marketsAdvanced.europe)
.plus(country.weight)
.toNumber();
} else {
marketsAdvanced.otherMarkets = new Big(marketsAdvanced.otherMarkets)
.plus(country.weight)
.toNumber();
}
}
} else {
markets[UNKNOWN_KEY] = new Big(markets[UNKNOWN_KEY])
.plus(value)
.toNumber();
marketsAdvanced[UNKNOWN_KEY] = new Big(marketsAdvanced[UNKNOWN_KEY])
.plus(value)
.toNumber();
}
holdings[item.symbol] = {
markets,
marketsAdvanced,
allocationInPercentage: filteredValueInBaseCurrency.eq(0)
? 0
: value.div(filteredValueInBaseCurrency).toNumber(),
@ -581,9 +633,10 @@ export class PortfolioService {
quantity: item.quantity.toNumber(),
sectors: symbolProfile.sectors,
symbol: item.symbol,
tags: item.tags,
transactionCount: item.transactionCount,
url: symbolProfile.url,
value: value.toNumber()
valueInBaseCurrency: value.toNumber()
};
}
@ -626,7 +679,7 @@ export class PortfolioService {
const emergencyFundInCash = emergencyFund
.minus(
this.getEmergencyFundPositionsValueInBaseCurrency({
activities: orders
holdings
})
)
.toNumber();
@ -643,7 +696,7 @@ export class PortfolioService {
holdings[userCurrency] = {
...emergencyFundCashPositions[userCurrency],
investment: emergencyFundInCash,
value: emergencyFundInCash
valueInBaseCurrency: emergencyFundInCash
};
}
@ -654,7 +707,7 @@ export class PortfolioService {
balanceInBaseCurrency: cashDetails.balanceInBaseCurrency,
emergencyFundPositionsValueInBaseCurrency:
this.getEmergencyFundPositionsValueInBaseCurrency({
activities: orders
holdings
})
});
@ -740,6 +793,7 @@ export class PortfolioService {
name: order.SymbolProfile?.name,
quantity: new Big(order.quantity),
symbol: order.SymbolProfile.symbol,
tags: order.tags,
type: order.type,
unitPrice: new Big(order.unitPrice)
}));
@ -897,9 +951,9 @@ export class PortfolioService {
)
};
} else {
const currentData = await this.dataProviderService.getQuotes([
{ dataSource: DataSource.YAHOO, symbol: aSymbol }
]);
const currentData = await this.dataProviderService.getQuotes({
items: [{ dataSource: DataSource.YAHOO, symbol: aSymbol }]
});
const marketPrice = currentData[aSymbol]?.marketPrice;
let historicalData = await this.dataProviderService.getHistorical(
@ -1000,15 +1054,15 @@ export class PortfolioService {
(item) => !item.quantity.eq(0)
);
const dataGatheringItem = positions.map((position) => {
const dataGatheringItems = positions.map(({ dataSource, symbol }) => {
return {
dataSource: position.dataSource,
symbol: position.symbol
dataSource,
symbol
};
});
const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.getQuotes(dataGatheringItem),
this.dataProviderService.getQuotes({ items: dataGatheringItems }),
this.symbolProfileService.getSymbolProfiles(
positions.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
@ -1276,7 +1330,7 @@ export class PortfolioService {
if (cashPositions[account.currency]) {
cashPositions[account.currency].investment += convertedBalance;
cashPositions[account.currency].value += convertedBalance;
cashPositions[account.currency].valueInBaseCurrency += convertedBalance;
} else {
cashPositions[account.currency] = this.getInitialCashPosition({
balance: convertedBalance,
@ -1288,7 +1342,9 @@ export class PortfolioService {
for (const symbol of Object.keys(cashPositions)) {
// Calculate allocations for each currency
cashPositions[symbol].allocationInPercentage = value.gt(0)
? new Big(cashPositions[symbol].value).div(value).toNumber()
? new Big(cashPositions[symbol].valueInBaseCurrency)
.div(value)
.toNumber()
: 0;
}
@ -1388,13 +1444,13 @@ export class PortfolioService {
}
private getEmergencyFundPositionsValueInBaseCurrency({
activities
holdings
}: {
activities: Activity[];
holdings: PortfolioDetails['holdings'];
}) {
const emergencyFundOrders = activities.filter((activity) => {
const emergencyFundHoldings = Object.values(holdings).filter(({ tags }) => {
return (
activity.tags?.some(({ id }) => {
tags?.some(({ id }) => {
return id === EMERGENCY_FUND_TAG_ID;
}) ?? false
);
@ -1402,18 +1458,9 @@ export class PortfolioService {
let valueInBaseCurrencyOfEmergencyFundPositions = new Big(0);
for (const order of emergencyFundOrders) {
if (order.type === 'BUY') {
valueInBaseCurrencyOfEmergencyFundPositions =
valueInBaseCurrencyOfEmergencyFundPositions.plus(
order.valueInBaseCurrency
);
} else if (order.type === 'SELL') {
valueInBaseCurrencyOfEmergencyFundPositions =
valueInBaseCurrencyOfEmergencyFundPositions.minus(
order.valueInBaseCurrency
);
}
for (const { valueInBaseCurrency } of emergencyFundHoldings) {
valueInBaseCurrencyOfEmergencyFundPositions =
valueInBaseCurrencyOfEmergencyFundPositions.plus(valueInBaseCurrency);
}
return valueInBaseCurrencyOfEmergencyFundPositions.toNumber();
@ -1472,8 +1519,9 @@ export class PortfolioService {
quantity: 0,
sectors: [],
symbol: currency,
tags: [],
transactionCount: 0,
value: balance
valueInBaseCurrency: balance
};
}
@ -1499,7 +1547,13 @@ export class PortfolioService {
);
}
private getLiabilities(activities: OrderWithAccount[]) {
private getLiabilities({
activities,
userCurrency
}: {
activities: OrderWithAccount[];
userCurrency: string;
}) {
return activities
.filter(({ type }) => {
return type === TypeOfOrder.LIABILITY;
@ -1508,7 +1562,7 @@ export class PortfolioService {
return this.exchangeRateDataService.toCurrency(
new Big(quantity).mul(unitPrice).toNumber(),
SymbolProfile.currency,
this.request.user.Settings.settings.baseCurrency
userCurrency
);
})
.reduce(
@ -1618,7 +1672,10 @@ export class PortfolioService {
const fees = this.getFees({ activities, userCurrency }).toNumber();
const firstOrderDate = activities[0]?.date;
const items = this.getItems(activities).toNumber();
const liabilities = this.getLiabilities(activities).toNumber();
const liabilities = this.getLiabilities({
activities,
userCurrency
}).toNumber();
const totalBuy = this.getTotalByType(activities, userCurrency, 'BUY');
const totalSell = this.getTotalByType(activities, userCurrency, 'SELL');
@ -1683,7 +1740,16 @@ export class PortfolioService {
totalBuy,
totalSell,
committedFunds: committedFunds.toNumber(),
emergencyFund: emergencyFund.toNumber(),
emergencyFund: {
assets: emergencyFundPositionsValueInBaseCurrency,
cash: emergencyFund
.minus(emergencyFundPositionsValueInBaseCurrency)
.toNumber(),
total: emergencyFund.toNumber()
},
fireWealth: new Big(performanceInformation.performance.currentValue)
.minus(emergencyFundPositionsValueInBaseCurrency)
.toNumber(),
ordersCount: activities.filter(({ type }) => {
return type === 'BUY' || type === 'SELL';
}).length
@ -1735,6 +1801,7 @@ export class PortfolioService {
name: order.SymbolProfile?.name,
quantity: new Big(order.quantity),
symbol: order.SymbolProfile.symbol,
tags: order.tags,
type: order.type,
unitPrice: new Big(
this.exchangeRateDataService.toCurrency(
@ -1775,12 +1842,12 @@ export class PortfolioService {
userId: string;
withExcludedAccounts?: boolean;
}) {
const ordersOfTypeItem = await this.orderService.getOrders({
const ordersOfTypeItemOrLiability = await this.orderService.getOrders({
filters,
userCurrency,
userId,
withExcludedAccounts,
types: ['ITEM']
types: ['ITEM', 'LIABILITY']
});
const accounts: PortfolioDetails['accounts'] = {};
@ -1820,13 +1887,14 @@ export class PortfolioService {
return accountId === account.id;
});
const ordersOfTypeItemByAccount = ordersOfTypeItem.filter(
({ accountId }) => {
const ordersOfTypeItemOrLiabilityByAccount =
ordersOfTypeItemOrLiability.filter(({ accountId }) => {
return accountId === account.id;
}
);
});
ordersByAccount = ordersByAccount.concat(ordersOfTypeItemByAccount);
ordersByAccount = ordersByAccount.concat(
ordersOfTypeItemOrLiabilityByAccount
);
accounts[account.id] = {
balance: account.balance,
@ -1866,7 +1934,7 @@ export class PortfolioService {
order.unitPrice ??
0);
if (order.type === 'SELL') {
if (order.type === 'LIABILITY' || order.type === 'SELL') {
currentValueOfSymbolInBaseCurrency *= -1;
}

View File

@ -0,0 +1,7 @@
import { Cache } from 'cache-manager';
import type { RedisStore } from './redis-store.interface';
export interface RedisCache extends Cache {
store: RedisStore;
}

View File

@ -0,0 +1,8 @@
import { Store } from 'cache-manager';
import { RedisClient } from 'redis';
export interface RedisStore extends Store {
getClient: () => RedisClient;
isCacheableValue: (value: any) => boolean;
name: 'redis';
}

View File

@ -1,21 +1,29 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { CACHE_MANAGER, Inject, Injectable } from '@nestjs/common';
import { Cache } from 'cache-manager';
import { CACHE_MANAGER, Inject, Injectable, Logger } from '@nestjs/common';
import type { RedisCache } from './interfaces/redis-cache.interface';
@Injectable()
export class RedisCacheService {
public constructor(
@Inject(CACHE_MANAGER) private readonly cache: Cache,
@Inject(CACHE_MANAGER) private readonly cache: RedisCache,
private readonly configurationService: ConfigurationService
) {}
) {
const client = cache.store.getClient();
client.on('error', (error) => {
Logger.error(error, 'RedisCacheService');
});
}
public async get(key: string): Promise<string> {
return await this.cache.get(key);
}
public getQuoteKey({ dataSource, symbol }: UniqueAsset) {
return `quote-${dataSource}-${symbol}`;
return `quote-${getAssetProfileIdentifier({ dataSource, symbol })}`;
}
public async remove(key: string) {

View File

@ -27,9 +27,9 @@ export class SymbolService {
dataGatheringItem: IDataGatheringItem;
includeHistoricalData?: number;
}): Promise<SymbolItem> {
const quotes = await this.dataProviderService.getQuotes([
dataGatheringItem
]);
const quotes = await this.dataProviderService.getQuotes({
items: [dataGatheringItem]
});
const { currency, marketPrice } = quotes[dataGatheringItem.symbol] ?? {};
if (dataGatheringItem.dataSource && marketPrice >= 0) {

View File

@ -14,6 +14,7 @@ import {
import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { Prisma, Role, User } from '@prisma/client';
import { differenceInDays } from 'date-fns';
import { sortBy } from 'lodash';
const crypto = require('crypto');
@ -123,7 +124,7 @@ export class UserService {
id,
provider,
role,
Settings,
Settings: Settings as UserWithSettings['Settings'],
thirdPartyId,
updatedAt,
activityCount: Analytics?.activityCount
@ -165,11 +166,26 @@ export class UserService {
user.subscription =
this.subscriptionService.getSubscription(Subscription);
if (
Analytics?.activityCount % 10 === 0 &&
user.subscription?.type === 'Basic'
) {
currentPermissions.push(permissions.enableSubscriptionInterstitial);
if (user.subscription?.type === 'Basic') {
const daysSinceRegistration = differenceInDays(
new Date(),
user.createdAt
);
let frequency = 20;
if (daysSinceRegistration > 180) {
frequency = 3;
} else if (daysSinceRegistration > 60) {
frequency = 5;
} else if (daysSinceRegistration > 30) {
frequency = 10;
} else if (daysSinceRegistration > 15) {
frequency = 15;
}
if (Analytics?.activityCount % frequency === 1) {
currentPermissions.push(permissions.enableSubscriptionInterstitial);
}
}
if (user.subscription?.type === 'Premium') {

View File

@ -0,0 +1 @@
["AU", "HK", "NZ", "SG"]

View File

@ -0,0 +1,19 @@
[
"AT",
"BE",
"CH",
"DE",
"DK",
"ES",
"FI",
"FR",
"GB",
"IE",
"IL",
"IT",
"LU",
"NL",
"NO",
"PT",
"SE"
]

View File

@ -6,494 +6,514 @@
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
<url>
<loc>https://ghostfol.io/de</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/blog</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/blog/2021/07/hallo-ghostfolio</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/features</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/haeufig-gestellte-fragen</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/maerkte</loc>
<changefreq>daily</changefreq>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/open</loc>
<changefreq>daily</changefreq>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/preise</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/registrierung</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ueber-uns</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ueber-uns/changelog</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ueber-uns/datenschutzbestimmungen</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ueber-uns/lizenz</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/about</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/about/changelog</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/about/license</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2021/07/hello-ghostfolio</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/01/ghostfolio-first-months-in-open-source</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/07/ghostfolio-meets-internet-identity</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/07/how-do-i-get-my-finances-in-order</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/08/500-stars-on-github</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/10/hacktoberfest-2022</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/11/black-friday-2022</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/12/the-importance-of-tracking-your-personal-finances</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/02/ghostfolio-meets-umbrel</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/03/ghostfolio-reaches-1000-stars-on-github</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/05/unlock-your-financial-potential-with-ghostfolio</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/07/exploring-the-path-to-fire</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/faq</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/features</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/markets</loc>
<changefreq>daily</changefreq>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/open</loc>
<changefreq>daily</changefreq>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/pricing</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/register</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-altoo</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-copilot-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-delta</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-divvydiary</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-exirio</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-folishare</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-getquin</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-gospatz</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-justetf</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-kubera</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-markets.sh</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-maybe-finance</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-monse</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-parqet</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-plannix</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-portfolio-dividend-tracker</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-portseido</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-projectionlab</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-seeking-alpha</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-sharesight</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-simple-portfolio</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-snowball-analytics</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-sumio</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-utluna</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-yeekatee</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/funcionalidades</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/mercados</loc>
<changefreq>daily</changefreq>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/open</loc>
<changefreq>daily</changefreq>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/precios</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/preguntas-mas-frecuentes</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/recursos</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/registro</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/sobre</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/sobre/changelog</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/sobre/licencia</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/sobre/politica-de-privacidad</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/a-propos</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/a-propos/changelog</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/a-propos/licence</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/a-propos/politique-de-confidentialite</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/enregistrement</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/fonctionnalites</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/foire-aux-questions</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/marches</loc>
<changefreq>daily</changefreq>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/open</loc>
<changefreq>daily</changefreq>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/prix</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/ressources</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/domande-piu-frequenti</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/funzionalita</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/informazioni-su</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/informazioni-su/changelog</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/informazioni-su/licenza</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/informazioni-su/informativa-sulla-privacy</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/iscrizione</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/mercati</loc>
<changefreq>daily</changefreq>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/open</loc>
<changefreq>daily</changefreq>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/prezzi</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/kenmerken</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/markten</loc>
<changefreq>daily</changefreq>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/open</loc>
<changefreq>daily</changefreq>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/over</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/over/changelog</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/over/licentie</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/over/privacybeleid</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/prijzen</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/registratie</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/vaak-gestelde-vragen</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/blog</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/funcionalidades</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/mercados</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/open</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/perguntas-mais-frequentes</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/precos</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/recursos</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/registo</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/sobre</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/sobre/changelog</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/sobre/licenca</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/sobre/politica-de-privacidade</loc>
<lastmod>2023-07-07T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
</urlset>

View File

@ -0,0 +1,10 @@
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common';
@Module({
exports: [AccountBalanceService],
imports: [PrismaModule],
providers: [AccountBalanceService]
})
export class AccountBalanceModule {}

View File

@ -0,0 +1,16 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Injectable } from '@nestjs/common';
import { AccountBalance, Prisma } from '@prisma/client';
@Injectable()
export class AccountBalanceService {
public constructor(private readonly prismaService: PrismaService) {}
public async createAccountBalance(
data: Prisma.AccountBalanceCreateInput
): Promise<AccountBalance> {
return this.prismaService.accountBalance.create({
data
});
}
}

View File

@ -2,6 +2,7 @@ import {
GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
@ -48,7 +49,7 @@ export class CronService {
name: GATHER_ASSET_PROFILE_PROCESS,
opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: `${dataSource}-${symbol}`
jobId: getAssetProfileIdentifier({ dataSource, symbol })
}
};
})

View File

@ -10,7 +10,11 @@ import {
GATHER_HISTORICAL_MARKET_DATA_PROCESS,
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS
} from '@ghostfolio/common/config';
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper';
import {
DATE_FORMAT,
getAssetProfileIdentifier,
resetHours
} from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { InjectQueue } from '@nestjs/bull';
import { Inject, Injectable, Logger } from '@nestjs/common';
@ -221,7 +225,10 @@ export class DataGatheringService {
name: GATHER_HISTORICAL_MARKET_DATA_PROCESS,
opts: {
...GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
jobId: `${dataSource}-${symbol}-${format(date, DATE_FORMAT)}`
jobId: `${getAssetProfileIdentifier({
dataSource,
symbol
})}-${format(date, DATE_FORMAT)}`
}
};
})

View File

@ -135,6 +135,8 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
let name = longName;
if (name) {
name = name.replace('&amp;', '&');
name = name.replace('Amundi Index Solutions - ', '');
name = name.replace('iShares ETF (CH) - ', '');
name = name.replace('iShares III Public Limited Company - ', '');

View File

@ -3,7 +3,6 @@ import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.in
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
IDataGatheringItem,
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
@ -12,6 +11,7 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_DATA_SOURCE_MAPPING } from '@ghostfolio/common/config';
import { DATE_FORMAT, getStartOfUtcDate } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import type { Granularity, UserWithSettings } from '@ghostfolio/common/types';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource, MarketData, SymbolProfile } from '@prisma/client';
@ -45,12 +45,15 @@ export class DataProviderService {
const dataProvider = this.getDataProvider(dataSource);
const symbol = dataProvider.getTestSymbol();
const quotes = await this.getQuotes([
{
dataSource,
symbol
}
]);
const quotes = await this.getQuotes({
items: [
{
dataSource,
symbol
}
],
useCache: false
});
if (quotes[symbol]?.marketPrice > 0) {
return true;
@ -59,14 +62,16 @@ export class DataProviderService {
return false;
}
public async getAssetProfiles(items: IDataGatheringItem[]): Promise<{
public async getAssetProfiles(items: UniqueAsset[]): Promise<{
[symbol: string]: Partial<SymbolProfile>;
}> {
const response: {
[symbol: string]: Partial<SymbolProfile>;
} = {};
const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource);
const itemsGroupedByDataSource = groupBy(items, ({ dataSource }) => {
return dataSource;
});
const promises = [];
@ -127,7 +132,7 @@ export class DataProviderService {
}
public async getHistorical(
aItems: IDataGatheringItem[],
aItems: UniqueAsset[],
aGranularity: Granularity = 'month',
from: Date,
to: Date
@ -155,11 +160,11 @@ export class DataProviderService {
)}'`
: '';
const dataSources = aItems.map((item) => {
return item.dataSource;
const dataSources = aItems.map(({ dataSource }) => {
return dataSource;
});
const symbols = aItems.map((item) => {
return item.symbol;
const symbols = aItems.map(({ symbol }) => {
return symbol;
});
try {
@ -192,7 +197,7 @@ export class DataProviderService {
}
public async getHistoricalRaw(
aDataGatheringItems: IDataGatheringItem[],
aDataGatheringItems: UniqueAsset[],
from: Date,
to: Date
): Promise<{
@ -229,7 +234,13 @@ export class DataProviderService {
return result;
}
public async getQuotes(items: IDataGatheringItem[]): Promise<{
public async getQuotes({
items,
useCache = true
}: {
items: UniqueAsset[];
useCache?: boolean;
}): Promise<{
[symbol: string]: IDataProviderResponse;
}> {
const response: {
@ -238,23 +249,24 @@ export class DataProviderService {
const startTimeTotal = performance.now();
// Get items from cache
const itemsToFetch: IDataGatheringItem[] = [];
const itemsToFetch: UniqueAsset[] = [];
for (const { dataSource, symbol } of items) {
const quoteString = await this.redisCacheService.get(
this.redisCacheService.getQuoteKey({ dataSource, symbol })
);
if (useCache) {
const quoteString = await this.redisCacheService.get(
this.redisCacheService.getQuoteKey({ dataSource, symbol })
);
if (quoteString) {
try {
const cachedDataProviderResponse = JSON.parse(quoteString);
response[symbol] = cachedDataProviderResponse;
} catch {}
if (quoteString) {
try {
const cachedDataProviderResponse = JSON.parse(quoteString);
response[symbol] = cachedDataProviderResponse;
continue;
} catch {}
}
}
if (!quoteString) {
itemsToFetch.push({ dataSource, symbol });
}
itemsToFetch.push({ dataSource, symbol });
}
const numberOfItemsInCache = Object.keys(response)?.length;

View File

@ -64,11 +64,11 @@ export class ExchangeRateDataService {
if (Object.keys(result).length !== this.currencyPairs.length) {
// Load currencies directly from data provider as a fallback
// if historical data is not fully available
const quotes = await this.dataProviderService.getQuotes(
this.currencyPairs.map(({ dataSource, symbol }) => {
const quotes = await this.dataProviderService.getQuotes({
items: this.currencyPairs.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
})
);
});
for (const symbol of Object.keys(quotes)) {
if (isNumber(quotes[symbol].marketPrice)) {
@ -125,9 +125,11 @@ export class ExchangeRateDataService {
return 0;
}
let factor = 1;
let factor: number;
if (aFromCurrency !== aToCurrency) {
if (aFromCurrency === aToCurrency) {
factor = 1;
} else {
if (this.exchangeRates[`${aFromCurrency}${aToCurrency}`]) {
factor = this.exchangeRates[`${aFromCurrency}${aToCurrency}`];
} else {
@ -171,7 +173,9 @@ export class ExchangeRateDataService {
let factor: number;
if (aFromCurrency !== aToCurrency) {
if (aFromCurrency === aToCurrency) {
factor = 1;
} else {
const dataSource =
this.dataProviderService.getDataSourceForExchangeRates();
const symbol = `${aFromCurrency}${aToCurrency}`;

View File

@ -29,6 +29,11 @@
"input": "",
"output": "./../assets"
},
{
"glob": "favicon.ico",
"input": "apps/client/src/assets",
"output": "./../"
},
{
"glob": "LICENSE",
"input": "",

View File

@ -8,11 +8,13 @@ import {
ViewChild
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatPaginator, PageEvent } from '@angular/material/paginator';
import { MatSort, Sort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { getDateFormatString } from '@ghostfolio/common/helper';
import { Filter, UniqueAsset, User } from '@ghostfolio/common/interfaces';
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
@ -26,8 +28,6 @@ import { AssetProfileDialog } from './asset-profile-dialog/asset-profile-dialog.
import { AssetProfileDialogParams } from './asset-profile-dialog/interfaces/interfaces';
import { CreateAssetProfileDialog } from './create-asset-profile-dialog/create-asset-profile-dialog.component';
import { CreateAssetProfileDialogParams } from './create-asset-profile-dialog/interfaces/interfaces';
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { MatPaginator, PageEvent } from '@angular/material/paginator';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -51,13 +51,26 @@ export class AdminMarketDataComponent
AssetSubClass.PRECIOUS_METAL,
AssetSubClass.PRIVATE_EQUITY,
AssetSubClass.STOCK
].map((assetSubClass) => {
return {
id: assetSubClass,
label: translate(assetSubClass),
type: 'ASSET_SUB_CLASS'
};
});
]
.map((assetSubClass) => {
return {
id: assetSubClass.toString(),
label: translate(assetSubClass),
type: <Filter['type']>'ASSET_SUB_CLASS'
};
})
.concat([
{
id: 'ETF_WITHOUT_COUNTRIES',
label: $localize`ETFs without Countries`,
type: <Filter['type']>'PRESET_ID'
},
{
id: 'ETF_WITHOUT_SECTORS',
label: $localize`ETFs without Sectors`,
type: <Filter['type']>'PRESET_ID'
}
]);
public currentDataSource: DataSource;
public currentSymbol: string;
public dataSource: MatTableDataSource<AdminMarketDataItem> =
@ -237,6 +250,12 @@ export class AdminMarketDataComponent
) {
this.isLoading = true;
this.pageSize =
this.activeFilters.length === 1 &&
this.activeFilters[0].type === 'PRESET_ID'
? undefined
: DEFAULT_PAGE_SIZE;
if (pageIndex === 0 && this.paginator) {
this.paginator.pageIndex = 0;
}

View File

@ -1,57 +1,110 @@
<div
class="align-items-center container d-flex flex-column h-100 justify-content-center overview p-0 position-relative"
>
<div class="row w-100">
<div class="col p-0">
<div class="chart-container mx-auto position-relative">
<div
*ngIf="hasPermissionToCreateOrder && historicalDataItems?.length === 0"
class="align-items-center d-flex h-100 justify-content-center w-100"
<div
*ngIf="hasPermissionToCreateOrder && historicalDataItems?.length === 0; else isUserActive"
class="justify-content-center row w-100"
>
<div class="col introduction">
<h4 i18n>Welcome to Ghostfolio</h4>
<p i18n>Ready to take control of your personal finances?</p>
<ol class="font-weight-bold">
<li
class="mb-2"
[ngClass]="{ 'text-muted': user?.accounts?.length > 1 }"
>
<div class="d-flex justify-content-center">
<gf-no-transactions-info-indicator></gf-no-transactions-info-indicator>
</div>
<a class="d-block" [routerLink]="['/accounts']"
><span i18n>Setup your accounts</span><br />
<span class="font-weight-normal" i18n
>Get a comprehensive financial overview by adding your bank and
brokerage accounts.</span
></a
>
</li>
<li class="mb-2">
<a class="d-block" [routerLink]="['/portfolio', 'activities']">
<span i18n>Capture your activities</span><br />
<span class="font-weight-normal" i18n
>Record your investment activities to keep your portfolio up to
date.</span
></a
>
</li>
<li class="mb-2">
<a class="d-block" [routerLink]="['/portfolio']">
<span i18n>Monitor and analyze your portfolio</span><br />
<span class="font-weight-normal" i18n
>Track your progress in real-time with comprehensive analysis and
insights.</span
>
</a>
</li>
</ol>
<div class="d-flex justify-content-center">
<a
*ngIf="user?.accounts?.length === 1"
color="primary"
mat-flat-button
[routerLink]="['/accounts']"
>
<ng-container i18n>Setup accounts</ng-container>
</a>
<a
*ngIf="user?.accounts?.length > 1"
color="primary"
mat-flat-button
[routerLink]="['/portfolio', 'activities']"
>
<ng-container i18n>Add activity</ng-container>
</a>
</div>
</div>
</div>
<ng-template #isUserActive>
<div class="row w-100">
<div class="col p-0">
<div class="chart-container mx-auto position-relative">
<gf-line-chart
class="position-absolute"
symbol="Performance"
unit="%"
[colorScheme]="user?.settings?.colorScheme"
[hidden]="historicalDataItems?.length === 0"
[historicalDataItems]="historicalDataItems"
[isAnimated]="user?.settings?.dateRange === '1d' ? false : true"
[locale]="user?.settings?.locale"
[ngClass]="{ 'pr-3': deviceType === 'mobile' }"
[showGradient]="true"
[showLoader]="false"
[showXAxis]="false"
[showYAxis]="false"
></gf-line-chart>
</div>
<gf-line-chart
class="position-absolute"
symbol="Performance"
unit="%"
[colorScheme]="user?.settings?.colorScheme"
[hidden]="historicalDataItems?.length === 0"
[historicalDataItems]="historicalDataItems"
[isAnimated]="user?.settings?.dateRange === '1d' ? false : true"
[locale]="user?.settings?.locale"
[ngClass]="{ 'pr-3': deviceType === 'mobile' }"
[showGradient]="true"
[showLoader]="false"
[showXAxis]="false"
[showYAxis]="false"
></gf-line-chart>
</div>
</div>
</div>
<div class="overview-container row mt-1">
<div class="col">
<gf-portfolio-performance
class="pb-4"
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[errors]="errors"
[isAllTimeHigh]="isAllTimeHigh"
[isAllTimeLow]="isAllTimeLow"
[isLoading]="isLoadingPerformance"
[locale]="user?.settings?.locale"
[performance]="performance"
[showDetails]="showDetails"
></gf-portfolio-performance>
<div *ngIf="showDetails" class="text-center">
<gf-toggle
[defaultValue]="user?.settings?.dateRange"
<div class="overview-container row mt-1">
<div class="col">
<gf-portfolio-performance
class="pb-4"
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[errors]="errors"
[isAllTimeHigh]="isAllTimeHigh"
[isAllTimeLow]="isAllTimeLow"
[isLoading]="isLoadingPerformance"
[options]="dateRangeOptions"
(change)="onChangeDateRange($event.value)"
></gf-toggle>
[locale]="user?.settings?.locale"
[performance]="performance"
[showDetails]="showDetails"
></gf-portfolio-performance>
<div *ngIf="showDetails" class="text-center">
<gf-toggle
[defaultValue]="user?.settings?.dateRange"
[isLoading]="isLoadingPerformance"
[options]="dateRangeOptions"
(change)="onChangeDateRange($event.value)"
></gf-toggle>
</div>
</div>
</div>
</div>
</ng-template>
</div>

View File

@ -1,5 +1,6 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { GfPortfolioPerformanceModule } from '@ghostfolio/client/components/portfolio-performance/portfolio-performance.module';
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
@ -16,6 +17,7 @@ import { HomeOverviewComponent } from './home-overview.component';
GfNoTransactionsInfoModule,
GfPortfolioPerformanceModule,
GfToggleModule,
MatButtonModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]

View File

@ -31,4 +31,8 @@
top: 0;
}
}
.introduction {
max-width: 50rem;
}
}

View File

@ -16,6 +16,8 @@ import { TokenStorageService } from '@ghostfolio/client/services/token-storage.s
templateUrl: 'login-with-access-token-dialog.html'
})
export class LoginWithAccessTokenDialog {
public isAccessTokenHidden = true;
public constructor(
@Inject(MAT_DIALOG_DATA) public data: any,
public dialogRef: MatDialogRef<LoginWithAccessTokenDialog>,
@ -38,6 +40,12 @@ export class LoginWithAccessTokenDialog {
this.dialogRef.close();
}
public onLoginWithAccessToken() {
if (this.data.accessToken) {
this.dialogRef.close(this.data);
}
}
public async onLoginWithInternetIdentity() {
try {
const { authToken } = await this.internetIdentityService.login();

View File

@ -6,15 +6,27 @@
<div class="py-3" mat-dialog-content>
<div class="align-items-center d-flex flex-column">
<mat-form-field appearance="outline" class="without-hint w-100">
<mat-label i18n>Security Token</mat-label>
<textarea
cdkTextareaAutosize
matInput
type="text"
[(ngModel)]="data.accessToken"
></textarea>
</mat-form-field>
<form class="w-100" (ngSubmit)="onLoginWithAccessToken()">
<mat-form-field appearance="outline" class="without-hint w-100">
<mat-label i18n>Security Token</mat-label>
<input
matInput
name="password"
[type]="isAccessTokenHidden ? 'password' : 'text'"
[(ngModel)]="data.accessToken"
/>
<button
mat-button
matSuffix
type="button"
(click)="isAccessTokenHidden = !isAccessTokenHidden"
>
<ion-icon
[name]="isAccessTokenHidden ? 'eye-outline' : 'eye-off-outline'"
></ion-icon>
</button>
</mat-form-field>
</form>
<ng-container *ngIf="data.hasPermissionToUseSocialLogin">
<div class="my-3 text-center text-muted" i18n>or</div>
<div class="d-flex flex-column">

View File

@ -163,7 +163,33 @@
[isCurrency]="true"
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.emergencyFund"
[value]="isLoading ? undefined : summary?.emergencyFund?.total"
></gf-value>
</div>
</div>
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 ml-3 text-truncate" i18n>Cash</div>
<div class="flex-column flex-wrap justify-content-end">
<gf-value
class="justify-content-end"
position="end"
[isCurrency]="true"
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.emergencyFund?.cash"
></gf-value>
</div>
</div>
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 ml-3 text-truncate" i18n>Assets</div>
<div class="flex-column flex-wrap justify-content-end">
<gf-value
class="justify-content-end"
position="end"
[isCurrency]="true"
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.emergencyFund?.assets"
></gf-value>
</div>
</div>

View File

@ -62,7 +62,7 @@ export class HttpResponseInterceptor implements HttpInterceptor {
undefined,
{ duration: 6000 }
);
} else {
} else if (!error.url.endsWith('auth/anonymous')) {
this.snackBarRef = this.snackBar.open(
$localize`This feature requires a subscription.`,
this.hasPermissionForSubscription

View File

@ -15,6 +15,10 @@ import {
import { ActivatedRoute, Router } from '@angular/router';
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
import { DataService } from '@ghostfolio/client/services/data.service';
import {
STAY_SIGNED_IN,
SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
import { getDateFormatString } from '@ghostfolio/common/helper';
@ -80,6 +84,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
private snackBar: MatSnackBar,
private route: ActivatedRoute,
private router: Router,
private settingsStorageService: SettingsStorageService,
private stripeService: StripeService,
private userService: UserService,
public webAuthnService: WebAuthnService
@ -397,6 +402,8 @@ export class AccountPageComponent implements OnDestroy, OnInit {
})
)
.subscribe(() => {
this.settingsStorageService.removeSetting(STAY_SIGNED_IN);
this.update();
});
}

View File

@ -235,7 +235,12 @@
</div>
</div>
<div class="align-items-center d-flex mt-4 py-1">
<div class="pr-1 w-50" i18n>Sign in with fingerprint</div>
<div class="pr-1 w-50">
<div i18n>Biometric Authentication</div>
<div class="hint-text text-muted" i18n>
Sign in with fingerprint
</div>
</div>
<div class="pl-1 w-50">
<mat-checkbox
#toggleSignInWithFingerprintEnabledElement

View File

@ -4,6 +4,12 @@
<h3 class="d-none d-sm-block mb-3 text-center">
Frequently Asked Questions (FAQ)
</h3>
<p>
Find quick answers to commonly asked questions about Ghostfolio in our
Frequently Asked Questions (FAQ) section. Discover what Ghostfolio is,
explore its features, and learn about our privacy practices. Get all the
information you need in one place.
</p>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title>What is Ghostfolio?</mat-card-title>
@ -22,7 +28,7 @@
</mat-card-header>
<mat-card-content>
With Ghostfolio, you can keep track of various assets like stocks,
ETFs or cryptocurrencies.
ETFs, bonds, cryptocurrencies and commodities.
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
@ -51,6 +57,17 @@
or <i>Google Sign</i>. We will guide you to set up your portfolio.
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title
>Will you spam me with emails once I sign up?</mat-card-title
></mat-card-header
>
<mat-card-content>
No, we do not even collect your email address, so you will not receive
any spam emails from us.
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title
@ -92,6 +109,30 @@
get for free.</mat-card-content
>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title
>Do you monetize or sell my financial data?</mat-card-title
></mat-card-header
>
<mat-card-content
>No, we value your privacy. We do not sell or share your financial
data with any third parties.</mat-card-content
>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title
>What is your business model?</mat-card-title
></mat-card-header
>
<mat-card-content
>By offering <a [routerLink]="['/pricing']">Ghostfolio Premium</a>, a
subscription plan with a managed hosting service and enhanced
features, we fund our business while providing added value to our
users.</mat-card-content
>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title

View File

@ -14,23 +14,13 @@ import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { DataService } from '@ghostfolio/client/services/data.service';
import { getDateFormatString } from '@ghostfolio/common/helper';
import { translate } from '@ghostfolio/ui/i18n';
import { AssetClass, AssetSubClass, Tag, Type } from '@prisma/client';
import { isUUID } from 'class-validator';
import { isString } from 'lodash';
import { EMPTY, Observable, Subject, lastValueFrom, of } from 'rxjs';
import {
catchError,
debounceTime,
distinctUntilChanged,
map,
startWith,
switchMap,
takeUntil
} from 'rxjs/operators';
import { catchError, map, startWith, takeUntil } from 'rxjs/operators';
import { CreateOrUpdateActivityDialogParams } from './interfaces/interfaces';
@ -61,6 +51,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
public separatorKeysCodes: number[] = [ENTER, COMMA];
public tags: Tag[] = [];
public total = 0;
public typesTranslationMap = new Map<Type, string>();
public Validators = Validators;
private unsubscribeSubject = new Subject<void>();
@ -91,6 +82,10 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
};
});
Object.keys(Type).forEach((type) => {
this.typesTranslationMap[Type[type]] = translate(Type[type]);
});
this.activityForm = this.formBuilder.group({
accountId: [this.data.activity?.accountId, Validators.required],
assetClass: [this.data.activity?.SymbolProfile?.assetClass],
@ -374,10 +369,6 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
});
}
public displayFn(aLookupItem: LookupItem) {
return aLookupItem?.symbol ?? '';
}
public onAddTag(event: MatAutocompleteSelectedEvent) {
this.activityForm.controls['tags'].setValue([
...(this.activityForm.controls['tags'].value ?? []),

View File

@ -11,11 +11,41 @@
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Type</mat-label>
<mat-select formControlName="type">
<mat-option i18n value="BUY">Buy</mat-option>
<mat-option i18n value="DIVIDEND">Dividend</mat-option>
<mat-option i18n value="ITEM">Item</mat-option>
<mat-option i18n value="LIABILITY">Liability</mat-option>
<mat-option i18n value="SELL">Sell</mat-option>
<mat-select-trigger
>{{ typesTranslationMap[activityForm.controls['type'].value]
}}</mat-select-trigger
>
<mat-option class="line-height-1" value="BUY">
<span><b>{{ typesTranslationMap['BUY'] }}</b></span>
<br />
<small class="text-muted text-nowrap" i18n
>Stocks, ETFs, bonds, cryptocurrencies, commodities</small
>
</mat-option>
<mat-option class="line-height-1" value="DIVIDEND">
<span><b>{{ typesTranslationMap['DIVIDEND'] }}</b></span>
</mat-option>
<mat-option class="line-height-1" value="LIABILITY">
<span><b>{{ typesTranslationMap['LIABILITY'] }}</b></span>
<br />
<small class="text-muted text-nowrap" i18n
>Mortgages, personal loans, credit cards</small
>
</mat-option>
<mat-option class="line-height-1" value="SELL">
<span><b>{{ typesTranslationMap['SELL'] }}</b></span>
<br />
<small class="text-muted text-nowrap" i18n
>Stocks, ETFs, bonds, cryptocurrencies, commodities</small
>
</mat-option>
<mat-option class="line-height-1" value="ITEM">
<span><b>{{ typesTranslationMap['ITEM'] }}</b></span>
<br />
<small class="text-muted text-nowrap" i18n
>Luxury items, real estate, private companies</small
>
</mat-option>
</mat-select>
</mat-form-field>
</div>

View File

@ -18,7 +18,7 @@ import {
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Market } from '@ghostfolio/common/types';
import { Market, MarketAdvanced } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n';
import { Account, AssetClass, DataSource, Platform } from '@prisma/client';
import { isNumber } from 'lodash';
@ -54,6 +54,13 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
public markets: {
[key in Market]: { name: string; value: number };
};
public marketsAdvanced: {
[key in MarketAdvanced]: {
id: MarketAdvanced;
name: string;
value: number;
};
};
public placeholder = '';
public platforms: {
[id: string]: Pick<Platform, 'name'> & {
@ -65,13 +72,8 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
public positions: {
[symbol: string]: Pick<
PortfolioPosition,
| 'assetClass'
| 'assetSubClass'
| 'currency'
| 'exchange'
| 'name'
| 'value'
> & { etfProvider: string };
'assetClass' | 'assetSubClass' | 'currency' | 'exchange' | 'name'
> & { etfProvider: string; value: number };
};
public sectors: {
[name: string]: { name: string; value: number };
@ -84,7 +86,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
value: number;
};
};
public UNKNOWN_KEY = UNKNOWN_KEY;
public user: User;
public worldMapChartFormat: string;
@ -139,6 +141,8 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
? $localize`Filter by account or tag...`
: '';
this.initialize();
return this.dataService.fetchPortfolioDetails({
filters: this.activeFilters
});
@ -146,6 +150,8 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
takeUntil(this.unsubscribeSubject)
)
.subscribe((portfolioDetails) => {
this.initialize();
this.portfolioDetails = portfolioDetails;
this.initializeAnalysisData();
@ -223,20 +229,68 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
}
};
this.markets = {
[UNKNOWN_KEY]: {
name: UNKNOWN_KEY,
value: 0
},
developedMarkets: {
name: 'developedMarkets',
value: undefined
value: 0
},
emergingMarkets: {
name: 'emergingMarkets',
value: undefined
value: 0
},
otherMarkets: {
name: 'otherMarkets',
value: undefined
value: 0
}
};
this.marketsAdvanced = {
[UNKNOWN_KEY]: {
id: UNKNOWN_KEY,
name: UNKNOWN_KEY,
value: 0
},
asiaPacific: {
id: 'asiaPacific',
name: translate('Asia-Pacific'),
value: 0
},
emergingMarkets: {
id: 'emergingMarkets',
name: translate('Emerging Markets'),
value: 0
},
europe: {
id: 'europe',
name: translate('Europe'),
value: 0
},
japan: {
id: 'japan',
name: translate('Japan'),
value: 0
},
northAmerica: {
id: 'northAmerica',
name: translate('North America'),
value: 0
},
otherMarkets: {
id: 'otherMarkets',
name: translate('Other Markets'),
value: 0
}
};
this.platforms = {};
this.portfolioDetails = {
accounts: {},
filteredValueInPercentage: 0,
holdings: {},
platforms: {},
summary: undefined
};
this.positions = {};
this.sectors = {
[UNKNOWN_KEY]: {
@ -254,8 +308,6 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
}
public initializeAnalysisData() {
this.initialize();
for (const [
id,
{ name, valueInBaseCurrency, valueInPercentage }
@ -283,7 +335,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
if (this.hasImpersonationId) {
value = position.allocationInPercentage;
} else {
value = position.value;
value = position.valueInBaseCurrency;
}
this.positions[symbol] = {
@ -303,50 +355,109 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
// Prepare analysis data by continents, countries and sectors except for cash
if (position.countries.length > 0) {
if (!this.markets.developedMarkets.value) {
this.markets.developedMarkets.value = 0;
}
if (!this.markets.emergingMarkets.value) {
this.markets.emergingMarkets.value = 0;
}
if (!this.markets.otherMarkets.value) {
this.markets.otherMarkets.value = 0;
}
this.markets.developedMarkets.value +=
position.markets.developedMarkets * position.value;
position.markets.developedMarkets *
(isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
: position.valueInPercentage);
this.markets.emergingMarkets.value +=
position.markets.emergingMarkets * position.value;
position.markets.emergingMarkets *
(isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
: position.valueInPercentage);
this.markets.otherMarkets.value +=
position.markets.otherMarkets * position.value;
position.markets.otherMarkets *
(isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
: position.valueInPercentage);
this.marketsAdvanced.asiaPacific.value +=
position.marketsAdvanced.asiaPacific *
(isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
: position.valueInPercentage);
this.marketsAdvanced.emergingMarkets.value +=
position.marketsAdvanced.emergingMarkets *
(isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
: position.valueInPercentage);
this.marketsAdvanced.europe.value +=
position.marketsAdvanced.europe *
(isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
: position.valueInPercentage);
this.marketsAdvanced.japan.value +=
position.marketsAdvanced.japan *
(isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
: position.valueInPercentage);
this.marketsAdvanced.northAmerica.value +=
position.marketsAdvanced.northAmerica *
(isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
: position.valueInPercentage);
for (const country of position.countries) {
const { code, continent, name, weight } = country;
if (this.continents[continent]?.value) {
this.continents[continent].value += weight * position.value;
this.continents[continent].value +=
weight *
(isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
: position.valueInPercentage);
} else {
this.continents[continent] = {
name: continent,
value: weight * this.portfolioDetails.holdings[symbol].value
value:
weight *
(isNumber(position.valueInBaseCurrency)
? this.portfolioDetails.holdings[symbol].valueInBaseCurrency
: this.portfolioDetails.holdings[symbol].valueInPercentage)
};
}
if (this.countries[code]?.value) {
this.countries[code].value += weight * position.value;
this.countries[code].value +=
weight *
(isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
: position.valueInPercentage);
} else {
this.countries[code] = {
name,
value: weight * this.portfolioDetails.holdings[symbol].value
value:
weight *
(isNumber(position.valueInBaseCurrency)
? this.portfolioDetails.holdings[symbol].valueInBaseCurrency
: this.portfolioDetails.holdings[symbol].valueInPercentage)
};
}
}
} else {
this.continents[UNKNOWN_KEY].value +=
this.portfolioDetails.holdings[symbol].value;
this.continents[UNKNOWN_KEY].value += isNumber(
position.valueInBaseCurrency
)
? this.portfolioDetails.holdings[symbol].valueInBaseCurrency
: this.portfolioDetails.holdings[symbol].valueInPercentage;
this.countries[UNKNOWN_KEY].value +=
this.portfolioDetails.holdings[symbol].value;
this.countries[UNKNOWN_KEY].value += isNumber(
position.valueInBaseCurrency
)
? this.portfolioDetails.holdings[symbol].valueInBaseCurrency
: this.portfolioDetails.holdings[symbol].valueInPercentage;
this.markets[UNKNOWN_KEY].value += isNumber(
position.valueInBaseCurrency
)
? this.portfolioDetails.holdings[symbol].valueInBaseCurrency
: this.portfolioDetails.holdings[symbol].valueInPercentage;
this.marketsAdvanced[UNKNOWN_KEY].value += isNumber(
position.valueInBaseCurrency
)
? this.portfolioDetails.holdings[symbol].valueInBaseCurrency
: this.portfolioDetails.holdings[symbol].valueInPercentage;
}
if (position.sectors.length > 0) {
@ -354,17 +465,28 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
const { name, weight } = sector;
if (this.sectors[name]?.value) {
this.sectors[name].value += weight * position.value;
this.sectors[name].value +=
weight *
(isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
: position.valueInPercentage);
} else {
this.sectors[name] = {
name,
value: weight * this.portfolioDetails.holdings[symbol].value
value:
weight *
(isNumber(position.valueInBaseCurrency)
? this.portfolioDetails.holdings[symbol].valueInBaseCurrency
: this.portfolioDetails.holdings[symbol].valueInPercentage)
};
}
}
} else {
this.sectors[UNKNOWN_KEY].value +=
this.portfolioDetails.holdings[symbol].value;
this.sectors[UNKNOWN_KEY].value += isNumber(
position.valueInBaseCurrency
)
? this.portfolioDetails.holdings[symbol].valueInBaseCurrency
: this.portfolioDetails.holdings[symbol].valueInPercentage;
}
}
@ -372,8 +494,8 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
dataSource: position.dataSource,
name: position.name,
symbol: prettifySymbol(symbol),
value: isNumber(position.value)
? position.value
value: isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
: position.valueInPercentage
};
}
@ -400,7 +522,8 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
const marketsTotal =
this.markets.developedMarkets.value +
this.markets.emergingMarkets.value +
this.markets.otherMarkets.value;
this.markets.otherMarkets.value +
this.markets[UNKNOWN_KEY].value;
this.markets.developedMarkets.value =
this.markets.developedMarkets.value / marketsTotal;
@ -408,6 +531,8 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
this.markets.emergingMarkets.value / marketsTotal;
this.markets.otherMarkets.value =
this.markets.otherMarkets.value / marketsTotal;
this.markets[UNKNOWN_KEY].value =
this.markets[UNKNOWN_KEY].value / marketsTotal;
}
public onAccountChartClicked({ symbol }: UniqueAsset) {

View File

@ -174,7 +174,7 @@
<mat-card appearance="outlined" class="mb-3">
<mat-card-header class="overflow-hidden w-100">
<mat-card-title class="align-items-center d-flex text-truncate"
><span i18n>By Country</span
><span i18n>By Market</span
><gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
@ -186,10 +186,8 @@
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[keys]="['name']"
[locale]="user?.settings?.locale"
[maxItems]="10"
[positions]="countries"
[positions]="marketsAdvanced"
></gf-portfolio-proportion-chart>
</mat-card-content>
</mat-card>
@ -217,7 +215,7 @@
></gf-world-map-chart>
</div>
<div class="row">
<div class="col-xs-12 col-md-4 my-2">
<div class="col-xs-12 col-md-3 my-2">
<gf-value
i18n
size="large"
@ -226,7 +224,7 @@
>Developed Markets</gf-value
>
</div>
<div class="col-xs-12 col-md-4 my-2">
<div class="col-xs-12 col-md-3 my-2">
<gf-value
i18n
size="large"
@ -235,7 +233,7 @@
>Emerging Markets</gf-value
>
</div>
<div class="col-xs-12 col-md-4 my-2">
<div class="col-xs-12 col-md-3 my-2">
<gf-value
i18n
size="large"
@ -244,12 +242,45 @@
>Other Markets</gf-value
>
</div>
<div class="col-xs-12 col-md-3 my-2">
<gf-value
i18n
size="large"
[isPercent]="true"
[value]="markets?.[UNKNOWN_KEY]?.value"
>No data available</gf-value
>
</div>
</div>
</mat-card-content>
</mat-card>
</div>
</div>
<div class="row">
<div class="col-md-4">
<mat-card appearance="outlined" class="mb-3">
<mat-card-header class="overflow-hidden w-100">
<mat-card-title class="align-items-center d-flex text-truncate"
><span i18n>By Country</span
><gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator
></mat-card-title>
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[keys]="['name']"
[locale]="user?.settings?.locale"
[maxItems]="10"
[positions]="countries"
></gf-portfolio-proportion-chart>
</mat-card-content>
</mat-card>
</div>
<div class="col-md-4">
<mat-card appearance="outlined" class="mb-3">
<mat-card-header class="overflow-hidden w-100">

View File

@ -51,7 +51,7 @@ export class FirePageComponent implements OnDestroy, OnInit {
return;
}
this.fireWealth = new Big(summary.currentValue);
this.fireWealth = new Big(summary.fireWealth);
this.withdrawalRatePerYear = this.fireWealth.mul(4).div(100);
this.withdrawalRatePerMonth = this.withdrawalRatePerYear.div(12);

View File

@ -33,18 +33,18 @@ export class PublicPageComponent implements OnInit {
};
public portfolioPublicDetails: PortfolioPublicDetails;
public positions: {
[symbol: string]: Pick<PortfolioPosition, 'currency' | 'name' | 'value'>;
[symbol: string]: Pick<PortfolioPosition, 'currency' | 'name'> & {
value: number;
};
};
public positionsArray: Pick<
PortfolioPosition,
'currency' | 'name' | 'netPerformancePercent' | 'symbol' | 'value'
>[];
public positionsArray: PortfolioPublicDetails['holdings'][string][];
public sectors: {
[name: string]: { name: string; value: number };
};
public symbols: {
[name: string]: { name: string; symbol: string; value: number };
};
public UNKNOWN_KEY = UNKNOWN_KEY;
private id: string;
private unsubscribeSubject = new Subject<void>();
@ -100,6 +100,10 @@ export class PublicPageComponent implements OnInit {
}
};
this.markets = {
[UNKNOWN_KEY]: {
name: UNKNOWN_KEY,
value: 0
},
developedMarkets: {
name: 'developedMarkets',
value: 0
@ -143,39 +147,47 @@ export class PublicPageComponent implements OnInit {
if (position.countries.length > 0) {
this.markets.developedMarkets.value +=
position.markets.developedMarkets * position.value;
position.markets.developedMarkets * position.valueInBaseCurrency;
this.markets.emergingMarkets.value +=
position.markets.emergingMarkets * position.value;
position.markets.emergingMarkets * position.valueInBaseCurrency;
this.markets.otherMarkets.value +=
position.markets.otherMarkets * position.value;
position.markets.otherMarkets * position.valueInBaseCurrency;
for (const country of position.countries) {
const { code, continent, name, weight } = country;
if (this.continents[continent]?.value) {
this.continents[continent].value += weight * position.value;
this.continents[continent].value +=
weight * position.valueInBaseCurrency;
} else {
this.continents[continent] = {
name: continent,
value: weight * this.portfolioPublicDetails.holdings[symbol].value
value:
weight *
this.portfolioPublicDetails.holdings[symbol].valueInBaseCurrency
};
}
if (this.countries[code]?.value) {
this.countries[code].value += weight * position.value;
this.countries[code].value += weight * position.valueInBaseCurrency;
} else {
this.countries[code] = {
name,
value: weight * this.portfolioPublicDetails.holdings[symbol].value
value:
weight *
this.portfolioPublicDetails.holdings[symbol].valueInBaseCurrency
};
}
}
} else {
this.continents[UNKNOWN_KEY].value +=
this.portfolioPublicDetails.holdings[symbol].value;
this.portfolioPublicDetails.holdings[symbol].valueInBaseCurrency;
this.countries[UNKNOWN_KEY].value +=
this.portfolioPublicDetails.holdings[symbol].value;
this.portfolioPublicDetails.holdings[symbol].valueInBaseCurrency;
this.markets[UNKNOWN_KEY].value +=
this.portfolioPublicDetails.holdings[symbol].valueInBaseCurrency;
}
if (position.sectors.length > 0) {
@ -183,24 +195,26 @@ export class PublicPageComponent implements OnInit {
const { name, weight } = sector;
if (this.sectors[name]?.value) {
this.sectors[name].value += weight * position.value;
this.sectors[name].value += weight * position.valueInBaseCurrency;
} else {
this.sectors[name] = {
name,
value: weight * this.portfolioPublicDetails.holdings[symbol].value
value:
weight *
this.portfolioPublicDetails.holdings[symbol].valueInBaseCurrency
};
}
}
} else {
this.sectors[UNKNOWN_KEY].value +=
this.portfolioPublicDetails.holdings[symbol].value;
this.portfolioPublicDetails.holdings[symbol].valueInBaseCurrency;
}
this.symbols[prettifySymbol(symbol)] = {
name: position.name,
symbol: prettifySymbol(symbol),
value: isNumber(position.value)
? position.value
value: isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
: position.valueInPercentage
};
}
@ -208,7 +222,8 @@ export class PublicPageComponent implements OnInit {
const marketsTotal =
this.markets.developedMarkets.value +
this.markets.emergingMarkets.value +
this.markets.otherMarkets.value;
this.markets.otherMarkets.value +
this.markets[UNKNOWN_KEY].value;
this.markets.developedMarkets.value =
this.markets.developedMarkets.value / marketsTotal;
@ -216,6 +231,8 @@ export class PublicPageComponent implements OnInit {
this.markets.emergingMarkets.value / marketsTotal;
this.markets.otherMarkets.value =
this.markets.otherMarkets.value / marketsTotal;
this.markets[UNKNOWN_KEY].value =
this.markets[UNKNOWN_KEY].value / marketsTotal;
}
public ngOnDestroy() {

View File

@ -84,7 +84,7 @@
></gf-world-map-chart>
</div>
<div class="row">
<div class="col-xs-12 col-md-4 my-2">
<div class="col-xs-12 col-md-3 my-2">
<gf-value
i18n
size="large"
@ -93,7 +93,7 @@
>Developed Markets</gf-value
>
</div>
<div class="col-xs-12 col-md-4 my-2">
<div class="col-xs-12 col-md-3 my-2">
<gf-value
i18n
size="large"
@ -102,7 +102,7 @@
>Emerging Markets</gf-value
>
</div>
<div class="col-xs-12 col-md-4 my-2">
<div class="col-xs-12 col-md-3 my-2">
<gf-value
i18n
size="large"
@ -111,6 +111,15 @@
>Other Markets</gf-value
>
</div>
<div class="col-xs-12 col-md-3 my-2">
<gf-value
i18n
size="large"
[isPercent]="true"
[value]="markets?.[UNKNOWN_KEY]?.value"
>No data available</gf-value
>
</div>
</div>
</mat-card-content>
</mat-card>

View File

@ -21,9 +21,15 @@
financial future.
</p>
<p>
Ghostfolio is open source software (OSS) where a community of
developers, contributors, and enthusiasts collaborate to enhance its
capabilities, security, and user experience.
Ghostfolio is an open source software (OSS), providing a
cost-effective alternative to {{ product2.name }} making it
particularly suitable for individuals on a tight budget, such as
those
<a href="../en/blog/2023/07/exploring-the-path-to-fire"
>pursuing Financial Independence, Retire Early (FIRE)</a
>. By leveraging the collective efforts of a community of developers
and personal finance enthusiasts, Ghostfolio continuously enhances
its capabilities, security, and user experience.
</p>
<p>
Lets dive deeper into the detailed comparison table below to gain a
@ -69,11 +75,21 @@
<td class="mat-mdc-cell px-3 py-2 text-right" i18n>
Available in
</td>
<td class="mat-mdc-cell px-1 py-2">{{ product1.languages }}</td>
<td class="mat-mdc-cell px-1 py-2">{{ product2.languages }}</td>
<td class="mat-mdc-cell px-1 py-2">
<ng-container
*ngFor="let language of product1.languages; last as isLast"
>{{ language }}{{ isLast ? '' : ', ' }}</ng-container
>
</td>
<td class="mat-mdc-cell px-1 py-2">
<ng-container
*ngFor="let language of product2.languages; last as isLast"
>{{ language }}{{ isLast ? '' : ', ' }}</ng-container
>
</td>
</tr>
<tr class="mat-mdc-row">
<td class="mat-mdc-cell px-3 py-2 text-right" i18n>
<td class="mat-mdc-cell px-3 py-2 text-right">
Open Source Software
</td>
<td class="mat-mdc-cell px-1 py-2">
@ -118,6 +134,25 @@
>
</td>
</tr>
<tr class="mat-mdc-row">
<td class="mat-mdc-cell px-3 py-2 text-right" i18n>
Use anonymously
</td>
<td class="mat-mdc-cell px-1 py-2">
<ng-container *ngIf="product1.useAnonymously === true" i18n
>✅ Yes</ng-container
><ng-container *ngIf="product1.useAnonymously === false" i18n
>❌ No</ng-container
>
</td>
<td class="mat-mdc-cell px-1 py-2">
<ng-container *ngIf="product2.useAnonymously === true" i18n
>✅ Yes</ng-container
><ng-container *ngIf="product2.useAnonymously === false" i18n
>❌ No</ng-container
>
</td>
</tr>
<tr class="mat-mdc-row">
<td class="mat-mdc-cell px-3 py-2 text-right" i18n>
Free Plan
@ -157,7 +192,19 @@
</tbody>
</table>
</section>
<section class="mb-4 py-3">
<section class="mb-4">
<p>
Please note that the information provided is based on our
independent research and analysis. This website is not affiliated
with {{ product2.name }} or any other product mentioned in the
comparison. As the landscape of personal finance tools evolves, it
is essential to verify any specific details or changes directly from
the respective product page. Data needs a refresh? Help us maintain
accurate data on
<a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>.
</p>
</section>
<section class="call-to-action mb-4 py-3 rounded">
<h2 class="h4 mb-0 text-center">
Ready to take your <strong>investments</strong> to the
<strong>next level</strong>?
@ -172,18 +219,6 @@
</a>
</div>
</section>
<section class="mb-4">
<small>
Please note that the information provided is based on our
independent research and analysis. This website is not affiliated
with {{ product2.name }} or any other product mentioned in the
comparison. As the landscape of personal finance tools evolves, it
is essential to verify any specific details or changes directly from
the respective product page. Data needs a refresh? Help us maintain
accurate data on
<a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>.
</small>
</section>
<section class="mb-4">
<ul class="list-inline">
<li class="list-inline-item">

View File

@ -10,8 +10,16 @@
color: rgba(var(--palette-primary-300), 1);
}
}
.call-to-action {
background-color: rgba(var(--palette-foreground-text), 0.02);
}
}
:host-context(.is-dark-theme) {
color: rgb(var(--light-primary-text));
.call-to-action {
background-color: rgba(var(--palette-foreground-text-dark), 0.02);
}
}

View File

@ -1,17 +1,23 @@
import { Product } from '@ghostfolio/common/interfaces';
import { AltooPageComponent } from './products/altoo-page.component';
import { CopilotMoneyPageComponent } from './products/copilot-money-page.component';
import { DeltaPageComponent } from './products/delta-page.component';
import { DivvyDiaryPageComponent } from './products/divvydiary-page.component';
import { ExirioPageComponent } from './products/exirio-page.component';
import { FolisharePageComponent } from './products/folishare-page.component';
import { GetquinPageComponent } from './products/getquin-page.component';
import { GoSpatzPageComponent } from './products/gospatz-page.component';
import { JustEtfPageComponent } from './products/justetf-page.component';
import { KuberaPageComponent } from './products/kubera-page.component';
import { MarketsShPageComponent } from './products/markets.sh-page.component';
import { MaybeFinancePageComponent } from './products/maybe-finance-page.component';
import { MonsePageComponent } from './products/monse-page.component';
import { ParqetPageComponent } from './products/parqet-page.component';
import { PlannixPageComponent } from './products/plannix-page.component';
import { PortfolioDividendTrackerPageComponent } from './products/portfolio-dividend-tracker-page.component';
import { PortseidoPageComponent } from './products/portseido-page.component';
import { ProjectionLabPageComponent } from './products/projectionlab-page.component';
import { SeekingAlphaPageComponent } from './products/seeking-alpha-page.component';
import { SharesightPageComponent } from './products/sharesight-page.component';
import { SimplePortfolioPageComponent } from './products/simple-portfolio-page.component';
@ -19,7 +25,6 @@ import { SnowballAnalyticsPageComponent } from './products/snowball-analytics-pa
import { SumioPageComponent } from './products/sumio-page.component';
import { UtlunaPageComponent } from './products/utluna-page.component';
import { YeekateePageComponent } from './products/yeekatee-page.component';
import { ProjectionLabPageComponent } from './products/projectionlab-page.component';
export const products: Product[] = [
{
@ -29,12 +34,21 @@ export const products: Product[] = [
hasSelfHostingAbility: true,
isOpenSource: true,
key: 'ghostfolio',
languages: 'Dutch, English, French, German, Italian, Portuguese, Spanish',
languages: [
'Dutch',
'English',
'French',
'German',
'Italian',
'Portuguese',
'Spanish'
],
name: 'Ghostfolio',
origin: 'Switzerland',
pricingPerYear: '$19',
region: 'Global',
slogan: 'Open Source Wealth Management'
slogan: 'Open Source Wealth Management',
useAnonymously: true
},
{
component: AltooPageComponent,
@ -46,6 +60,30 @@ export const products: Product[] = [
origin: 'Switzerland',
slogan: 'Simplicity for Complex Wealth'
},
{
component: CopilotMoneyPageComponent,
founded: 2019,
hasFreePlan: false,
hasSelfHostingAbility: false,
isOpenSource: false,
key: 'copilot-money',
name: 'Copilot Money',
origin: 'United States',
pricingPerYear: '$70',
slogan: 'Do money better with Copilot'
},
{
component: DeltaPageComponent,
founded: 2017,
hasFreePlan: true,
hasSelfHostingAbility: false,
isOpenSource: false,
key: 'delta',
name: 'Delta Investment Tracker',
note: 'Acquired by eToro',
origin: 'Belgium',
slogan: 'The app to track all your investments. Make smart moves only.'
},
{
component: DivvyDiaryPageComponent,
founded: 2019,
@ -53,7 +91,7 @@ export const products: Product[] = [
hasSelfHostingAbility: false,
isOpenSource: false,
key: 'divvydiary',
languages: 'English, German',
languages: ['English', 'German'],
name: 'DivvyDiary',
origin: 'Germany',
pricingPerYear: '€65',
@ -77,7 +115,7 @@ export const products: Product[] = [
hasSelfHostingAbility: false,
isOpenSource: false,
key: 'folishare',
languages: 'English, German',
languages: ['English', 'German'],
name: 'folishare',
origin: 'Austria',
pricingPerYear: '$65',
@ -90,12 +128,22 @@ export const products: Product[] = [
hasSelfHostingAbility: false,
isOpenSource: false,
key: 'getquin',
languages: 'English, German',
languages: ['English', 'German'],
name: 'getquin',
origin: 'Germany',
pricingPerYear: '€48',
slogan: 'Portfolio Tracker, Analysis & Community'
},
{
component: GoSpatzPageComponent,
hasFreePlan: true,
hasSelfHostingAbility: false,
isOpenSource: false,
key: 'gospatz',
name: 'goSPATZ',
origin: 'Germany',
slogan: 'Volle Kontrolle über deine Investitionen'
},
{
component: JustEtfPageComponent,
founded: 2011,
@ -120,13 +168,27 @@ export const products: Product[] = [
pricingPerYear: '$150',
slogan: 'The Time Machine for your Net Worth'
},
{
component: MarketsShPageComponent,
founded: 2022,
hasFreePlan: true,
hasSelfHostingAbility: false,
isOpenSource: false,
key: 'markets.sh',
languages: ['English'],
name: 'markets.sh',
origin: 'Germany',
pricingPerYear: '€168',
region: 'Global',
slogan: 'Track your investments'
},
{
component: MaybeFinancePageComponent,
founded: 2021,
hasSelfHostingAbility: false,
isOpenSource: false,
key: 'maybe-finance',
languages: 'English',
languages: ['English'],
name: 'Maybe Finance',
note: 'Sunset in 2023',
origin: 'United States',
@ -158,13 +220,23 @@ export const products: Product[] = [
region: 'Austria, Germany, Switzerland',
slogan: 'Dein Vermögen immer im Blick'
},
{
component: PlannixPageComponent,
founded: 2023,
hasSelfHostingAbility: false,
isOpenSource: false,
key: 'plannix',
name: 'Plannix',
origin: 'Italy',
slogan: 'Your Personal Finance Hub'
},
{
component: PortfolioDividendTrackerPageComponent,
hasFreePlan: false,
hasSelfHostingAbility: false,
isOpenSource: false,
key: 'portfolio-dividend-tracker',
languages: 'English, Dutch',
languages: ['English', 'Dutch'],
name: 'Portfolio Dividend Tracker',
origin: 'Netherlands',
pricingPerYear: '€60',
@ -177,7 +249,7 @@ export const products: Product[] = [
hasSelfHostingAbility: false,
isOpenSource: false,
key: 'portseido',
languages: 'Dutch, English, French, German',
languages: ['Dutch', 'English', 'French', 'German'],
name: 'Portseido',
origin: 'Thailand',
pricingPerYear: '$96',
@ -260,11 +332,12 @@ export const products: Product[] = [
hasSelfHostingAbility: false,
isOpenSource: false,
key: 'utluna',
languages: 'English, French, German',
languages: ['English', 'French', 'German'],
name: 'Utluna',
origin: 'Switzerland',
pricingPerYear: '$300',
slogan: 'Your Portfolio. Revealed.'
slogan: 'Your Portfolio. Revealed.',
useAnonymously: true
},
{
component: YeekateePageComponent,

View File

@ -0,0 +1,24 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
@Component({
host: { class: 'page' },
imports: [CommonModule, MatButtonModule, RouterModule],
selector: 'gf-copilot-money-page',
standalone: true,
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class CopilotMoneyPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});
public product2 = products.find(({ key }) => {
return key === 'copilot-money';
});
}

View File

@ -0,0 +1,24 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
@Component({
host: { class: 'page' },
imports: [CommonModule, MatButtonModule, RouterModule],
selector: 'gf-delta-page',
standalone: true,
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class DeltaPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});
public product2 = products.find(({ key }) => {
return key === 'delta';
});
}

View File

@ -0,0 +1,24 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
@Component({
host: { class: 'page' },
imports: [CommonModule, MatButtonModule, RouterModule],
selector: 'gf-gospatz-page',
standalone: true,
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class GoSpatzPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});
public product2 = products.find(({ key }) => {
return key === 'gospatz';
});
}

View File

@ -0,0 +1,24 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
@Component({
host: { class: 'page' },
imports: [CommonModule, MatButtonModule, RouterModule],
selector: 'gf-markets-sh-page',
standalone: true,
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class MarketsShPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});
public product2 = products.find(({ key }) => {
return key === 'markets.sh';
});
}

View File

@ -0,0 +1,24 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
@Component({
host: { class: 'page' },
imports: [CommonModule, MatButtonModule, RouterModule],
selector: 'gf-plannix-page',
standalone: true,
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class PlannixPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});
public product2 = products.find(({ key }) => {
return key === 'plannix';
});
}

View File

@ -1,4 +1,7 @@
import { Component, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import { InfoItem } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Subject } from 'rxjs';
@Component({
@ -8,11 +11,21 @@ import { Subject } from 'rxjs';
templateUrl: './resources-page.html'
})
export class ResourcesPageComponent implements OnInit {
public hasPermissionForSubscription: boolean;
public info: InfoItem;
private unsubscribeSubject = new Subject<void>();
public constructor() {}
public constructor(private dataService: DataService) {
this.info = this.dataService.fetchInfo();
}
public ngOnInit() {}
public ngOnInit() {
this.hasPermissionForSubscription = hasPermission(
this.info?.globalPermissions,
permissions.enableSubscription
);
}
public ngOnDestroy() {
this.unsubscribeSubject.next();

View File

@ -170,7 +170,7 @@
</div>
</div>
</div>
<div class="mb-4 media">
<div *ngIf="hasPermissionForSubscription" class="mb-4 media">
<div class="media-body">
<h3 class="h5 mt-0">Personal Finance Tools</h3>
<div class="mb-1">
@ -179,7 +179,7 @@
monitor investments, and make informed financial decisions.
</div>
<div>
<a i18n [routerLink]="['/resources', 'personal-finance-tools']"
<a [routerLink]="['/resources', 'personal-finance-tools']"
>Personal Finance Tools →</a
>
</div>

View File

@ -19,6 +19,7 @@ import { DataSource, MarketData, Platform, Prisma } from '@prisma/client';
import { JobStatus } from 'bull';
import { format, parseISO } from 'date-fns';
import { Observable, map } from 'rxjs';
import { DataService } from './data.service';
@Injectable({
@ -94,7 +95,9 @@ export class AdminService {
params = params.append('sortDirection', sortDirection);
}
params = params.append('take', take);
if (take) {
params = params.append('take', take);
}
return this.http.get<AdminMarketData>('/api/v1/admin/market-data', {
params

View File

@ -57,6 +57,7 @@ export class DataService {
ACCOUNT: filtersByAccount,
ASSET_CLASS: filtersByAssetClass,
ASSET_SUB_CLASS: filtersByAssetSubClass,
PRESET_ID: filtersByPresetId,
TAG: filtersByTag
} = groupBy(filters, (filter) => {
return filter.type;
@ -95,6 +96,10 @@ export class DataService {
);
}
if (filtersByPresetId) {
params = params.append('presetId', filtersByPresetId[0].id);
}
if (filtersByTag) {
params = params.append(
'tags',
@ -410,10 +415,10 @@ export class DataService {
map((response) => {
if (response.holdings) {
for (const symbol of Object.keys(response.holdings)) {
response.holdings[symbol].value = isNumber(
response.holdings[symbol].value
response.holdings[symbol].valueInBaseCurrency = isNumber(
response.holdings[symbol].valueInBaseCurrency
)
? response.holdings[symbol].value
? response.holdings[symbol].valueInBaseCurrency
: response.holdings[symbol].valueInPercentage;
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,7 @@ import { getDate, getMonth, getYear, parse, subDays } from 'date-fns';
import { de, es, fr, it, nl, pt } from 'date-fns/locale';
import { ghostfolioScraperApiSymbolPrefix, locale } from './config';
import { Benchmark } from './interfaces';
import { Benchmark, UniqueAsset } from './interfaces';
import { ColorScheme } from './types';
const NUMERIC_REGEXP = /[-]{0,1}[\d]*[.,]{0,1}[\d]+/g;
@ -64,6 +64,10 @@ export function extractNumberFromString(aString: string): number {
}
}
export function getAssetProfileIdentifier({ dataSource, symbol }: UniqueAsset) {
return `${dataSource}-${symbol}`;
}
export function getBackgroundColor(aColorScheme: ColorScheme) {
return getCssVariable(
aColorScheme === 'DARK' ||

View File

@ -1,5 +1,11 @@
export interface Filter {
id: string;
label?: string;
type: 'ACCOUNT' | 'ASSET_CLASS' | 'ASSET_SUB_CLASS' | 'SYMBOL' | 'TAG';
type:
| 'ACCOUNT'
| 'ASSET_CLASS'
| 'ASSET_SUB_CLASS'
| 'PRESET_ID'
| 'SYMBOL'
| 'TAG';
}

View File

@ -1,6 +1,6 @@
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
import { AssetClass, AssetSubClass, DataSource, Tag } from '@prisma/client';
import { Market, MarketState } from '../types';
import { Market, MarketAdvanced, MarketState } from '../types';
import { Country } from './country.interface';
import { Sector } from './sector.interface';
@ -20,16 +20,18 @@ export interface PortfolioPosition {
marketChangePercent?: number;
marketPrice: number;
markets?: { [key in Market]: number };
marketsAdvanced?: { [key in MarketAdvanced]: number };
marketState: MarketState;
name: string;
netPerformance: number;
netPerformancePercent: number;
quantity: number;
sectors: Sector[];
transactionCount: number;
symbol: string;
tags?: Tag[];
transactionCount: number;
type?: string;
url?: string;
value?: number;
valueInBaseCurrency?: number;
valueInPercentage?: number;
}

View File

@ -17,7 +17,7 @@ export interface PortfolioPublicDetails {
| 'sectors'
| 'symbol'
| 'url'
| 'value'
| 'valueInBaseCurrency'
| 'valueInPercentage'
>;
};

View File

@ -5,9 +5,14 @@ export interface PortfolioSummary extends PortfolioPerformance {
cash: number;
committedFunds: number;
dividend: number;
emergencyFund: number;
emergencyFund: {
assets: number;
cash: number;
total: number;
};
excludedAccountsAndActivities: number;
fees: number;
fireWealth: number;
firstOrderDate: Date;
items: number;
liabilities: number;

View File

@ -5,11 +5,12 @@ export interface Product {
hasSelfHostingAbility?: boolean;
isOpenSource: boolean;
key: string;
languages?: string;
languages?: string[];
name: string;
note?: string;
origin?: string;
pricingPerYear?: string;
region?: string;
slogan?: string;
useAnonymously?: boolean;
}

View File

@ -1,4 +1,4 @@
import { DataSource } from '@prisma/client';
import { DataSource, Tag } from '@prisma/client';
import Big from 'big.js';
export interface TimelinePosition {
@ -15,5 +15,6 @@ export interface TimelinePosition {
netPerformancePercentage: Big;
quantity: Big;
symbol: string;
tags?: Tag[];
transactionCount: number;
}

View File

@ -5,6 +5,8 @@ import type { ColorScheme } from './color-scheme.type';
import type { DateRange } from './date-range.type';
import type { Granularity } from './granularity.type';
import type { GroupBy } from './group-by.type';
import type { MarketAdvanced } from './market-advanced.type';
import type { MarketDataPreset } from './market-data-preset.type';
import type { MarketState } from './market-state.type';
import type { Market } from './market.type';
import type { OrderWithAccount } from './order-with-account.type';
@ -23,6 +25,8 @@ export type {
Granularity,
GroupBy,
Market,
MarketAdvanced,
MarketDataPreset,
MarketState,
OrderWithAccount,
RequestWithUser,

View File

@ -0,0 +1,8 @@
export type MarketAdvanced =
| 'asiaPacific'
| 'emergingMarkets'
| 'europe'
| 'japan'
| 'northAmerica'
| 'otherMarkets'
| 'UNKNOWN';

View File

@ -0,0 +1 @@
export type MarketDataPreset = 'ETF_WITHOUT_COUNTRIES' | 'ETF_WITHOUT_SECTORS';

View File

@ -1 +1,5 @@
export type Market = 'developedMarkets' | 'emergingMarkets' | 'otherMarkets';
export type Market =
| 'developedMarkets'
| 'emergingMarkets'
| 'otherMarkets'
| 'UNKNOWN';

View File

@ -383,13 +383,14 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy, OnInit {
}
private getTotalValue() {
let totalValue = new Big(0);
const paginatedData = this.getPaginatedData();
for (const activity of paginatedData) {
if (isNumber(activity.valueInBaseCurrency)) {
if (activity.type === 'BUY' || activity.type === 'ITEM') {
totalValue = totalValue.plus(activity.valueInBaseCurrency);
} else if (activity.type === 'SELL') {
let totalValue = new Big(0);
for (const { type, valueInBaseCurrency } of paginatedData) {
if (isNumber(valueInBaseCurrency)) {
if (type === 'BUY' || type === 'ITEM') {
totalValue = totalValue.plus(valueInBaseCurrency);
} else if (type === 'LIABILITY' || type === 'SELL') {
return null;
}
} else {

View File

@ -61,7 +61,7 @@
</td>
</ng-container>
<ng-container matColumnDef="value">
<ng-container matColumnDef="valueInBaseCurrency">
<th
*matHeaderCellDef
class="d-none d-lg-table-cell justify-content-end px-1"
@ -79,7 +79,7 @@
<gf-value
[isCurrency]="true"
[locale]="locale"
[value]="isLoading ? undefined : element.value"
[value]="isLoading ? undefined : element.valueInBaseCurrency"
></gf-value>
</div>
</td>

View File

@ -55,7 +55,7 @@ export class HoldingsTableComponent implements OnChanges, OnDestroy, OnInit {
this.displayedColumns = ['icon', 'nameWithSymbol', 'dateOfFirstActivity'];
if (this.hasPermissionToShowValues) {
this.displayedColumns.push('value');
this.displayedColumns.push('valueInBaseCurrency');
}
this.displayedColumns.push('allocationInPercentage');

View File

@ -2,6 +2,7 @@ import '@angular/localize/init';
const locales = {
ACCOUNT: $localize`Account`,
'Asia-Pacific': $localize`Asia-Pacific`,
ASSET_CLASS: $localize`Asset Class`,
ASSET_SUB_CLASS: $localize`Asset Sub Class`,
CORE: $localize`Core`,
@ -12,10 +13,12 @@ const locales = {
GRANT: $localize`Grant`,
HIGHER_RISK: $localize`Higher Risk`,
IMPORT_ACTIVITY_ERROR_IS_DUPLICATE: $localize`This activity already exists.`,
Japan: $localize`Japan`,
LOWER_RISK: $localize`Lower Risk`,
MONTH: $localize`Month`,
MONTHS: $localize`Months`,
OTHER: $localize`Other`,
PRESET_ID: $localize`Preset`,
RETIREMENT_PROVISION: $localize`Retirement Provision`,
SATELLITE: $localize`Satellite`,
SECURITIES: $localize`Securities`,
@ -24,6 +27,13 @@ const locales = {
YEAR: $localize`Year`,
YEARS: $localize`Years`,
// Activity types
BUY: $localize`Buy`,
DIVIDEND: $localize`Dividend`,
ITEM: $localize`Valuable`,
LIABILITY: $localize`Liability`,
SELL: $localize`Sell`,
// enum AssetClass
CASH: $localize`Cash`,
COMMODITY: $localize`Commodity`,

View File

@ -90,7 +90,7 @@ export class PortfolioProportionChartComponent
[symbol: string]: {
color?: string;
name: string;
subCategory: { [symbol: string]: { value: Big } };
subCategory?: { [symbol: string]: { value: Big } };
value: Big;
};
} = {};
@ -99,65 +99,76 @@ export class PortfolioProportionChartComponent
[UNKNOWN_KEY]: `rgba(${getTextColor(this.colorScheme)}, 0.12)`
};
Object.keys(this.positions).forEach((symbol) => {
if (this.positions[symbol][this.keys[0]]?.toUpperCase()) {
if (chartData[this.positions[symbol][this.keys[0]].toUpperCase()]) {
chartData[this.positions[symbol][this.keys[0]].toUpperCase()].value =
if (this.keys.length > 0) {
Object.keys(this.positions).forEach((symbol) => {
if (this.positions[symbol][this.keys[0]]?.toUpperCase()) {
if (chartData[this.positions[symbol][this.keys[0]].toUpperCase()]) {
chartData[
this.positions[symbol][this.keys[0]].toUpperCase()
].value = chartData[
this.positions[symbol][this.keys[0]].toUpperCase()
].value.plus(this.positions[symbol].value);
if (
chartData[this.positions[symbol][this.keys[0]].toUpperCase()]
.subCategory[this.positions[symbol][this.keys[1]]]
) {
chartData[
this.positions[symbol][this.keys[0]].toUpperCase()
].subCategory[this.positions[symbol][this.keys[1]]].value =
if (
chartData[this.positions[symbol][this.keys[0]].toUpperCase()]
.subCategory[this.positions[symbol][this.keys[1]]]
) {
chartData[
this.positions[symbol][this.keys[0]].toUpperCase()
].subCategory[this.positions[symbol][this.keys[1]]].value.plus(
this.positions[symbol].value
);
].subCategory[this.positions[symbol][this.keys[1]]].value =
chartData[
this.positions[symbol][this.keys[0]].toUpperCase()
].subCategory[this.positions[symbol][this.keys[1]]].value.plus(
this.positions[symbol].value
);
} else {
chartData[
this.positions[symbol][this.keys[0]].toUpperCase()
].subCategory[
this.positions[symbol][this.keys[1]] ?? UNKNOWN_KEY
] = { value: new Big(this.positions[symbol].value) };
}
} else {
chartData[
this.positions[symbol][this.keys[0]].toUpperCase()
].subCategory[this.positions[symbol][this.keys[1]] ?? UNKNOWN_KEY] =
{ value: new Big(this.positions[symbol].value) };
chartData[this.positions[symbol][this.keys[0]].toUpperCase()] = {
name: this.positions[symbol][this.keys[0]],
subCategory: {},
value: new Big(this.positions[symbol].value ?? 0)
};
if (this.positions[symbol][this.keys[1]]) {
chartData[
this.positions[symbol][this.keys[0]].toUpperCase()
].subCategory = {
[this.positions[symbol][this.keys[1]]]: {
value: new Big(this.positions[symbol].value)
}
};
}
}
} else {
chartData[this.positions[symbol][this.keys[0]].toUpperCase()] = {
name: this.positions[symbol][this.keys[0]],
subCategory: {},
value: new Big(this.positions[symbol].value ?? 0)
};
if (this.positions[symbol][this.keys[1]]) {
chartData[
this.positions[symbol][this.keys[0]].toUpperCase()
].subCategory = {
[this.positions[symbol][this.keys[1]]]: {
value: new Big(this.positions[symbol].value)
}
if (chartData[UNKNOWN_KEY]) {
chartData[UNKNOWN_KEY].value = chartData[UNKNOWN_KEY].value.plus(
this.positions[symbol].value
);
} else {
chartData[UNKNOWN_KEY] = {
name: this.positions[symbol].name,
subCategory: this.keys[1]
? { [this.keys[1]]: { value: new Big(0) } }
: undefined,
value: new Big(this.positions[symbol].value)
};
}
}
} else {
if (chartData[UNKNOWN_KEY]) {
chartData[UNKNOWN_KEY].value = chartData[UNKNOWN_KEY].value.plus(
this.positions[symbol].value
);
} else {
chartData[UNKNOWN_KEY] = {
name: this.positions[symbol].name,
subCategory: this.keys[1]
? { [this.keys[1]]: { value: new Big(0) } }
: undefined,
value: new Big(this.positions[symbol].value)
};
}
}
});
});
} else {
Object.keys(this.positions).forEach((symbol) => {
chartData[symbol] = {
name: this.positions[symbol].name,
value: new Big(this.positions[symbol].value)
};
});
}
let chartDataSorted = Object.entries(chartData)
.sort((a, b) => {

View File

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "1.287.0",
"version": "1.296.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"scripts": {
@ -79,7 +79,7 @@
"@nestjs/platform-express": "9.1.4",
"@nestjs/schedule": "2.1.0",
"@nestjs/serve-static": "3.0.0",
"@prisma/client": "4.15.0",
"@prisma/client": "4.16.2",
"@simplewebauthn/browser": "5.2.1",
"@simplewebauthn/server": "5.2.1",
"@stripe/stripe-js": "1.47.0",
@ -120,14 +120,14 @@
"passport": "0.6.0",
"passport-google-oauth20": "2.0.0",
"passport-jwt": "4.0.0",
"prisma": "4.15.0",
"prisma": "4.16.2",
"reflect-metadata": "0.1.13",
"rxjs": "7.5.6",
"stripe": "11.12.0",
"svgmap": "2.6.0",
"twitter-api-v2": "1.14.2",
"uuid": "9.0.0",
"yahoo-finance2": "2.4.1",
"yahoo-finance2": "2.4.3",
"zone.js": "0.12.0"
},
"devDependencies": {
@ -165,7 +165,7 @@
"@types/color": "3.0.3",
"@types/google-spreadsheet": "3.1.5",
"@types/jest": "29.4.4",
"@types/lodash": "4.14.191",
"@types/lodash": "4.14.195",
"@types/marked": "4.0.8",
"@types/node": "18.11.18",
"@types/papaparse": "5.3.7",

View File

@ -0,0 +1,27 @@
-- CreateTable
CREATE TABLE "AccountBalance" (
"accountId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"id" TEXT NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
"userId" TEXT NOT NULL,
"value" DOUBLE PRECISION NOT NULL,
CONSTRAINT "AccountBalance_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "AccountBalance" ADD CONSTRAINT "AccountBalance_accountId_userId_fkey" FOREIGN KEY ("accountId", "userId") REFERENCES "Account"("id", "userId") ON DELETE CASCADE ON UPDATE CASCADE;
-- Migrate current account balance to time series (AccountBalance[])
INSERT INTO "AccountBalance" ("accountId", "createdAt", "date", "id", "updatedAt", "userId", "value")
SELECT
"id",
"updatedAt",
"updatedAt",
"id",
"updatedAt",
"userId",
"balance"
FROM "Account";

View File

@ -21,25 +21,37 @@ model Access {
}
model Account {
accountType AccountType @default(SECURITIES)
balance Float @default(0)
accountType AccountType @default(SECURITIES)
balance Float @default(0)
balances AccountBalance[]
comment String?
createdAt DateTime @default(now())
createdAt DateTime @default(now())
currency String?
id String @default(uuid())
isDefault Boolean @default(false)
isExcluded Boolean @default(false)
id String @default(uuid())
isDefault Boolean @default(false)
isExcluded Boolean @default(false)
name String?
platformId String?
updatedAt DateTime @updatedAt
updatedAt DateTime @updatedAt
userId String
Platform Platform? @relation(fields: [platformId], references: [id])
User User @relation(fields: [userId], references: [id])
Platform Platform? @relation(fields: [platformId], references: [id])
User User @relation(fields: [userId], references: [id])
Order Order[]
@@id([id, userId])
}
model AccountBalance {
accountId String
createdAt DateTime @default(now())
date DateTime @default(now())
id String @id @default(uuid())
updatedAt DateTime @updatedAt
userId String
value Float
Account Account @relation(fields: [accountId, userId], onDelete: Cascade, references: [id, userId])
}
model Analytics {
activityCount Int @default(0)
country String?

View File

@ -3738,22 +3738,22 @@
resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33"
integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==
"@prisma/client@4.15.0":
version "4.15.0"
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-4.15.0.tgz#f52ec6ca6fbde37395a54b0a9e5da603a9de15f3"
integrity sha512-xnROvyABcGiwqRNdrObHVZkD9EjkJYHOmVdlKy1yGgI+XOzvMzJ4tRg3dz1pUlsyhKxXGCnjIQjWW+2ur+YXuw==
"@prisma/client@4.16.2":
version "4.16.2"
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-4.16.2.tgz#3bb9ebd49b35c8236b3d468d0215192267016e2b"
integrity sha512-qCoEyxv1ZrQ4bKy39GnylE8Zq31IRmm8bNhNbZx7bF2cU5aiCCnSa93J2imF88MBjn7J9eUQneNxUQVJdl/rPQ==
dependencies:
"@prisma/engines-version" "4.15.0-28.8fbc245156db7124f997f4cecdd8d1219e360944"
"@prisma/engines-version" "4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81"
"@prisma/engines-version@4.15.0-28.8fbc245156db7124f997f4cecdd8d1219e360944":
version "4.15.0-28.8fbc245156db7124f997f4cecdd8d1219e360944"
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-4.15.0-28.8fbc245156db7124f997f4cecdd8d1219e360944.tgz#8d880becf996cffe08c78ad5afab6bc06090c990"
integrity sha512-sVOig4tjGxxlYaFcXgE71f/rtFhzyYrfyfNFUsxCIEJyVKU9rdOWIlIwQ2NQ7PntvGnn+x0XuFo4OC1jvPJKzg==
"@prisma/engines-version@4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81":
version "4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81"
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81.tgz#d3b5dcf95b6d220e258cbf6ae19b06d30a7e9f14"
integrity sha512-q617EUWfRIDTriWADZ4YiWRZXCa/WuhNgLTVd+HqWLffjMSPzyM5uOWoauX91wvQClSKZU4pzI4JJLQ9Kl62Qg==
"@prisma/engines@4.15.0":
version "4.15.0"
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-4.15.0.tgz#d8687a9fda615fab88b75b466931280289de9e26"
integrity sha512-FTaOCGs0LL0OW68juZlGxFtYviZa4xdQj/rQEdat2txw0s3Vu/saAPKjNVXfIgUsGXmQ72HPgNr6935/P8FNAA==
"@prisma/engines@4.16.2":
version "4.16.2"
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-4.16.2.tgz#5ec8dd672c2173d597e469194916ad4826ce2e5f"
integrity sha512-vx1nxVvN4QeT/cepQce68deh/Turxy5Mr+4L4zClFuK1GlxN3+ivxfuv+ej/gvidWn1cE1uAhW7ALLNlYbRUAw==
"@samverschueren/stream-to-observable@^0.3.0":
version "0.3.1"
@ -4860,10 +4860,10 @@
dependencies:
"@types/node" "*"
"@types/lodash@4.14.191":
version "4.14.191"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.191.tgz#09511e7f7cba275acd8b419ddac8da9a6a79e2fa"
integrity sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ==
"@types/lodash@4.14.195":
version "4.14.195"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.195.tgz#bafc975b252eb6cea78882ce8a7b6bf22a6de632"
integrity sha512-Hwx9EUgdwf2GLarOjQp5ZH8ZmblzcbTBC2wtQWNKARBSxM9ezRIAUpeDTgoQRAFB0+8CNWXVA9+MaSOzOF3nPg==
"@types/lodash@^4.14.167":
version "4.14.194"
@ -14544,12 +14544,12 @@ pretty-hrtime@^1.0.3:
resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1"
integrity sha512-66hKPCr+72mlfiSjlEB1+45IjXSqvVAIy6mocupoww4tBFE9R9IhwwUGoI4G++Tc9Aq+2rxOt0RFU6gPcrte0A==
prisma@4.15.0:
version "4.15.0"
resolved "https://registry.yarnpkg.com/prisma/-/prisma-4.15.0.tgz#4faa94f0d584828b68468953ff0bc88f37912c8c"
integrity sha512-iKZZpobPl48gTcSZVawLMQ3lEy6BnXwtoMj7hluoGFYu2kQ6F9LBuBrUyF95zRVnNo8/3KzLXJXJ5TEnLSJFiA==
prisma@4.16.2:
version "4.16.2"
resolved "https://registry.yarnpkg.com/prisma/-/prisma-4.16.2.tgz#469e0a0991c6ae5bcde289401726bb012253339e"
integrity sha512-SYCsBvDf0/7XSJyf2cHTLjLeTLVXYfqp7pG5eEVafFLeT0u/hLFz/9W196nDRGUOo1JfPatAEb+uEnTQImQC1g==
dependencies:
"@prisma/engines" "4.15.0"
"@prisma/engines" "4.16.2"
prismjs@^1.28.0:
version "1.29.0"
@ -17558,10 +17558,10 @@ y18n@^5.0.5:
resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55"
integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==
yahoo-finance2@2.4.1:
version "2.4.1"
resolved "https://registry.yarnpkg.com/yahoo-finance2/-/yahoo-finance2-2.4.1.tgz#2ccd422e33228fc34d42e919b0d2fdd8d3f76bbf"
integrity sha512-jl5oHr25RC24nOmoIiDqjnc/Iiy3MZAB+dIPCyUR+o5uz72xHfTZDM9tPeheggmlAGV+KftPP6smZ6L6lNgkSQ==
yahoo-finance2@2.4.3:
version "2.4.3"
resolved "https://registry.yarnpkg.com/yahoo-finance2/-/yahoo-finance2-2.4.3.tgz#be4099182dc0a2e2908779e04d7b802688c15f0e"
integrity sha512-LVcl+h4XBMe3N/l8BOZdDFoK7AGMiblSBE00dU9t2zB0Zfxa6QQMESnUkJ1m35RWBr8QXFJyJnToPt+qKiEQXQ==
dependencies:
"@types/tough-cookie" "^4.0.2"
ajv "8.10.0"