Compare commits
47 Commits
Author | SHA1 | Date | |
---|---|---|---|
90dc34380e | |||
286e41eb21 | |||
4973d0261d | |||
c4a62dfd68 | |||
4d6be0a507 | |||
b259ab7b0c | |||
e1ac5245c7 | |||
d4fea075af | |||
cef7fa79de | |||
ca05397dcd | |||
2a11977001 | |||
fb1a5c93ef | |||
77e9791e03 | |||
efd9e7a5c7 | |||
d9ced885e1 | |||
5fe07cb85f | |||
af008aa74f | |||
ca7bf27c20 | |||
0866587cab | |||
622bb8b0cf | |||
16b9fbe00e | |||
c9353d0a39 | |||
ea101dd3bd | |||
cd67ce82fa | |||
d5b3c52602 | |||
bdf72164b1 | |||
455a2d2e92 | |||
9c0f46b587 | |||
8533606177 | |||
6728e04ff7 | |||
2bf4f1237a | |||
4857b2e620 | |||
68a9a7f6f9 | |||
81ef95e13e | |||
b633132757 | |||
2b0f961370 | |||
30f1a3514a | |||
ed735e0b29 | |||
b89ccd2dde | |||
df6d39377f | |||
d5d14497d6 | |||
09c300661a | |||
92382e0b4d | |||
c25f532487 | |||
5d26d94586 | |||
73b6784e9f | |||
6159f48a62 |
95
CHANGELOG.md
95
CHANGELOG.md
@ -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 (`&`) in the asset profile
|
||||
- Improved the language localization for German (`de`)
|
||||
|
||||
## 1.287.0 - 2023-07-09
|
||||
|
||||
### Changed
|
||||
|
@ -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
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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 }));
|
||||
|
@ -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],
|
||||
|
@ -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 }
|
||||
});
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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')
|
||||
|
@ -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 {}
|
||||
|
@ -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,
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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
|
||||
});
|
||||
|
||||
|
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,7 @@
|
||||
import { Cache } from 'cache-manager';
|
||||
|
||||
import type { RedisStore } from './redis-store.interface';
|
||||
|
||||
export interface RedisCache extends Cache {
|
||||
store: RedisStore;
|
||||
}
|
@ -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';
|
||||
}
|
@ -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) {
|
||||
|
@ -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) {
|
||||
|
@ -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') {
|
||||
|
1
apps/api/src/assets/countries/asia-pacific-markets.json
Normal file
1
apps/api/src/assets/countries/asia-pacific-markets.json
Normal file
@ -0,0 +1 @@
|
||||
["AU", "HK", "NZ", "SG"]
|
19
apps/api/src/assets/countries/europe-markets.json
Normal file
19
apps/api/src/assets/countries/europe-markets.json
Normal file
@ -0,0 +1,19 @@
|
||||
[
|
||||
"AT",
|
||||
"BE",
|
||||
"CH",
|
||||
"DE",
|
||||
"DK",
|
||||
"ES",
|
||||
"FI",
|
||||
"FR",
|
||||
"GB",
|
||||
"IE",
|
||||
"IL",
|
||||
"IT",
|
||||
"LU",
|
||||
"NL",
|
||||
"NO",
|
||||
"PT",
|
||||
"SE"
|
||||
]
|
@ -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>
|
@ -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 {}
|
@ -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
|
||||
});
|
||||
}
|
||||
}
|
@ -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 })
|
||||
}
|
||||
};
|
||||
})
|
||||
|
@ -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)}`
|
||||
}
|
||||
};
|
||||
})
|
||||
|
@ -135,6 +135,8 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
|
||||
let name = longName;
|
||||
|
||||
if (name) {
|
||||
name = name.replace('&', '&');
|
||||
|
||||
name = name.replace('Amundi Index Solutions - ', '');
|
||||
name = name.replace('iShares ETF (CH) - ', '');
|
||||
name = name.replace('iShares III Public Limited Company - ', '');
|
||||
|
@ -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;
|
||||
|
@ -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}`;
|
||||
|
@ -29,6 +29,11 @@
|
||||
"input": "",
|
||||
"output": "./../assets"
|
||||
},
|
||||
{
|
||||
"glob": "favicon.ico",
|
||||
"input": "apps/client/src/assets",
|
||||
"output": "./../"
|
||||
},
|
||||
{
|
||||
"glob": "LICENSE",
|
||||
"input": "",
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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]
|
||||
|
@ -31,4 +31,8 @@
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.introduction {
|
||||
max-width: 50rem;
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -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">
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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 ?? []),
|
||||
|
@ -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>
|
||||
|
@ -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) {
|
||||
|
@ -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">
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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() {
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
Let’s 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">
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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';
|
||||
});
|
||||
}
|
@ -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';
|
||||
});
|
||||
}
|
@ -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';
|
||||
});
|
||||
}
|
@ -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';
|
||||
});
|
||||
}
|
@ -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';
|
||||
});
|
||||
}
|
@ -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();
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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
@ -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' ||
|
||||
|
@ -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';
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -17,7 +17,7 @@ export interface PortfolioPublicDetails {
|
||||
| 'sectors'
|
||||
| 'symbol'
|
||||
| 'url'
|
||||
| 'value'
|
||||
| 'valueInBaseCurrency'
|
||||
| 'valueInPercentage'
|
||||
>;
|
||||
};
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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,
|
||||
|
8
libs/common/src/lib/types/market-advanced.type.ts
Normal file
8
libs/common/src/lib/types/market-advanced.type.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export type MarketAdvanced =
|
||||
| 'asiaPacific'
|
||||
| 'emergingMarkets'
|
||||
| 'europe'
|
||||
| 'japan'
|
||||
| 'northAmerica'
|
||||
| 'otherMarkets'
|
||||
| 'UNKNOWN';
|
1
libs/common/src/lib/types/market-data-preset.type.ts
Normal file
1
libs/common/src/lib/types/market-data-preset.type.ts
Normal file
@ -0,0 +1 @@
|
||||
export type MarketDataPreset = 'ETF_WITHOUT_COUNTRIES' | 'ETF_WITHOUT_SECTORS';
|
@ -1 +1,5 @@
|
||||
export type Market = 'developedMarkets' | 'emergingMarkets' | 'otherMarkets';
|
||||
export type Market =
|
||||
| 'developedMarkets'
|
||||
| 'emergingMarkets'
|
||||
| 'otherMarkets'
|
||||
| 'UNKNOWN';
|
||||
|
@ -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 {
|
||||
|
@ -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>
|
||||
|
@ -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');
|
||||
|
@ -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`,
|
||||
|
@ -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) => {
|
||||
|
10
package.json
10
package.json
@ -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",
|
||||
|
@ -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";
|
@ -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?
|
||||
|
52
yarn.lock
52
yarn.lock
@ -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"
|
||||
|
Reference in New Issue
Block a user