Compare commits
55 Commits
Author | SHA1 | Date | |
---|---|---|---|
0d676a46c8 | |||
97db144e01 | |||
cec55127c8 | |||
f3f359bcfb | |||
601e6f4147 | |||
e228b4925c | |||
62e3ffe413 | |||
6af885fde0 | |||
dd15bba359 | |||
43fca7ff43 | |||
faa6af5694 | |||
d2ea7a0bfb | |||
3f6319e00b | |||
5601299648 | |||
6060c7cfe0 | |||
ba78c2783d | |||
48eee5f865 | |||
f4a8acdb46 | |||
1d6ba22598 | |||
e38be8d710 | |||
da5be3fb57 | |||
b5317a7f95 | |||
43afb16808 | |||
d5c56fb16c | |||
b94c1f280b | |||
acc59866a3 | |||
c9fc3e402d | |||
6c1317f978 | |||
89be438e66 | |||
9d6214e93a | |||
0640b24290 | |||
6eb9d9d973 | |||
9ecc3176a5 | |||
96434c5a54 | |||
4063c62a17 | |||
890c5b986c | |||
423bd92b89 | |||
5dc331e386 | |||
744dc51dcd | |||
b0c53d050a | |||
830569b38e | |||
35b4aef06f | |||
bc2fd9c970 | |||
c42a8aebed | |||
fad1adb91b | |||
9cd37f8de0 | |||
d49b90d7a5 | |||
130a9ea062 | |||
ffc6309850 | |||
976cc7f243 | |||
7067aca04b | |||
1c9805bb96 | |||
8227a2d91a | |||
194aee97db | |||
0f77169952 |
2
.github/workflows/build-code.yml
vendored
2
.github/workflows/build-code.yml
vendored
@ -13,7 +13,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
node_version:
|
||||
- 18
|
||||
- 20
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
90
CHANGELOG.md
90
CHANGELOG.md
@ -5,6 +5,94 @@ 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).
|
||||
|
||||
## 2.98.0 - 2024-07-27
|
||||
|
||||
### Added
|
||||
|
||||
- Set up the language localization for Catalan (`ca`)
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the account selector of the create or update activity dialog
|
||||
- Improved the handling of the numerical precision in the value component
|
||||
- Skipped derived currencies in the get quotes functionality of the data provider service
|
||||
- Improved the language localization for Spanish (`es`)
|
||||
- Upgraded `angular` from version `18.0.4` to `18.1.1`
|
||||
- Upgraded `Nx` from version `19.4.3` to `19.5.1`
|
||||
- Upgraded `prisma` from version `5.16.1` to `5.17.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the dividend import from a data provider for holdings without an account
|
||||
- Fixed an issue in the public page related to a non-existent access
|
||||
|
||||
## 2.97.0 - 2024-07-20
|
||||
|
||||
### Added
|
||||
|
||||
- Added _selfh.st_ to the _As seen in_ section on the landing page
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the numerical precision in the holding detail dialog
|
||||
- Improved the handling of the numerical precision in the value component
|
||||
- Optimized the 7d data gathering by prioritizing the currencies
|
||||
- Improved the language localization for German (`de`)
|
||||
- Upgraded `Node.js` from version `18` to `20` (`Dockerfile`)
|
||||
- Upgraded `Nx` from version `19.4.0` to `19.4.3`
|
||||
- Upgraded `prettier` from version `3.3.1` to `3.3.3`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the table sorting of the holdings tab on the home page
|
||||
|
||||
## 2.96.0 - 2024-07-13
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the chart of the holdings tab on the home page (experimental)
|
||||
- Separated the icon purposes in the `site.webmanifest`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue in the portfolio summary with the currency conversion of fees
|
||||
- Fixed an issue in the the search for a holding
|
||||
- Removed the show condition of the experimental features setting in the user settings
|
||||
|
||||
## 2.95.0 - 2024-07-12
|
||||
|
||||
### Added
|
||||
|
||||
- Added a chart to the holdings tab of the home page (experimental)
|
||||
|
||||
## 2.94.0 - 2024-07-09
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the language localization for German (`de`)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed a pagination issue in the activities endpoint by adding `id` as a secondary sort criterion to `date` to ensure consistent ordering
|
||||
|
||||
## 2.93.0 - 2024-07-07
|
||||
|
||||
### Added
|
||||
|
||||
- Added the _Crypto Coins Heatmap_ to the resources section
|
||||
- Added the _Stock Heatmap_ to the resources section
|
||||
- Extended the content of the _Self-Hosting_ section by the platforms concept on the Frequently Asked Questions (FAQ) page
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the allocations by ETF holding on the allocations page for the impersonation mode (experimental)
|
||||
- Improved the detection of REST APIs (`JSON`) used via the scraper configuration
|
||||
- Improved the usability to delete an asset profile of type currency in the historical market data table and the asset profile details dialog of the admin control
|
||||
- Refreshed the cryptocurrencies list
|
||||
- Refactored the thresholds of the rules in the _X-ray_ section
|
||||
- Removed the obsolete `version` from the `docker-compose` files
|
||||
- Upgraded `Nx` from version `19.2.2` to `19.4.0`
|
||||
|
||||
## 2.92.0 - 2024-06-30
|
||||
|
||||
### Added
|
||||
@ -4765,7 +4853,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- Added the attribute `precision` in the value component
|
||||
- Added the attribute `precision` to the value component
|
||||
|
||||
### Fixed
|
||||
|
||||
|
@ -10,7 +10,7 @@ Remove permission in `UserService` using `without()`
|
||||
|
||||
### Frontend
|
||||
|
||||
Use `*ngIf="user?.settings?.isExperimentalFeatures"` in HTML template
|
||||
Use `@if (user?.settings?.isExperimentalFeatures) {}` in HTML template
|
||||
|
||||
## Git
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
FROM --platform=$BUILDPLATFORM node:18-slim as builder
|
||||
FROM --platform=$BUILDPLATFORM node:20-slim as builder
|
||||
|
||||
# Build application and add additional files
|
||||
WORKDIR /ghostfolio
|
||||
@ -50,7 +50,7 @@ COPY package.json /ghostfolio/dist/apps/api
|
||||
RUN yarn database:generate-typings
|
||||
|
||||
# Image to run, copy everything needed from builder
|
||||
FROM node:18-slim
|
||||
FROM node:20-slim
|
||||
|
||||
LABEL org.opencontainers.image.source="https://github.com/ghostfolio/ghostfolio"
|
||||
|
||||
|
62
README.md
62
README.md
@ -7,7 +7,7 @@
|
||||
**Open Source Wealth Management Software**
|
||||
|
||||
[**Ghostfol.io**](https://ghostfol.io) | [**Live Demo**](https://ghostfol.io/en/demo) | [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) | [**FAQ**](https://ghostfol.io/en/faq) |
|
||||
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**X**](https://twitter.com/ghostfolio_)
|
||||
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**X**](https://x.com/ghostfolio_)
|
||||
|
||||
[](https://www.buymeacoffee.com/ghostfolio)
|
||||
[](#contributing)
|
||||
@ -47,7 +47,7 @@ Ghostfolio is for you if you are...
|
||||
|
||||
- ✅ Create, update and delete transactions
|
||||
- ✅ Multi account management
|
||||
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `YTD`, `1Y`, `5Y`, `Max`
|
||||
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `WTD`, `MTD`, `YTD`, `1Y`, `5Y`, `Max`
|
||||
- ✅ Various charts
|
||||
- ✅ Static analysis to identify potential risks in your portfolio
|
||||
- ✅ Import and export transactions
|
||||
@ -87,21 +87,21 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
|
||||
|
||||
| Name | Type | Default Value | Description |
|
||||
| ------------------------ | ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ACCESS_TOKEN_SALT` | string | | A random string used as salt for access tokens |
|
||||
| `API_KEY_COINGECKO_DEMO` | string (`optional`) | | The _CoinGecko_ Demo API key |
|
||||
| `API_KEY_COINGECKO_PRO` | string (`optional`) | | The _CoinGecko_ Pro API |
|
||||
| `DATABASE_URL` | string | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
||||
| `HOST` | string (`optional`) | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
||||
| `JWT_SECRET_KEY` | string | | A random string used for _JSON Web Tokens_ (JWT) |
|
||||
| `PORT` | number (`optional`) | `3333` | The port where the Ghostfolio application will run on |
|
||||
| `POSTGRES_DB` | string | | The name of the _PostgreSQL_ database |
|
||||
| `POSTGRES_PASSWORD` | string | | The password of the _PostgreSQL_ database |
|
||||
| `POSTGRES_USER` | string | | The user of the _PostgreSQL_ database |
|
||||
| `REDIS_DB` | number (`optional`) | `0` | The database index of _Redis_ |
|
||||
| `REDIS_HOST` | string | | The host where _Redis_ is running |
|
||||
| `REDIS_PASSWORD` | string | | The password of _Redis_ |
|
||||
| `REDIS_PORT` | number | | The port where _Redis_ is running |
|
||||
| `REQUEST_TIMEOUT` | number (`optional`) | `2000` | The timeout of network requests to data providers in milliseconds |
|
||||
| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens |
|
||||
| `API_KEY_COINGECKO_DEMO` | `string` (optional) | | The _CoinGecko_ Demo API key |
|
||||
| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key |
|
||||
| `DATABASE_URL` | `string` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
||||
| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
||||
| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) |
|
||||
| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on |
|
||||
| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database |
|
||||
| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database |
|
||||
| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database |
|
||||
| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ |
|
||||
| `REDIS_HOST` | `string` | | The host where _Redis_ is running |
|
||||
| `REDIS_PASSWORD` | `string` | | The password of _Redis_ |
|
||||
| `REDIS_PORT` | `number` | | The port where _Redis_ is running |
|
||||
| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds |
|
||||
|
||||
### Run with Docker Compose
|
||||
|
||||
@ -149,7 +149,7 @@ Ghostfolio is available for various home server systems, including [CasaOS](http
|
||||
### Prerequisites
|
||||
|
||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||
- [Node.js](https://nodejs.org/en/download) (version 18+)
|
||||
- [Node.js](https://nodejs.org/en/download) (version 20+)
|
||||
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||
- Create a local copy of this Git repository (clone)
|
||||
- Copy the file `.env.dev` to `.env` and populate it with your data (`cp .env.dev .env`)
|
||||
@ -233,18 +233,18 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
| ---------- | ------------------- | ----------------------------------------------------------------------------- |
|
||||
| accountId | string (`optional`) | Id of the account |
|
||||
| comment | string (`optional`) | Comment of the activity |
|
||||
| currency | string | `CHF` \| `EUR` \| `USD` etc. |
|
||||
| dataSource | string | `COINGECKO` \| `MANUAL` (for type `ITEM`) \| `YAHOO` |
|
||||
| date | string | Date in the format `ISO-8601` |
|
||||
| fee | number | Fee of the activity |
|
||||
| quantity | number | Quantity of the activity |
|
||||
| symbol | string | Symbol of the activity (suitable for `dataSource`) |
|
||||
| type | string | `BUY` \| `DIVIDEND` \| `FEE` \| `INTEREST` \| `ITEM` \| `LIABILITY` \| `SELL` |
|
||||
| unitPrice | number | Price per unit of the activity |
|
||||
| Field | Type | Description |
|
||||
| ------------ | ------------------- | ----------------------------------------------------------------------------- |
|
||||
| `accountId` | `string` (optional) | Id of the account |
|
||||
| `comment` | `string` (optional) | Comment of the activity |
|
||||
| `currency` | `string` | `CHF` \| `EUR` \| `USD` etc. |
|
||||
| `dataSource` | `string` | `COINGECKO` \| `MANUAL` (for type `ITEM`) \| `YAHOO` |
|
||||
| `date` | `string` | Date in the format `ISO-8601` |
|
||||
| `fee` | `number` | Fee of the activity |
|
||||
| `quantity` | `number` | Quantity of the activity |
|
||||
| `symbol` | `string` | Symbol of the activity (suitable for `dataSource`) |
|
||||
| `type` | `string` | `BUY` \| `DIVIDEND` \| `FEE` \| `INTEREST` \| `ITEM` \| `LIABILITY` \| `SELL` |
|
||||
| `unitPrice` | `number` | Price per unit of the activity |
|
||||
|
||||
#### Response
|
||||
|
||||
@ -275,7 +275,7 @@ Are you building your own project? Add the `ghostfolio` topic to your _GitHub_ r
|
||||
|
||||
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
|
||||
|
||||
Not sure what to work on? We have [some ideas](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22), even for [newcomers](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22). Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://twitter.com/ghostfolio_) on _X_. We would love to hear from you.
|
||||
Not sure what to work on? We have [some ideas](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22), even for [newcomers](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22). Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://x.com/ghostfolio_) on _X_. We would love to hear from you.
|
||||
|
||||
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).
|
||||
|
||||
|
@ -174,8 +174,8 @@ export class AccountService {
|
||||
ACCOUNT: filtersByAccount,
|
||||
ASSET_CLASS: filtersByAssetClass,
|
||||
TAG: filtersByTag
|
||||
} = groupBy(filters, (filter) => {
|
||||
return filter.type;
|
||||
} = groupBy(filters, ({ type }) => {
|
||||
return type;
|
||||
});
|
||||
|
||||
if (filtersByAccount?.length > 0) {
|
||||
|
@ -81,10 +81,11 @@ export class AdminController {
|
||||
@Post('gather/max')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async gatherMax(): Promise<void> {
|
||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||
const assetProfileIdentifiers =
|
||||
await this.dataGatheringService.getAllAssetProfileIdentifiers();
|
||||
|
||||
await this.dataGatheringService.addJobsToQueue(
|
||||
uniqueAssets.map(({ dataSource, symbol }) => {
|
||||
assetProfileIdentifiers.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
data: {
|
||||
dataSource,
|
||||
@ -107,10 +108,11 @@ export class AdminController {
|
||||
@Post('gather/profile-data')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async gatherProfileData(): Promise<void> {
|
||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||
const assetProfileIdentifiers =
|
||||
await this.dataGatheringService.getAllAssetProfileIdentifiers();
|
||||
|
||||
await this.dataGatheringService.addJobsToQueue(
|
||||
uniqueAssets.map(({ dataSource, symbol }) => {
|
||||
assetProfileIdentifiers.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
data: {
|
||||
dataSource,
|
||||
|
@ -27,12 +27,13 @@ import {
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { MarketDataPreset } from '@ghostfolio/common/types';
|
||||
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
AssetClass,
|
||||
AssetSubClass,
|
||||
DataSource,
|
||||
Prisma,
|
||||
PrismaClient,
|
||||
Property,
|
||||
SymbolProfile
|
||||
} from '@prisma/client';
|
||||
@ -212,98 +213,113 @@ export class AdminService {
|
||||
}
|
||||
}
|
||||
|
||||
let [assetProfiles, count] = await Promise.all([
|
||||
this.prismaService.symbolProfile.findMany({
|
||||
orderBy,
|
||||
skip,
|
||||
take,
|
||||
where,
|
||||
select: {
|
||||
_count: {
|
||||
select: { Order: true }
|
||||
},
|
||||
assetClass: true,
|
||||
assetSubClass: true,
|
||||
comment: true,
|
||||
countries: true,
|
||||
currency: true,
|
||||
dataSource: true,
|
||||
id: true,
|
||||
name: true,
|
||||
Order: {
|
||||
orderBy: [{ date: 'asc' }],
|
||||
select: { date: true },
|
||||
take: 1
|
||||
},
|
||||
scraperConfiguration: true,
|
||||
sectors: true,
|
||||
symbol: true
|
||||
const extendedPrismaClient = this.getExtendedPrismaClient();
|
||||
|
||||
try {
|
||||
let [assetProfiles, count] = await Promise.all([
|
||||
extendedPrismaClient.symbolProfile.findMany({
|
||||
orderBy,
|
||||
skip,
|
||||
take,
|
||||
where,
|
||||
select: {
|
||||
_count: {
|
||||
select: { Order: true }
|
||||
},
|
||||
assetClass: true,
|
||||
assetSubClass: true,
|
||||
comment: true,
|
||||
countries: true,
|
||||
currency: true,
|
||||
dataSource: true,
|
||||
id: true,
|
||||
isUsedByUsersWithSubscription: true,
|
||||
name: true,
|
||||
Order: {
|
||||
orderBy: [{ date: 'asc' }],
|
||||
select: { date: true },
|
||||
take: 1
|
||||
},
|
||||
scraperConfiguration: true,
|
||||
sectors: true,
|
||||
symbol: true
|
||||
}
|
||||
}),
|
||||
this.prismaService.symbolProfile.count({ where })
|
||||
]);
|
||||
|
||||
let marketData: AdminMarketDataItem[] = await Promise.all(
|
||||
assetProfiles.map(
|
||||
async ({
|
||||
_count,
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
comment,
|
||||
countries,
|
||||
currency,
|
||||
dataSource,
|
||||
id,
|
||||
isUsedByUsersWithSubscription,
|
||||
name,
|
||||
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,
|
||||
currency,
|
||||
countriesCount,
|
||||
dataSource,
|
||||
id,
|
||||
name,
|
||||
symbol,
|
||||
marketDataItemCount,
|
||||
sectorsCount,
|
||||
activitiesCount: _count.Order,
|
||||
date: Order?.[0]?.date,
|
||||
isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
}),
|
||||
this.prismaService.symbolProfile.count({ where })
|
||||
]);
|
||||
|
||||
let marketData: AdminMarketDataItem[] = assetProfiles.map(
|
||||
({
|
||||
_count,
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
comment,
|
||||
countries,
|
||||
currency,
|
||||
dataSource,
|
||||
id,
|
||||
name,
|
||||
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,
|
||||
currency,
|
||||
countriesCount,
|
||||
dataSource,
|
||||
id,
|
||||
name,
|
||||
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;
|
||||
}
|
||||
|
||||
count = marketData.length;
|
||||
return {
|
||||
count,
|
||||
marketData
|
||||
};
|
||||
} finally {
|
||||
await extendedPrismaClient.$disconnect();
|
||||
|
||||
Logger.debug('Disconnect extended prisma client', 'AdminService');
|
||||
}
|
||||
|
||||
return {
|
||||
count,
|
||||
marketData
|
||||
};
|
||||
}
|
||||
|
||||
public async getMarketDataBySymbol({
|
||||
@ -431,6 +447,52 @@ export class AdminService {
|
||||
return response;
|
||||
}
|
||||
|
||||
private getExtendedPrismaClient() {
|
||||
Logger.debug('Connect extended prisma client', 'AdminService');
|
||||
|
||||
const symbolProfileExtension = Prisma.defineExtension((client) => {
|
||||
return client.$extends({
|
||||
result: {
|
||||
symbolProfile: {
|
||||
isUsedByUsersWithSubscription: {
|
||||
compute: async ({ id }) => {
|
||||
const { _count } =
|
||||
await this.prismaService.symbolProfile.findUnique({
|
||||
select: {
|
||||
_count: {
|
||||
select: {
|
||||
Order: {
|
||||
where: {
|
||||
User: {
|
||||
Subscription: {
|
||||
some: {
|
||||
expiresAt: {
|
||||
gt: new Date()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
where: {
|
||||
id
|
||||
}
|
||||
});
|
||||
|
||||
return _count.Order > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return new PrismaClient().$extends(symbolProfileExtension);
|
||||
}
|
||||
|
||||
private async getMarketDataForCurrencies(): Promise<AdminMarketData> {
|
||||
const marketDataItems = await this.prismaService.marketData.groupBy({
|
||||
_count: true,
|
||||
|
@ -72,9 +72,13 @@ export class ImportService {
|
||||
})
|
||||
]);
|
||||
|
||||
const accounts = orders.map((order) => {
|
||||
return order.Account;
|
||||
});
|
||||
const accounts = orders
|
||||
.filter(({ Account }) => {
|
||||
return !!Account;
|
||||
})
|
||||
.map(({ Account }) => {
|
||||
return Account;
|
||||
});
|
||||
|
||||
const Account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;
|
||||
|
||||
|
@ -291,7 +291,8 @@ export class OrderService {
|
||||
withExcludedAccounts?: boolean;
|
||||
}): Promise<Activities> {
|
||||
let orderBy: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput> = [
|
||||
{ date: 'asc' }
|
||||
{ date: 'asc' },
|
||||
{ id: 'asc' }
|
||||
];
|
||||
const where: Prisma.OrderWhereInput = { userId };
|
||||
|
||||
@ -311,10 +312,14 @@ export class OrderService {
|
||||
ACCOUNT: filtersByAccount,
|
||||
ASSET_CLASS: filtersByAssetClass,
|
||||
TAG: filtersByTag
|
||||
} = groupBy(filters, (filter) => {
|
||||
return filter.type;
|
||||
} = groupBy(filters, ({ type }) => {
|
||||
return type;
|
||||
});
|
||||
|
||||
const searchQuery = filters?.find(({ type }) => {
|
||||
return type === 'SEARCH_QUERY';
|
||||
})?.id;
|
||||
|
||||
if (filtersByAccount?.length > 0) {
|
||||
where.accountId = {
|
||||
in: filtersByAccount.map(({ id }) => {
|
||||
@ -356,6 +361,30 @@ export class OrderService {
|
||||
};
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
const searchQueryWhereInput: Prisma.SymbolProfileWhereInput[] = [
|
||||
{ id: { mode: 'insensitive', startsWith: searchQuery } },
|
||||
{ isin: { mode: 'insensitive', startsWith: searchQuery } },
|
||||
{ name: { mode: 'insensitive', startsWith: searchQuery } },
|
||||
{ symbol: { mode: 'insensitive', startsWith: searchQuery } }
|
||||
];
|
||||
|
||||
if (where.SymbolProfile) {
|
||||
where.SymbolProfile = {
|
||||
AND: [
|
||||
where.SymbolProfile,
|
||||
{
|
||||
OR: searchQueryWhereInput
|
||||
}
|
||||
]
|
||||
};
|
||||
} else {
|
||||
where.SymbolProfile = {
|
||||
OR: searchQueryWhereInput
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (filtersByTag?.length > 0) {
|
||||
where.tags = {
|
||||
some: {
|
||||
@ -367,7 +396,7 @@ export class OrderService {
|
||||
}
|
||||
|
||||
if (sortColumn) {
|
||||
orderBy = [{ [sortColumn]: sortDirection }];
|
||||
orderBy = [{ [sortColumn]: sortDirection }, { id: sortDirection }];
|
||||
}
|
||||
|
||||
if (types) {
|
||||
|
@ -300,6 +300,12 @@ export abstract class PortfolioCalculator {
|
||||
const errors: ResponseError['errors'] = [];
|
||||
|
||||
for (const item of lastTransactionPoint.items) {
|
||||
const feeInBaseCurrency = item.fee.mul(
|
||||
exchangeRatesByCurrency[`${item.currency}${this.currency}`]?.[
|
||||
lastTransactionPoint.date
|
||||
]
|
||||
);
|
||||
|
||||
const marketPriceInBaseCurrency = (
|
||||
marketSymbolMap[endDateString]?.[item.symbol] ?? item.averagePrice
|
||||
).mul(
|
||||
@ -340,10 +346,11 @@ export abstract class PortfolioCalculator {
|
||||
hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors;
|
||||
|
||||
positions.push({
|
||||
dividend: totalDividend,
|
||||
dividendInBaseCurrency: totalDividendInBaseCurrency,
|
||||
feeInBaseCurrency,
|
||||
timeWeightedInvestment,
|
||||
timeWeightedInvestmentWithCurrencyEffect,
|
||||
dividend: totalDividend,
|
||||
dividendInBaseCurrency: totalDividendInBaseCurrency,
|
||||
averagePrice: item.averagePrice,
|
||||
currency: item.currency,
|
||||
dataSource: item.dataSource,
|
||||
|
@ -168,6 +168,7 @@ describe('PortfolioCalculator', () => {
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('3.2'),
|
||||
feeInBaseCurrency: new Big('3.2'),
|
||||
firstBuyDate: '2021-11-22',
|
||||
grossPerformance: new Big('-12.6'),
|
||||
grossPerformancePercentage: new Big('-0.04408677396780965649'),
|
||||
|
@ -153,6 +153,7 @@ describe('PortfolioCalculator', () => {
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('3.2'),
|
||||
feeInBaseCurrency: new Big('3.2'),
|
||||
firstBuyDate: '2021-11-22',
|
||||
grossPerformance: new Big('-12.6'),
|
||||
grossPerformancePercentage: new Big('-0.0440867739678096571'),
|
||||
|
@ -138,6 +138,7 @@ describe('PortfolioCalculator', () => {
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('1.55'),
|
||||
feeInBaseCurrency: new Big('1.55'),
|
||||
firstBuyDate: '2021-11-30',
|
||||
grossPerformance: new Big('24.6'),
|
||||
grossPerformancePercentage: new Big('0.09004392386530014641'),
|
||||
|
@ -166,6 +166,7 @@ describe('PortfolioCalculator', () => {
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('0'),
|
||||
feeInBaseCurrency: new Big('0'),
|
||||
firstBuyDate: '2015-01-01',
|
||||
grossPerformance: new Big('27172.74'),
|
||||
grossPerformancePercentage: new Big('42.41978276196153750666'),
|
||||
|
@ -123,6 +123,7 @@ describe('PortfolioCalculator', () => {
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('49'),
|
||||
feeInBaseCurrency: new Big('49'),
|
||||
firstBuyDate: '2021-09-01',
|
||||
grossPerformance: null,
|
||||
grossPerformancePercentage: null,
|
||||
|
@ -151,6 +151,7 @@ describe('PortfolioCalculator', () => {
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('1'),
|
||||
feeInBaseCurrency: new Big('0.9238'),
|
||||
firstBuyDate: '2023-01-03',
|
||||
grossPerformance: new Big('27.33'),
|
||||
grossPerformancePercentage: new Big('0.3066651705565529623'),
|
||||
@ -177,7 +178,7 @@ describe('PortfolioCalculator', () => {
|
||||
valueInBaseCurrency: new Big('103.10483')
|
||||
}
|
||||
],
|
||||
totalFeesWithCurrencyEffect: new Big('1'),
|
||||
totalFeesWithCurrencyEffect: new Big('0.9238'),
|
||||
totalInterestWithCurrencyEffect: new Big('0'),
|
||||
totalInvestment: new Big('89.12'),
|
||||
totalInvestmentWithCurrencyEffect: new Big('82.329056'),
|
||||
|
@ -123,6 +123,7 @@ describe('PortfolioCalculator', () => {
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('0'),
|
||||
feeInBaseCurrency: new Big('0'),
|
||||
firstBuyDate: '2022-01-01',
|
||||
grossPerformance: null,
|
||||
grossPerformancePercentage: null,
|
||||
|
@ -153,6 +153,7 @@ describe('PortfolioCalculator', () => {
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('4.25'),
|
||||
feeInBaseCurrency: new Big('4.25'),
|
||||
firstBuyDate: '2022-03-07',
|
||||
grossPerformance: new Big('21.93'),
|
||||
grossPerformancePercentage: new Big('0.15113417083448194384'),
|
||||
|
@ -183,6 +183,7 @@ describe('PortfolioCalculator', () => {
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('0'),
|
||||
feeInBaseCurrency: new Big('0'),
|
||||
firstBuyDate: '2022-03-07',
|
||||
grossPerformance: new Big('19.86'),
|
||||
grossPerformancePercentage: new Big('0.13100263852242744063'),
|
||||
|
@ -34,9 +34,9 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
|
||||
let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0);
|
||||
|
||||
for (const currentPosition of positions) {
|
||||
if (currentPosition.fee) {
|
||||
if (currentPosition.feeInBaseCurrency) {
|
||||
totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus(
|
||||
currentPosition.fee
|
||||
currentPosition.feeInBaseCurrency
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -496,9 +496,6 @@ export class PortfolioController {
|
||||
@Param('accessId') accessId
|
||||
): Promise<PortfolioPublicDetails> {
|
||||
const access = await this.accessService.access({ id: accessId });
|
||||
const user = await this.userService.user({
|
||||
id: access.userId
|
||||
});
|
||||
|
||||
if (!access) {
|
||||
throw new HttpException(
|
||||
@ -508,6 +505,11 @@ export class PortfolioController {
|
||||
}
|
||||
|
||||
let hasDetails = true;
|
||||
|
||||
const user = await this.userService.user({
|
||||
id: access.userId
|
||||
});
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
hasDetails = user.subscription.type === 'Premium';
|
||||
}
|
||||
|
@ -499,7 +499,17 @@ export class PortfolioService {
|
||||
grossPerformancePercentageWithCurrencyEffect?.toNumber() ?? 0,
|
||||
grossPerformanceWithCurrencyEffect:
|
||||
grossPerformanceWithCurrencyEffect?.toNumber() ?? 0,
|
||||
holdings: assetProfile.holdings,
|
||||
holdings: assetProfile.holdings.map(
|
||||
({ allocationInPercentage, name }) => {
|
||||
return {
|
||||
allocationInPercentage,
|
||||
name,
|
||||
valueInBaseCurrency: valueInBaseCurrency
|
||||
.mul(allocationInPercentage)
|
||||
.toNumber()
|
||||
};
|
||||
}
|
||||
),
|
||||
investment: investment.toNumber(),
|
||||
marketState: dataProviderResponse?.marketState ?? 'delayed',
|
||||
name: assetProfile.name,
|
||||
|
@ -237,6 +237,7 @@ export class UserService {
|
||||
|
||||
currentPermissions = without(
|
||||
currentPermissions,
|
||||
permissions.accessHoldingsChart,
|
||||
permissions.createAccess
|
||||
);
|
||||
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -4,6 +4,12 @@
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
|
||||
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
|
||||
<!--
|
||||
<url>
|
||||
<loc>https://ghostfol.io/ca</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
-->
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -441,10 +447,10 @@
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<!--
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pl</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pl</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
-->
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt</loc>
|
||||
|
@ -55,10 +55,10 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
|
||||
const maxInvestmentRatio = maxItem?.investment / totalInvestment || 0;
|
||||
|
||||
if (maxInvestmentRatio > ruleSettings.threshold) {
|
||||
if (maxInvestmentRatio > ruleSettings.thresholdMax) {
|
||||
return {
|
||||
evaluation: `Over ${
|
||||
ruleSettings.threshold * 100
|
||||
ruleSettings.thresholdMax * 100
|
||||
}% of your current investment is at ${maxItem.name} (${(
|
||||
maxInvestmentRatio * 100
|
||||
).toPrecision(3)}%)`,
|
||||
@ -70,7 +70,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
evaluation: `The major part of your current investment is at ${
|
||||
maxItem.name
|
||||
} (${(maxInvestmentRatio * 100).toPrecision(3)}%) and does not exceed ${
|
||||
ruleSettings.threshold * 100
|
||||
ruleSettings.thresholdMax * 100
|
||||
}%`,
|
||||
value: true
|
||||
};
|
||||
@ -80,12 +80,12 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
return {
|
||||
baseCurrency: aUserSettings.baseCurrency,
|
||||
isActive: true,
|
||||
threshold: 0.5
|
||||
thresholdMax: 0.5
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface Settings extends RuleSettings {
|
||||
baseCurrency: string;
|
||||
threshold: number;
|
||||
thresholdMax: number;
|
||||
}
|
||||
|
@ -41,10 +41,10 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
|
||||
const maxValueRatio = maxItem?.value / totalValue || 0;
|
||||
|
||||
if (maxValueRatio > ruleSettings.threshold) {
|
||||
if (maxValueRatio > ruleSettings.thresholdMax) {
|
||||
return {
|
||||
evaluation: `Over ${
|
||||
ruleSettings.threshold * 100
|
||||
ruleSettings.thresholdMax * 100
|
||||
}% of your current investment is in ${maxItem.groupKey} (${(
|
||||
maxValueRatio * 100
|
||||
).toPrecision(3)}%)`,
|
||||
@ -56,7 +56,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
evaluation: `The major part of your current investment is in ${
|
||||
maxItem?.groupKey ?? ruleSettings.baseCurrency
|
||||
} (${(maxValueRatio * 100).toPrecision(3)}%) and does not exceed ${
|
||||
ruleSettings.threshold * 100
|
||||
ruleSettings.thresholdMax * 100
|
||||
}%`,
|
||||
value: true
|
||||
};
|
||||
@ -66,12 +66,12 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
return {
|
||||
baseCurrency: aUserSettings.baseCurrency,
|
||||
isActive: true,
|
||||
threshold: 0.5
|
||||
thresholdMax: 0.5
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface Settings extends RuleSettings {
|
||||
baseCurrency: string;
|
||||
threshold: number;
|
||||
thresholdMax: number;
|
||||
}
|
||||
|
@ -19,16 +19,16 @@ export class EmergencyFundSetup extends Rule<Settings> {
|
||||
}
|
||||
|
||||
public evaluate(ruleSettings: Settings) {
|
||||
if (this.emergencyFund > ruleSettings.threshold) {
|
||||
if (this.emergencyFund < ruleSettings.thresholdMin) {
|
||||
return {
|
||||
evaluation: 'An emergency fund has been set up',
|
||||
value: true
|
||||
evaluation: 'No emergency fund has been set up',
|
||||
value: false
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
evaluation: 'No emergency fund has been set up',
|
||||
value: false
|
||||
evaluation: 'An emergency fund has been set up',
|
||||
value: true
|
||||
};
|
||||
}
|
||||
|
||||
@ -36,12 +36,12 @@ export class EmergencyFundSetup extends Rule<Settings> {
|
||||
return {
|
||||
baseCurrency: aUserSettings.baseCurrency,
|
||||
isActive: true,
|
||||
threshold: 0
|
||||
thresholdMin: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface Settings extends RuleSettings {
|
||||
baseCurrency: string;
|
||||
threshold: number;
|
||||
thresholdMin: number;
|
||||
}
|
||||
|
@ -26,10 +26,10 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
|
||||
? this.fees / this.totalInvestment
|
||||
: 0;
|
||||
|
||||
if (feeRatio > ruleSettings.threshold) {
|
||||
if (feeRatio > ruleSettings.thresholdMax) {
|
||||
return {
|
||||
evaluation: `The fees do exceed ${
|
||||
ruleSettings.threshold * 100
|
||||
ruleSettings.thresholdMax * 100
|
||||
}% of your initial investment (${(feeRatio * 100).toPrecision(3)}%)`,
|
||||
value: false
|
||||
};
|
||||
@ -37,7 +37,7 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
|
||||
|
||||
return {
|
||||
evaluation: `The fees do not exceed ${
|
||||
ruleSettings.threshold * 100
|
||||
ruleSettings.thresholdMax * 100
|
||||
}% of your initial investment (${(feeRatio * 100).toPrecision(3)}%)`,
|
||||
value: true
|
||||
};
|
||||
@ -47,12 +47,12 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
|
||||
return {
|
||||
baseCurrency: aUserSettings.baseCurrency,
|
||||
isActive: true,
|
||||
threshold: 0.01
|
||||
thresholdMax: 0.01
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface Settings extends RuleSettings {
|
||||
baseCurrency: string;
|
||||
threshold: number;
|
||||
thresholdMax: number;
|
||||
}
|
||||
|
@ -45,10 +45,11 @@ export class CronService {
|
||||
@Cron(CronService.EVERY_SUNDAY_AT_LUNCH_TIME)
|
||||
public async runEverySundayAtTwelvePm() {
|
||||
if (await this.isDataGatheringEnabled()) {
|
||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||
const assetProfileIdentifiers =
|
||||
await this.dataGatheringService.getAllAssetProfileIdentifiers();
|
||||
|
||||
await this.dataGatheringService.addJobsToQueue(
|
||||
uniqueAssets.map(({ dataSource, symbol }) => {
|
||||
assetProfileIdentifiers.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
data: {
|
||||
dataSource,
|
||||
|
@ -10,6 +10,7 @@ import {
|
||||
DATA_GATHERING_QUEUE,
|
||||
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
|
||||
DATA_GATHERING_QUEUE_PRIORITY_LOW,
|
||||
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
|
||||
GATHER_HISTORICAL_MARKET_DATA_PROCESS,
|
||||
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
|
||||
PROPERTY_BENCHMARKS
|
||||
@ -62,9 +63,22 @@ export class DataGatheringService {
|
||||
}
|
||||
|
||||
public async gather7Days() {
|
||||
const dataGatheringItems = await this.getSymbols7D();
|
||||
await this.gatherSymbols({
|
||||
dataGatheringItems,
|
||||
dataGatheringItems: await this.getCurrencies7D(),
|
||||
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
|
||||
});
|
||||
|
||||
await this.gatherSymbols({
|
||||
dataGatheringItems: await this.getSymbols7D({
|
||||
withUserSubscription: true
|
||||
}),
|
||||
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
|
||||
});
|
||||
|
||||
await this.gatherSymbols({
|
||||
dataGatheringItems: await this.getSymbols7D({
|
||||
withUserSubscription: false
|
||||
}),
|
||||
priority: DATA_GATHERING_QUEUE_PRIORITY_LOW
|
||||
});
|
||||
}
|
||||
@ -138,7 +152,7 @@ export class DataGatheringService {
|
||||
});
|
||||
|
||||
if (!uniqueAssets) {
|
||||
uniqueAssets = await this.getUniqueAssets();
|
||||
uniqueAssets = await this.getAllAssetProfileIdentifiers();
|
||||
}
|
||||
|
||||
if (uniqueAssets.length <= 0) {
|
||||
@ -270,7 +284,7 @@ export class DataGatheringService {
|
||||
);
|
||||
}
|
||||
|
||||
public async getUniqueAssets(): Promise<UniqueAsset[]> {
|
||||
public async getAllAssetProfileIdentifiers(): Promise<UniqueAsset[]> {
|
||||
const symbolProfiles = await this.prismaService.symbolProfile.findMany({
|
||||
orderBy: [{ symbol: 'asc' }]
|
||||
});
|
||||
@ -290,73 +304,83 @@ export class DataGatheringService {
|
||||
});
|
||||
}
|
||||
|
||||
private getEarliestDate(aStartDate: Date) {
|
||||
return min([aStartDate, subYears(new Date(), 10)]);
|
||||
}
|
||||
|
||||
private async getSymbols7D(): Promise<IDataGatheringItem[]> {
|
||||
const startDate = subDays(resetHours(new Date()), 7);
|
||||
|
||||
const symbolProfiles = await this.prismaService.symbolProfile.findMany({
|
||||
orderBy: [{ symbol: 'asc' }],
|
||||
select: {
|
||||
dataSource: true,
|
||||
scraperConfiguration: true,
|
||||
symbol: true
|
||||
}
|
||||
});
|
||||
|
||||
// Only consider symbols with incomplete market data for the last
|
||||
// 7 days
|
||||
const symbolsWithCompleteMarketData = (
|
||||
private async getAssetProfileIdentifiersWithCompleteMarketData(): Promise<
|
||||
UniqueAsset[]
|
||||
> {
|
||||
return (
|
||||
await this.prismaService.marketData.groupBy({
|
||||
_count: true,
|
||||
by: ['symbol'],
|
||||
by: ['dataSource', 'symbol'],
|
||||
orderBy: [{ symbol: 'asc' }],
|
||||
where: {
|
||||
date: { gt: startDate },
|
||||
date: { gt: subDays(resetHours(new Date()), 7) },
|
||||
state: 'CLOSE'
|
||||
}
|
||||
})
|
||||
)
|
||||
.filter((group) => {
|
||||
return group._count >= 6;
|
||||
.filter(({ _count }) => {
|
||||
return _count >= 6;
|
||||
})
|
||||
.map((group) => {
|
||||
return group.symbol;
|
||||
.map(({ dataSource, symbol }) => {
|
||||
return { dataSource, symbol };
|
||||
});
|
||||
}
|
||||
|
||||
private async getCurrencies7D(): Promise<IDataGatheringItem[]> {
|
||||
const assetProfileIdentifiersWithCompleteMarketData =
|
||||
await this.getAssetProfileIdentifiersWithCompleteMarketData();
|
||||
|
||||
return this.exchangeRateDataService
|
||||
.getCurrencyPairs()
|
||||
.filter(({ dataSource, symbol }) => {
|
||||
return !assetProfileIdentifiersWithCompleteMarketData.some((item) => {
|
||||
return item.dataSource === dataSource && item.symbol === symbol;
|
||||
});
|
||||
})
|
||||
.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
dataSource,
|
||||
symbol,
|
||||
date: subDays(resetHours(new Date()), 7)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private getEarliestDate(aStartDate: Date) {
|
||||
return min([aStartDate, subYears(new Date(), 10)]);
|
||||
}
|
||||
|
||||
private async getSymbols7D({
|
||||
withUserSubscription = false
|
||||
}: {
|
||||
withUserSubscription?: boolean;
|
||||
}): Promise<IDataGatheringItem[]> {
|
||||
const symbolProfiles =
|
||||
await this.symbolProfileService.getSymbolProfilesByUserSubscription({
|
||||
withUserSubscription
|
||||
});
|
||||
|
||||
const symbolProfilesToGather = symbolProfiles
|
||||
const assetProfileIdentifiersWithCompleteMarketData =
|
||||
await this.getAssetProfileIdentifiersWithCompleteMarketData();
|
||||
|
||||
return symbolProfiles
|
||||
.filter(({ dataSource, scraperConfiguration, symbol }) => {
|
||||
const manualDataSourceWithScraperConfiguration =
|
||||
dataSource === 'MANUAL' && !isEmpty(scraperConfiguration);
|
||||
|
||||
return (
|
||||
!symbolsWithCompleteMarketData.includes(symbol) &&
|
||||
!assetProfileIdentifiersWithCompleteMarketData.some((item) => {
|
||||
return item.dataSource === dataSource && item.symbol === symbol;
|
||||
}) &&
|
||||
(dataSource !== 'MANUAL' || manualDataSourceWithScraperConfiguration)
|
||||
);
|
||||
})
|
||||
.map((symbolProfile) => {
|
||||
return {
|
||||
...symbolProfile,
|
||||
date: startDate
|
||||
date: subDays(resetHours(new Date()), 7)
|
||||
};
|
||||
});
|
||||
|
||||
const currencyPairsToGather = this.exchangeRateDataService
|
||||
.getCurrencyPairs()
|
||||
.filter(({ symbol }) => {
|
||||
return !symbolsWithCompleteMarketData.includes(symbol);
|
||||
})
|
||||
.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
dataSource,
|
||||
symbol,
|
||||
date: startDate
|
||||
};
|
||||
});
|
||||
|
||||
return [...currencyPairsToGather, ...symbolProfilesToGather];
|
||||
}
|
||||
|
||||
private async getSymbolsMax(): Promise<IDataGatheringItem[]> {
|
||||
|
@ -14,7 +14,12 @@ import {
|
||||
DERIVED_CURRENCIES,
|
||||
PROPERTY_DATA_SOURCE_MAPPING
|
||||
} from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, getStartOfUtcDate } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
DATE_FORMAT,
|
||||
getCurrencyFromSymbol,
|
||||
getStartOfUtcDate,
|
||||
isDerivedCurrency
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import type { Granularity, UserWithSettings } from '@ghostfolio/common/types';
|
||||
|
||||
@ -423,13 +428,18 @@ export class DataProviderService {
|
||||
continue;
|
||||
}
|
||||
|
||||
const symbols = dataGatheringItems.map((dataGatheringItem) => {
|
||||
return dataGatheringItem.symbol;
|
||||
});
|
||||
const symbols = dataGatheringItems
|
||||
.filter(({ symbol }) => {
|
||||
return !isDerivedCurrency(getCurrencyFromSymbol(symbol));
|
||||
})
|
||||
.map(({ symbol }) => {
|
||||
return symbol;
|
||||
});
|
||||
|
||||
const maximumNumberOfSymbolsPerRequest =
|
||||
dataProvider.getMaxNumberOfSymbolsPerRequest?.() ??
|
||||
Number.MAX_SAFE_INTEGER;
|
||||
|
||||
for (
|
||||
let i = 0;
|
||||
i < symbols.length;
|
||||
|
@ -246,7 +246,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
for (const { close, code, timestamp } of quotes) {
|
||||
let currency: string;
|
||||
|
||||
if (code.endsWith('.FOREX')) {
|
||||
if (this.isForex(code)) {
|
||||
currency = this.convertFromEodSymbol(code)?.replace(
|
||||
DEFAULT_CURRENCY,
|
||||
''
|
||||
@ -272,7 +272,10 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
currency,
|
||||
dataSource: this.getName(),
|
||||
marketPrice: close,
|
||||
marketState: isToday(new Date(timestamp * 1000)) ? 'open' : 'closed'
|
||||
marketState:
|
||||
this.isForex(code) || isToday(new Date(timestamp * 1000))
|
||||
? 'open'
|
||||
: 'closed'
|
||||
};
|
||||
} else {
|
||||
Logger.error(
|
||||
@ -311,7 +314,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
items: searchResult
|
||||
.filter(({ currency, symbol }) => {
|
||||
// Remove 'NA' currency and exchange rates
|
||||
return currency?.length === 3 && !symbol.endsWith('.FOREX');
|
||||
return currency?.length === 3 && !this.isForex(symbol);
|
||||
})
|
||||
.map(
|
||||
({
|
||||
@ -349,7 +352,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
private convertFromEodSymbol(aEodSymbol: string) {
|
||||
let symbol = aEodSymbol;
|
||||
|
||||
if (symbol.endsWith('.FOREX')) {
|
||||
if (this.isForex(symbol)) {
|
||||
symbol = symbol.replace('GBX', 'GBp');
|
||||
symbol = symbol.replace('.FOREX', '');
|
||||
}
|
||||
@ -451,6 +454,10 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
return searchResult;
|
||||
}
|
||||
|
||||
private isForex(aCode: string) {
|
||||
return aCode?.endsWith('.FOREX') || false;
|
||||
}
|
||||
|
||||
private parseAssetClass({
|
||||
Exchange,
|
||||
Type
|
||||
|
@ -257,7 +257,7 @@ export class ManualService implements DataProviderInterface {
|
||||
signal: abortController.signal
|
||||
});
|
||||
|
||||
if (headers['content-type'] === 'application/json') {
|
||||
if (headers['content-type'].includes('application/json')) {
|
||||
const data = JSON.parse(body);
|
||||
const value = String(
|
||||
jsonpath.query(data, scraperConfiguration.selector)[0]
|
||||
|
@ -91,6 +91,40 @@ export class SymbolProfileService {
|
||||
});
|
||||
}
|
||||
|
||||
public async getSymbolProfilesByUserSubscription({
|
||||
withUserSubscription = false
|
||||
}: {
|
||||
withUserSubscription?: boolean;
|
||||
}) {
|
||||
return this.prismaService.symbolProfile.findMany({
|
||||
include: {
|
||||
Order: {
|
||||
include: {
|
||||
User: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: [{ symbol: 'asc' }],
|
||||
where: {
|
||||
Order: withUserSubscription
|
||||
? {
|
||||
some: {
|
||||
User: {
|
||||
Subscription: { some: { expiresAt: { gt: new Date() } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
: {
|
||||
every: {
|
||||
User: {
|
||||
Subscription: { none: { expiresAt: { gt: new Date() } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public updateSymbolProfile({
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
@ -221,8 +255,9 @@ export class SymbolProfileService {
|
||||
const { name, weight } = holding as Prisma.JsonObject;
|
||||
|
||||
return {
|
||||
allocationInPercentage: weight as number,
|
||||
name: (name as string) ?? UNKNOWN_KEY,
|
||||
valueInBaseCurrency: weight as number
|
||||
valueInBaseCurrency: undefined
|
||||
};
|
||||
}
|
||||
);
|
||||
|
@ -70,7 +70,7 @@ export class TwitterBotService {
|
||||
await this.twitterClient.v2.tweet(status);
|
||||
|
||||
Logger.log(
|
||||
`Fear & Greed Index has been tweeted: https://twitter.com/ghostfolio_/status/${createdTweet.id}`,
|
||||
`Fear & Greed Index has been posted: https://x.com/ghostfolio_/status/${createdTweet.id}`,
|
||||
'TwitterBotService'
|
||||
);
|
||||
}
|
||||
|
@ -36,6 +36,10 @@
|
||||
"ngswConfigPath": "apps/client/ngsw-config.json"
|
||||
},
|
||||
"configurations": {
|
||||
"development-ca": {
|
||||
"baseHref": "/ca/",
|
||||
"localize": ["ca"]
|
||||
},
|
||||
"development-de": {
|
||||
"baseHref": "/de/",
|
||||
"localize": ["de"]
|
||||
@ -212,6 +216,7 @@
|
||||
"includeContext": true,
|
||||
"outputPath": "src/locales",
|
||||
"targetFiles": [
|
||||
"messages.ca.xlf",
|
||||
"messages.de.xlf",
|
||||
"messages.es.xlf",
|
||||
"messages.fr.xlf",
|
||||
@ -240,6 +245,10 @@
|
||||
},
|
||||
"i18n": {
|
||||
"locales": {
|
||||
"ca": {
|
||||
"baseHref": "/ca/",
|
||||
"translation": "apps/client/src/locales/messages.ca.xlf"
|
||||
},
|
||||
"de": {
|
||||
"baseHref": "/de/",
|
||||
"translation": "apps/client/src/locales/messages.de.xlf"
|
||||
|
@ -138,13 +138,18 @@
|
||||
<li>
|
||||
<a
|
||||
class="align-items-baseline d-flex"
|
||||
href="https://twitter.com/ghostfolio_"
|
||||
href="https://x.com/ghostfolio_"
|
||||
target="_blank"
|
||||
title="Follow Ghostfolio on X (formerly Twitter)"
|
||||
>X (formerly Twitter)<ion-icon class="ml-1" name="open-outline"
|
||||
/></a>
|
||||
</li>
|
||||
<li> </li>
|
||||
<!--
|
||||
<li>
|
||||
<a href="../ca" title="Ghostfolio en català">Català</a>
|
||||
</li>
|
||||
-->
|
||||
<li>
|
||||
<a href="../de" title="Ghostfolio in Deutsch">Deutsch</a>
|
||||
</li>
|
||||
|
@ -6,8 +6,14 @@ import {
|
||||
ghostfolioScraperApiSymbolPrefix
|
||||
} from '@ghostfolio/common/config';
|
||||
import { getDateFormatString } from '@ghostfolio/common/helper';
|
||||
import { Filter, UniqueAsset, User } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
Filter,
|
||||
InfoItem,
|
||||
UniqueAsset,
|
||||
User
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
|
||||
import { SelectionModel } from '@angular/cdk/collections';
|
||||
@ -97,22 +103,11 @@ export class AdminMarketDataComponent
|
||||
new MatTableDataSource();
|
||||
public defaultDateFormat: string;
|
||||
public deviceType: string;
|
||||
public displayedColumns = [
|
||||
'select',
|
||||
'nameWithSymbol',
|
||||
'dataSource',
|
||||
'assetClass',
|
||||
'assetSubClass',
|
||||
'date',
|
||||
'activitiesCount',
|
||||
'marketDataItemCount',
|
||||
'sectorsCount',
|
||||
'countriesCount',
|
||||
'comment',
|
||||
'actions'
|
||||
];
|
||||
public displayedColumns: string[] = [];
|
||||
public filters$ = new Subject<Filter[]>();
|
||||
public ghostfolioScraperApiSymbolPrefix = ghostfolioScraperApiSymbolPrefix;
|
||||
public hasPermissionForSubscription: boolean;
|
||||
public info: InfoItem;
|
||||
public isLoading = false;
|
||||
public isUUID = isUUID;
|
||||
public placeholder = '';
|
||||
@ -124,7 +119,7 @@ export class AdminMarketDataComponent
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
public constructor(
|
||||
private adminMarketDataService: AdminMarketDataService,
|
||||
public adminMarketDataService: AdminMarketDataService,
|
||||
private adminService: AdminService,
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
@ -134,6 +129,33 @@ export class AdminMarketDataComponent
|
||||
private router: Router,
|
||||
private userService: UserService
|
||||
) {
|
||||
this.info = this.dataService.fetchInfo();
|
||||
|
||||
this.hasPermissionForSubscription = hasPermission(
|
||||
this.info?.globalPermissions,
|
||||
permissions.enableSubscription
|
||||
);
|
||||
|
||||
this.displayedColumns = [
|
||||
'select',
|
||||
'nameWithSymbol',
|
||||
'dataSource',
|
||||
'assetClass',
|
||||
'assetSubClass',
|
||||
'date',
|
||||
'activitiesCount',
|
||||
'marketDataItemCount',
|
||||
'sectorsCount',
|
||||
'countriesCount'
|
||||
];
|
||||
|
||||
if (this.hasPermissionForSubscription) {
|
||||
this.displayedColumns.push('isUsedByUsersWithSubscription');
|
||||
}
|
||||
|
||||
this.displayedColumns.push('comment');
|
||||
this.displayedColumns.push('actions');
|
||||
|
||||
this.route.queryParams
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((params) => {
|
||||
|
@ -24,11 +24,11 @@
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell></th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
@if (
|
||||
!(
|
||||
element.activitiesCount !== 0 ||
|
||||
element.isBenchmark ||
|
||||
element.symbol.startsWith(ghostfolioScraperApiSymbolPrefix)
|
||||
)
|
||||
adminMarketDataService.hasPermissionToDeleteAssetProfile({
|
||||
activitiesCount: element.activitiesCount,
|
||||
isBenchmark: element.isBenchmark,
|
||||
symbol: element.symbol
|
||||
})
|
||||
) {
|
||||
<mat-checkbox
|
||||
color="primary"
|
||||
@ -144,6 +144,15 @@
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="isUsedByUsersWithSubscription">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell></th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
@if (element.isUsedByUsersWithSubscription) {
|
||||
<gf-premium-indicator [enableLink]="false" />
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="comment">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell></th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
@ -178,7 +187,7 @@
|
||||
[disabled]="!selection.hasValue()"
|
||||
(click)="onDeleteAssetProfiles()"
|
||||
>
|
||||
<ng-container i18n>Delete Asset Profiles</ng-container>
|
||||
<ng-container i18n>Delete Profiles</ng-container>
|
||||
</button>
|
||||
</mat-menu>
|
||||
</th>
|
||||
@ -209,9 +218,11 @@
|
||||
<button
|
||||
mat-menu-item
|
||||
[disabled]="
|
||||
element.activitiesCount !== 0 ||
|
||||
element.isBenchmark ||
|
||||
element.symbol.startsWith(ghostfolioScraperApiSymbolPrefix)
|
||||
!adminMarketDataService.hasPermissionToDeleteAssetProfile({
|
||||
activitiesCount: element.activitiesCount,
|
||||
isBenchmark: element.isBenchmark,
|
||||
symbol: element.symbol
|
||||
})
|
||||
"
|
||||
(click)="
|
||||
onDeleteAssetProfile({
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
|
||||
import { GfActivitiesFilterComponent } from '@ghostfolio/ui/activities-filter';
|
||||
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
|
||||
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
@ -24,6 +25,7 @@ import { GfCreateAssetProfileDialogModule } from './create-asset-profile-dialog/
|
||||
GfActivitiesFilterComponent,
|
||||
GfAssetProfileDialogModule,
|
||||
GfCreateAssetProfileDialogModule,
|
||||
GfPremiumIndicatorComponent,
|
||||
GfSymbolModule,
|
||||
MatButtonModule,
|
||||
MatCheckboxModule,
|
||||
|
@ -1,5 +1,10 @@
|
||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config';
|
||||
import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
AdminMarketDataItem,
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Injectable } from '@angular/core';
|
||||
import { EMPTY, catchError, finalize, forkJoin, takeUntil } from 'rxjs';
|
||||
@ -26,7 +31,7 @@ export class AdminMarketDataService {
|
||||
|
||||
public deleteAssetProfiles(uniqueAssets: UniqueAsset[]) {
|
||||
const confirmation = confirm(
|
||||
$localize`Do you really want to delete these asset profiles?`
|
||||
$localize`Do you really want to delete these profiles?`
|
||||
);
|
||||
|
||||
if (confirmation) {
|
||||
@ -37,7 +42,7 @@ export class AdminMarketDataService {
|
||||
forkJoin(deleteRequests)
|
||||
.pipe(
|
||||
catchError(() => {
|
||||
alert($localize`Oops! Could not delete asset profiles.`);
|
||||
alert($localize`Oops! Could not delete profiles.`);
|
||||
|
||||
return EMPTY;
|
||||
}),
|
||||
@ -50,4 +55,17 @@ export class AdminMarketDataService {
|
||||
.subscribe(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
public hasPermissionToDeleteAssetProfile({
|
||||
activitiesCount,
|
||||
isBenchmark,
|
||||
symbol
|
||||
}: Pick<AdminMarketDataItem, 'activitiesCount' | 'isBenchmark' | 'symbol'>) {
|
||||
return (
|
||||
activitiesCount === 0 &&
|
||||
!isBenchmark &&
|
||||
!isCurrency(getCurrencyFromSymbol(symbol)) &&
|
||||
!symbol.startsWith(ghostfolioScraperApiSymbolPrefix)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -87,7 +87,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
public constructor(
|
||||
private adminMarketDataService: AdminMarketDataService,
|
||||
public adminMarketDataService: AdminMarketDataService,
|
||||
private adminService: AdminService,
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
@Inject(MAT_DIALOG_DATA) public data: AssetProfileDialogParams,
|
||||
|
@ -48,9 +48,11 @@
|
||||
mat-menu-item
|
||||
type="button"
|
||||
[disabled]="
|
||||
assetProfile?.activitiesCount !== 0 ||
|
||||
isBenchmark ||
|
||||
data.symbol.startsWith(ghostfolioScraperApiSymbolPrefix)
|
||||
!adminMarketDataService.hasPermissionToDeleteAssetProfile({
|
||||
activitiesCount: assetProfile?.activitiesCount,
|
||||
isBenchmark: isBenchmark,
|
||||
symbol: data.symbol
|
||||
})
|
||||
"
|
||||
(click)="
|
||||
onDeleteProfileData({
|
||||
|
@ -12,11 +12,7 @@
|
||||
<div class="d-flex my-3">
|
||||
<div class="w-50" i18n>User Count</div>
|
||||
<div class="w-50">
|
||||
<gf-value
|
||||
[locale]="user?.settings?.locale"
|
||||
[precision]="0"
|
||||
[value]="userCount"
|
||||
/>
|
||||
<gf-value [locale]="user?.settings?.locale" [value]="userCount" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex my-3">
|
||||
@ -24,7 +20,6 @@
|
||||
<div class="w-50">
|
||||
<gf-value
|
||||
[locale]="user?.settings?.locale"
|
||||
[precision]="0"
|
||||
[value]="transactionCount"
|
||||
/>
|
||||
@if (transactionCount && userCount) {
|
||||
|
@ -4,6 +4,7 @@ import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-foote
|
||||
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { NUMERICAL_PRECISION_THRESHOLD } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
DataProviderInfo,
|
||||
@ -84,18 +85,22 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
||||
public dataProviderInfo: DataProviderInfo;
|
||||
public dataSource: MatTableDataSource<Activity>;
|
||||
public dividendInBaseCurrency: number;
|
||||
public dividendInBaseCurrencyPrecision = 2;
|
||||
public dividendYieldPercentWithCurrencyEffect: number;
|
||||
public feeInBaseCurrency: number;
|
||||
public firstBuyDate: string;
|
||||
public historicalDataItems: LineChartItem[];
|
||||
public investment: number;
|
||||
public investmentPrecision = 2;
|
||||
public marketPrice: number;
|
||||
public maxPrice: number;
|
||||
public minPrice: number;
|
||||
public netPerformance: number;
|
||||
public netPerformancePrecision = 2;
|
||||
public netPerformancePercent: number;
|
||||
public netPerformancePercentWithCurrencyEffect: number;
|
||||
public netPerformanceWithCurrencyEffect: number;
|
||||
public netPerformanceWithCurrencyEffectPrecision = 2;
|
||||
public quantity: number;
|
||||
public quantityPrecision = 2;
|
||||
public reportDataGlitchMail: string;
|
||||
@ -161,10 +166,20 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
||||
this.dataProviderInfo = dataProviderInfo;
|
||||
this.dataSource = new MatTableDataSource(orders.reverse());
|
||||
this.dividendInBaseCurrency = dividendInBaseCurrency;
|
||||
|
||||
if (
|
||||
this.data.deviceType === 'mobile' &&
|
||||
this.dividendInBaseCurrency >= NUMERICAL_PRECISION_THRESHOLD
|
||||
) {
|
||||
this.dividendInBaseCurrencyPrecision = 0;
|
||||
}
|
||||
|
||||
this.dividendYieldPercentWithCurrencyEffect =
|
||||
dividendYieldPercentWithCurrencyEffect;
|
||||
|
||||
this.feeInBaseCurrency = feeInBaseCurrency;
|
||||
this.firstBuyDate = firstBuyDate;
|
||||
|
||||
this.historicalDataItems = historicalData.map(
|
||||
({ averagePrice, date, marketPrice }) => {
|
||||
this.benchmarkDataItems.push({
|
||||
@ -178,17 +193,58 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
this.investment = investment;
|
||||
|
||||
if (
|
||||
this.data.deviceType === 'mobile' &&
|
||||
this.investment >= NUMERICAL_PRECISION_THRESHOLD
|
||||
) {
|
||||
this.investmentPrecision = 0;
|
||||
}
|
||||
|
||||
this.marketPrice = marketPrice;
|
||||
this.maxPrice = maxPrice;
|
||||
this.minPrice = minPrice;
|
||||
this.netPerformance = netPerformance;
|
||||
|
||||
if (
|
||||
this.data.deviceType === 'mobile' &&
|
||||
this.netPerformance >= NUMERICAL_PRECISION_THRESHOLD
|
||||
) {
|
||||
this.netPerformancePrecision = 0;
|
||||
}
|
||||
|
||||
this.netPerformancePercent = netPerformancePercent;
|
||||
|
||||
this.netPerformancePercentWithCurrencyEffect =
|
||||
netPerformancePercentWithCurrencyEffect;
|
||||
|
||||
this.netPerformanceWithCurrencyEffect =
|
||||
netPerformanceWithCurrencyEffect;
|
||||
|
||||
if (
|
||||
this.data.deviceType === 'mobile' &&
|
||||
this.netPerformanceWithCurrencyEffect >=
|
||||
NUMERICAL_PRECISION_THRESHOLD
|
||||
) {
|
||||
this.netPerformanceWithCurrencyEffectPrecision = 0;
|
||||
}
|
||||
|
||||
this.quantity = quantity;
|
||||
|
||||
if (Number.isInteger(this.quantity)) {
|
||||
this.quantityPrecision = 0;
|
||||
} else if (this.SymbolProfile?.assetSubClass === 'CRYPTOCURRENCY') {
|
||||
if (this.quantity < 1) {
|
||||
this.quantityPrecision = 7;
|
||||
} else if (this.quantity < 1000) {
|
||||
this.quantityPrecision = 5;
|
||||
} else if (this.quantity >= 10000000) {
|
||||
this.quantityPrecision = 0;
|
||||
}
|
||||
}
|
||||
|
||||
this.reportDataGlitchMail = `mailto:hi@ghostfol.io?Subject=Ghostfolio Data Glitch Report&body=Hello%0D%0DI would like to report a data glitch for%0D%0DSymbol: ${SymbolProfile?.symbol}%0DData Source: ${SymbolProfile?.dataSource}%0D%0DAdditional notes:%0D%0DCan you please take a look?%0D%0DKind regards`;
|
||||
this.sectors = {};
|
||||
this.SymbolProfile = SymbolProfile;
|
||||
@ -282,18 +338,6 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
);
|
||||
|
||||
if (Number.isInteger(this.quantity)) {
|
||||
this.quantityPrecision = 0;
|
||||
} else if (this.SymbolProfile?.assetSubClass === 'CRYPTOCURRENCY') {
|
||||
if (this.quantity < 1) {
|
||||
this.quantityPrecision = 7;
|
||||
} else if (this.quantity < 1000) {
|
||||
this.quantityPrecision = 5;
|
||||
} else if (this.quantity > 10000000) {
|
||||
this.quantityPrecision = 0;
|
||||
}
|
||||
}
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}
|
||||
);
|
||||
|
@ -47,6 +47,7 @@
|
||||
[colorizeSign]="true"
|
||||
[isCurrency]="true"
|
||||
[locale]="data.locale"
|
||||
[precision]="netPerformanceWithCurrencyEffectPrecision"
|
||||
[unit]="data.baseCurrency"
|
||||
[value]="netPerformanceWithCurrencyEffect"
|
||||
>Change with currency effect</gf-value
|
||||
@ -58,6 +59,7 @@
|
||||
[colorizeSign]="true"
|
||||
[isCurrency]="true"
|
||||
[locale]="data.locale"
|
||||
[precision]="netPerformancePrecision"
|
||||
[unit]="data.baseCurrency"
|
||||
[value]="netPerformance"
|
||||
>Change</gf-value
|
||||
@ -160,6 +162,7 @@
|
||||
size="medium"
|
||||
[isCurrency]="true"
|
||||
[locale]="data.locale"
|
||||
[precision]="investmentPrecision"
|
||||
[unit]="data.baseCurrency"
|
||||
[value]="investment"
|
||||
>Investment</gf-value
|
||||
@ -172,6 +175,7 @@
|
||||
size="medium"
|
||||
[isCurrency]="true"
|
||||
[locale]="data.locale"
|
||||
[precision]="dividendInBaseCurrencyPrecision"
|
||||
[unit]="data.baseCurrency"
|
||||
[value]="dividendInBaseCurrency"
|
||||
>Dividend</gf-value
|
||||
|
@ -1,11 +1,21 @@
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { PortfolioPosition, User } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
PortfolioPosition,
|
||||
UniqueAsset,
|
||||
User
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { HoldingType, ToggleOption } from '@ghostfolio/common/types';
|
||||
import {
|
||||
HoldingType,
|
||||
HoldingViewMode,
|
||||
ToggleOption
|
||||
} from '@ghostfolio/common/types';
|
||||
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { FormControl } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
@ -18,6 +28,7 @@ import { takeUntil } from 'rxjs/operators';
|
||||
export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||
public deviceType: string;
|
||||
public hasImpersonationId: boolean;
|
||||
public hasPermissionToAccessHoldingsChart: boolean;
|
||||
public hasPermissionToCreateOrder: boolean;
|
||||
public holdings: PortfolioPosition[];
|
||||
public holdingType: HoldingType = 'ACTIVE';
|
||||
@ -26,6 +37,7 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||
{ label: $localize`Closed`, value: 'CLOSED' }
|
||||
];
|
||||
public user: User;
|
||||
public viewModeFormControl = new FormControl<HoldingViewMode>('TABLE');
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
@ -34,6 +46,7 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||
private dataService: DataService,
|
||||
private deviceService: DeviceDetectorService,
|
||||
private impersonationStorageService: ImpersonationStorageService,
|
||||
private router: Router,
|
||||
private userService: UserService
|
||||
) {}
|
||||
|
||||
@ -53,20 +66,17 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||
if (state?.user) {
|
||||
this.user = state.user;
|
||||
|
||||
this.hasPermissionToAccessHoldingsChart = hasPermission(
|
||||
this.user.permissions,
|
||||
permissions.accessHoldingsChart
|
||||
);
|
||||
|
||||
this.hasPermissionToCreateOrder = hasPermission(
|
||||
this.user.permissions,
|
||||
permissions.createOrder
|
||||
);
|
||||
|
||||
this.holdings = undefined;
|
||||
|
||||
this.fetchHoldings()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ holdings }) => {
|
||||
this.holdings = holdings;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
this.initialize();
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}
|
||||
@ -76,15 +86,15 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||
public onChangeHoldingType(aHoldingType: HoldingType) {
|
||||
this.holdingType = aHoldingType;
|
||||
|
||||
this.holdings = undefined;
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
this.fetchHoldings()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ holdings }) => {
|
||||
this.holdings = holdings;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
public onSymbolClicked({ dataSource, symbol }: UniqueAsset) {
|
||||
if (dataSource && symbol) {
|
||||
this.router.navigate([], {
|
||||
queryParams: { dataSource, symbol, holdingDetailDialog: true }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
@ -104,4 +114,27 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||
range: this.user?.settings?.dateRange
|
||||
});
|
||||
}
|
||||
|
||||
private initialize() {
|
||||
this.viewModeFormControl.disable();
|
||||
|
||||
if (
|
||||
this.hasPermissionToAccessHoldingsChart &&
|
||||
this.holdingType === 'ACTIVE'
|
||||
) {
|
||||
this.viewModeFormControl.enable();
|
||||
} else if (this.holdingType === 'CLOSED') {
|
||||
this.viewModeFormControl.setValue('TABLE');
|
||||
}
|
||||
|
||||
this.holdings = undefined;
|
||||
|
||||
this.fetchHoldings()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ holdings }) => {
|
||||
this.holdings = holdings;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -6,33 +6,62 @@
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-lg">
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-toggle
|
||||
class="d-none d-lg-block"
|
||||
[defaultValue]="holdingType"
|
||||
[isLoading]="false"
|
||||
[options]="holdingTypeOptions"
|
||||
(change)="onChangeHoldingType($event.value)"
|
||||
/>
|
||||
</div>
|
||||
<gf-holdings-table
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[deviceType]="deviceType"
|
||||
[hasPermissionToCreateActivity]="hasPermissionToCreateOrder"
|
||||
[holdings]="holdings"
|
||||
[locale]="user?.settings?.locale"
|
||||
/>
|
||||
@if (hasPermissionToCreateOrder && holdings?.length > 0) {
|
||||
<div class="text-center">
|
||||
<a
|
||||
class="mt-3"
|
||||
i18n
|
||||
mat-stroked-button
|
||||
[routerLink]="['/portfolio', 'activities']"
|
||||
>Manage Activities</a
|
||||
>
|
||||
<div class="d-flex">
|
||||
@if (user?.settings?.isExperimentalFeatures) {
|
||||
<div class="d-flex">
|
||||
<div class="d-none d-lg-block">
|
||||
<mat-button-toggle-group
|
||||
[formControl]="viewModeFormControl"
|
||||
[hideSingleSelectionIndicator]="true"
|
||||
>
|
||||
<mat-button-toggle i18n-title title="Table" value="TABLE">
|
||||
<ion-icon name="reorder-four-outline" />
|
||||
</mat-button-toggle>
|
||||
<mat-button-toggle i18n-title title="Chart" value="CHART">
|
||||
<ion-icon name="grid-outline" />
|
||||
</mat-button-toggle>
|
||||
</mat-button-toggle-group>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="align-items-center d-flex flex-grow-1 justify-content-end">
|
||||
<gf-toggle
|
||||
class="d-none d-lg-block"
|
||||
[defaultValue]="holdingType"
|
||||
[isLoading]="false"
|
||||
[options]="holdingTypeOptions"
|
||||
(change)="onChangeHoldingType($event.value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@if (viewModeFormControl.value === 'CHART') {
|
||||
<gf-treemap-chart
|
||||
class="mt-3"
|
||||
cursor="pointer"
|
||||
[holdings]="holdings"
|
||||
(treemapChartClicked)="onSymbolClicked($event)"
|
||||
/>
|
||||
}
|
||||
<div [ngClass]="{ 'd-none': viewModeFormControl.value !== 'TABLE' }">
|
||||
<gf-holdings-table
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[deviceType]="deviceType"
|
||||
[hasPermissionToCreateActivity]="hasPermissionToCreateOrder"
|
||||
[holdings]="holdings"
|
||||
[locale]="user?.settings?.locale"
|
||||
/>
|
||||
@if (hasPermissionToCreateOrder && holdings?.length > 0) {
|
||||
<div class="text-center">
|
||||
<a
|
||||
class="mt-3"
|
||||
i18n
|
||||
mat-stroked-button
|
||||
[routerLink]="['/portfolio', 'activities']"
|
||||
>Manage Activities</a
|
||||
>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,9 +1,12 @@
|
||||
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
|
||||
import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table';
|
||||
import { GfTreemapChartComponent } from '@ghostfolio/ui/treemap-chart';
|
||||
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatButtonToggleModule } from '@angular/material/button-toggle';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
import { HomeHoldingsComponent } from './home-holdings.component';
|
||||
@ -12,9 +15,13 @@ import { HomeHoldingsComponent } from './home-holdings.component';
|
||||
declarations: [HomeHoldingsComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
GfHoldingsTableComponent,
|
||||
GfToggleModule,
|
||||
GfTreemapChartComponent,
|
||||
MatButtonModule,
|
||||
MatButtonToggleModule,
|
||||
ReactiveFormsModule,
|
||||
RouterModule
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
|
@ -1,3 +1,9 @@
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
.mat-button-toggle-group {
|
||||
.mat-button-toggle-appearance-standard {
|
||||
--mat-standard-button-toggle-height: 1.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ import { LayoutService } from '@ghostfolio/client/core/layout.service';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { NUMERICAL_PRECISION_THRESHOLD } from '@ghostfolio/common/config';
|
||||
import {
|
||||
LineChartItem,
|
||||
PortfolioPerformance,
|
||||
@ -34,6 +35,7 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
|
||||
public isAllTimeLow: boolean;
|
||||
public isLoadingPerformance = true;
|
||||
public performance: PortfolioPerformance;
|
||||
public precision = 2;
|
||||
public showDetails = false;
|
||||
public unit: string;
|
||||
public user: User;
|
||||
@ -67,6 +69,12 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
|
||||
public ngOnInit() {
|
||||
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||
|
||||
this.showDetails =
|
||||
!this.user.settings.isRestrictedView &&
|
||||
this.user.settings.viewMode !== 'ZEN';
|
||||
|
||||
this.unit = this.showDetails ? this.user.settings.baseCurrency : '%';
|
||||
|
||||
this.impersonationStorageService
|
||||
.onChangeHasImpersonation()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
@ -81,12 +89,6 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
|
||||
.subscribe(() => {
|
||||
this.update();
|
||||
});
|
||||
|
||||
this.showDetails =
|
||||
!this.user.settings.isRestrictedView &&
|
||||
this.user.settings.viewMode !== 'ZEN';
|
||||
|
||||
this.unit = this.showDetails ? this.user.settings.baseCurrency : '%';
|
||||
}
|
||||
|
||||
public onChangeDateRange(dateRange: DateRange) {
|
||||
@ -134,6 +136,14 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
);
|
||||
|
||||
if (
|
||||
this.deviceType === 'mobile' &&
|
||||
this.performance.currentValueInBaseCurrency >=
|
||||
NUMERICAL_PRECISION_THRESHOLD
|
||||
) {
|
||||
this.precision = 0;
|
||||
}
|
||||
|
||||
this.isLoadingPerformance = false;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
|
@ -88,6 +88,7 @@
|
||||
[isLoading]="isLoadingPerformance"
|
||||
[locale]="user?.settings?.locale"
|
||||
[performance]="performance"
|
||||
[precision]="precision"
|
||||
[showDetails]="showDetails"
|
||||
[unit]="unit"
|
||||
/>
|
||||
|
@ -14,7 +14,6 @@ import {
|
||||
ElementRef,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnInit,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { CountUp } from 'countup.js';
|
||||
@ -26,7 +25,7 @@ import { isNumber } from 'lodash';
|
||||
templateUrl: './portfolio-performance.component.html',
|
||||
styleUrls: ['./portfolio-performance.component.scss']
|
||||
})
|
||||
export class PortfolioPerformanceComponent implements OnChanges, OnInit {
|
||||
export class PortfolioPerformanceComponent implements OnChanges {
|
||||
@Input() deviceType: string;
|
||||
@Input() errors: ResponseError['errors'];
|
||||
@Input() isAllTimeHigh: boolean;
|
||||
@ -34,6 +33,7 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
|
||||
@Input() isLoading: boolean;
|
||||
@Input() locale = getLocale();
|
||||
@Input() performance: PortfolioPerformance;
|
||||
@Input() precision: number;
|
||||
@Input() showDetails: boolean;
|
||||
@Input() unit: string;
|
||||
|
||||
@ -41,9 +41,9 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
|
||||
|
||||
public constructor() {}
|
||||
|
||||
public ngOnInit() {}
|
||||
|
||||
public ngOnChanges() {
|
||||
this.precision = this.precision >= 0 ? this.precision : 2;
|
||||
|
||||
if (this.isLoading) {
|
||||
if (this.value?.nativeElement) {
|
||||
this.value.nativeElement.innerHTML = '';
|
||||
@ -52,11 +52,7 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
|
||||
if (isNumber(this.performance?.currentValueInBaseCurrency)) {
|
||||
new CountUp('value', this.performance?.currentValueInBaseCurrency, {
|
||||
decimal: getNumberFormatDecimal(this.locale),
|
||||
decimalPlaces:
|
||||
this.deviceType === 'mobile' &&
|
||||
this.performance?.currentValueInBaseCurrency >= 100000
|
||||
? 0
|
||||
: 2,
|
||||
decimalPlaces: this.precision,
|
||||
duration: 1,
|
||||
separator: getNumberFormatGroup(this.locale)
|
||||
}).start();
|
||||
|
@ -47,6 +47,7 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit {
|
||||
public isWebAuthnEnabled: boolean;
|
||||
public language = document.documentElement.lang;
|
||||
public locales = [
|
||||
'ca',
|
||||
'de',
|
||||
'de-CH',
|
||||
'en-GB',
|
||||
|
@ -70,6 +70,14 @@
|
||||
"
|
||||
>
|
||||
<mat-option [value]="null" />
|
||||
@if (user?.settings?.isExperimentalFeatures) {
|
||||
<!--
|
||||
<mat-option value="de"
|
||||
>Català (<ng-container i18n>Community</ng-container
|
||||
>)</mat-option
|
||||
>
|
||||
-->
|
||||
}
|
||||
<mat-option value="de">Deutsch</mat-option>
|
||||
<mat-option value="en">English</mat-option>
|
||||
@if (user?.settings?.isExperimentalFeatures) {
|
||||
@ -95,10 +103,12 @@
|
||||
>)</mat-option
|
||||
>
|
||||
@if (user?.settings?.isExperimentalFeatures) {
|
||||
<mat-option value="pl"
|
||||
>Polski (<ng-container i18n>Community</ng-container
|
||||
>)</mat-option
|
||||
>
|
||||
<!--
|
||||
<mat-option value="pl"
|
||||
>Polski (<ng-container i18n>Community</ng-container
|
||||
>)</mat-option
|
||||
>
|
||||
-->
|
||||
}
|
||||
<mat-option value="pt"
|
||||
>Português (<ng-container i18n>Community</ng-container
|
||||
@ -196,25 +206,23 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@if (hasPermissionToUpdateUserSettings) {
|
||||
<div class="align-items-center d-flex mt-4 py-1">
|
||||
<div class="pr-1 w-50">
|
||||
<div i18n>Experimental Features</div>
|
||||
<div class="hint-text text-muted" i18n>
|
||||
Sneak peek at upcoming functionality
|
||||
</div>
|
||||
</div>
|
||||
<div class="pl-1 w-50">
|
||||
<mat-slide-toggle
|
||||
color="primary"
|
||||
hideIcon="true"
|
||||
[checked]="user.settings.isExperimentalFeatures"
|
||||
[disabled]="!hasPermissionToUpdateUserSettings"
|
||||
(change)="onExperimentalFeaturesChange($event)"
|
||||
/>
|
||||
<div class="align-items-center d-flex mt-4 py-1">
|
||||
<div class="pr-1 w-50">
|
||||
<div i18n>Experimental Features</div>
|
||||
<div class="hint-text text-muted" i18n>
|
||||
Sneak peek at upcoming functionality
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="pl-1 w-50">
|
||||
<mat-slide-toggle
|
||||
color="primary"
|
||||
hideIcon="true"
|
||||
[checked]="user.settings.isExperimentalFeatures"
|
||||
[disabled]="!hasPermissionToUpdateUserSettings"
|
||||
(change)="onExperimentalFeaturesChange($event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="align-items-center d-flex mt-4 py-1">
|
||||
<div class="pr-1 w-50">
|
||||
Ghostfolio <ng-container i18n>User ID</ng-container>
|
||||
|
@ -55,7 +55,7 @@
|
||||
>
|
||||
community, post to
|
||||
<a
|
||||
href="https://twitter.com/ghostfolio_"
|
||||
href="https://x.com/ghostfolio_"
|
||||
title="Post to Ghostfolio on X (formerly Twitter)"
|
||||
>@ghostfolio_</a
|
||||
>
|
||||
@ -75,7 +75,7 @@
|
||||
<p class="align-items-center d-flex justify-content-center">
|
||||
<a
|
||||
class="mx-2"
|
||||
href="https://twitter.com/ghostfolio_"
|
||||
href="https://x.com/ghostfolio_"
|
||||
mat-icon-button
|
||||
title="Follow Ghostfolio on X (formerly Twitter)"
|
||||
>
|
||||
|
@ -131,9 +131,9 @@
|
||||
</p>
|
||||
<p>
|
||||
Du erreichst mich per E-Mail unter
|
||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> oder auf
|
||||
Twitter
|
||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>.
|
||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> oder auf X
|
||||
(ehemals Twitter)
|
||||
<a href="https://x.com/ghostfolio_">@ghostfolio_</a>.
|
||||
</p>
|
||||
<p>
|
||||
Ich freue mich, von dir zu hören.<br />
|
||||
|
@ -126,8 +126,9 @@
|
||||
</p>
|
||||
<p>
|
||||
You can reach me by e-mail at
|
||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
|
||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>.
|
||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on X
|
||||
(formerly Twitter)
|
||||
<a href="https://x.com/ghostfolio_">@ghostfolio_</a>.
|
||||
</p>
|
||||
<p>
|
||||
I look forward to hearing from you.<br />
|
||||
|
@ -39,7 +39,7 @@
|
||||
</p>
|
||||
<p>
|
||||
At the end of 2021, Ghostfolio reached an important milestone:
|
||||
<a href="https://twitter.com/ghostfolio_/status/1470075774640218121"
|
||||
<a href="https://x.com/ghostfolio_/status/1470075774640218121"
|
||||
>100 stars</a
|
||||
>
|
||||
on GitHub. This is really exciting with almost no marketing. I am a
|
||||
@ -100,9 +100,10 @@
|
||||
of users. In the future, I would like to involve more contributors
|
||||
to further extend the functionality of Ghostfolio (e.g. with new
|
||||
reports). Get in touch with me by e-mail at
|
||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
|
||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a> if
|
||||
you are interested, I’m happy to discuss ideas.
|
||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on X
|
||||
(formerly Twitter)
|
||||
<a href="https://x.com/ghostfolio_">@ghostfolio_</a> if you are
|
||||
interested, I’m happy to discuss ideas.
|
||||
</p>
|
||||
<p>
|
||||
I would like to say thank you for all your feedback and support
|
||||
|
@ -90,8 +90,9 @@
|
||||
<p>
|
||||
If you would like to provide feedback or get involved in further
|
||||
development of Ghostfolio, please get in touch by e-mail via
|
||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
|
||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>.
|
||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on X
|
||||
(formerly Twitter)
|
||||
<a href="https://x.com/ghostfolio_">@ghostfolio_</a>.
|
||||
</p>
|
||||
<p>
|
||||
I look forward to hearing from you.<br />
|
||||
|
@ -34,9 +34,9 @@
|
||||
>Slack</a
|
||||
>
|
||||
as well as 100 followers on
|
||||
<a href="https://twitter.com/ghostfolio_">Twitter</a>. If you have
|
||||
not joined yet, this is a good time to make sure you do not miss out
|
||||
on any future updates.
|
||||
<a href="https://x.com/ghostfolio_">Twitter</a>. If you have not
|
||||
joined yet, this is a good time to make sure you do not miss out on
|
||||
any future updates.
|
||||
</p>
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
@ -91,9 +91,10 @@
|
||||
engineering to realize the full potential of open source software.
|
||||
If you are a web developer and interested in personal finance,
|
||||
please get in touch by e-mail via
|
||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
|
||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>. We
|
||||
are happy to discuss ideas.
|
||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on X
|
||||
(formerly Twitter)
|
||||
<a href="https://x.com/ghostfolio_">@ghostfolio_</a>. We are
|
||||
happy to discuss ideas.
|
||||
</p>
|
||||
<p>
|
||||
We would like to say thank you for all your feedback and support
|
||||
|
@ -84,8 +84,8 @@
|
||||
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
||||
>Slack</a
|
||||
>
|
||||
community or get in touch on Twitter
|
||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a> or by
|
||||
community or get in touch on X (formerly Twitter)
|
||||
<a href="https://x.com/ghostfolio_">@ghostfolio_</a> or by
|
||||
e-mail via <a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a>.
|
||||
</p>
|
||||
<p>
|
||||
|
@ -90,8 +90,8 @@
|
||||
target="_blank"
|
||||
>Slack</a
|
||||
>
|
||||
community or via Twitter
|
||||
<a href="https://twitter.com/ghostfolio_" target="_blank"
|
||||
community or via X (formerly Twitter)
|
||||
<a href="https://x.com/ghostfolio_" target="_blank"
|
||||
>@ghostfolio_</a
|
||||
>. We look forward to hearing from you!
|
||||
</p>
|
||||
|
@ -122,8 +122,9 @@
|
||||
>Slack</a
|
||||
>
|
||||
community or connect with
|
||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a> on
|
||||
Twitter. We are happy to discuss ideas and get you involved.
|
||||
<a href="https://x.com/ghostfolio_">@ghostfolio_</a> on X
|
||||
(formerly Twitter). We are happy to discuss ideas and get you
|
||||
involved.
|
||||
</p>
|
||||
<p>Thank you for all your feedback and support.</p>
|
||||
<p>
|
||||
|
@ -25,7 +25,7 @@
|
||||
<p>
|
||||
OSS Friends started as a simple
|
||||
<a
|
||||
href="https://twitter.com/formbricks/status/1660735970281508878"
|
||||
href="https://x.com/formbricks/status/1660735970281508878"
|
||||
target="_blank"
|
||||
>post</a
|
||||
>
|
||||
|
@ -123,7 +123,7 @@
|
||||
</li>
|
||||
<li>
|
||||
On
|
||||
<a href="https://twitter.com/ghostfolio_" target="_blank">X</a>
|
||||
<a href="https://x.com/ghostfolio_" target="_blank">X</a>
|
||||
(formerly Twitter), over
|
||||
<strong>300 investors and personal finance enthusiasts</strong>
|
||||
follow Ghostfolio, keen to stay updated on the latest
|
||||
@ -151,7 +151,7 @@
|
||||
<p>
|
||||
<strong>Follow us on X</strong>: For release updates and market
|
||||
insights, follow
|
||||
<a href="https://twitter.com/ghostfolio_" target="_blank"
|
||||
<a href="https://x.com/ghostfolio_" target="_blank"
|
||||
>Ghostfolio on X</a
|
||||
>. It is the perfect place to stay informed and connect with our
|
||||
team.
|
||||
|
@ -89,7 +89,7 @@
|
||||
>Slack</a
|
||||
>
|
||||
community or get in touch on X
|
||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>.
|
||||
<a href="https://x.com/ghostfolio_">@ghostfolio_</a>.
|
||||
</p>
|
||||
<p>
|
||||
We look forward to hearing from you.<br />
|
||||
|
@ -122,7 +122,7 @@
|
||||
>
|
||||
community,
|
||||
<a
|
||||
href="https://twitter.com/ghostfolio_"
|
||||
href="https://x.com/ghostfolio_"
|
||||
title="Post to Ghostfolio on X (formerly Twitter)"
|
||||
>@ghostfolio_</a
|
||||
>
|
||||
@ -152,7 +152,7 @@
|
||||
>Slack </a
|
||||
>community, post to
|
||||
<a
|
||||
href="https://twitter.com/ghostfolio_"
|
||||
href="https://x.com/ghostfolio_"
|
||||
title="Post to Ghostfolio on X (formerly Twitter)"
|
||||
>@ghostfolio_</a
|
||||
>
|
||||
|
@ -151,7 +151,7 @@
|
||||
>Slack </a
|
||||
>community, post to
|
||||
<a
|
||||
href="https://twitter.com/ghostfolio_"
|
||||
href="https://x.com/ghostfolio_"
|
||||
title="Post to Ghostfolio on X (formerly Twitter)"
|
||||
>@ghostfolio_</a
|
||||
>
|
||||
|
@ -86,6 +86,17 @@
|
||||
</ol>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<mat-card appearance="outlined" class="mb-3">
|
||||
<mat-card-header>
|
||||
<mat-card-title>What is the concept of platforms?</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
Platforms are used to group multiple accounts, such as a savings
|
||||
account and a trading account at the same bank. By assigning accounts
|
||||
to the same platform, they are displayed with a unified icon and you
|
||||
gain insights into platform-specific risks.
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<mat-card appearance="outlined" class="mb-3">
|
||||
<mat-card-header>
|
||||
<mat-card-title>How do I add a new platform?</mat-card-title>
|
||||
@ -186,7 +197,7 @@
|
||||
>Slack </a
|
||||
>community, post to
|
||||
<a
|
||||
href="https://twitter.com/ghostfolio_"
|
||||
href="https://x.com/ghostfolio_"
|
||||
title="Post to Ghostfolio on X (formerly Twitter)"
|
||||
>@ghostfolio_</a
|
||||
>
|
||||
|
@ -145,8 +145,9 @@
|
||||
</h4>
|
||||
<p class="m-0">
|
||||
Check the rate of return of your portfolio for
|
||||
<code>Today</code>, <code>YTD</code>, <code>1Y</code>,
|
||||
<code>5Y</code>, and <code>Max</code>.
|
||||
<code>Today</code>, <code>WTD</code>, <code>MTD</code>,
|
||||
<code>YTD</code>, <code>1Y</code>, <code>5Y</code>, and
|
||||
<code>Max</code>.
|
||||
</p>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
@ -241,9 +242,11 @@
|
||||
<h4 i18n>Multi-Language</h4>
|
||||
<p class="m-0">
|
||||
Use Ghostfolio in multiple languages: English,
|
||||
<!-- Chinese, -->Dutch, French, German, Italian,
|
||||
<!-- Polish, -->Portuguese, Spanish and Turkish are currently
|
||||
supported.
|
||||
<!--Català, -->
|
||||
<!-- Chinese, -->
|
||||
Dutch, French, German, Italian,
|
||||
<!-- Polish, -->
|
||||
Portuguese, Spanish and Turkish are currently supported.
|
||||
</p>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
|
@ -186,6 +186,14 @@
|
||||
title="Sackgeld.com – Apps für ein höheres Sackgeld"
|
||||
></a>
|
||||
</div>
|
||||
<div class="align-items-center col-md-3 d-flex justify-content-center my-1">
|
||||
<a
|
||||
class="d-block logo logo-selfh-st mask"
|
||||
href="https://selfh.st"
|
||||
target="_blank"
|
||||
title="selfh.st — Self-hosted content and software"
|
||||
></a>
|
||||
</div>
|
||||
<div class="col-md-3 d-flex justify-content-center my-1">
|
||||
<a
|
||||
class="d-block logo logo-sourceforge mask"
|
||||
|
@ -80,6 +80,11 @@
|
||||
mask-image: url('/assets/images/logo-sackgeld.png');
|
||||
}
|
||||
|
||||
&.logo-selfh-st {
|
||||
mask-image: url('/assets/images/logo-selfh-st.svg');
|
||||
max-height: 1.25rem;
|
||||
}
|
||||
|
||||
&.logo-sourceforge {
|
||||
mask-image: url('/assets/images/logo-sourceforge.svg');
|
||||
}
|
||||
@ -131,6 +136,7 @@
|
||||
&.logo-privacy-tools,
|
||||
&.logo-reddit,
|
||||
&.logo-sackgeld,
|
||||
&.logo-selfh-st,
|
||||
&.logo-sourceforge,
|
||||
&.logo-umbrel,
|
||||
&.logo-unraid {
|
||||
|
@ -51,6 +51,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
public filteredTagsObservable: Observable<Tag[]> = of([]);
|
||||
public isLoading = false;
|
||||
public isToday = isToday;
|
||||
public mode: 'create' | 'update';
|
||||
public platforms: { id: string; name: string }[];
|
||||
public separatorKeysCodes: number[] = [ENTER, COMMA];
|
||||
public tags: Tag[] = [];
|
||||
@ -71,6 +72,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
) {}
|
||||
|
||||
public ngOnInit() {
|
||||
this.mode = this.data.activity.id ? 'update' : 'create';
|
||||
this.locale = this.data.user?.settings?.locale;
|
||||
this.dateAdapter.setLocale(this.locale);
|
||||
|
||||
@ -92,7 +94,9 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
|
||||
this.activityForm = this.formBuilder.group({
|
||||
accountId: [
|
||||
this.data.accounts.length === 1 && !this.data.activity?.accountId
|
||||
this.data.accounts.length === 1 &&
|
||||
!this.data.activity?.accountId &&
|
||||
this.mode === 'create'
|
||||
? this.data.accounts[0].id
|
||||
: this.data.activity?.accountId,
|
||||
Validators.required
|
||||
@ -479,18 +483,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
};
|
||||
|
||||
try {
|
||||
if (this.data.activity.id) {
|
||||
(activity as UpdateOrderDto).id = this.data.activity.id;
|
||||
|
||||
await validateObjectForForm({
|
||||
classDto: UpdateOrderDto,
|
||||
form: this.activityForm,
|
||||
ignoreFields: ['dataSource', 'date'],
|
||||
object: activity as UpdateOrderDto
|
||||
});
|
||||
|
||||
this.dialogRef.close(activity as UpdateOrderDto);
|
||||
} else {
|
||||
if (this.mode === 'create') {
|
||||
(activity as CreateOrderDto).updateAccountBalance =
|
||||
this.activityForm.get('updateAccountBalance').value;
|
||||
|
||||
@ -502,6 +495,17 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
});
|
||||
|
||||
this.dialogRef.close(activity as CreateOrderDto);
|
||||
} else {
|
||||
(activity as UpdateOrderDto).id = this.data.activity.id;
|
||||
|
||||
await validateObjectForForm({
|
||||
classDto: UpdateOrderDto,
|
||||
form: this.activityForm,
|
||||
ignoreFields: ['dataSource', 'date'],
|
||||
object: activity as UpdateOrderDto
|
||||
});
|
||||
|
||||
this.dialogRef.close(activity as UpdateOrderDto);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
@ -4,10 +4,10 @@
|
||||
(keyup.enter)="activityForm.valid && onSubmit()"
|
||||
(ngSubmit)="onSubmit()"
|
||||
>
|
||||
@if (data.activity.id) {
|
||||
<h1 i18n mat-dialog-title>Update activity</h1>
|
||||
} @else {
|
||||
@if (mode === 'create') {
|
||||
<h1 i18n mat-dialog-title>Add activity</h1>
|
||||
} @else {
|
||||
<h1 i18n mat-dialog-title>Update activity</h1>
|
||||
}
|
||||
<div class="flex-grow-1 py-3" mat-dialog-content>
|
||||
<div class="mb-3">
|
||||
@ -76,16 +76,17 @@
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div [ngClass]="{ 'mb-3': data.activity.id }">
|
||||
<div [ngClass]="{ 'mb-3': mode === 'update' }">
|
||||
<mat-form-field
|
||||
appearance="outline"
|
||||
class="w-100"
|
||||
[ngClass]="{ 'mb-1 without-hint': !data.activity.id }"
|
||||
[ngClass]="{ 'mb-1 without-hint': mode === 'create' }"
|
||||
>
|
||||
<mat-label i18n>Account</mat-label>
|
||||
<mat-select formControlName="accountId">
|
||||
@if (
|
||||
!activityForm.get('accountId').hasValidator(Validators.required)
|
||||
!activityForm.get('accountId').hasValidator(Validators.required) ||
|
||||
(!activityForm.get('accountId').value && mode === 'update')
|
||||
) {
|
||||
<mat-option [value]="null" />
|
||||
}
|
||||
@ -106,7 +107,7 @@
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="mb-3" [ngClass]="{ 'd-none': data.activity.id }">
|
||||
<div class="mb-3" [ngClass]="{ 'd-none': mode === 'update' }">
|
||||
<mat-checkbox color="primary" formControlName="updateAccountBalance" i18n
|
||||
>Update Cash Balance</mat-checkbox
|
||||
>
|
||||
|
@ -454,30 +454,22 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
|
||||
if (position.holdings.length > 0) {
|
||||
for (const holding of position.holdings) {
|
||||
const { name, valueInBaseCurrency } = holding;
|
||||
const { allocationInPercentage, name, valueInBaseCurrency } =
|
||||
holding;
|
||||
|
||||
if (
|
||||
!this.hasImpersonationId &&
|
||||
!this.user.settings.isRestrictedView
|
||||
) {
|
||||
if (this.topHoldingsMap[name]?.value) {
|
||||
this.topHoldingsMap[name].value +=
|
||||
valueInBaseCurrency *
|
||||
(isNumber(position.valueInBaseCurrency)
|
||||
? position.valueInBaseCurrency
|
||||
: position.valueInPercentage);
|
||||
} else {
|
||||
this.topHoldingsMap[name] = {
|
||||
name,
|
||||
value:
|
||||
valueInBaseCurrency *
|
||||
(isNumber(position.valueInBaseCurrency)
|
||||
? this.portfolioDetails.holdings[symbol]
|
||||
.valueInBaseCurrency
|
||||
: this.portfolioDetails.holdings[symbol]
|
||||
.valueInPercentage)
|
||||
};
|
||||
}
|
||||
if (this.topHoldingsMap[name]?.value) {
|
||||
this.topHoldingsMap[name].value += isNumber(valueInBaseCurrency)
|
||||
? valueInBaseCurrency
|
||||
: allocationInPercentage *
|
||||
this.portfolioDetails.holdings[symbol].valueInPercentage;
|
||||
} else {
|
||||
this.topHoldingsMap[name] = {
|
||||
name,
|
||||
value: isNumber(valueInBaseCurrency)
|
||||
? valueInBaseCurrency
|
||||
: allocationInPercentage *
|
||||
this.portfolioDetails.holdings[symbol].valueInPercentage
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -562,6 +554,14 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
|
||||
this.topHoldings = Object.values(this.topHoldingsMap)
|
||||
.map(({ name, value }) => {
|
||||
if (this.hasImpersonationId || this.user.settings.isRestrictedView) {
|
||||
return {
|
||||
name,
|
||||
allocationInPercentage: value,
|
||||
valueInBaseCurrency: null
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
allocationInPercentage:
|
||||
@ -570,7 +570,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
return b.valueInBaseCurrency - a.valueInBaseCurrency;
|
||||
return b.allocationInPercentage - a.allocationInPercentage;
|
||||
});
|
||||
|
||||
if (this.topHoldings.length > MAX_TOP_HOLDINGS) {
|
||||
|
@ -253,7 +253,8 @@
|
||||
} @else {
|
||||
{{ baseCurrency }} <strong>{{ price }}</strong>
|
||||
}
|
||||
<span i18n>per year</span></span
|
||||
<span> </span>
|
||||
<span i18n>per year</span></span
|
||||
>
|
||||
</p>
|
||||
@if (
|
||||
|
@ -152,7 +152,7 @@
|
||||
</div>
|
||||
<div class="row my-5">
|
||||
<div class="col-md-10 offset-md-1">
|
||||
<h2 class="h4 mb-1 text-center">
|
||||
<h2 class="h4 mb-1 text-center" i18n>
|
||||
Would you like to <strong>refine</strong> your
|
||||
<strong>personal investment strategy</strong>?
|
||||
</h2>
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { Product } from '@ghostfolio/common/interfaces';
|
||||
import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
@ -26,6 +27,7 @@ export class GfProductPageComponent implements OnInit {
|
||||
'/' + $localize`resources`,
|
||||
'personal-finance-tools'
|
||||
];
|
||||
public tags: string[];
|
||||
|
||||
public constructor(
|
||||
private dataService: DataService,
|
||||
@ -56,7 +58,7 @@ export class GfProductPageComponent implements OnInit {
|
||||
],
|
||||
name: 'Ghostfolio',
|
||||
origin: $localize`Switzerland`,
|
||||
region: $localize`Global`,
|
||||
regions: [$localize`Global`],
|
||||
slogan: 'Open Source Wealth Management',
|
||||
useAnonymously: true
|
||||
};
|
||||
@ -64,5 +66,41 @@ export class GfProductPageComponent implements OnInit {
|
||||
this.product2 = personalFinanceTools.find(({ key }) => {
|
||||
return key === this.route.snapshot.data['key'];
|
||||
});
|
||||
|
||||
if (this.product2.origin) {
|
||||
this.product2.origin = translate(this.product2.origin);
|
||||
}
|
||||
|
||||
if (this.product2.regions) {
|
||||
this.product2.regions = this.product2.regions.map((region) => {
|
||||
return translate(region);
|
||||
});
|
||||
}
|
||||
|
||||
this.tags = [
|
||||
this.product1.name,
|
||||
this.product2.name,
|
||||
$localize`Alternative`,
|
||||
$localize`App`,
|
||||
$localize`Budgeting`,
|
||||
$localize`Community`,
|
||||
$localize`Family Office`,
|
||||
`Fintech`,
|
||||
$localize`Investment`,
|
||||
$localize`Investor`,
|
||||
$localize`Open Source`,
|
||||
`OSS`,
|
||||
$localize`Personal Finance`,
|
||||
$localize`Privacy`,
|
||||
$localize`Portfolio`,
|
||||
$localize`Software`,
|
||||
$localize`Tool`,
|
||||
$localize`User Experience`,
|
||||
$localize`Wealth`,
|
||||
$localize`Wealth Management`,
|
||||
`WealthTech`
|
||||
].sort((a, b) => {
|
||||
return a.localeCompare(b, undefined, { sensitivity: 'base' });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -80,8 +80,24 @@
|
||||
</tr>
|
||||
<tr class="mat-mdc-row">
|
||||
<td class="mat-mdc-cell px-3 py-2 text-right" i18n>Region</td>
|
||||
<td class="mat-mdc-cell px-1 py-2">{{ product1.region }}</td>
|
||||
<td class="mat-mdc-cell px-1 py-2">{{ product2.region }}</td>
|
||||
<td class="mat-mdc-cell px-1 py-2">
|
||||
@for (
|
||||
region of product1.regions;
|
||||
track region;
|
||||
let isLast = $last
|
||||
) {
|
||||
{{ region }}{{ isLast ? '' : ', ' }}
|
||||
}
|
||||
</td>
|
||||
<td class="mat-mdc-cell px-1 py-2">
|
||||
@for (
|
||||
region of product2.regions;
|
||||
track region;
|
||||
let isLast = $last
|
||||
) {
|
||||
{{ region }}{{ isLast ? '' : ', ' }}
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="mat-mdc-row">
|
||||
<td class="mat-mdc-cell px-3 py-2 text-right" i18n>
|
||||
@ -236,69 +252,11 @@
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<ul class="list-inline">
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Alternative</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">App</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Budgeting</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Community</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Family Office</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Fintech</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">{{ product1.name }}</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Investment</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Investor</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Open Source</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">OSS</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Personal Finance</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Privacy</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Portfolio</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Software</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Tool</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">User Experience</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Wealth</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">WealthTech</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Wealth Management</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">{{ product2.name }}</span>
|
||||
</li>
|
||||
@for (tag of tags; track tag) {
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">{{ tag }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
<nav aria-label="breadcrumb">
|
||||
|
@ -55,6 +55,22 @@
|
||||
</div>
|
||||
<h2 class="h4 mb-3" i18n>Markets</h2>
|
||||
<div class="mb-5">
|
||||
<div class="mb-4 media">
|
||||
<div class="media-body">
|
||||
<h3 class="h5 mt-0">Crypto Coins Heatmap</h3>
|
||||
<div class="mb-1">
|
||||
With the <i>Crypto Coins Heatmap</i> you can track the daily
|
||||
market movements of cryptocurrencies as a visual snapshot.
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://www.tradingview.com/heatmap/crypto"
|
||||
target="_blank"
|
||||
>Crypto Coins Heatmap →</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4 media">
|
||||
<div class="media-body">
|
||||
<h3 class="h5 mt-0">Fear & Greed Index</h3>
|
||||
@ -73,10 +89,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="media">
|
||||
<div class="media-body">
|
||||
<div class="mb-4 media">
|
||||
<h3 class="h5 mt-0">Inflation Chart</h3>
|
||||
<div class="mb-1">
|
||||
Inflation Chart helps you find the intrinsic value of stock
|
||||
<i>Inflation Chart</i> helps you find the intrinsic value of stock
|
||||
markets, stock prices, goods and services by adjusting them to the
|
||||
amount of the money supply (M0, M1, M2) or price of other goods
|
||||
(food or oil).
|
||||
@ -88,6 +104,22 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="media">
|
||||
<div class="media-body">
|
||||
<h3 class="h5 mt-0">Stock Heatmap</h3>
|
||||
<div class="mb-1">
|
||||
With the <i>Stock Heatmap</i> you can track the daily market
|
||||
movements of stocks as a visual snapshot.
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://www.tradingview.com/heatmap/stock"
|
||||
target="_blank"
|
||||
>Stock Heatmap →</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h2 class="h4 mb-3" i18n>Glossary</h2>
|
||||
<div>
|
||||
|
1
apps/client/src/assets/images/logo-selfh-st.svg
Normal file
1
apps/client/src/assets/images/logo-selfh-st.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 323 87"><defs><style>.cls-1{opacity:0.1;}</style></defs><g class="cls-1"><path d="M201.13,52.72a30.78,30.78,0,0,1,3-13.73,21.72,21.72,0,0,1,8.55-9.33,24.91,24.91,0,0,1,12.94-3.31q10.48,0,17.11,6.42t7.39,17.41l.1,3.55q0,11.91-6.66,19.11T225.68,80q-11.2,0-17.88-7.17t-6.67-19.53Zm13.83,1q0,7.38,2.77,11.29a10,10,0,0,0,15.79.05q2.83-3.87,2.83-12.34,0-7.25-2.83-11.22a9.18,9.18,0,0,0-7.94-4,9,9,0,0,0-7.85,4C215.88,44.09,215,48.18,215,53.7Z"/></g><path d="M35.37,64.78a4.47,4.47,0,0,0-2.52-4,28.42,28.42,0,0,0-8.06-2.6Q6.33,54.3,6.32,42.48A14.24,14.24,0,0,1,12,31q5.72-4.62,15-4.62,9.86,0,15.77,4.65a14.61,14.61,0,0,1,5.91,12H34.84a6.71,6.71,0,0,0-1.91-4.9q-1.92-1.93-6-1.94a8.26,8.26,0,0,0-5.4,1.58,5,5,0,0,0-1.92,4,4.27,4.27,0,0,0,2.18,3.71A22.51,22.51,0,0,0,29.15,48a60.73,60.73,0,0,1,8.7,2.32q11,4,11,13.93a13.49,13.49,0,0,1-6.08,11.46Q36.66,80,27,80a27.37,27.37,0,0,1-11.56-2.32,19.36,19.36,0,0,1-7.92-6.36,14.83,14.83,0,0,1-2.87-8.73H17.8a7.23,7.23,0,0,0,2.73,5.64,10.82,10.82,0,0,0,6.8,2,10,10,0,0,0,6-1.5A4.69,4.69,0,0,0,35.37,64.78Z"/><path d="M79.58,80q-11.39,0-18.54-7T53.89,54.44V53.1a31.28,31.28,0,0,1,3-14,22.21,22.21,0,0,1,8.54-9.47,24,24,0,0,1,12.61-3.33q10.62,0,16.73,6.7t6.1,19V57.7h-33a12.79,12.79,0,0,0,4,8.13,12.24,12.24,0,0,0,8.54,3.06q8,0,12.49-5.79l6.8,7.61a20.71,20.71,0,0,1-8.43,6.87A27.65,27.65,0,0,1,79.58,80ZM78,37.5a8.62,8.62,0,0,0-6.67,2.79,14.43,14.43,0,0,0-3.28,8H87.29V47.16A10.34,10.34,0,0,0,84.8,40,8.94,8.94,0,0,0,78,37.5Z"/><path d="M121.36,79.09H107.48V5.59h13.88Z"/><path d="M134.57,79.09V37.46h-7.71V27.31h7.71v-4.4q0-8.71,5-13.52t14-4.81a32.27,32.27,0,0,1,7,1l-.14,10.72a17.57,17.57,0,0,0-4.22-.43q-7.8,0-7.8,7.32v4.16h10.29V37.46H148.44V79.09Z"/><path d="M177.44,33a17.28,17.28,0,0,1,13.83-6.61q16.85,0,17.09,19.58V79.09H194.53V46.31q0-4.45-1.92-6.58t-6.36-2.13q-6.08,0-8.81,4.69v36.8H163.62V5.59h13.82Z"/><path d="M271.22,64.78a4.48,4.48,0,0,0-2.51-4,28.42,28.42,0,0,0-8.06-2.6q-18.48-3.88-18.47-15.7A14.22,14.22,0,0,1,247.9,31q5.72-4.62,15-4.62,9.86,0,15.77,4.65a14.64,14.64,0,0,1,5.91,12H270.7a6.68,6.68,0,0,0-1.92-4.9c-1.27-1.29-3.27-1.94-6-1.94a8.31,8.31,0,0,0-5.41,1.58,5,5,0,0,0-1.91,4,4.27,4.27,0,0,0,2.18,3.71A22.52,22.52,0,0,0,265,48a60.54,60.54,0,0,1,8.71,2.32q11,4,11,13.93a13.51,13.51,0,0,1-6.08,11.46Q272.52,80,262.9,80a27.37,27.37,0,0,1-11.56-2.32,19.24,19.24,0,0,1-7.92-6.36,14.83,14.83,0,0,1-2.87-8.73h13.11a7.23,7.23,0,0,0,2.73,5.64,10.79,10.79,0,0,0,6.79,2,10.07,10.07,0,0,0,6-1.5A4.71,4.71,0,0,0,271.22,64.78Z"/><path d="M308.17,14.58V27.31H317V37.46h-8.85V63.3a6.13,6.13,0,0,0,1.1,4.11c.73.83,2.13,1.25,4.21,1.25a22.24,22.24,0,0,0,4.06-.34V78.8A28.42,28.42,0,0,1,309.17,80q-14.55,0-14.83-14.69V37.46h-7.56V27.31h7.56V14.58Z"/><path d="M222.41,75.64a4.12,4.12,0,0,1,1.07-2.85,3.87,3.87,0,0,1,3-1.17,4,4,0,0,1,3,1.17,4.06,4.06,0,0,1,1.1,2.85,3.68,3.68,0,0,1-1.1,2.75,4.14,4.14,0,0,1-3,1.08,4,4,0,0,1-3-1.08A3.73,3.73,0,0,1,222.41,75.64Z"/></svg>
|
After Width: | Height: | Size: 2.9 KiB |
@ -7,10 +7,16 @@
|
||||
{
|
||||
"sizes": "192x192",
|
||||
"src": "/assets/android-chrome-192x192.png",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"purpose": "any",
|
||||
"sizes": "512x512",
|
||||
"src": "/assets/android-chrome-512x512.png",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"purpose": "maskable",
|
||||
"sizes": "512x512",
|
||||
"src": "/assets/android-chrome-512x512.png",
|
||||
"type": "image/png"
|
||||
@ -21,5 +27,5 @@
|
||||
"short_name": "Ghostfolio",
|
||||
"start_url": "/en/",
|
||||
"theme_color": "#FFFFFF",
|
||||
"url": "https://www.ghostfol.io"
|
||||
"url": "https://ghostfol.io"
|
||||
}
|
||||
|
6682
apps/client/src/locales/messages.ca.xlf
Normal file
6682
apps/client/src/locales/messages.ca.xlf
Normal file
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
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
@ -1,4 +1,3 @@
|
||||
version: '3.9'
|
||||
services:
|
||||
ghostfolio:
|
||||
build: ../
|
||||
|
@ -1,4 +1,3 @@
|
||||
version: '3.9'
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
|
@ -1,4 +1,3 @@
|
||||
version: '3.9'
|
||||
services:
|
||||
ghostfolio:
|
||||
image: ghostfolio/ghostfolio:latest
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user