Compare commits
131 Commits
Author | SHA1 | Date | |
---|---|---|---|
43f5bb7773 | |||
e85cc0fcfc | |||
dc1948016f | |||
4410040a14 | |||
b2ed0b2c80 | |||
42fe653e1e | |||
8a81fa814f | |||
98f3fa9d7c | |||
202e27fe25 | |||
757ff527d0 | |||
41f5801b5e | |||
4c7657a90e | |||
aef650753e | |||
420f331be9 | |||
e0068c4d5d | |||
85661884a6 | |||
8f6203d296 | |||
2fa723dc3c | |||
a500fb72c5 | |||
02db0db733 | |||
c87b08ca8b | |||
fcc2ab1a48 | |||
7efda2f890 | |||
3794a61d2d | |||
c1d1ea9dde | |||
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 | |||
0f8dc62c53 | |||
554136cdcd | |||
83b5cfff1f | |||
dcec3accf0 | |||
f08b0b570b | |||
8386fec98a | |||
4d3dff3e5b | |||
76890e63fa | |||
4fb2aebf4f | |||
ed5cd3b978 | |||
469c1936b4 | |||
8b3cc5c11a | |||
ee086638f3 | |||
58d1abbd38 | |||
ba979cbae2 | |||
8cda43bb63 | |||
c4499df74c | |||
24bcc15b6a | |||
ff121243e4 | |||
70e633b997 | |||
0780ee4adb | |||
09613f9324 | |||
8642b1a7af | |||
f96f861341 | |||
a201fc7a97 | |||
a97110348c | |||
a25d5b9dc0 | |||
6c2acf2aa6 | |||
519827045a | |||
79a7e12a9f | |||
bf20a5de82 | |||
0adefe14e1 | |||
f24561cc3d | |||
873fd53715 | |||
e5d8faf2dc | |||
65d3bd2802 | |||
ad60373813 | |||
b725e6e2ec | |||
88c420ca5e | |||
118e17f78c | |||
cc92592d86 | |||
46eb3254a9 | |||
2477491f18 | |||
5fc9fde129 | |||
00e50c6abe | |||
8131a7ad03 | |||
f5e6f7dcfe | |||
87501e094d | |||
d3bfdf78c3 | |||
fc4e6ae6db | |||
23e4d5454d |
@ -24,12 +24,18 @@
|
|||||||
{
|
{
|
||||||
"files": ["*.ts", "*.tsx"],
|
"files": ["*.ts", "*.tsx"],
|
||||||
"extends": ["plugin:@nx/typescript"],
|
"extends": ["plugin:@nx/typescript"],
|
||||||
"rules": {}
|
"rules": {
|
||||||
|
"@typescript-eslint/no-extra-semi": "error",
|
||||||
|
"no-extra-semi": "off"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"files": ["*.js", "*.jsx"],
|
"files": ["*.js", "*.jsx"],
|
||||||
"extends": ["plugin:@nx/javascript"],
|
"extends": ["plugin:@nx/javascript"],
|
||||||
"rules": {}
|
"rules": {
|
||||||
|
"@typescript-eslint/no-extra-semi": "error",
|
||||||
|
"no-extra-semi": "off"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"files": ["*.ts"],
|
"files": ["*.ts"],
|
||||||
|
12
.github/workflows/build-code.yml
vendored
12
.github/workflows/build-code.yml
vendored
@ -13,7 +13,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
node_version:
|
node_version:
|
||||||
- 18
|
- 20
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@ -24,16 +24,16 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node_version }}
|
node-version: ${{ matrix.node_version }}
|
||||||
cache: 'yarn'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: yarn install --frozen-lockfile
|
run: npm ci
|
||||||
|
|
||||||
- name: Check formatting
|
- name: Check formatting
|
||||||
run: yarn format:check
|
run: npm run format:check
|
||||||
|
|
||||||
- name: Execute tests
|
- name: Execute tests
|
||||||
run: yarn test
|
run: npm test
|
||||||
|
|
||||||
- name: Build application
|
- name: Build application
|
||||||
run: yarn build:production
|
run: npm run build:production
|
||||||
|
5
.gitignore
vendored
5
.gitignore
vendored
@ -5,8 +5,8 @@
|
|||||||
/tmp
|
/tmp
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
/.yarn
|
|
||||||
/node_modules
|
/node_modules
|
||||||
|
npm-debug.log
|
||||||
|
|
||||||
# IDEs and editors
|
# IDEs and editors
|
||||||
/.idea
|
/.idea
|
||||||
@ -28,15 +28,14 @@
|
|||||||
.env
|
.env
|
||||||
.env.prod
|
.env.prod
|
||||||
.nx/cache
|
.nx/cache
|
||||||
|
.nx/workspace-data
|
||||||
/.sass-cache
|
/.sass-cache
|
||||||
/connect.lock
|
/connect.lock
|
||||||
/coverage
|
/coverage
|
||||||
/dist
|
/dist
|
||||||
/libpeerconnection.log
|
/libpeerconnection.log
|
||||||
npm-debug.log
|
|
||||||
testem.log
|
testem.log
|
||||||
/typings
|
/typings
|
||||||
yarn-error.log
|
|
||||||
|
|
||||||
# System Files
|
# System Files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
/.nx/cache
|
/.nx/cache
|
||||||
|
/.nx/workspace-data
|
||||||
/apps/client/src/polyfills.ts
|
/apps/client/src/polyfills.ts
|
||||||
/dist
|
/dist
|
||||||
/test/import
|
/test/import
|
||||||
|
230
CHANGELOG.md
230
CHANGELOG.md
@ -5,6 +5,234 @@ 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/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## 2.102.0 - 2024-08-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support to clone an activity from the account detail dialog (experimental)
|
||||||
|
- Added support to edit an activity from the account detail dialog (experimental)
|
||||||
|
- Added support to clone an activity from the holding detail dialog (experimental)
|
||||||
|
- Added support to edit an activity from the holding detail dialog (experimental)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the caching of the benchmarks in the markets overview by returning cached data and recalculating in the background when it expires
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
- Improved the language localization for Polish (`pl`)
|
||||||
|
- Upgraded `Nx` from version `19.5.1` to `19.5.6`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the cache flush endpoint response
|
||||||
|
|
||||||
|
## 2.101.0 - 2024-08-03
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Hardened container security by switching to a non-root user, setting the filesystem to read-only, and dropping unnecessary capabilities
|
||||||
|
|
||||||
|
## 2.100.0 - 2024-08-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support to manage tags of holdings in the holding detail dialog
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the color assignment in the chart of the holdings tab on the home page (experimental)
|
||||||
|
- Persisted the view mode of the holdings tab on the home page (experimental)
|
||||||
|
- Improved the language localization for Catalan (`ca`)
|
||||||
|
- Improved the language localization for Spanish (`es`)
|
||||||
|
|
||||||
|
## 2.99.0 - 2024-07-29
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Migrated the usage of `yarn` to `npm`
|
||||||
|
- Upgraded `storybook` from version `7.0.9` to `8.2.5`
|
||||||
|
- Downgraded `marked` from version `13.0.0` to `12.0.2`
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
- Added support for bulk deletion of asset profiles from the market data table in the admin control panel
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Added support for derived currencies in the currency validation
|
||||||
|
- Added support for automatic deletion of unused asset profiles when deleting activities
|
||||||
|
- Improved the caching of the benchmarks in the markets overview (only cache if needed)
|
||||||
|
- Upgraded `prisma` from version `5.15.0` to `5.16.1`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with the all time high in the benchmarks of the markets overview
|
||||||
|
|
||||||
|
## 2.91.0 - 2024-06-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a benchmarks preset to the historical market data table of the admin control panel
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Upgraded `angular` from version `18.0.2` to `18.0.4`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the dialog position (center) on mobile
|
||||||
|
- Fixed the horizontal overflow in the historical market data table of the admin control panel
|
||||||
|
- Changed the mechanism of the `INTRADAY` data gathering to persist data only if the market state is `OPEN`
|
||||||
|
- Fixed the creation of activities with `MANUAL` data source (with no historical market data)
|
||||||
|
|
||||||
|
## 2.90.0 - 2024-06-22
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a dialog for the benchmarks in the markets overview
|
||||||
|
- Extended the asset profile details dialog of the admin control for currencies
|
||||||
|
- Extended the content of the _Self-Hosting_ section by the mobile app question on the Frequently Asked Questions (FAQ) page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Moved the indicator for active filters from experimental to general availability
|
||||||
|
- Improved the error handling in the biometric authentication registration
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
- Set up SSL for local development
|
||||||
|
- Upgraded the _Stripe_ dependencies
|
||||||
|
- Upgraded `marked` from version `9.1.6` to `13.0.0`
|
||||||
|
- Upgraded `ngx-device-detector` from version `5.0.1` to `8.0.0`
|
||||||
|
- Upgraded `ngx-markdown` from version `17.1.1` to `18.0.0`
|
||||||
|
- Upgraded `zone.js` from version `0.14.5` to `0.14.7`
|
||||||
|
|
||||||
|
## 2.89.0 - 2024-06-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the historical market data table with currencies preset by date and activities count in the admin control panel
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the date validation in the create, import and update activities endpoints
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
|
||||||
|
## 2.88.0 - 2024-06-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Set the image source label in `Dockerfile`
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the style of the blog post list
|
||||||
|
- Migrated the `@ghostfolio/client` components to control flow
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
- Upgraded `angular` from version `17.3.10` to `18.0.2`
|
||||||
|
- Upgraded `Nx` from version `19.0.5` to `19.2.2`
|
||||||
|
|
||||||
|
## 2.87.0 - 2024-06-08
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the portfolio summary
|
||||||
|
- Improved the allocations by ETF holding on the allocations page (experimental)
|
||||||
|
- Improved the error handling in the `HttpResponseInterceptor`
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
- Upgraded `prisma` from version `5.14.0` to `5.15.0`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue in the _FIRE_ calculator
|
||||||
|
|
||||||
## 2.86.0 - 2024-06-07
|
## 2.86.0 - 2024-06-07
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@ -4672,7 +4900,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added the attribute `precision` in the value component
|
- Added the attribute `precision` to the value component
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
@ -10,7 +10,7 @@ Remove permission in `UserService` using `without()`
|
|||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
Use `*ngIf="user?.settings?.isExperimentalFeatures"` in HTML template
|
Use `@if (user?.settings?.isExperimentalFeatures) {}` in HTML template
|
||||||
|
|
||||||
## Git
|
## Git
|
||||||
|
|
||||||
@ -30,26 +30,26 @@ Use `*ngIf="user?.settings?.isExperimentalFeatures"` in HTML template
|
|||||||
|
|
||||||
#### Upgrade
|
#### Upgrade
|
||||||
|
|
||||||
1. Run `yarn nx migrate latest`
|
1. Run `npx nx migrate latest`
|
||||||
1. Make sure `package.json` changes make sense and then run `yarn install`
|
1. Make sure `package.json` changes make sense and then run `npm install`
|
||||||
1. Run `yarn nx migrate --run-migrations` (Run `YARN_NODE_LINKER="node-modules" NX_MIGRATE_SKIP_INSTALL=1 yarn nx migrate --run-migrations` due to https://github.com/nrwl/nx/issues/16338)
|
1. Run `npx nx migrate --run-migrations`
|
||||||
|
|
||||||
### Prisma
|
### Prisma
|
||||||
|
|
||||||
#### Access database via GUI
|
#### Access database via GUI
|
||||||
|
|
||||||
Run `yarn database:gui`
|
Run `npm run database:gui`
|
||||||
|
|
||||||
https://www.prisma.io/studio
|
https://www.prisma.io/studio
|
||||||
|
|
||||||
#### Synchronize schema with database for prototyping
|
#### Synchronize schema with database for prototyping
|
||||||
|
|
||||||
Run `yarn database:push`
|
Run `npm run database:push`
|
||||||
|
|
||||||
https://www.prisma.io/docs/concepts/components/prisma-migrate/db-push
|
https://www.prisma.io/docs/concepts/components/prisma-migrate/db-push
|
||||||
|
|
||||||
#### Create schema migration
|
#### Create schema migration
|
||||||
|
|
||||||
Run `yarn prisma migrate dev --name added_job_title`
|
Run `npm run prisma migrate dev --name added_job_title`
|
||||||
|
|
||||||
https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate
|
https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate
|
||||||
|
46
Dockerfile
46
Dockerfile
@ -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
|
# Build application and add additional files
|
||||||
WORKDIR /ghostfolio
|
WORKDIR /ghostfolio
|
||||||
@ -8,18 +8,17 @@ WORKDIR /ghostfolio
|
|||||||
COPY ./CHANGELOG.md CHANGELOG.md
|
COPY ./CHANGELOG.md CHANGELOG.md
|
||||||
COPY ./LICENSE LICENSE
|
COPY ./LICENSE LICENSE
|
||||||
COPY ./package.json package.json
|
COPY ./package.json package.json
|
||||||
COPY ./yarn.lock yarn.lock
|
COPY ./package-lock.json package-lock.json
|
||||||
COPY ./.yarnrc .yarnrc
|
|
||||||
COPY ./prisma/schema.prisma prisma/schema.prisma
|
COPY ./prisma/schema.prisma prisma/schema.prisma
|
||||||
|
|
||||||
RUN apt update && apt install -y \
|
RUN apt-get update && apt-get install -y --no-install-suggests \
|
||||||
g++ \
|
g++ \
|
||||||
git \
|
git \
|
||||||
make \
|
make \
|
||||||
openssl \
|
openssl \
|
||||||
python3 \
|
python3 \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
RUN yarn install
|
RUN npm install
|
||||||
|
|
||||||
# See https://github.com/nrwl/nx/issues/6586 for further details
|
# See https://github.com/nrwl/nx/issues/6586 for further details
|
||||||
COPY ./decorate-angular-cli.js decorate-angular-cli.js
|
COPY ./decorate-angular-cli.js decorate-angular-cli.js
|
||||||
@ -33,31 +32,36 @@ COPY ./tsconfig.base.json tsconfig.base.json
|
|||||||
COPY ./libs libs
|
COPY ./libs libs
|
||||||
COPY ./apps apps
|
COPY ./apps apps
|
||||||
|
|
||||||
RUN yarn build:production
|
RUN npm run build:production
|
||||||
|
|
||||||
# Prepare the dist image with additional node_modules
|
# Prepare the dist image with additional node_modules
|
||||||
WORKDIR /ghostfolio/dist/apps/api
|
WORKDIR /ghostfolio/dist/apps/api
|
||||||
# package.json was generated by the build process, however the original
|
# package.json was generated by the build process, however the original
|
||||||
# yarn.lock needs to be used to ensure the same versions
|
# package-lock.json needs to be used to ensure the same versions
|
||||||
COPY ./yarn.lock /ghostfolio/dist/apps/api/yarn.lock
|
COPY ./package-lock.json /ghostfolio/dist/apps/api/package-lock.json
|
||||||
|
|
||||||
RUN yarn
|
RUN npm install
|
||||||
COPY prisma /ghostfolio/dist/apps/api/prisma
|
COPY prisma /ghostfolio/dist/apps/api/prisma
|
||||||
|
|
||||||
# Overwrite the generated package.json with the original one to ensure having
|
# Overwrite the generated package.json with the original one to ensure having
|
||||||
# all the scripts
|
# all the scripts
|
||||||
COPY package.json /ghostfolio/dist/apps/api
|
COPY package.json /ghostfolio/dist/apps/api
|
||||||
RUN yarn database:generate-typings
|
RUN npm run database:generate-typings
|
||||||
|
|
||||||
# Image to run, copy everything needed from builder
|
# Image to run, copy everything needed from builder
|
||||||
FROM node:18-slim
|
FROM node:20-slim
|
||||||
RUN apt update && apt install -y \
|
LABEL org.opencontainers.image.source="https://github.com/ghostfolio/ghostfolio"
|
||||||
curl \
|
ENV NODE_ENV=production
|
||||||
openssl \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y --no-install-suggests \
|
||||||
|
curl \
|
||||||
|
openssl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
||||||
COPY ./docker/entrypoint.sh /ghostfolio/entrypoint.sh
|
COPY ./docker/entrypoint.sh /ghostfolio/entrypoint.sh
|
||||||
|
RUN chown -R node:node /ghostfolio
|
||||||
WORKDIR /ghostfolio/apps/api
|
WORKDIR /ghostfolio/apps/api
|
||||||
EXPOSE ${PORT:-3333}
|
EXPOSE ${PORT:-3333}
|
||||||
|
USER node
|
||||||
CMD [ "/ghostfolio/entrypoint.sh" ]
|
CMD [ "/ghostfolio/entrypoint.sh" ]
|
||||||
|
83
README.md
83
README.md
@ -7,7 +7,7 @@
|
|||||||
**Open Source Wealth Management Software**
|
**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) |
|
[**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)
|
[](https://www.buymeacoffee.com/ghostfolio)
|
||||||
[](#contributing)
|
[](#contributing)
|
||||||
@ -47,7 +47,7 @@ Ghostfolio is for you if you are...
|
|||||||
|
|
||||||
- ✅ Create, update and delete transactions
|
- ✅ Create, update and delete transactions
|
||||||
- ✅ Multi account management
|
- ✅ 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
|
- ✅ Various charts
|
||||||
- ✅ Static analysis to identify potential risks in your portfolio
|
- ✅ Static analysis to identify potential risks in your portfolio
|
||||||
- ✅ Import and export transactions
|
- ✅ Import and export transactions
|
||||||
@ -71,7 +71,7 @@ The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://
|
|||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
|
The frontend is built with [Angular](https://angular.dev) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
|
||||||
|
|
||||||
## Self-hosting
|
## Self-hosting
|
||||||
|
|
||||||
@ -87,21 +87,21 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
|
|||||||
|
|
||||||
| Name | Type | Default Value | Description |
|
| Name | Type | Default Value | Description |
|
||||||
| ------------------------ | ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------ | ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `ACCESS_TOKEN_SALT` | string | | A random string used as salt for access tokens |
|
| `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_DEMO` | `string` (optional) | | The _CoinGecko_ Demo API key |
|
||||||
| `API_KEY_COINGECKO_PRO` | string (`optional`) | | The _CoinGecko_ Pro API |
|
| `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` |
|
| `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 |
|
| `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) |
|
| `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 |
|
| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on |
|
||||||
| `POSTGRES_DB` | string | | The name of the _PostgreSQL_ database |
|
| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database |
|
||||||
| `POSTGRES_PASSWORD` | string | | The password of the _PostgreSQL_ database |
|
| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database |
|
||||||
| `POSTGRES_USER` | string | | The user of the _PostgreSQL_ database |
|
| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database |
|
||||||
| `REDIS_DB` | number (`optional`) | `0` | The database index of _Redis_ |
|
| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ |
|
||||||
| `REDIS_HOST` | string | | The host where _Redis_ is running |
|
| `REDIS_HOST` | `string` | | The host where _Redis_ is running |
|
||||||
| `REDIS_PASSWORD` | string | | The password of _Redis_ |
|
| `REDIS_PASSWORD` | `string` | | The password of _Redis_ |
|
||||||
| `REDIS_PORT` | number | | The port where _Redis_ is running |
|
| `REDIS_PORT` | `number` | | The port where _Redis_ is running |
|
||||||
| `REQUEST_TIMEOUT` | number (`optional`) | `2000` | The timeout of network requests to data providers in milliseconds |
|
| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds |
|
||||||
|
|
||||||
### Run with Docker Compose
|
### Run with Docker Compose
|
||||||
|
|
||||||
@ -149,50 +149,49 @@ Ghostfolio is available for various home server systems, including [CasaOS](http
|
|||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
- [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)
|
- 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`)
|
- Copy the file `.env.dev` to `.env` and populate it with your data (`cp .env.dev .env`)
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
1. Run `yarn install`
|
1. Run `npm install`
|
||||||
1. Run `docker compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
1. Run `docker compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||||
1. Run `yarn database:setup` to initialize the database schema
|
1. Run `npm run database:setup` to initialize the database schema
|
||||||
1. Run `git config core.hooksPath ./git-hooks/` to setup git hooks
|
1. Run `git config core.hooksPath ./git-hooks/` to setup git hooks
|
||||||
1. Start the server and the client (see [_Development_](#Development))
|
1. Start the server and the client (see [_Development_](#Development))
|
||||||
1. Open http://localhost:4200/en in your browser
|
1. Open https://localhost:4200/en in your browser
|
||||||
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
||||||
|
|
||||||
### Start Server
|
### Start Server
|
||||||
|
|
||||||
#### Debug
|
#### Debug
|
||||||
|
|
||||||
Run `yarn watch:server` and click _Debug API_ in [Visual Studio Code](https://code.visualstudio.com)
|
Run `npm run watch:server` and click _Debug API_ in [Visual Studio Code](https://code.visualstudio.com)
|
||||||
|
|
||||||
#### Serve
|
#### Serve
|
||||||
|
|
||||||
Run `yarn start:server`
|
Run `npm run start:server`
|
||||||
|
|
||||||
### Start Client
|
### Start Client
|
||||||
|
|
||||||
Run `yarn start:client` and open http://localhost:4200/en in your browser
|
Run `npm run start:client` and open https://localhost:4200/en in your browser
|
||||||
|
|
||||||
### Start _Storybook_
|
### Start _Storybook_
|
||||||
|
|
||||||
Run `yarn start:storybook`
|
Run `npm run start:storybook`
|
||||||
|
|
||||||
### Migrate Database
|
### Migrate Database
|
||||||
|
|
||||||
With the following command you can keep your database schema in sync:
|
With the following command you can keep your database schema in sync:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yarn database:push
|
npm run database:push
|
||||||
```
|
```
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Run `yarn test`
|
Run `npm test`
|
||||||
|
|
||||||
## Public API
|
## Public API
|
||||||
|
|
||||||
@ -233,18 +232,18 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| ---------- | ------------------- | ----------------------------------------------------------------------------- |
|
| ------------ | ------------------- | ----------------------------------------------------------------------------- |
|
||||||
| accountId | string (`optional`) | Id of the account |
|
| `accountId` | `string` (optional) | Id of the account |
|
||||||
| comment | string (`optional`) | Comment of the activity |
|
| `comment` | `string` (optional) | Comment of the activity |
|
||||||
| currency | string | `CHF` \| `EUR` \| `USD` etc. |
|
| `currency` | `string` | `CHF` \| `EUR` \| `USD` etc. |
|
||||||
| dataSource | string | `COINGECKO` \| `MANUAL` (for type `ITEM`) \| `YAHOO` |
|
| `dataSource` | `string` | `COINGECKO` \| `MANUAL` (for type `ITEM`) \| `YAHOO` |
|
||||||
| date | string | Date in the format `ISO-8601` |
|
| `date` | `string` | Date in the format `ISO-8601` |
|
||||||
| fee | number | Fee of the activity |
|
| `fee` | `number` | Fee of the activity |
|
||||||
| quantity | number | Quantity of the activity |
|
| `quantity` | `number` | Quantity of the activity |
|
||||||
| symbol | string | Symbol of the activity (suitable for `dataSource`) |
|
| `symbol` | `string` | Symbol of the activity (suitable for `dataSource`) |
|
||||||
| type | string | `BUY` \| `DIVIDEND` \| `FEE` \| `INTEREST` \| `ITEM` \| `LIABILITY` \| `SELL` |
|
| `type` | `string` | `BUY` \| `DIVIDEND` \| `FEE` \| `INTEREST` \| `ITEM` \| `LIABILITY` \| `SELL` |
|
||||||
| unitPrice | number | Price per unit of the activity |
|
| `unitPrice` | `number` | Price per unit of the activity |
|
||||||
|
|
||||||
#### Response
|
#### Response
|
||||||
|
|
||||||
@ -275,7 +274,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.
|
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).
|
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,
|
ACCOUNT: filtersByAccount,
|
||||||
ASSET_CLASS: filtersByAssetClass,
|
ASSET_CLASS: filtersByAssetClass,
|
||||||
TAG: filtersByTag
|
TAG: filtersByTag
|
||||||
} = groupBy(filters, (filter) => {
|
} = groupBy(filters, ({ type }) => {
|
||||||
return filter.type;
|
return type;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (filtersByAccount?.length > 0) {
|
if (filtersByAccount?.length > 0) {
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
|
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
|
||||||
|
|
||||||
import { Transform, TransformFnParams } from 'class-transformer';
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsISO4217CurrencyCode,
|
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
@ -20,7 +21,7 @@ export class CreateAccountDto {
|
|||||||
)
|
)
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
|
||||||
@IsISO4217CurrencyCode()
|
@IsCurrencyCode()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
|
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
|
||||||
|
|
||||||
import { Transform, TransformFnParams } from 'class-transformer';
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsISO4217CurrencyCode,
|
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
@ -20,7 +21,7 @@ export class UpdateAccountDto {
|
|||||||
)
|
)
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
|
||||||
@IsISO4217CurrencyCode()
|
@IsCurrencyCode()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@ -81,10 +81,11 @@ export class AdminController {
|
|||||||
@Post('gather/max')
|
@Post('gather/max')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async gatherMax(): Promise<void> {
|
public async gatherMax(): Promise<void> {
|
||||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
const assetProfileIdentifiers =
|
||||||
|
await this.dataGatheringService.getAllAssetProfileIdentifiers();
|
||||||
|
|
||||||
await this.dataGatheringService.addJobsToQueue(
|
await this.dataGatheringService.addJobsToQueue(
|
||||||
uniqueAssets.map(({ dataSource, symbol }) => {
|
assetProfileIdentifiers.map(({ dataSource, symbol }) => {
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
dataSource,
|
dataSource,
|
||||||
@ -107,10 +108,11 @@ export class AdminController {
|
|||||||
@Post('gather/profile-data')
|
@Post('gather/profile-data')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async gatherProfileData(): Promise<void> {
|
public async gatherProfileData(): Promise<void> {
|
||||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
const assetProfileIdentifiers =
|
||||||
|
await this.dataGatheringService.getAllAssetProfileIdentifiers();
|
||||||
|
|
||||||
await this.dataGatheringService.addJobsToQueue(
|
await this.dataGatheringService.addJobsToQueue(
|
||||||
uniqueAssets.map(({ dataSource, symbol }) => {
|
assetProfileIdentifiers.map(({ dataSource, symbol }) => {
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
dataSource,
|
dataSource,
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
|
||||||
|
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
||||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||||
@ -19,11 +21,13 @@ import { QueueModule } from './queue/queue.module';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ApiModule,
|
ApiModule,
|
||||||
|
BenchmarkModule,
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
MarketDataModule,
|
MarketDataModule,
|
||||||
|
OrderModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
PropertyModule,
|
PropertyModule,
|
||||||
QueueModule,
|
QueueModule,
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
|
||||||
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||||
import { environment } from '@ghostfolio/api/environments/environment';
|
import { environment } from '@ghostfolio/api/environments/environment';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
@ -13,22 +15,25 @@ import {
|
|||||||
PROPERTY_IS_READ_ONLY_MODE,
|
PROPERTY_IS_READ_ONLY_MODE,
|
||||||
PROPERTY_IS_USER_SIGNUP_ENABLED
|
PROPERTY_IS_USER_SIGNUP_ENABLED
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
|
import { isCurrency, getCurrencyFromSymbol } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
AdminData,
|
AdminData,
|
||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
AdminMarketDataDetails,
|
AdminMarketDataDetails,
|
||||||
AdminMarketDataItem,
|
AdminMarketDataItem,
|
||||||
Filter,
|
AssetProfileIdentifier,
|
||||||
UniqueAsset
|
EnhancedSymbolProfile,
|
||||||
|
Filter
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { MarketDataPreset } from '@ghostfolio/common/types';
|
import { MarketDataPreset } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
AssetClass,
|
AssetClass,
|
||||||
AssetSubClass,
|
AssetSubClass,
|
||||||
DataSource,
|
DataSource,
|
||||||
Prisma,
|
Prisma,
|
||||||
|
PrismaClient,
|
||||||
Property,
|
Property,
|
||||||
SymbolProfile
|
SymbolProfile
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
@ -38,10 +43,12 @@ import { groupBy } from 'lodash';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminService {
|
export class AdminService {
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly benchmarkService: BenchmarkService,
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly marketDataService: MarketDataService,
|
private readonly marketDataService: MarketDataService,
|
||||||
|
private readonly orderService: OrderService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly propertyService: PropertyService,
|
private readonly propertyService: PropertyService,
|
||||||
private readonly subscriptionService: SubscriptionService,
|
private readonly subscriptionService: SubscriptionService,
|
||||||
@ -52,7 +59,9 @@ export class AdminService {
|
|||||||
currency,
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
}: UniqueAsset & { currency?: string }): Promise<SymbolProfile | never> {
|
}: AssetProfileIdentifier & { currency?: string }): Promise<
|
||||||
|
SymbolProfile | never
|
||||||
|
> {
|
||||||
try {
|
try {
|
||||||
if (dataSource === 'MANUAL') {
|
if (dataSource === 'MANUAL') {
|
||||||
return this.symbolProfileService.add({
|
return this.symbolProfileService.add({
|
||||||
@ -89,7 +98,10 @@ export class AdminService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
public async deleteProfileData({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}: AssetProfileIdentifier) {
|
||||||
await this.marketDataService.deleteMany({ dataSource, symbol });
|
await this.marketDataService.deleteMany({ dataSource, symbol });
|
||||||
await this.symbolProfileService.delete({ dataSource, symbol });
|
await this.symbolProfileService.delete({ dataSource, symbol });
|
||||||
}
|
}
|
||||||
@ -147,7 +159,16 @@ export class AdminService {
|
|||||||
[{ symbol: 'asc' }];
|
[{ symbol: 'asc' }];
|
||||||
const where: Prisma.SymbolProfileWhereInput = {};
|
const where: Prisma.SymbolProfileWhereInput = {};
|
||||||
|
|
||||||
if (presetId === 'CURRENCIES') {
|
if (presetId === 'BENCHMARKS') {
|
||||||
|
const benchmarkAssetProfiles =
|
||||||
|
await this.benchmarkService.getBenchmarkAssetProfiles();
|
||||||
|
|
||||||
|
where.id = {
|
||||||
|
in: benchmarkAssetProfiles.map(({ id }) => {
|
||||||
|
return id;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
} else if (presetId === 'CURRENCIES') {
|
||||||
return this.getMarketDataForCurrencies();
|
return this.getMarketDataForCurrencies();
|
||||||
} else if (
|
} else if (
|
||||||
presetId === 'ETF_WITHOUT_COUNTRIES' ||
|
presetId === 'ETF_WITHOUT_COUNTRIES' ||
|
||||||
@ -197,104 +218,129 @@ export class AdminService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let [assetProfiles, count] = await Promise.all([
|
const extendedPrismaClient = this.getExtendedPrismaClient();
|
||||||
this.prismaService.symbolProfile.findMany({
|
|
||||||
orderBy,
|
try {
|
||||||
skip,
|
let [assetProfiles, count] = await Promise.all([
|
||||||
take,
|
extendedPrismaClient.symbolProfile.findMany({
|
||||||
where,
|
orderBy,
|
||||||
select: {
|
skip,
|
||||||
_count: {
|
take,
|
||||||
select: { Order: true }
|
where,
|
||||||
},
|
select: {
|
||||||
assetClass: true,
|
_count: {
|
||||||
assetSubClass: true,
|
select: { Order: true }
|
||||||
comment: true,
|
},
|
||||||
countries: true,
|
assetClass: true,
|
||||||
currency: true,
|
assetSubClass: true,
|
||||||
dataSource: true,
|
comment: true,
|
||||||
id: true,
|
countries: true,
|
||||||
name: true,
|
currency: true,
|
||||||
Order: {
|
dataSource: true,
|
||||||
orderBy: [{ date: 'asc' }],
|
id: true,
|
||||||
select: { date: true },
|
isUsedByUsersWithSubscription: true,
|
||||||
take: 1
|
name: true,
|
||||||
},
|
Order: {
|
||||||
scraperConfiguration: true,
|
orderBy: [{ date: 'asc' }],
|
||||||
sectors: true,
|
select: { date: true },
|
||||||
symbol: 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 = marketData.length;
|
||||||
({
|
|
||||||
_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;
|
return {
|
||||||
|
count,
|
||||||
|
marketData
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await extendedPrismaClient.$disconnect();
|
||||||
|
|
||||||
|
Logger.debug('Disconnect extended prisma client', 'AdminService');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
|
||||||
count,
|
|
||||||
marketData
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getMarketDataBySymbol({
|
public async getMarketDataBySymbol({
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
}: UniqueAsset): Promise<AdminMarketDataDetails> {
|
}: AssetProfileIdentifier): Promise<AdminMarketDataDetails> {
|
||||||
|
let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0;
|
||||||
|
let currency: EnhancedSymbolProfile['currency'] = '-';
|
||||||
|
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
|
||||||
|
|
||||||
|
if (isCurrency(getCurrencyFromSymbol(symbol))) {
|
||||||
|
currency = getCurrencyFromSymbol(symbol);
|
||||||
|
({ activitiesCount, dateOfFirstActivity } =
|
||||||
|
await this.orderService.getStatisticsByCurrency(currency));
|
||||||
|
}
|
||||||
|
|
||||||
const [[assetProfile], marketData] = await Promise.all([
|
const [[assetProfile], marketData] = await Promise.all([
|
||||||
this.symbolProfileService.getSymbolProfiles([
|
this.symbolProfileService.getSymbolProfiles([
|
||||||
{
|
{
|
||||||
@ -322,8 +368,11 @@ export class AdminService {
|
|||||||
return {
|
return {
|
||||||
marketData,
|
marketData,
|
||||||
assetProfile: assetProfile ?? {
|
assetProfile: assetProfile ?? {
|
||||||
symbol,
|
activitiesCount,
|
||||||
currency: '-'
|
currency,
|
||||||
|
dataSource,
|
||||||
|
dateOfFirstActivity,
|
||||||
|
symbol
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -342,7 +391,7 @@ export class AdminService {
|
|||||||
symbol,
|
symbol,
|
||||||
symbolMapping,
|
symbolMapping,
|
||||||
url
|
url
|
||||||
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
}: AssetProfileIdentifier & Prisma.SymbolProfileUpdateInput) {
|
||||||
const symbolProfileOverrides = {
|
const symbolProfileOverrides = {
|
||||||
assetClass: assetClass as AssetClass,
|
assetClass: assetClass as AssetClass,
|
||||||
assetSubClass: assetSubClass as AssetSubClass,
|
assetSubClass: assetSubClass as AssetSubClass,
|
||||||
@ -350,28 +399,28 @@ export class AdminService {
|
|||||||
url: url as string
|
url: url as string
|
||||||
};
|
};
|
||||||
|
|
||||||
const updatedSymbolProfile: Prisma.SymbolProfileUpdateInput & UniqueAsset =
|
const updatedSymbolProfile: AssetProfileIdentifier &
|
||||||
{
|
Prisma.SymbolProfileUpdateInput = {
|
||||||
comment,
|
comment,
|
||||||
countries,
|
countries,
|
||||||
currency,
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
holdings,
|
holdings,
|
||||||
scraperConfiguration,
|
scraperConfiguration,
|
||||||
sectors,
|
sectors,
|
||||||
symbol,
|
symbol,
|
||||||
symbolMapping,
|
symbolMapping,
|
||||||
...(dataSource === 'MANUAL'
|
...(dataSource === 'MANUAL'
|
||||||
? { assetClass, assetSubClass, name, url }
|
? { assetClass, assetSubClass, name, url }
|
||||||
: {
|
: {
|
||||||
SymbolProfileOverrides: {
|
SymbolProfileOverrides: {
|
||||||
upsert: {
|
upsert: {
|
||||||
create: symbolProfileOverrides,
|
create: symbolProfileOverrides,
|
||||||
update: symbolProfileOverrides
|
update: symbolProfileOverrides
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
};
|
})
|
||||||
|
};
|
||||||
|
|
||||||
await this.symbolProfileService.updateSymbolProfile(updatedSymbolProfile);
|
await this.symbolProfileService.updateSymbolProfile(updatedSymbolProfile);
|
||||||
|
|
||||||
@ -403,36 +452,97 @@ export class AdminService {
|
|||||||
return response;
|
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> {
|
private async getMarketDataForCurrencies(): Promise<AdminMarketData> {
|
||||||
const marketDataItems = await this.prismaService.marketData.groupBy({
|
const marketDataItems = await this.prismaService.marketData.groupBy({
|
||||||
_count: true,
|
_count: true,
|
||||||
by: ['dataSource', 'symbol']
|
by: ['dataSource', 'symbol']
|
||||||
});
|
});
|
||||||
|
|
||||||
const marketData: AdminMarketDataItem[] = this.exchangeRateDataService
|
const marketDataPromise: Promise<AdminMarketDataItem>[] =
|
||||||
.getCurrencyPairs()
|
this.exchangeRateDataService
|
||||||
.map(({ dataSource, symbol }) => {
|
.getCurrencyPairs()
|
||||||
const marketDataItemCount =
|
.map(async ({ dataSource, symbol }) => {
|
||||||
marketDataItems.find((marketDataItem) => {
|
let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0;
|
||||||
return (
|
let currency: EnhancedSymbolProfile['currency'] = '-';
|
||||||
marketDataItem.dataSource === dataSource &&
|
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
|
||||||
marketDataItem.symbol === symbol
|
|
||||||
);
|
|
||||||
})?._count ?? 0;
|
|
||||||
|
|
||||||
return {
|
if (isCurrency(getCurrencyFromSymbol(symbol))) {
|
||||||
dataSource,
|
currency = getCurrencyFromSymbol(symbol);
|
||||||
marketDataItemCount,
|
({ activitiesCount, dateOfFirstActivity } =
|
||||||
symbol,
|
await this.orderService.getStatisticsByCurrency(currency));
|
||||||
assetClass: AssetClass.LIQUIDITY,
|
}
|
||||||
countriesCount: 0,
|
|
||||||
currency: symbol.replace(DEFAULT_CURRENCY, ''),
|
|
||||||
id: undefined,
|
|
||||||
name: symbol,
|
|
||||||
sectorsCount: 0
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
|
const marketDataItemCount =
|
||||||
|
marketDataItems.find((marketDataItem) => {
|
||||||
|
return (
|
||||||
|
marketDataItem.dataSource === dataSource &&
|
||||||
|
marketDataItem.symbol === symbol
|
||||||
|
);
|
||||||
|
})?._count ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
activitiesCount,
|
||||||
|
currency,
|
||||||
|
dataSource,
|
||||||
|
marketDataItemCount,
|
||||||
|
symbol,
|
||||||
|
assetClass: AssetClass.LIQUIDITY,
|
||||||
|
assetSubClass: AssetSubClass.CASH,
|
||||||
|
countriesCount: 0,
|
||||||
|
date: dateOfFirstActivity,
|
||||||
|
id: undefined,
|
||||||
|
name: symbol,
|
||||||
|
sectorsCount: 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const marketData = await Promise.all(marketDataPromise);
|
||||||
return { marketData, count: marketData.length };
|
return { marketData, count: marketData.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
|
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
|
||||||
|
|
||||||
import { AssetClass, AssetSubClass, Prisma } from '@prisma/client';
|
import { AssetClass, AssetSubClass, Prisma } from '@prisma/client';
|
||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsISO4217CurrencyCode,
|
|
||||||
IsObject,
|
IsObject,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
@ -26,7 +27,7 @@ export class UpdateAssetProfileDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
countries?: Prisma.InputJsonArray;
|
countries?: Prisma.InputJsonArray;
|
||||||
|
|
||||||
@IsISO4217CurrencyCode()
|
@IsCurrencyCode()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
currency?: string;
|
currency?: string;
|
||||||
|
|
||||||
|
@ -25,6 +25,7 @@ import { AccessModule } from './access/access.module';
|
|||||||
import { AccountModule } from './account/account.module';
|
import { AccountModule } from './account/account.module';
|
||||||
import { AdminModule } from './admin/admin.module';
|
import { AdminModule } from './admin/admin.module';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
|
import { AssetModule } from './asset/asset.module';
|
||||||
import { AuthDeviceModule } from './auth-device/auth-device.module';
|
import { AuthDeviceModule } from './auth-device/auth-device.module';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { BenchmarkModule } from './benchmark/benchmark.module';
|
import { BenchmarkModule } from './benchmark/benchmark.module';
|
||||||
@ -51,6 +52,7 @@ import { UserModule } from './user/user.module';
|
|||||||
AdminModule,
|
AdminModule,
|
||||||
AccessModule,
|
AccessModule,
|
||||||
AccountModule,
|
AccountModule,
|
||||||
|
AssetModule,
|
||||||
AuthDeviceModule,
|
AuthDeviceModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
BenchmarkModule,
|
BenchmarkModule,
|
||||||
|
29
apps/api/src/app/asset/asset.controller.ts
Normal file
29
apps/api/src/app/asset/asset.controller.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { AdminService } from '@ghostfolio/api/app/admin/admin.service';
|
||||||
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
||||||
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
|
||||||
|
import type { AdminMarketDataDetails } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
|
import { Controller, Get, Param, UseInterceptors } from '@nestjs/common';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
|
import { pick } from 'lodash';
|
||||||
|
|
||||||
|
@Controller('asset')
|
||||||
|
export class AssetController {
|
||||||
|
public constructor(private readonly adminService: AdminService) {}
|
||||||
|
|
||||||
|
@Get(':dataSource/:symbol')
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
|
public async getAsset(
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
): Promise<AdminMarketDataDetails> {
|
||||||
|
const { assetProfile, marketData } =
|
||||||
|
await this.adminService.getMarketDataBySymbol({ dataSource, symbol });
|
||||||
|
|
||||||
|
return {
|
||||||
|
marketData,
|
||||||
|
assetProfile: pick(assetProfile, ['dataSource', 'name', 'symbol'])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
17
apps/api/src/app/asset/asset.module.ts
Normal file
17
apps/api/src/app/asset/asset.module.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { AdminModule } from '@ghostfolio/api/app/admin/admin.module';
|
||||||
|
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
||||||
|
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
|
||||||
|
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { AssetController } from './asset.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [AssetController],
|
||||||
|
imports: [
|
||||||
|
AdminModule,
|
||||||
|
TransformDataSourceInRequestModule,
|
||||||
|
TransformDataSourceInResponseModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class AssetModule {}
|
@ -4,9 +4,9 @@ import { getInterval } from '@ghostfolio/api/helper/portfolio.helper';
|
|||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
|
||||||
import type {
|
import type {
|
||||||
|
AssetProfileIdentifier,
|
||||||
BenchmarkMarketDataDetails,
|
BenchmarkMarketDataDetails,
|
||||||
BenchmarkResponse,
|
BenchmarkResponse
|
||||||
UniqueAsset
|
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { permissions } from '@ghostfolio/common/permissions';
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
|
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
|
||||||
@ -41,7 +41,9 @@ export class BenchmarkController {
|
|||||||
@HasPermission(permissions.accessAdminControl)
|
@HasPermission(permissions.accessAdminControl)
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) {
|
public async addBenchmark(
|
||||||
|
@Body() { dataSource, symbol }: AssetProfileIdentifier
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
const benchmark = await this.benchmarkService.addBenchmark({
|
const benchmark = await this.benchmarkService.addBenchmark({
|
||||||
dataSource,
|
dataSource,
|
||||||
@ -105,7 +107,7 @@ export class BenchmarkController {
|
|||||||
@Get(':dataSource/:symbol/:startDateString')
|
@Get(':dataSource/:symbol/:startDateString')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
public async getBenchmarkMarketDataBySymbol(
|
public async getBenchmarkMarketDataForUser(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('startDateString') startDateString: string,
|
@Param('startDateString') startDateString: string,
|
||||||
@Param('symbol') symbol: string,
|
@Param('symbol') symbol: string,
|
||||||
@ -117,7 +119,7 @@ export class BenchmarkController {
|
|||||||
);
|
);
|
||||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||||
|
|
||||||
return this.benchmarkService.getMarketDataBySymbol({
|
return this.benchmarkService.getMarketDataForUser({
|
||||||
dataSource,
|
dataSource,
|
||||||
endDate,
|
endDate,
|
||||||
startDate,
|
startDate,
|
||||||
|
@ -17,11 +17,11 @@ import {
|
|||||||
resetHours
|
resetHours
|
||||||
} from '@ghostfolio/common/helper';
|
} from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
|
AssetProfileIdentifier,
|
||||||
Benchmark,
|
Benchmark,
|
||||||
BenchmarkMarketDataDetails,
|
BenchmarkMarketDataDetails,
|
||||||
BenchmarkProperty,
|
BenchmarkProperty,
|
||||||
BenchmarkResponse,
|
BenchmarkResponse
|
||||||
UniqueAsset
|
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { BenchmarkTrend } from '@ghostfolio/common/types';
|
import { BenchmarkTrend } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
@ -29,15 +29,19 @@ import { Injectable, Logger } from '@nestjs/common';
|
|||||||
import { SymbolProfile } from '@prisma/client';
|
import { SymbolProfile } from '@prisma/client';
|
||||||
import { Big } from 'big.js';
|
import { Big } from 'big.js';
|
||||||
import {
|
import {
|
||||||
|
addHours,
|
||||||
differenceInDays,
|
differenceInDays,
|
||||||
eachDayOfInterval,
|
eachDayOfInterval,
|
||||||
format,
|
format,
|
||||||
|
isAfter,
|
||||||
isSameDay,
|
isSameDay,
|
||||||
subDays
|
subDays
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
import { isNumber, last, uniqBy } from 'lodash';
|
import { isNumber, last, uniqBy } from 'lodash';
|
||||||
import ms from 'ms';
|
import ms from 'ms';
|
||||||
|
|
||||||
|
import { BenchmarkValue } from './interfaces/benchmark-value.interface';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BenchmarkService {
|
export class BenchmarkService {
|
||||||
private readonly CACHE_KEY_BENCHMARKS = 'BENCHMARKS';
|
private readonly CACHE_KEY_BENCHMARKS = 'BENCHMARKS';
|
||||||
@ -61,7 +65,10 @@ export class BenchmarkService {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getBenchmarkTrends({ dataSource, symbol }: UniqueAsset) {
|
public async getBenchmarkTrends({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}: AssetProfileIdentifier) {
|
||||||
const historicalData = await this.marketDataService.marketDataItems({
|
const historicalData = await this.marketDataService.marketDataItems({
|
||||||
orderBy: {
|
orderBy: {
|
||||||
date: 'desc'
|
date: 'desc'
|
||||||
@ -89,94 +96,26 @@ export class BenchmarkService {
|
|||||||
enableSharing = false,
|
enableSharing = false,
|
||||||
useCache = true
|
useCache = true
|
||||||
} = {}): Promise<BenchmarkResponse['benchmarks']> {
|
} = {}): Promise<BenchmarkResponse['benchmarks']> {
|
||||||
let benchmarks: BenchmarkResponse['benchmarks'];
|
|
||||||
|
|
||||||
if (useCache) {
|
if (useCache) {
|
||||||
try {
|
try {
|
||||||
benchmarks = JSON.parse(
|
const cachedBenchmarkValue = await this.redisCacheService.get(
|
||||||
await this.redisCacheService.get(this.CACHE_KEY_BENCHMARKS)
|
this.CACHE_KEY_BENCHMARKS
|
||||||
);
|
);
|
||||||
|
|
||||||
if (benchmarks) {
|
const { benchmarks, expiration }: BenchmarkValue =
|
||||||
return benchmarks;
|
JSON.parse(cachedBenchmarkValue);
|
||||||
|
|
||||||
|
if (isAfter(new Date(), new Date(expiration))) {
|
||||||
|
this.calculateAndCacheBenchmarks({
|
||||||
|
enableSharing
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return benchmarks;
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles({
|
return this.calculateAndCacheBenchmarks({ enableSharing });
|
||||||
enableSharing
|
|
||||||
});
|
|
||||||
|
|
||||||
const promisesAllTimeHighs: Promise<{ date: Date; marketPrice: number }>[] =
|
|
||||||
[];
|
|
||||||
const promisesBenchmarkTrends: Promise<{
|
|
||||||
trend50d: BenchmarkTrend;
|
|
||||||
trend200d: BenchmarkTrend;
|
|
||||||
}>[] = [];
|
|
||||||
|
|
||||||
const quotes = await this.dataProviderService.getQuotes({
|
|
||||||
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
|
||||||
return { dataSource, symbol };
|
|
||||||
}),
|
|
||||||
requestTimeout: ms('30 seconds'),
|
|
||||||
useCache: false
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
|
|
||||||
promisesAllTimeHighs.push(
|
|
||||||
this.marketDataService.getMax({ dataSource, symbol })
|
|
||||||
);
|
|
||||||
promisesBenchmarkTrends.push(
|
|
||||||
this.getBenchmarkTrends({ dataSource, symbol })
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [allTimeHighs, benchmarkTrends] = await Promise.all([
|
|
||||||
Promise.all(promisesAllTimeHighs),
|
|
||||||
Promise.all(promisesBenchmarkTrends)
|
|
||||||
]);
|
|
||||||
let storeInCache = true;
|
|
||||||
|
|
||||||
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
|
|
||||||
const { marketPrice } =
|
|
||||||
quotes[benchmarkAssetProfiles[index].symbol] ?? {};
|
|
||||||
|
|
||||||
let performancePercentFromAllTimeHigh = 0;
|
|
||||||
|
|
||||||
if (allTimeHigh?.marketPrice && marketPrice) {
|
|
||||||
performancePercentFromAllTimeHigh = this.calculateChangeInPercentage(
|
|
||||||
allTimeHigh.marketPrice,
|
|
||||||
marketPrice
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
storeInCache = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
marketCondition: this.getMarketCondition(
|
|
||||||
performancePercentFromAllTimeHigh
|
|
||||||
),
|
|
||||||
name: benchmarkAssetProfiles[index].name,
|
|
||||||
performances: {
|
|
||||||
allTimeHigh: {
|
|
||||||
date: allTimeHigh?.date,
|
|
||||||
performancePercent: performancePercentFromAllTimeHigh
|
|
||||||
}
|
|
||||||
},
|
|
||||||
trend50d: benchmarkTrends[index].trend50d,
|
|
||||||
trend200d: benchmarkTrends[index].trend200d
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
if (storeInCache) {
|
|
||||||
await this.redisCacheService.set(
|
|
||||||
this.CACHE_KEY_BENCHMARKS,
|
|
||||||
JSON.stringify(benchmarks),
|
|
||||||
ms('2 hours') / 1000
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return benchmarks;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getBenchmarkAssetProfiles({
|
public async getBenchmarkAssetProfiles({
|
||||||
@ -213,7 +152,7 @@ export class BenchmarkService {
|
|||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getMarketDataBySymbol({
|
public async getMarketDataForUser({
|
||||||
dataSource,
|
dataSource,
|
||||||
endDate = new Date(),
|
endDate = new Date(),
|
||||||
startDate,
|
startDate,
|
||||||
@ -223,7 +162,7 @@ export class BenchmarkService {
|
|||||||
endDate?: Date;
|
endDate?: Date;
|
||||||
startDate: Date;
|
startDate: Date;
|
||||||
userCurrency: string;
|
userCurrency: string;
|
||||||
} & UniqueAsset): Promise<BenchmarkMarketDataDetails> {
|
} & AssetProfileIdentifier): Promise<BenchmarkMarketDataDetails> {
|
||||||
const marketData: { date: string; value: number }[] = [];
|
const marketData: { date: string; value: number }[] = [];
|
||||||
|
|
||||||
const days = differenceInDays(endDate, startDate) + 1;
|
const days = differenceInDays(endDate, startDate) + 1;
|
||||||
@ -343,7 +282,7 @@ export class BenchmarkService {
|
|||||||
public async addBenchmark({
|
public async addBenchmark({
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
}: UniqueAsset): Promise<Partial<SymbolProfile>> {
|
}: AssetProfileIdentifier): Promise<Partial<SymbolProfile>> {
|
||||||
const assetProfile = await this.prismaService.symbolProfile.findFirst({
|
const assetProfile = await this.prismaService.symbolProfile.findFirst({
|
||||||
where: {
|
where: {
|
||||||
dataSource,
|
dataSource,
|
||||||
@ -380,7 +319,7 @@ export class BenchmarkService {
|
|||||||
public async deleteBenchmark({
|
public async deleteBenchmark({
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
}: UniqueAsset): Promise<Partial<SymbolProfile>> {
|
}: AssetProfileIdentifier): Promise<Partial<SymbolProfile>> {
|
||||||
const assetProfile = await this.prismaService.symbolProfile.findFirst({
|
const assetProfile = await this.prismaService.symbolProfile.findFirst({
|
||||||
where: {
|
where: {
|
||||||
dataSource,
|
dataSource,
|
||||||
@ -414,10 +353,99 @@ export class BenchmarkService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async calculateAndCacheBenchmarks({
|
||||||
|
enableSharing = false
|
||||||
|
}): Promise<BenchmarkResponse['benchmarks']> {
|
||||||
|
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles({
|
||||||
|
enableSharing
|
||||||
|
});
|
||||||
|
|
||||||
|
const promisesAllTimeHighs: Promise<{ date: Date; marketPrice: number }>[] =
|
||||||
|
[];
|
||||||
|
const promisesBenchmarkTrends: Promise<{
|
||||||
|
trend50d: BenchmarkTrend;
|
||||||
|
trend200d: BenchmarkTrend;
|
||||||
|
}>[] = [];
|
||||||
|
|
||||||
|
const quotes = await this.dataProviderService.getQuotes({
|
||||||
|
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
||||||
|
return { dataSource, symbol };
|
||||||
|
}),
|
||||||
|
requestTimeout: ms('30 seconds'),
|
||||||
|
useCache: false
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
|
||||||
|
promisesAllTimeHighs.push(
|
||||||
|
this.marketDataService.getMax({ dataSource, symbol })
|
||||||
|
);
|
||||||
|
promisesBenchmarkTrends.push(
|
||||||
|
this.getBenchmarkTrends({ dataSource, symbol })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [allTimeHighs, benchmarkTrends] = await Promise.all([
|
||||||
|
Promise.all(promisesAllTimeHighs),
|
||||||
|
Promise.all(promisesBenchmarkTrends)
|
||||||
|
]);
|
||||||
|
let storeInCache = true;
|
||||||
|
|
||||||
|
const benchmarks = allTimeHighs.map((allTimeHigh, index) => {
|
||||||
|
const { marketPrice } =
|
||||||
|
quotes[benchmarkAssetProfiles[index].symbol] ?? {};
|
||||||
|
|
||||||
|
let performancePercentFromAllTimeHigh = 0;
|
||||||
|
|
||||||
|
if (allTimeHigh?.marketPrice && marketPrice) {
|
||||||
|
performancePercentFromAllTimeHigh = this.calculateChangeInPercentage(
|
||||||
|
allTimeHigh.marketPrice,
|
||||||
|
marketPrice
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
storeInCache = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
dataSource: benchmarkAssetProfiles[index].dataSource,
|
||||||
|
marketCondition: this.getMarketCondition(
|
||||||
|
performancePercentFromAllTimeHigh
|
||||||
|
),
|
||||||
|
name: benchmarkAssetProfiles[index].name,
|
||||||
|
performances: {
|
||||||
|
allTimeHigh: {
|
||||||
|
date: allTimeHigh?.date,
|
||||||
|
performancePercent:
|
||||||
|
performancePercentFromAllTimeHigh >= 0
|
||||||
|
? 0
|
||||||
|
: performancePercentFromAllTimeHigh
|
||||||
|
}
|
||||||
|
},
|
||||||
|
symbol: benchmarkAssetProfiles[index].symbol,
|
||||||
|
trend50d: benchmarkTrends[index].trend50d,
|
||||||
|
trend200d: benchmarkTrends[index].trend200d
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (storeInCache) {
|
||||||
|
const expiration = addHours(new Date(), 2);
|
||||||
|
|
||||||
|
await this.redisCacheService.set(
|
||||||
|
this.CACHE_KEY_BENCHMARKS,
|
||||||
|
JSON.stringify(<BenchmarkValue>{
|
||||||
|
benchmarks,
|
||||||
|
expiration: expiration.getTime()
|
||||||
|
}),
|
||||||
|
ms('12 hours') / 1000
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return benchmarks;
|
||||||
|
}
|
||||||
|
|
||||||
private getMarketCondition(
|
private getMarketCondition(
|
||||||
aPerformanceInPercent: number
|
aPerformanceInPercent: number
|
||||||
): Benchmark['marketCondition'] {
|
): Benchmark['marketCondition'] {
|
||||||
if (aPerformanceInPercent === 0) {
|
if (aPerformanceInPercent >= 0) {
|
||||||
return 'ALL_TIME_HIGH';
|
return 'ALL_TIME_HIGH';
|
||||||
} else if (aPerformanceInPercent <= -0.2) {
|
} else if (aPerformanceInPercent <= -0.2) {
|
||||||
return 'BEAR_MARKET';
|
return 'BEAR_MARKET';
|
||||||
|
@ -0,0 +1,6 @@
|
|||||||
|
import { BenchmarkResponse } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
|
export interface BenchmarkValue {
|
||||||
|
benchmarks: BenchmarkResponse['benchmarks'];
|
||||||
|
expiration: number;
|
||||||
|
}
|
2
apps/api/src/app/cache/cache.controller.ts
vendored
2
apps/api/src/app/cache/cache.controller.ts
vendored
@ -14,6 +14,6 @@ export class CacheController {
|
|||||||
@Post('flush')
|
@Post('flush')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async flushCache(): Promise<void> {
|
public async flushCache(): Promise<void> {
|
||||||
return this.redisCacheService.reset();
|
await this.redisCacheService.reset();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -19,7 +19,7 @@ import {
|
|||||||
getAssetProfileIdentifier,
|
getAssetProfileIdentifier,
|
||||||
parseDate
|
parseDate
|
||||||
} from '@ghostfolio/common/helper';
|
} from '@ghostfolio/common/helper';
|
||||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
|
||||||
import {
|
import {
|
||||||
AccountWithPlatform,
|
AccountWithPlatform,
|
||||||
OrderWithAccount,
|
OrderWithAccount,
|
||||||
@ -51,7 +51,7 @@ export class ImportService {
|
|||||||
dataSource,
|
dataSource,
|
||||||
symbol,
|
symbol,
|
||||||
userCurrency
|
userCurrency
|
||||||
}: UniqueAsset & { userCurrency: string }): Promise<Activity[]> {
|
}: AssetProfileIdentifier & { userCurrency: string }): Promise<Activity[]> {
|
||||||
try {
|
try {
|
||||||
const { firstBuyDate, historicalData, orders } =
|
const { firstBuyDate, historicalData, orders } =
|
||||||
await this.portfolioService.getPosition(dataSource, undefined, symbol);
|
await this.portfolioService.getPosition(dataSource, undefined, symbol);
|
||||||
@ -72,9 +72,13 @@ export class ImportService {
|
|||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const accounts = orders.map((order) => {
|
const accounts = orders
|
||||||
return order.Account;
|
.filter(({ Account }) => {
|
||||||
});
|
return !!Account;
|
||||||
|
})
|
||||||
|
.map(({ Account }) => {
|
||||||
|
return Account;
|
||||||
|
});
|
||||||
|
|
||||||
const Account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;
|
const Account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;
|
||||||
|
|
||||||
|
@ -7,7 +7,6 @@ import { ConfigurationModule } from '@ghostfolio/api/services/configuration/conf
|
|||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
|
||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
|
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { HttpException, Injectable } from '@nestjs/common';
|
import { HttpException, Injectable } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
@ -17,7 +17,7 @@ export class LogoService {
|
|||||||
public async getLogoByDataSourceAndSymbol({
|
public async getLogoByDataSourceAndSymbol({
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
}: UniqueAsset) {
|
}: AssetProfileIdentifier) {
|
||||||
if (!DataSource[dataSource]) {
|
if (!DataSource[dataSource]) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
|
||||||
|
import { IsAfter1970Constraint } from '@ghostfolio/common/validator-constraints/is-after-1970';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AssetClass,
|
AssetClass,
|
||||||
AssetSubClass,
|
AssetSubClass,
|
||||||
@ -10,12 +13,12 @@ import {
|
|||||||
IsArray,
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsISO4217CurrencyCode,
|
|
||||||
IsISO8601,
|
IsISO8601,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
Min
|
Min,
|
||||||
|
Validate
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { isString } from 'lodash';
|
import { isString } from 'lodash';
|
||||||
|
|
||||||
@ -39,10 +42,10 @@ export class CreateOrderDto {
|
|||||||
)
|
)
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
|
||||||
@IsISO4217CurrencyCode()
|
@IsCurrencyCode()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
@IsISO4217CurrencyCode()
|
@IsCurrencyCode()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
customCurrency?: string;
|
customCurrency?: string;
|
||||||
|
|
||||||
@ -51,6 +54,7 @@ export class CreateOrderDto {
|
|||||||
dataSource?: DataSource;
|
dataSource?: DataSource;
|
||||||
|
|
||||||
@IsISO8601()
|
@IsISO8601()
|
||||||
|
@Validate(IsAfter1970Constraint)
|
||||||
date: string;
|
date: string;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
|
@ -36,7 +36,7 @@ import { parseISO } from 'date-fns';
|
|||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { CreateOrderDto } from './create-order.dto';
|
import { CreateOrderDto } from './create-order.dto';
|
||||||
import { Activities } from './interfaces/activities.interface';
|
import { Activities, Activity } from './interfaces/activities.interface';
|
||||||
import { OrderService } from './order.service';
|
import { OrderService } from './order.service';
|
||||||
import { UpdateOrderDto } from './update-order.dto';
|
import { UpdateOrderDto } from './update-order.dto';
|
||||||
|
|
||||||
@ -66,7 +66,6 @@ export class OrderController {
|
|||||||
|
|
||||||
return this.orderService.deleteOrders({
|
return this.orderService.deleteOrders({
|
||||||
filters,
|
filters,
|
||||||
userCurrency: this.request.user.Settings.settings.baseCurrency,
|
|
||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -141,6 +140,38 @@ export class OrderController {
|
|||||||
return { activities, count };
|
return { activities, count };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
|
public async getOrderById(
|
||||||
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
||||||
|
@Param('id') id: string
|
||||||
|
): Promise<Activity> {
|
||||||
|
const impersonationUserId =
|
||||||
|
await this.impersonationService.validateImpersonationId(impersonationId);
|
||||||
|
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||||
|
|
||||||
|
const { activities } = await this.orderService.getOrders({
|
||||||
|
userCurrency,
|
||||||
|
userId: impersonationUserId || this.request.user.id,
|
||||||
|
withExcludedAccounts: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const activity = activities.find((activity) => {
|
||||||
|
return activity.id === id;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!activity) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
|
StatusCodes.NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return activity;
|
||||||
|
}
|
||||||
|
|
||||||
@HasPermission(permissions.createOrder)
|
@HasPermission(permissions.createOrder)
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
@ -10,7 +10,11 @@ import {
|
|||||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
||||||
import { Filter, UniqueAsset } from '@ghostfolio/common/interfaces';
|
import {
|
||||||
|
AssetProfileIdentifier,
|
||||||
|
EnhancedSymbolProfile,
|
||||||
|
Filter
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
@ -42,6 +46,39 @@ export class OrderService {
|
|||||||
private readonly symbolProfileService: SymbolProfileService
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
public async assignTags({
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
tags,
|
||||||
|
userId
|
||||||
|
}: { tags: Tag[]; userId: string } & AssetProfileIdentifier) {
|
||||||
|
const orders = await this.prismaService.order.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
SymbolProfile: {
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
orders.map(({ id }) =>
|
||||||
|
this.prismaService.order.update({
|
||||||
|
data: {
|
||||||
|
tags: {
|
||||||
|
// The set operation replaces all existing connections with the provided ones
|
||||||
|
set: tags.map(({ id }) => {
|
||||||
|
return { id };
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
where: { id }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public async createOrder(
|
public async createOrder(
|
||||||
data: Prisma.OrderCreateInput & {
|
data: Prisma.OrderCreateInput & {
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
@ -180,7 +217,15 @@ export class OrderService {
|
|||||||
where
|
where
|
||||||
});
|
});
|
||||||
|
|
||||||
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(order.type)) {
|
const [symbolProfile] =
|
||||||
|
await this.symbolProfileService.getSymbolProfilesByIds([
|
||||||
|
order.symbolProfileId
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (
|
||||||
|
['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(order.type) ||
|
||||||
|
symbolProfile.activitiesCount === 0
|
||||||
|
) {
|
||||||
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -196,18 +241,16 @@ export class OrderService {
|
|||||||
|
|
||||||
public async deleteOrders({
|
public async deleteOrders({
|
||||||
filters,
|
filters,
|
||||||
userCurrency,
|
|
||||||
userId
|
userId
|
||||||
}: {
|
}: {
|
||||||
filters?: Filter[];
|
filters?: Filter[];
|
||||||
userCurrency: string;
|
|
||||||
userId: string;
|
userId: string;
|
||||||
}): Promise<number> {
|
}): Promise<number> {
|
||||||
const { activities } = await this.getOrders({
|
const { activities } = await this.getOrders({
|
||||||
filters,
|
filters,
|
||||||
userId,
|
userId,
|
||||||
userCurrency,
|
|
||||||
includeDrafts: true,
|
includeDrafts: true,
|
||||||
|
userCurrency: undefined,
|
||||||
withExcludedAccounts: true
|
withExcludedAccounts: true
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -221,6 +264,19 @@ export class OrderService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const symbolProfiles =
|
||||||
|
await this.symbolProfileService.getSymbolProfilesByIds(
|
||||||
|
activities.map(({ symbolProfileId }) => {
|
||||||
|
return symbolProfileId;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const { activitiesCount, id } of symbolProfiles) {
|
||||||
|
if (activitiesCount === 0) {
|
||||||
|
await this.symbolProfileService.deleteById(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.eventEmitter.emit(
|
this.eventEmitter.emit(
|
||||||
PortfolioChangedEvent.getName(),
|
PortfolioChangedEvent.getName(),
|
||||||
new PortfolioChangedEvent({ userId })
|
new PortfolioChangedEvent({ userId })
|
||||||
@ -229,7 +285,7 @@ export class OrderService {
|
|||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getLatestOrder({ dataSource, symbol }: UniqueAsset) {
|
public async getLatestOrder({ dataSource, symbol }: AssetProfileIdentifier) {
|
||||||
return this.prismaService.order.findFirst({
|
return this.prismaService.order.findFirst({
|
||||||
orderBy: {
|
orderBy: {
|
||||||
date: 'desc'
|
date: 'desc'
|
||||||
@ -268,7 +324,8 @@ export class OrderService {
|
|||||||
withExcludedAccounts?: boolean;
|
withExcludedAccounts?: boolean;
|
||||||
}): Promise<Activities> {
|
}): Promise<Activities> {
|
||||||
let orderBy: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput> = [
|
let orderBy: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput> = [
|
||||||
{ date: 'asc' }
|
{ date: 'asc' },
|
||||||
|
{ id: 'asc' }
|
||||||
];
|
];
|
||||||
const where: Prisma.OrderWhereInput = { userId };
|
const where: Prisma.OrderWhereInput = { userId };
|
||||||
|
|
||||||
@ -288,10 +345,14 @@ export class OrderService {
|
|||||||
ACCOUNT: filtersByAccount,
|
ACCOUNT: filtersByAccount,
|
||||||
ASSET_CLASS: filtersByAssetClass,
|
ASSET_CLASS: filtersByAssetClass,
|
||||||
TAG: filtersByTag
|
TAG: filtersByTag
|
||||||
} = groupBy(filters, (filter) => {
|
} = groupBy(filters, ({ type }) => {
|
||||||
return filter.type;
|
return type;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const searchQuery = filters?.find(({ type }) => {
|
||||||
|
return type === 'SEARCH_QUERY';
|
||||||
|
})?.id;
|
||||||
|
|
||||||
if (filtersByAccount?.length > 0) {
|
if (filtersByAccount?.length > 0) {
|
||||||
where.accountId = {
|
where.accountId = {
|
||||||
in: filtersByAccount.map(({ id }) => {
|
in: filtersByAccount.map(({ id }) => {
|
||||||
@ -333,6 +394,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) {
|
if (filtersByTag?.length > 0) {
|
||||||
where.tags = {
|
where.tags = {
|
||||||
some: {
|
some: {
|
||||||
@ -344,7 +429,7 @@ export class OrderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sortColumn) {
|
if (sortColumn) {
|
||||||
orderBy = [{ [sortColumn]: sortDirection }];
|
orderBy = [{ [sortColumn]: sortDirection }, { id: sortDirection }];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (types) {
|
if (types) {
|
||||||
@ -379,7 +464,7 @@ export class OrderService {
|
|||||||
this.prismaService.order.count({ where })
|
this.prismaService.order.count({ where })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const uniqueAssets = uniqBy(
|
const assetProfileIdentifiers = uniqBy(
|
||||||
orders.map(({ SymbolProfile }) => {
|
orders.map(({ SymbolProfile }) => {
|
||||||
return {
|
return {
|
||||||
dataSource: SymbolProfile.dataSource,
|
dataSource: SymbolProfile.dataSource,
|
||||||
@ -394,8 +479,9 @@ export class OrderService {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const assetProfiles =
|
const assetProfiles = await this.symbolProfileService.getSymbolProfiles(
|
||||||
await this.symbolProfileService.getSymbolProfiles(uniqueAssets);
|
assetProfileIdentifiers
|
||||||
|
);
|
||||||
|
|
||||||
const activities = orders.map((order) => {
|
const activities = orders.map((order) => {
|
||||||
const assetProfile = assetProfiles.find(({ dataSource, symbol }) => {
|
const assetProfile = assetProfiles.find(({ dataSource, symbol }) => {
|
||||||
@ -429,6 +515,26 @@ export class OrderService {
|
|||||||
return { activities, count };
|
return { activities, count };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getStatisticsByCurrency(
|
||||||
|
currency: EnhancedSymbolProfile['currency']
|
||||||
|
): Promise<{
|
||||||
|
activitiesCount: EnhancedSymbolProfile['activitiesCount'];
|
||||||
|
dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
|
||||||
|
}> {
|
||||||
|
const { _count, _min } = await this.prismaService.order.aggregate({
|
||||||
|
_count: true,
|
||||||
|
_min: {
|
||||||
|
date: true
|
||||||
|
},
|
||||||
|
where: { SymbolProfile: { currency } }
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
activitiesCount: _count as number,
|
||||||
|
dateOfFirstActivity: _min.date
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public async order(
|
public async order(
|
||||||
orderWhereUniqueInput: Prisma.OrderWhereUniqueInput
|
orderWhereUniqueInput: Prisma.OrderWhereUniqueInput
|
||||||
): Promise<Order | null> {
|
): Promise<Order | null> {
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
|
||||||
|
import { IsAfter1970Constraint } from '@ghostfolio/common/validator-constraints/is-after-1970';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AssetClass,
|
AssetClass,
|
||||||
AssetSubClass,
|
AssetSubClass,
|
||||||
@ -9,12 +12,12 @@ import { Transform, TransformFnParams } from 'class-transformer';
|
|||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsISO4217CurrencyCode,
|
|
||||||
IsISO8601,
|
IsISO8601,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
Min
|
Min,
|
||||||
|
Validate
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { isString } from 'lodash';
|
import { isString } from 'lodash';
|
||||||
|
|
||||||
@ -38,10 +41,10 @@ export class UpdateOrderDto {
|
|||||||
)
|
)
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
|
||||||
@IsISO4217CurrencyCode()
|
@IsCurrencyCode()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
@IsISO4217CurrencyCode()
|
@IsCurrencyCode()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
customCurrency?: string;
|
customCurrency?: string;
|
||||||
|
|
||||||
@ -49,6 +52,7 @@ export class UpdateOrderDto {
|
|||||||
dataSource: DataSource;
|
dataSource: DataSource;
|
||||||
|
|
||||||
@IsISO8601()
|
@IsISO8601()
|
||||||
|
@Validate(IsAfter1970Constraint)
|
||||||
date: string;
|
date: string;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
|
@ -1,5 +1,8 @@
|
|||||||
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
|
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
|
||||||
import { SymbolMetrics, UniqueAsset } from '@ghostfolio/common/interfaces';
|
import {
|
||||||
|
AssetProfileIdentifier,
|
||||||
|
SymbolMetrics
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
|
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
|
||||||
|
|
||||||
export class MWRPortfolioCalculator extends PortfolioCalculator {
|
export class MWRPortfolioCalculator extends PortfolioCalculator {
|
||||||
@ -27,7 +30,7 @@ export class MWRPortfolioCalculator extends PortfolioCalculator {
|
|||||||
};
|
};
|
||||||
start: Date;
|
start: Date;
|
||||||
step?: number;
|
step?: number;
|
||||||
} & UniqueAsset): SymbolMetrics {
|
} & AssetProfileIdentifier): SymbolMetrics {
|
||||||
throw new Error('Method not implemented.');
|
throw new Error('Method not implemented.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -19,12 +19,12 @@ import {
|
|||||||
resetHours
|
resetHours
|
||||||
} from '@ghostfolio/common/helper';
|
} from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
|
AssetProfileIdentifier,
|
||||||
DataProviderInfo,
|
DataProviderInfo,
|
||||||
HistoricalDataItem,
|
HistoricalDataItem,
|
||||||
InvestmentItem,
|
InvestmentItem,
|
||||||
ResponseError,
|
ResponseError,
|
||||||
SymbolMetrics,
|
SymbolMetrics
|
||||||
UniqueAsset
|
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
|
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
|
||||||
import { DateRange, GroupBy } from '@ghostfolio/common/types';
|
import { DateRange, GroupBy } from '@ghostfolio/common/types';
|
||||||
@ -300,6 +300,12 @@ export abstract class PortfolioCalculator {
|
|||||||
const errors: ResponseError['errors'] = [];
|
const errors: ResponseError['errors'] = [];
|
||||||
|
|
||||||
for (const item of lastTransactionPoint.items) {
|
for (const item of lastTransactionPoint.items) {
|
||||||
|
const feeInBaseCurrency = item.fee.mul(
|
||||||
|
exchangeRatesByCurrency[`${item.currency}${this.currency}`]?.[
|
||||||
|
lastTransactionPoint.date
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
const marketPriceInBaseCurrency = (
|
const marketPriceInBaseCurrency = (
|
||||||
marketSymbolMap[endDateString]?.[item.symbol] ?? item.averagePrice
|
marketSymbolMap[endDateString]?.[item.symbol] ?? item.averagePrice
|
||||||
).mul(
|
).mul(
|
||||||
@ -340,24 +346,25 @@ export abstract class PortfolioCalculator {
|
|||||||
hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors;
|
hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors;
|
||||||
|
|
||||||
positions.push({
|
positions.push({
|
||||||
dividend: totalDividend,
|
feeInBaseCurrency,
|
||||||
dividendInBaseCurrency: totalDividendInBaseCurrency,
|
|
||||||
timeWeightedInvestment,
|
timeWeightedInvestment,
|
||||||
timeWeightedInvestmentWithCurrencyEffect,
|
timeWeightedInvestmentWithCurrencyEffect,
|
||||||
|
dividend: totalDividend,
|
||||||
|
dividendInBaseCurrency: totalDividendInBaseCurrency,
|
||||||
averagePrice: item.averagePrice,
|
averagePrice: item.averagePrice,
|
||||||
currency: item.currency,
|
currency: item.currency,
|
||||||
dataSource: item.dataSource,
|
dataSource: item.dataSource,
|
||||||
fee: item.fee,
|
fee: item.fee,
|
||||||
firstBuyDate: item.firstBuyDate,
|
firstBuyDate: item.firstBuyDate,
|
||||||
grossPerformance: !hasErrors ? grossPerformance ?? null : null,
|
grossPerformance: !hasErrors ? (grossPerformance ?? null) : null,
|
||||||
grossPerformancePercentage: !hasErrors
|
grossPerformancePercentage: !hasErrors
|
||||||
? grossPerformancePercentage ?? null
|
? (grossPerformancePercentage ?? null)
|
||||||
: null,
|
: null,
|
||||||
grossPerformancePercentageWithCurrencyEffect: !hasErrors
|
grossPerformancePercentageWithCurrencyEffect: !hasErrors
|
||||||
? grossPerformancePercentageWithCurrencyEffect ?? null
|
? (grossPerformancePercentageWithCurrencyEffect ?? null)
|
||||||
: null,
|
: null,
|
||||||
grossPerformanceWithCurrencyEffect: !hasErrors
|
grossPerformanceWithCurrencyEffect: !hasErrors
|
||||||
? grossPerformanceWithCurrencyEffect ?? null
|
? (grossPerformanceWithCurrencyEffect ?? null)
|
||||||
: null,
|
: null,
|
||||||
investment: totalInvestment,
|
investment: totalInvestment,
|
||||||
investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect,
|
investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect,
|
||||||
@ -365,15 +372,15 @@ export abstract class PortfolioCalculator {
|
|||||||
marketSymbolMap[endDateString]?.[item.symbol]?.toNumber() ?? null,
|
marketSymbolMap[endDateString]?.[item.symbol]?.toNumber() ?? null,
|
||||||
marketPriceInBaseCurrency:
|
marketPriceInBaseCurrency:
|
||||||
marketPriceInBaseCurrency?.toNumber() ?? null,
|
marketPriceInBaseCurrency?.toNumber() ?? null,
|
||||||
netPerformance: !hasErrors ? netPerformance ?? null : null,
|
netPerformance: !hasErrors ? (netPerformance ?? null) : null,
|
||||||
netPerformancePercentage: !hasErrors
|
netPerformancePercentage: !hasErrors
|
||||||
? netPerformancePercentage ?? null
|
? (netPerformancePercentage ?? null)
|
||||||
: null,
|
: null,
|
||||||
netPerformancePercentageWithCurrencyEffect: !hasErrors
|
netPerformancePercentageWithCurrencyEffect: !hasErrors
|
||||||
? netPerformancePercentageWithCurrencyEffect ?? null
|
? (netPerformancePercentageWithCurrencyEffect ?? null)
|
||||||
: null,
|
: null,
|
||||||
netPerformanceWithCurrencyEffect: !hasErrors
|
netPerformanceWithCurrencyEffect: !hasErrors
|
||||||
? netPerformanceWithCurrencyEffect ?? null
|
? (netPerformanceWithCurrencyEffect ?? null)
|
||||||
: null,
|
: null,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
symbol: item.symbol,
|
symbol: item.symbol,
|
||||||
@ -898,7 +905,7 @@ export abstract class PortfolioCalculator {
|
|||||||
};
|
};
|
||||||
start: Date;
|
start: Date;
|
||||||
step?: number;
|
step?: number;
|
||||||
} & UniqueAsset): SymbolMetrics;
|
} & AssetProfileIdentifier): SymbolMetrics;
|
||||||
|
|
||||||
public getTransactionPoints() {
|
public getTransactionPoints() {
|
||||||
return this.transactionPoints;
|
return this.transactionPoints;
|
||||||
|
@ -168,6 +168,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
dividend: new Big('0'),
|
dividend: new Big('0'),
|
||||||
dividendInBaseCurrency: new Big('0'),
|
dividendInBaseCurrency: new Big('0'),
|
||||||
fee: new Big('3.2'),
|
fee: new Big('3.2'),
|
||||||
|
feeInBaseCurrency: new Big('3.2'),
|
||||||
firstBuyDate: '2021-11-22',
|
firstBuyDate: '2021-11-22',
|
||||||
grossPerformance: new Big('-12.6'),
|
grossPerformance: new Big('-12.6'),
|
||||||
grossPerformancePercentage: new Big('-0.04408677396780965649'),
|
grossPerformancePercentage: new Big('-0.04408677396780965649'),
|
||||||
|
@ -153,6 +153,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
dividend: new Big('0'),
|
dividend: new Big('0'),
|
||||||
dividendInBaseCurrency: new Big('0'),
|
dividendInBaseCurrency: new Big('0'),
|
||||||
fee: new Big('3.2'),
|
fee: new Big('3.2'),
|
||||||
|
feeInBaseCurrency: new Big('3.2'),
|
||||||
firstBuyDate: '2021-11-22',
|
firstBuyDate: '2021-11-22',
|
||||||
grossPerformance: new Big('-12.6'),
|
grossPerformance: new Big('-12.6'),
|
||||||
grossPerformancePercentage: new Big('-0.0440867739678096571'),
|
grossPerformancePercentage: new Big('-0.0440867739678096571'),
|
||||||
|
@ -138,6 +138,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
dividend: new Big('0'),
|
dividend: new Big('0'),
|
||||||
dividendInBaseCurrency: new Big('0'),
|
dividendInBaseCurrency: new Big('0'),
|
||||||
fee: new Big('1.55'),
|
fee: new Big('1.55'),
|
||||||
|
feeInBaseCurrency: new Big('1.55'),
|
||||||
firstBuyDate: '2021-11-30',
|
firstBuyDate: '2021-11-30',
|
||||||
grossPerformance: new Big('24.6'),
|
grossPerformance: new Big('24.6'),
|
||||||
grossPerformancePercentage: new Big('0.09004392386530014641'),
|
grossPerformancePercentage: new Big('0.09004392386530014641'),
|
||||||
|
@ -166,6 +166,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
dividend: new Big('0'),
|
dividend: new Big('0'),
|
||||||
dividendInBaseCurrency: new Big('0'),
|
dividendInBaseCurrency: new Big('0'),
|
||||||
fee: new Big('0'),
|
fee: new Big('0'),
|
||||||
|
feeInBaseCurrency: new Big('0'),
|
||||||
firstBuyDate: '2015-01-01',
|
firstBuyDate: '2015-01-01',
|
||||||
grossPerformance: new Big('27172.74'),
|
grossPerformance: new Big('27172.74'),
|
||||||
grossPerformancePercentage: new Big('42.41978276196153750666'),
|
grossPerformancePercentage: new Big('42.41978276196153750666'),
|
||||||
|
@ -123,6 +123,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
dividend: new Big('0'),
|
dividend: new Big('0'),
|
||||||
dividendInBaseCurrency: new Big('0'),
|
dividendInBaseCurrency: new Big('0'),
|
||||||
fee: new Big('49'),
|
fee: new Big('49'),
|
||||||
|
feeInBaseCurrency: new Big('49'),
|
||||||
firstBuyDate: '2021-09-01',
|
firstBuyDate: '2021-09-01',
|
||||||
grossPerformance: null,
|
grossPerformance: null,
|
||||||
grossPerformancePercentage: null,
|
grossPerformancePercentage: null,
|
||||||
|
@ -151,6 +151,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
dividend: new Big('0'),
|
dividend: new Big('0'),
|
||||||
dividendInBaseCurrency: new Big('0'),
|
dividendInBaseCurrency: new Big('0'),
|
||||||
fee: new Big('1'),
|
fee: new Big('1'),
|
||||||
|
feeInBaseCurrency: new Big('0.9238'),
|
||||||
firstBuyDate: '2023-01-03',
|
firstBuyDate: '2023-01-03',
|
||||||
grossPerformance: new Big('27.33'),
|
grossPerformance: new Big('27.33'),
|
||||||
grossPerformancePercentage: new Big('0.3066651705565529623'),
|
grossPerformancePercentage: new Big('0.3066651705565529623'),
|
||||||
@ -177,7 +178,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
valueInBaseCurrency: new Big('103.10483')
|
valueInBaseCurrency: new Big('103.10483')
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
totalFeesWithCurrencyEffect: new Big('1'),
|
totalFeesWithCurrencyEffect: new Big('0.9238'),
|
||||||
totalInterestWithCurrencyEffect: new Big('0'),
|
totalInterestWithCurrencyEffect: new Big('0'),
|
||||||
totalInvestment: new Big('89.12'),
|
totalInvestment: new Big('89.12'),
|
||||||
totalInvestmentWithCurrencyEffect: new Big('82.329056'),
|
totalInvestmentWithCurrencyEffect: new Big('82.329056'),
|
||||||
|
@ -123,6 +123,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
dividend: new Big('0'),
|
dividend: new Big('0'),
|
||||||
dividendInBaseCurrency: new Big('0'),
|
dividendInBaseCurrency: new Big('0'),
|
||||||
fee: new Big('0'),
|
fee: new Big('0'),
|
||||||
|
feeInBaseCurrency: new Big('0'),
|
||||||
firstBuyDate: '2022-01-01',
|
firstBuyDate: '2022-01-01',
|
||||||
grossPerformance: null,
|
grossPerformance: null,
|
||||||
grossPerformancePercentage: null,
|
grossPerformancePercentage: null,
|
||||||
|
@ -153,6 +153,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
dividend: new Big('0'),
|
dividend: new Big('0'),
|
||||||
dividendInBaseCurrency: new Big('0'),
|
dividendInBaseCurrency: new Big('0'),
|
||||||
fee: new Big('4.25'),
|
fee: new Big('4.25'),
|
||||||
|
feeInBaseCurrency: new Big('4.25'),
|
||||||
firstBuyDate: '2022-03-07',
|
firstBuyDate: '2022-03-07',
|
||||||
grossPerformance: new Big('21.93'),
|
grossPerformance: new Big('21.93'),
|
||||||
grossPerformancePercentage: new Big('0.15113417083448194384'),
|
grossPerformancePercentage: new Big('0.15113417083448194384'),
|
||||||
|
@ -183,6 +183,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
dividend: new Big('0'),
|
dividend: new Big('0'),
|
||||||
dividendInBaseCurrency: new Big('0'),
|
dividendInBaseCurrency: new Big('0'),
|
||||||
fee: new Big('0'),
|
fee: new Big('0'),
|
||||||
|
feeInBaseCurrency: new Big('0'),
|
||||||
firstBuyDate: '2022-03-07',
|
firstBuyDate: '2022-03-07',
|
||||||
grossPerformance: new Big('19.86'),
|
grossPerformance: new Big('19.86'),
|
||||||
grossPerformancePercentage: new Big('0.13100263852242744063'),
|
grossPerformancePercentage: new Big('0.13100263852242744063'),
|
||||||
|
@ -2,7 +2,10 @@ import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/po
|
|||||||
import { PortfolioOrderItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order-item.interface';
|
import { PortfolioOrderItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order-item.interface';
|
||||||
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
|
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
|
||||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
import { SymbolMetrics, UniqueAsset } from '@ghostfolio/common/interfaces';
|
import {
|
||||||
|
AssetProfileIdentifier,
|
||||||
|
SymbolMetrics
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
|
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
|
||||||
|
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
@ -34,9 +37,9 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
|
|||||||
let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0);
|
let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0);
|
||||||
|
|
||||||
for (const currentPosition of positions) {
|
for (const currentPosition of positions) {
|
||||||
if (currentPosition.fee) {
|
if (currentPosition.feeInBaseCurrency) {
|
||||||
totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus(
|
totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus(
|
||||||
currentPosition.fee
|
currentPosition.feeInBaseCurrency
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -151,7 +154,7 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
|
|||||||
};
|
};
|
||||||
start: Date;
|
start: Date;
|
||||||
step?: number;
|
step?: number;
|
||||||
} & UniqueAsset): SymbolMetrics {
|
} & AssetProfileIdentifier): SymbolMetrics {
|
||||||
const currentExchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)];
|
const currentExchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)];
|
||||||
const currentValues: { [date: string]: Big } = {};
|
const currentValues: { [date: string]: Big } = {};
|
||||||
const currentValuesWithCurrencyEffect: { [date: string]: Big } = {};
|
const currentValuesWithCurrencyEffect: { [date: string]: Big } = {};
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { DataSource, MarketData } from '@prisma/client';
|
import { DataSource, MarketData } from '@prisma/client';
|
||||||
|
|
||||||
@ -24,32 +24,32 @@ jest.mock('@ghostfolio/api/services/market-data/market-data.service', () => {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
getRange: ({
|
getRange: ({
|
||||||
|
assetProfileIdentifiers,
|
||||||
dateRangeEnd,
|
dateRangeEnd,
|
||||||
dateRangeStart,
|
dateRangeStart
|
||||||
uniqueAssets
|
|
||||||
}: {
|
}: {
|
||||||
|
assetProfileIdentifiers: AssetProfileIdentifier[];
|
||||||
dateRangeEnd: Date;
|
dateRangeEnd: Date;
|
||||||
dateRangeStart: Date;
|
dateRangeStart: Date;
|
||||||
uniqueAssets: UniqueAsset[];
|
|
||||||
}) => {
|
}) => {
|
||||||
return Promise.resolve<MarketData[]>([
|
return Promise.resolve<MarketData[]>([
|
||||||
{
|
{
|
||||||
createdAt: dateRangeStart,
|
createdAt: dateRangeStart,
|
||||||
dataSource: uniqueAssets[0].dataSource,
|
dataSource: assetProfileIdentifiers[0].dataSource,
|
||||||
date: dateRangeStart,
|
date: dateRangeStart,
|
||||||
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
|
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
|
||||||
marketPrice: 1841.823902,
|
marketPrice: 1841.823902,
|
||||||
state: 'CLOSE',
|
state: 'CLOSE',
|
||||||
symbol: uniqueAssets[0].symbol
|
symbol: assetProfileIdentifiers[0].symbol
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
createdAt: dateRangeEnd,
|
createdAt: dateRangeEnd,
|
||||||
dataSource: uniqueAssets[0].dataSource,
|
dataSource: assetProfileIdentifiers[0].dataSource,
|
||||||
date: dateRangeEnd,
|
date: dateRangeEnd,
|
||||||
id: '082d6893-df27-4c91-8a5d-092e84315b56',
|
id: '082d6893-df27-4c91-8a5d-092e84315b56',
|
||||||
marketPrice: 1847.839966,
|
marketPrice: 1847.839966,
|
||||||
state: 'CLOSE',
|
state: 'CLOSE',
|
||||||
symbol: uniqueAssets[0].symbol
|
symbol: assetProfileIdentifiers[0].symbol
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
@ -3,9 +3,9 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
|
|||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
import { resetHours } from '@ghostfolio/common/helper';
|
import { resetHours } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
|
AssetProfileIdentifier,
|
||||||
DataProviderInfo,
|
DataProviderInfo,
|
||||||
ResponseError,
|
ResponseError
|
||||||
UniqueAsset
|
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
@ -80,17 +80,16 @@ export class CurrentRateService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const uniqueAssets: UniqueAsset[] = dataGatheringItems.map(
|
const assetProfileIdentifiers: AssetProfileIdentifier[] =
|
||||||
({ dataSource, symbol }) => {
|
dataGatheringItems.map(({ dataSource, symbol }) => {
|
||||||
return { dataSource, symbol };
|
return { dataSource, symbol };
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
promises.push(
|
promises.push(
|
||||||
this.marketDataService
|
this.marketDataService
|
||||||
.getRange({
|
.getRange({
|
||||||
dateQuery,
|
assetProfileIdentifiers,
|
||||||
uniqueAssets
|
dateQuery
|
||||||
})
|
})
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
return data.map(({ dataSource, date, marketPrice, symbol }) => {
|
return data.map(({ dataSource, date, marketPrice, symbol }) => {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
export interface GetValueObject extends UniqueAsset {
|
export interface GetValueObject extends AssetProfileIdentifier {
|
||||||
date: Date;
|
date: Date;
|
||||||
marketPrice: number;
|
marketPrice: number;
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { AccessService } from '@ghostfolio/api/app/access/access.service';
|
import { AccessService } from '@ghostfolio/api/app/access/access.service';
|
||||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import {
|
import {
|
||||||
hasNotDefinedValuesInObject,
|
hasNotDefinedValuesInObject,
|
||||||
@ -29,7 +30,8 @@ import {
|
|||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import {
|
import {
|
||||||
hasReadRestrictedAccessPermission,
|
hasReadRestrictedAccessPermission,
|
||||||
isRestrictedView
|
isRestrictedView,
|
||||||
|
permissions
|
||||||
} from '@ghostfolio/common/permissions';
|
} from '@ghostfolio/common/permissions';
|
||||||
import type {
|
import type {
|
||||||
DateRange,
|
DateRange,
|
||||||
@ -38,12 +40,14 @@ import type {
|
|||||||
} from '@ghostfolio/common/types';
|
} from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
Headers,
|
Headers,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
Param,
|
Param,
|
||||||
|
Put,
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
@ -51,12 +55,13 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { AssetClass, AssetSubClass } from '@prisma/client';
|
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
|
||||||
import { Big } from 'big.js';
|
import { Big } from 'big.js';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface';
|
import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface';
|
||||||
import { PortfolioService } from './portfolio.service';
|
import { PortfolioService } from './portfolio.service';
|
||||||
|
import { UpdateHoldingTagsDto } from './update-holding-tags.dto';
|
||||||
|
|
||||||
@Controller('portfolio')
|
@Controller('portfolio')
|
||||||
export class PortfolioController {
|
export class PortfolioController {
|
||||||
@ -204,6 +209,7 @@ export class PortfolioController {
|
|||||||
: undefined,
|
: undefined,
|
||||||
countries: hasDetails ? portfolioPosition.countries : [],
|
countries: hasDetails ? portfolioPosition.countries : [],
|
||||||
currency: hasDetails ? portfolioPosition.currency : undefined,
|
currency: hasDetails ? portfolioPosition.currency : undefined,
|
||||||
|
holdings: hasDetails ? portfolioPosition.holdings : [],
|
||||||
markets: hasDetails ? portfolioPosition.markets : undefined,
|
markets: hasDetails ? portfolioPosition.markets : undefined,
|
||||||
marketsAdvanced: hasDetails
|
marketsAdvanced: hasDetails
|
||||||
? portfolioPosition.marketsAdvanced
|
? portfolioPosition.marketsAdvanced
|
||||||
@ -495,9 +501,6 @@ export class PortfolioController {
|
|||||||
@Param('accessId') accessId
|
@Param('accessId') accessId
|
||||||
): Promise<PortfolioPublicDetails> {
|
): Promise<PortfolioPublicDetails> {
|
||||||
const access = await this.accessService.access({ id: accessId });
|
const access = await this.accessService.access({ id: accessId });
|
||||||
const user = await this.userService.user({
|
|
||||||
id: access.userId
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!access) {
|
if (!access) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
@ -507,6 +510,11 @@ export class PortfolioController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let hasDetails = true;
|
let hasDetails = true;
|
||||||
|
|
||||||
|
const user = await this.userService.user({
|
||||||
|
id: access.userId
|
||||||
|
});
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
hasDetails = user.subscription.type === 'Premium';
|
hasDetails = user.subscription.type === 'Premium';
|
||||||
}
|
}
|
||||||
@ -563,23 +571,23 @@ export class PortfolioController {
|
|||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async getPosition(
|
public async getPosition(
|
||||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||||
@Param('dataSource') dataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('symbol') symbol
|
@Param('symbol') symbol: string
|
||||||
): Promise<PortfolioHoldingDetail> {
|
): Promise<PortfolioHoldingDetail> {
|
||||||
const position = await this.portfolioService.getPosition(
|
const holding = await this.portfolioService.getPosition(
|
||||||
dataSource,
|
dataSource,
|
||||||
impersonationId,
|
impersonationId,
|
||||||
symbol
|
symbol
|
||||||
);
|
);
|
||||||
|
|
||||||
if (position) {
|
if (!holding) {
|
||||||
return position;
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
|
StatusCodes.NOT_FOUND
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new HttpException(
|
return holding;
|
||||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
|
||||||
StatusCodes.NOT_FOUND
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('report')
|
@Get('report')
|
||||||
@ -602,4 +610,36 @@ export class PortfolioController {
|
|||||||
|
|
||||||
return report;
|
return report;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.updateOrder)
|
||||||
|
@Put('position/:dataSource/:symbol/tags')
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
public async updateHoldingTags(
|
||||||
|
@Body() data: UpdateHoldingTagsDto,
|
||||||
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
): Promise<void> {
|
||||||
|
const holding = await this.portfolioService.getPosition(
|
||||||
|
dataSource,
|
||||||
|
impersonationId,
|
||||||
|
symbol
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!holding) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
|
StatusCodes.NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.portfolioService.updateTags({
|
||||||
|
dataSource,
|
||||||
|
impersonationId,
|
||||||
|
symbol,
|
||||||
|
tags: data.tags,
|
||||||
|
userId: this.request.user.id
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,78 +0,0 @@
|
|||||||
import { Big } from 'big.js';
|
|
||||||
|
|
||||||
import { PortfolioService } from './portfolio.service';
|
|
||||||
|
|
||||||
describe('PortfolioService', () => {
|
|
||||||
let portfolioService: PortfolioService;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
portfolioService = new PortfolioService(
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('annualized performance percentage', () => {
|
|
||||||
it('Get annualized performance', async () => {
|
|
||||||
expect(
|
|
||||||
portfolioService
|
|
||||||
.getAnnualizedPerformancePercent({
|
|
||||||
daysInMarket: NaN, // differenceInDays of date-fns returns NaN for the same day
|
|
||||||
netPerformancePercentage: new Big(0)
|
|
||||||
})
|
|
||||||
.toNumber()
|
|
||||||
).toEqual(0);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
portfolioService
|
|
||||||
.getAnnualizedPerformancePercent({
|
|
||||||
daysInMarket: 0,
|
|
||||||
netPerformancePercentage: new Big(0)
|
|
||||||
})
|
|
||||||
.toNumber()
|
|
||||||
).toEqual(0);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Source: https://www.readyratios.com/reference/analysis/annualized_rate.html
|
|
||||||
*/
|
|
||||||
expect(
|
|
||||||
portfolioService
|
|
||||||
.getAnnualizedPerformancePercent({
|
|
||||||
daysInMarket: 65, // < 1 year
|
|
||||||
netPerformancePercentage: new Big(0.1025)
|
|
||||||
})
|
|
||||||
.toNumber()
|
|
||||||
).toBeCloseTo(0.729705);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
portfolioService
|
|
||||||
.getAnnualizedPerformancePercent({
|
|
||||||
daysInMarket: 365, // 1 year
|
|
||||||
netPerformancePercentage: new Big(0.05)
|
|
||||||
})
|
|
||||||
.toNumber()
|
|
||||||
).toBeCloseTo(0.05);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Source: https://www.investopedia.com/terms/a/annualized-total-return.asp#annualized-return-formula-and-calculation
|
|
||||||
*/
|
|
||||||
expect(
|
|
||||||
portfolioService
|
|
||||||
.getAnnualizedPerformancePercent({
|
|
||||||
daysInMarket: 575, // > 1 year
|
|
||||||
netPerformancePercentage: new Big(0.2374)
|
|
||||||
})
|
|
||||||
.toNumber()
|
|
||||||
).toBeCloseTo(0.145);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@ -18,6 +18,7 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
|
|||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
|
import { getAnnualizedPerformancePercent } from '@ghostfolio/common/calculation-helper';
|
||||||
import {
|
import {
|
||||||
DEFAULT_CURRENCY,
|
DEFAULT_CURRENCY,
|
||||||
EMERGENCY_FUND_TAG_ID,
|
EMERGENCY_FUND_TAG_ID,
|
||||||
@ -58,7 +59,8 @@ import {
|
|||||||
DataSource,
|
DataSource,
|
||||||
Order,
|
Order,
|
||||||
Platform,
|
Platform,
|
||||||
Prisma
|
Prisma,
|
||||||
|
Tag
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
import { Big } from 'big.js';
|
import { Big } from 'big.js';
|
||||||
import {
|
import {
|
||||||
@ -70,7 +72,7 @@ import {
|
|||||||
parseISO,
|
parseISO,
|
||||||
set
|
set
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
import { isEmpty, isNumber, last, uniq, uniqBy } from 'lodash';
|
import { isEmpty, uniq, uniqBy } from 'lodash';
|
||||||
|
|
||||||
import { PortfolioCalculator } from './calculator/portfolio-calculator';
|
import { PortfolioCalculator } from './calculator/portfolio-calculator';
|
||||||
import {
|
import {
|
||||||
@ -206,24 +208,6 @@ export class PortfolioService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public getAnnualizedPerformancePercent({
|
|
||||||
daysInMarket,
|
|
||||||
netPerformancePercentage
|
|
||||||
}: {
|
|
||||||
daysInMarket: number;
|
|
||||||
netPerformancePercentage: Big;
|
|
||||||
}): Big {
|
|
||||||
if (isNumber(daysInMarket) && daysInMarket > 0) {
|
|
||||||
const exponent = new Big(365).div(daysInMarket).toNumber();
|
|
||||||
|
|
||||||
return new Big(
|
|
||||||
Math.pow(netPerformancePercentage.plus(1).toNumber(), exponent)
|
|
||||||
).minus(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Big(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getDividends({
|
public async getDividends({
|
||||||
activities,
|
activities,
|
||||||
groupBy
|
groupBy
|
||||||
@ -499,7 +483,17 @@ export class PortfolioService {
|
|||||||
grossPerformancePercentageWithCurrencyEffect?.toNumber() ?? 0,
|
grossPerformancePercentageWithCurrencyEffect?.toNumber() ?? 0,
|
||||||
grossPerformanceWithCurrencyEffect:
|
grossPerformanceWithCurrencyEffect:
|
||||||
grossPerformanceWithCurrencyEffect?.toNumber() ?? 0,
|
grossPerformanceWithCurrencyEffect?.toNumber() ?? 0,
|
||||||
holdings: assetProfile.holdings,
|
holdings: assetProfile.holdings.map(
|
||||||
|
({ allocationInPercentage, name }) => {
|
||||||
|
return {
|
||||||
|
allocationInPercentage,
|
||||||
|
name,
|
||||||
|
valueInBaseCurrency: valueInBaseCurrency
|
||||||
|
.mul(allocationInPercentage)
|
||||||
|
.toNumber()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
),
|
||||||
investment: investment.toNumber(),
|
investment: investment.toNumber(),
|
||||||
marketState: dataProviderResponse?.marketState ?? 'delayed',
|
marketState: dataProviderResponse?.marketState ?? 'delayed',
|
||||||
name: assetProfile.name,
|
name: assetProfile.name,
|
||||||
@ -703,7 +697,7 @@ export class PortfolioService {
|
|||||||
return Account;
|
return Account;
|
||||||
});
|
});
|
||||||
|
|
||||||
const dividendYieldPercent = this.getAnnualizedPerformancePercent({
|
const dividendYieldPercent = getAnnualizedPerformancePercent({
|
||||||
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
|
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
|
||||||
netPerformancePercentage: timeWeightedInvestment.eq(0)
|
netPerformancePercentage: timeWeightedInvestment.eq(0)
|
||||||
? new Big(0)
|
? new Big(0)
|
||||||
@ -711,7 +705,7 @@ export class PortfolioService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const dividendYieldPercentWithCurrencyEffect =
|
const dividendYieldPercentWithCurrencyEffect =
|
||||||
this.getAnnualizedPerformancePercent({
|
getAnnualizedPerformancePercent({
|
||||||
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
|
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
|
||||||
netPerformancePercentage: timeWeightedInvestmentWithCurrencyEffect.eq(
|
netPerformancePercentage: timeWeightedInvestmentWithCurrencyEffect.eq(
|
||||||
0
|
0
|
||||||
@ -1311,6 +1305,24 @@ export class PortfolioService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async updateTags({
|
||||||
|
dataSource,
|
||||||
|
impersonationId,
|
||||||
|
symbol,
|
||||||
|
tags,
|
||||||
|
userId
|
||||||
|
}: {
|
||||||
|
dataSource: DataSource;
|
||||||
|
impersonationId: string;
|
||||||
|
symbol: string;
|
||||||
|
tags: Tag[];
|
||||||
|
userId: string;
|
||||||
|
}) {
|
||||||
|
userId = await this.getUserId(impersonationId, userId);
|
||||||
|
|
||||||
|
await this.orderService.assignTags({ dataSource, symbol, tags, userId });
|
||||||
|
}
|
||||||
|
|
||||||
private async getCashPositions({
|
private async getCashPositions({
|
||||||
cashDetails,
|
cashDetails,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
@ -1714,13 +1726,13 @@ export class PortfolioService {
|
|||||||
|
|
||||||
const daysInMarket = differenceInDays(new Date(), firstOrderDate);
|
const daysInMarket = differenceInDays(new Date(), firstOrderDate);
|
||||||
|
|
||||||
const annualizedPerformancePercent = this.getAnnualizedPerformancePercent({
|
const annualizedPerformancePercent = getAnnualizedPerformancePercent({
|
||||||
daysInMarket,
|
daysInMarket,
|
||||||
netPerformancePercentage: new Big(netPerformancePercentage)
|
netPerformancePercentage: new Big(netPerformancePercentage)
|
||||||
})?.toNumber();
|
})?.toNumber();
|
||||||
|
|
||||||
const annualizedPerformancePercentWithCurrencyEffect =
|
const annualizedPerformancePercentWithCurrencyEffect =
|
||||||
this.getAnnualizedPerformancePercent({
|
getAnnualizedPerformancePercent({
|
||||||
daysInMarket,
|
daysInMarket,
|
||||||
netPerformancePercentage: new Big(
|
netPerformancePercentage: new Big(
|
||||||
netPerformancePercentageWithCurrencyEffect
|
netPerformancePercentageWithCurrencyEffect
|
||||||
|
7
apps/api/src/app/portfolio/update-holding-tags.dto.ts
Normal file
7
apps/api/src/app/portfolio/update-holding-tags.dto.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { Tag } from '@prisma/client';
|
||||||
|
import { IsArray } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateHoldingTagsDto {
|
||||||
|
@IsArray()
|
||||||
|
tags: Tag[];
|
||||||
|
}
|
@ -1,6 +1,6 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
||||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
@ -28,7 +28,7 @@ export class RedisCacheService {
|
|||||||
return `portfolio-snapshot-${userId}`;
|
return `portfolio-snapshot-${userId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getQuoteKey({ dataSource, symbol }: UniqueAsset) {
|
public getQuoteKey({ dataSource, symbol }: AssetProfileIdentifier) {
|
||||||
return `quote-${getAssetProfileIdentifier({ dataSource, symbol })}`;
|
return `quote-${getAssetProfileIdentifier({ dataSource, symbol })}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import {
|
import {
|
||||||
DATE_FORMAT,
|
DATE_FORMAT,
|
||||||
getYesterday,
|
getYesterday,
|
||||||
interpolate
|
interpolate
|
||||||
} from '@ghostfolio/common/helper';
|
} from '@ghostfolio/common/helper';
|
||||||
|
import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools';
|
||||||
|
|
||||||
import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common';
|
import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
@ -14,7 +16,9 @@ import * as path from 'path';
|
|||||||
export class SitemapController {
|
export class SitemapController {
|
||||||
public sitemapXml = '';
|
public sitemapXml = '';
|
||||||
|
|
||||||
public constructor() {
|
public constructor(
|
||||||
|
private readonly configurationService: ConfigurationService
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
this.sitemapXml = fs.readFileSync(
|
this.sitemapXml = fs.readFileSync(
|
||||||
path.join(__dirname, 'assets', 'sitemap.xml'),
|
path.join(__dirname, 'assets', 'sitemap.xml'),
|
||||||
@ -25,11 +29,51 @@ export class SitemapController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@Version(VERSION_NEUTRAL)
|
@Version(VERSION_NEUTRAL)
|
||||||
public async flushCache(@Res() response: Response): Promise<void> {
|
public async getSitemapXml(@Res() response: Response): Promise<void> {
|
||||||
|
const currentDate = format(getYesterday(), DATE_FORMAT);
|
||||||
|
|
||||||
response.setHeader('content-type', 'application/xml');
|
response.setHeader('content-type', 'application/xml');
|
||||||
response.send(
|
response.send(
|
||||||
interpolate(this.sitemapXml, {
|
interpolate(this.sitemapXml, {
|
||||||
currentDate: format(getYesterday(), DATE_FORMAT)
|
currentDate,
|
||||||
|
personalFinanceTools: this.configurationService.get(
|
||||||
|
'ENABLE_FEATURE_SUBSCRIPTION'
|
||||||
|
)
|
||||||
|
? personalFinanceTools
|
||||||
|
.map(({ alias, key }) => {
|
||||||
|
return [
|
||||||
|
'<url>',
|
||||||
|
` <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-${alias ?? key}</loc>`,
|
||||||
|
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
|
||||||
|
'</url>',
|
||||||
|
'<url>',
|
||||||
|
` <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-${alias ?? key}</loc>`,
|
||||||
|
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
|
||||||
|
'</url>',
|
||||||
|
'<url>',
|
||||||
|
` <loc>https://ghostfol.io/es/recursos/personal-finance-tools/alternativa-de-software-libre-a-${alias ?? key}</loc>`,
|
||||||
|
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
|
||||||
|
'</url>',
|
||||||
|
'<url>',
|
||||||
|
` <loc>https://ghostfol.io/fr/ressources/personal-finance-tools/alternative-open-source-a-${alias ?? key}</loc>`,
|
||||||
|
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
|
||||||
|
'</url>',
|
||||||
|
'<url>',
|
||||||
|
` <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-${alias ?? key}</loc>`,
|
||||||
|
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
|
||||||
|
'</url>',
|
||||||
|
'<url>',
|
||||||
|
` <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-${alias ?? key}</loc>`,
|
||||||
|
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
|
||||||
|
'</url>',
|
||||||
|
'<url>',
|
||||||
|
` <loc>https://ghostfol.io/pt/recursos/personal-finance-tools/alternativa-de-software-livre-ao-${alias ?? key}</loc>`,
|
||||||
|
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
|
||||||
|
'</url>'
|
||||||
|
].join('\n');
|
||||||
|
})
|
||||||
|
.join('\n')
|
||||||
|
: ''
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,11 @@
|
|||||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { SitemapController } from './sitemap.controller';
|
import { SitemapController } from './sitemap.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [SitemapController]
|
controllers: [SitemapController],
|
||||||
|
imports: [ConfigurationModule]
|
||||||
})
|
})
|
||||||
export class SitemapModule {}
|
export class SitemapModule {}
|
||||||
|
@ -22,7 +22,7 @@ export class SubscriptionService {
|
|||||||
this.stripe = new Stripe(
|
this.stripe = new Stripe(
|
||||||
this.configurationService.get('STRIPE_SECRET_KEY'),
|
this.configurationService.get('STRIPE_SECRET_KEY'),
|
||||||
{
|
{
|
||||||
apiVersion: '2022-11-15'
|
apiVersion: '2024-04-10'
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,9 @@
|
|||||||
import { HistoricalDataItem, UniqueAsset } from '@ghostfolio/common/interfaces';
|
import {
|
||||||
|
AssetProfileIdentifier,
|
||||||
|
HistoricalDataItem
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
export interface SymbolItem extends UniqueAsset {
|
export interface SymbolItem extends AssetProfileIdentifier {
|
||||||
currency: string;
|
currency: string;
|
||||||
historicalData: HistoricalDataItem[];
|
historicalData: HistoricalDataItem[];
|
||||||
marketPrice: number;
|
marketPrice: number;
|
||||||
|
@ -40,13 +40,13 @@ export class SymbolService {
|
|||||||
const days = includeHistoricalData;
|
const days = includeHistoricalData;
|
||||||
|
|
||||||
const marketData = await this.marketDataService.getRange({
|
const marketData = await this.marketDataService.getRange({
|
||||||
dateQuery: { gte: subDays(new Date(), days) },
|
assetProfileIdentifiers: [
|
||||||
uniqueAssets: [
|
|
||||||
{
|
{
|
||||||
dataSource: dataGatheringItem.dataSource,
|
dataSource: dataGatheringItem.dataSource,
|
||||||
symbol: dataGatheringItem.symbol
|
symbol: dataGatheringItem.symbol
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
dateQuery: { gte: subDays(new Date(), days) }
|
||||||
});
|
});
|
||||||
|
|
||||||
historicalData = marketData.map(({ date, marketPrice: value }) => {
|
historicalData = marketData.map(({ date, marketPrice: value }) => {
|
||||||
|
@ -1,13 +1,14 @@
|
|||||||
|
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
|
||||||
import type {
|
import type {
|
||||||
ColorScheme,
|
ColorScheme,
|
||||||
DateRange,
|
DateRange,
|
||||||
|
HoldingsViewMode,
|
||||||
ViewMode
|
ViewMode
|
||||||
} from '@ghostfolio/common/types';
|
} from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsISO4217CurrencyCode,
|
|
||||||
IsISO8601,
|
IsISO8601,
|
||||||
IsIn,
|
IsIn,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
@ -21,7 +22,7 @@ export class UpdateUserSettingDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
annualInterestRate?: number;
|
annualInterestRate?: number;
|
||||||
|
|
||||||
@IsISO4217CurrencyCode()
|
@IsCurrencyCode()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
baseCurrency?: string;
|
baseCurrency?: string;
|
||||||
|
|
||||||
@ -66,6 +67,10 @@ export class UpdateUserSettingDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
'filters.tags'?: string[];
|
'filters.tags'?: string[];
|
||||||
|
|
||||||
|
@IsIn(<HoldingsViewMode[]>['CHART', 'TABLE'])
|
||||||
|
@IsOptional()
|
||||||
|
holdingsViewMode?: HoldingsViewMode;
|
||||||
|
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
isExperimentalFeatures?: boolean;
|
isExperimentalFeatures?: boolean;
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
@ -19,6 +20,7 @@ import { UserService } from './user.service';
|
|||||||
secret: process.env.JWT_SECRET_KEY,
|
secret: process.env.JWT_SECRET_KEY,
|
||||||
signOptions: { expiresIn: '30 days' }
|
signOptions: { expiresIn: '30 days' }
|
||||||
}),
|
}),
|
||||||
|
OrderModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
PropertyModule,
|
PropertyModule,
|
||||||
SubscriptionModule,
|
SubscriptionModule,
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||||
import { environment } from '@ghostfolio/api/environments/environment';
|
import { environment } from '@ghostfolio/api/environments/environment';
|
||||||
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
|
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
|
||||||
@ -40,6 +41,7 @@ export class UserService {
|
|||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly eventEmitter: EventEmitter2,
|
private readonly eventEmitter: EventEmitter2,
|
||||||
|
private readonly orderService: OrderService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly propertyService: PropertyService,
|
private readonly propertyService: PropertyService,
|
||||||
private readonly subscriptionService: SubscriptionService,
|
private readonly subscriptionService: SubscriptionService,
|
||||||
@ -188,7 +190,7 @@ export class UserService {
|
|||||||
(user.Settings.settings as UserSettings).dateRange =
|
(user.Settings.settings as UserSettings).dateRange =
|
||||||
(user.Settings.settings as UserSettings).viewMode === 'ZEN'
|
(user.Settings.settings as UserSettings).viewMode === 'ZEN'
|
||||||
? 'max'
|
? 'max'
|
||||||
: (user.Settings.settings as UserSettings)?.dateRange ?? 'max';
|
: ((user.Settings.settings as UserSettings)?.dateRange ?? 'max');
|
||||||
|
|
||||||
// Set default value for view mode
|
// Set default value for view mode
|
||||||
if (!(user.Settings.settings as UserSettings).viewMode) {
|
if (!(user.Settings.settings as UserSettings).viewMode) {
|
||||||
@ -235,11 +237,15 @@ export class UserService {
|
|||||||
|
|
||||||
currentPermissions = without(
|
currentPermissions = without(
|
||||||
currentPermissions,
|
currentPermissions,
|
||||||
|
permissions.accessHoldingsChart,
|
||||||
permissions.createAccess
|
permissions.createAccess
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reset benchmark
|
// Reset benchmark
|
||||||
user.Settings.settings.benchmark = undefined;
|
user.Settings.settings.benchmark = undefined;
|
||||||
|
|
||||||
|
// Reset holdings view mode
|
||||||
|
user.Settings.settings.holdingsViewMode = undefined;
|
||||||
} else if (user.subscription?.type === 'Premium') {
|
} else if (user.subscription?.type === 'Premium') {
|
||||||
currentPermissions.push(permissions.reportDataGlitch);
|
currentPermissions.push(permissions.reportDataGlitch);
|
||||||
|
|
||||||
@ -398,8 +404,8 @@ export class UserService {
|
|||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.prismaService.order.deleteMany({
|
await this.orderService.deleteOrders({
|
||||||
where: { userId: where.id }
|
userId: where.id
|
||||||
});
|
});
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -4,6 +4,12 @@
|
|||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
|
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
|
||||||
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
|
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>
|
<url>
|
||||||
<loc>https://ghostfol.io/de</loc>
|
<loc>https://ghostfol.io/de</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -54,230 +60,6 @@
|
|||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools</loc>
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-8figures</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-allinvestview</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-allvue-systems</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-altoo</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-basil-finance</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-beanvest</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-capitally</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-capmon</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-compound-planning</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-copilot-money</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-de.fi</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-delta</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-divvydiary</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-empower</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-exirio</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-fina</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-finary</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-finwise</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-folishare</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-getquin</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-gospatz</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-intuit-mint</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-justetf</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-koyfin</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-kubera</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-magnifi</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-markets.sh</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-maybe-finance</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-monarch-money</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-monse</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-navexa</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-parqet</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-plannix</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-portfolio-dividend-tracker</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-portfolio-visualizer</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-portseido</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-projectionlab</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-rocket-money</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-seeking-alpha</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-sharesight</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-simple-portfolio</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-snowball-analytics</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-stock-events</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-stockle</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-stockmarketeye</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-stonksfolio</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-sumio</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-tiller</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-utluna</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-vyzer</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-wallmine</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-wealthfolio</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-wealthica</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-whal</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-yeekatee</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-ynab</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/de/ueber-uns</loc>
|
<loc>https://ghostfol.io/de/ueber-uns</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -432,230 +214,6 @@
|
|||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools</loc>
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-8figures</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-allinvestview</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-allvue-systems</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-altoo</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-basil-finance</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-beanvest</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-capitally</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-capmon</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-compound-planning</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-copilot-money</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-de.fi</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-delta</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-divvydiary</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-empower</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-exirio</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-fina</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-finary</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-finwise</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-folishare</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-getquin</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-gospatz</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-intuit-mint</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-justetf</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-koyfin</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-kubera</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-magnifi</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-markets.sh</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-maybe-finance</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-monarch-money</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-monse</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-navexa</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-parqet</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-plannix</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-portfolio-dividend-tracker</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-portfolio-visualizer</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-portseido</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-projectionlab</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-rocket-money</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-seeking-alpha</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-sharesight</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-simple-portfolio</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-snowball-analytics</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-stock-events</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-stockle</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-stockmarketeye</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-stonksfolio</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-sumio</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-tiller</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-utluna</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-vyzer</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-wallmine</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-wealthfolio</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-wealthica</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-whal</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-yeekatee</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-ynab</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/es</loc>
|
<loc>https://ghostfol.io/es</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -686,6 +244,10 @@
|
|||||||
<loc>https://ghostfol.io/es/recursos</loc>
|
<loc>https://ghostfol.io/es/recursos</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/es/recursos/personal-finance-tools</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/es/registro</loc>
|
<loc>https://ghostfol.io/es/registro</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -764,6 +326,10 @@
|
|||||||
<loc>https://ghostfol.io/fr/ressources</loc>
|
<loc>https://ghostfol.io/fr/ressources</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/fr/ressources/personal-finance-tools</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/it</loc>
|
<loc>https://ghostfol.io/it</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -822,230 +388,6 @@
|
|||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools</loc>
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-8figures</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-allinvestview</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-allvue-systems</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-altoo</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-basil-finance</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-beanvest</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-capitally</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-capmon</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-compound-planning</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-copilot-money</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-de.fi</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-delta</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-divvydiary</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-empower</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-exirio</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-fina</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-finary</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-finwise</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-folishare</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-getquin</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-gospatz</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-intuit-mint</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-justetf</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-koyfin</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-kubera</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-magnifi</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-markets.sh</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-maybe-finance</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-monarch-money</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-monse</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-navexa</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-parqet</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-plannix</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-portfolio-dividend-tracker</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-portfolio-visualizer</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-portseido</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-projectionlab</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-rocket-money</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-seeking-alpha</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-sharesight</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-simple-portfolio</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-snowball-analytics</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-stock-events</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-stockle</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-stockmarketeye</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-stonksfolio</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-sumio</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-tiller</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-utluna</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-vyzer</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-wallmine</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-wealthfolio</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-wealthica</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-whal</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-yeekatee</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-ynab</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/nl</loc>
|
<loc>https://ghostfol.io/nl</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -1058,230 +400,6 @@
|
|||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools</loc>
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-8figures</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-allinvestview</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-allvue-systems</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-altoo</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-basil-finance</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-beanvest</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-capitally</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-capmon</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-compound-planning</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-copilot-money</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-de.fi</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-delta</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-divvydiary</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-empower</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-exirio</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-fina</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-finary</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-finwise</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-folishare</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-getquin</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-gospatz</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-intuit-mint</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-justetf</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-koyfin</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-kubera</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-magnifi</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-markets.sh</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-maybe-finance</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-monarch-money</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-monse</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-navexa</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-parqet</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-plannix</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-portfolio-dividend-tracker</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-portfolio-visualizer</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-portseido</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-projectionlab</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-rocket-money</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-seeking-alpha</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-sharesight</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-simple-portfolio</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-snowball-analytics</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-stock-events</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-stockle</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-stockmarketeye</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-stonksfolio</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-sumio</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-tiller</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-utluna</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-vyzer</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-wallmine</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-wealthfolio</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-wealthica</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-whal</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-yeekatee</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-ynab</loc>
|
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
|
||||||
</url>
|
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/nl/functionaliteiten</loc>
|
<loc>https://ghostfol.io/nl/functionaliteiten</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -1329,10 +447,10 @@
|
|||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
<!--
|
<!--
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/pl</loc>
|
<loc>https://ghostfol.io/pl</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
-->
|
-->
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/pt</loc>
|
<loc>https://ghostfol.io/pt</loc>
|
||||||
@ -1366,6 +484,10 @@
|
|||||||
<loc>https://ghostfol.io/pt/recursos</loc>
|
<loc>https://ghostfol.io/pt/recursos</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/pt/recursos/personal-finance-tools</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/pt/registo</loc>
|
<loc>https://ghostfol.io/pt/registo</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -1400,4 +522,5 @@
|
|||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
-->
|
-->
|
||||||
|
${personalFinanceTools}
|
||||||
</urlset>
|
</urlset>
|
||||||
|
@ -55,10 +55,10 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
|||||||
|
|
||||||
const maxInvestmentRatio = maxItem?.investment / totalInvestment || 0;
|
const maxInvestmentRatio = maxItem?.investment / totalInvestment || 0;
|
||||||
|
|
||||||
if (maxInvestmentRatio > ruleSettings.threshold) {
|
if (maxInvestmentRatio > ruleSettings.thresholdMax) {
|
||||||
return {
|
return {
|
||||||
evaluation: `Over ${
|
evaluation: `Over ${
|
||||||
ruleSettings.threshold * 100
|
ruleSettings.thresholdMax * 100
|
||||||
}% of your current investment is at ${maxItem.name} (${(
|
}% of your current investment is at ${maxItem.name} (${(
|
||||||
maxInvestmentRatio * 100
|
maxInvestmentRatio * 100
|
||||||
).toPrecision(3)}%)`,
|
).toPrecision(3)}%)`,
|
||||||
@ -70,7 +70,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
|||||||
evaluation: `The major part of your current investment is at ${
|
evaluation: `The major part of your current investment is at ${
|
||||||
maxItem.name
|
maxItem.name
|
||||||
} (${(maxInvestmentRatio * 100).toPrecision(3)}%) and does not exceed ${
|
} (${(maxInvestmentRatio * 100).toPrecision(3)}%) and does not exceed ${
|
||||||
ruleSettings.threshold * 100
|
ruleSettings.thresholdMax * 100
|
||||||
}%`,
|
}%`,
|
||||||
value: true
|
value: true
|
||||||
};
|
};
|
||||||
@ -80,12 +80,12 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
|||||||
return {
|
return {
|
||||||
baseCurrency: aUserSettings.baseCurrency,
|
baseCurrency: aUserSettings.baseCurrency,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
threshold: 0.5
|
thresholdMax: 0.5
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Settings extends RuleSettings {
|
interface Settings extends RuleSettings {
|
||||||
baseCurrency: string;
|
baseCurrency: string;
|
||||||
threshold: number;
|
thresholdMax: number;
|
||||||
}
|
}
|
||||||
|
@ -41,10 +41,10 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
|||||||
|
|
||||||
const maxValueRatio = maxItem?.value / totalValue || 0;
|
const maxValueRatio = maxItem?.value / totalValue || 0;
|
||||||
|
|
||||||
if (maxValueRatio > ruleSettings.threshold) {
|
if (maxValueRatio > ruleSettings.thresholdMax) {
|
||||||
return {
|
return {
|
||||||
evaluation: `Over ${
|
evaluation: `Over ${
|
||||||
ruleSettings.threshold * 100
|
ruleSettings.thresholdMax * 100
|
||||||
}% of your current investment is in ${maxItem.groupKey} (${(
|
}% of your current investment is in ${maxItem.groupKey} (${(
|
||||||
maxValueRatio * 100
|
maxValueRatio * 100
|
||||||
).toPrecision(3)}%)`,
|
).toPrecision(3)}%)`,
|
||||||
@ -56,7 +56,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
|||||||
evaluation: `The major part of your current investment is in ${
|
evaluation: `The major part of your current investment is in ${
|
||||||
maxItem?.groupKey ?? ruleSettings.baseCurrency
|
maxItem?.groupKey ?? ruleSettings.baseCurrency
|
||||||
} (${(maxValueRatio * 100).toPrecision(3)}%) and does not exceed ${
|
} (${(maxValueRatio * 100).toPrecision(3)}%) and does not exceed ${
|
||||||
ruleSettings.threshold * 100
|
ruleSettings.thresholdMax * 100
|
||||||
}%`,
|
}%`,
|
||||||
value: true
|
value: true
|
||||||
};
|
};
|
||||||
@ -66,12 +66,12 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
|||||||
return {
|
return {
|
||||||
baseCurrency: aUserSettings.baseCurrency,
|
baseCurrency: aUserSettings.baseCurrency,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
threshold: 0.5
|
thresholdMax: 0.5
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Settings extends RuleSettings {
|
interface Settings extends RuleSettings {
|
||||||
baseCurrency: string;
|
baseCurrency: string;
|
||||||
threshold: number;
|
thresholdMax: number;
|
||||||
}
|
}
|
||||||
|
@ -19,16 +19,16 @@ export class EmergencyFundSetup extends Rule<Settings> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public evaluate(ruleSettings: Settings) {
|
public evaluate(ruleSettings: Settings) {
|
||||||
if (this.emergencyFund > ruleSettings.threshold) {
|
if (this.emergencyFund < ruleSettings.thresholdMin) {
|
||||||
return {
|
return {
|
||||||
evaluation: 'An emergency fund has been set up',
|
evaluation: 'No emergency fund has been set up',
|
||||||
value: true
|
value: false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
evaluation: 'No emergency fund has been set up',
|
evaluation: 'An emergency fund has been set up',
|
||||||
value: false
|
value: true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -36,12 +36,12 @@ export class EmergencyFundSetup extends Rule<Settings> {
|
|||||||
return {
|
return {
|
||||||
baseCurrency: aUserSettings.baseCurrency,
|
baseCurrency: aUserSettings.baseCurrency,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
threshold: 0
|
thresholdMin: 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Settings extends RuleSettings {
|
interface Settings extends RuleSettings {
|
||||||
baseCurrency: string;
|
baseCurrency: string;
|
||||||
threshold: number;
|
thresholdMin: number;
|
||||||
}
|
}
|
||||||
|
@ -26,10 +26,10 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
|
|||||||
? this.fees / this.totalInvestment
|
? this.fees / this.totalInvestment
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
if (feeRatio > ruleSettings.threshold) {
|
if (feeRatio > ruleSettings.thresholdMax) {
|
||||||
return {
|
return {
|
||||||
evaluation: `The fees do exceed ${
|
evaluation: `The fees do exceed ${
|
||||||
ruleSettings.threshold * 100
|
ruleSettings.thresholdMax * 100
|
||||||
}% of your initial investment (${(feeRatio * 100).toPrecision(3)}%)`,
|
}% of your initial investment (${(feeRatio * 100).toPrecision(3)}%)`,
|
||||||
value: false
|
value: false
|
||||||
};
|
};
|
||||||
@ -37,7 +37,7 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
evaluation: `The fees do not exceed ${
|
evaluation: `The fees do not exceed ${
|
||||||
ruleSettings.threshold * 100
|
ruleSettings.thresholdMax * 100
|
||||||
}% of your initial investment (${(feeRatio * 100).toPrecision(3)}%)`,
|
}% of your initial investment (${(feeRatio * 100).toPrecision(3)}%)`,
|
||||||
value: true
|
value: true
|
||||||
};
|
};
|
||||||
@ -47,12 +47,12 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
|
|||||||
return {
|
return {
|
||||||
baseCurrency: aUserSettings.baseCurrency,
|
baseCurrency: aUserSettings.baseCurrency,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
threshold: 0.01
|
thresholdMax: 0.01
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Settings extends RuleSettings {
|
interface Settings extends RuleSettings {
|
||||||
baseCurrency: string;
|
baseCurrency: string;
|
||||||
threshold: number;
|
thresholdMax: number;
|
||||||
}
|
}
|
||||||
|
@ -45,10 +45,11 @@ export class CronService {
|
|||||||
@Cron(CronService.EVERY_SUNDAY_AT_LUNCH_TIME)
|
@Cron(CronService.EVERY_SUNDAY_AT_LUNCH_TIME)
|
||||||
public async runEverySundayAtTwelvePm() {
|
public async runEverySundayAtTwelvePm() {
|
||||||
if (await this.isDataGatheringEnabled()) {
|
if (await this.isDataGatheringEnabled()) {
|
||||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
const assetProfileIdentifiers =
|
||||||
|
await this.dataGatheringService.getAllAssetProfileIdentifiers();
|
||||||
|
|
||||||
await this.dataGatheringService.addJobsToQueue(
|
await this.dataGatheringService.addJobsToQueue(
|
||||||
uniqueAssets.map(({ dataSource, symbol }) => {
|
assetProfileIdentifiers.map(({ dataSource, symbol }) => {
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
dataSource,
|
dataSource,
|
||||||
|
@ -7,7 +7,7 @@ import {
|
|||||||
GATHER_HISTORICAL_MARKET_DATA_PROCESS
|
GATHER_HISTORICAL_MARKET_DATA_PROCESS
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { DATE_FORMAT, getStartOfUtcDate } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, getStartOfUtcDate } from '@ghostfolio/common/helper';
|
||||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { Process, Processor } from '@nestjs/bull';
|
import { Process, Processor } from '@nestjs/bull';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
@ -35,7 +35,7 @@ export class DataGatheringProcessor {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Process({ concurrency: 1, name: GATHER_ASSET_PROFILE_PROCESS })
|
@Process({ concurrency: 1, name: GATHER_ASSET_PROFILE_PROCESS })
|
||||||
public async gatherAssetProfile(job: Job<UniqueAsset>) {
|
public async gatherAssetProfile(job: Job<AssetProfileIdentifier>) {
|
||||||
try {
|
try {
|
||||||
Logger.log(
|
Logger.log(
|
||||||
`Asset profile data gathering has been started for ${job.data.symbol} (${job.data.dataSource})`,
|
`Asset profile data gathering has been started for ${job.data.symbol} (${job.data.dataSource})`,
|
||||||
|
@ -10,6 +10,7 @@ import {
|
|||||||
DATA_GATHERING_QUEUE,
|
DATA_GATHERING_QUEUE,
|
||||||
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
|
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
|
||||||
DATA_GATHERING_QUEUE_PRIORITY_LOW,
|
DATA_GATHERING_QUEUE_PRIORITY_LOW,
|
||||||
|
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
|
||||||
GATHER_HISTORICAL_MARKET_DATA_PROCESS,
|
GATHER_HISTORICAL_MARKET_DATA_PROCESS,
|
||||||
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
|
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
|
||||||
PROPERTY_BENCHMARKS
|
PROPERTY_BENCHMARKS
|
||||||
@ -19,7 +20,10 @@ import {
|
|||||||
getAssetProfileIdentifier,
|
getAssetProfileIdentifier,
|
||||||
resetHours
|
resetHours
|
||||||
} from '@ghostfolio/common/helper';
|
} from '@ghostfolio/common/helper';
|
||||||
import { BenchmarkProperty, UniqueAsset } from '@ghostfolio/common/interfaces';
|
import {
|
||||||
|
AssetProfileIdentifier,
|
||||||
|
BenchmarkProperty
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { InjectQueue } from '@nestjs/bull';
|
import { InjectQueue } from '@nestjs/bull';
|
||||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
@ -62,9 +66,22 @@ export class DataGatheringService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async gather7Days() {
|
public async gather7Days() {
|
||||||
const dataGatheringItems = await this.getSymbols7D();
|
|
||||||
await this.gatherSymbols({
|
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
|
priority: DATA_GATHERING_QUEUE_PRIORITY_LOW
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -77,7 +94,7 @@ export class DataGatheringService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async gatherSymbol({ dataSource, symbol }: UniqueAsset) {
|
public async gatherSymbol({ dataSource, symbol }: AssetProfileIdentifier) {
|
||||||
await this.marketDataService.deleteMany({ dataSource, symbol });
|
await this.marketDataService.deleteMany({ dataSource, symbol });
|
||||||
|
|
||||||
const dataGatheringItems = (await this.getSymbolsMax()).filter(
|
const dataGatheringItems = (await this.getSymbolsMax()).filter(
|
||||||
@ -132,23 +149,29 @@ export class DataGatheringService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async gatherAssetProfiles(aUniqueAssets?: UniqueAsset[]) {
|
public async gatherAssetProfiles(
|
||||||
let uniqueAssets = aUniqueAssets?.filter((dataGatheringItem) => {
|
aAssetProfileIdentifiers?: AssetProfileIdentifier[]
|
||||||
return dataGatheringItem.dataSource !== 'MANUAL';
|
) {
|
||||||
});
|
let assetProfileIdentifiers = aAssetProfileIdentifiers?.filter(
|
||||||
|
(dataGatheringItem) => {
|
||||||
|
return dataGatheringItem.dataSource !== 'MANUAL';
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (!uniqueAssets) {
|
if (!assetProfileIdentifiers) {
|
||||||
uniqueAssets = await this.getUniqueAssets();
|
assetProfileIdentifiers = await this.getAllAssetProfileIdentifiers();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uniqueAssets.length <= 0) {
|
if (assetProfileIdentifiers.length <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const assetProfiles =
|
const assetProfiles = await this.dataProviderService.getAssetProfiles(
|
||||||
await this.dataProviderService.getAssetProfiles(uniqueAssets);
|
assetProfileIdentifiers
|
||||||
const symbolProfiles =
|
);
|
||||||
await this.symbolProfileService.getSymbolProfiles(uniqueAssets);
|
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
|
||||||
|
assetProfileIdentifiers
|
||||||
|
);
|
||||||
|
|
||||||
for (const [symbol, assetProfile] of Object.entries(assetProfiles)) {
|
for (const [symbol, assetProfile] of Object.entries(assetProfiles)) {
|
||||||
const symbolMapping = symbolProfiles.find((symbolProfile) => {
|
const symbolMapping = symbolProfiles.find((symbolProfile) => {
|
||||||
@ -234,7 +257,7 @@ export class DataGatheringService {
|
|||||||
'DataGatheringService'
|
'DataGatheringService'
|
||||||
);
|
);
|
||||||
|
|
||||||
if (uniqueAssets.length === 1) {
|
if (assetProfileIdentifiers.length === 1) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -270,7 +293,9 @@ export class DataGatheringService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getUniqueAssets(): Promise<UniqueAsset[]> {
|
public async getAllAssetProfileIdentifiers(): Promise<
|
||||||
|
AssetProfileIdentifier[]
|
||||||
|
> {
|
||||||
const symbolProfiles = await this.prismaService.symbolProfile.findMany({
|
const symbolProfiles = await this.prismaService.symbolProfile.findMany({
|
||||||
orderBy: [{ symbol: 'asc' }]
|
orderBy: [{ symbol: 'asc' }]
|
||||||
});
|
});
|
||||||
@ -290,73 +315,83 @@ export class DataGatheringService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private getEarliestDate(aStartDate: Date) {
|
private async getAssetProfileIdentifiersWithCompleteMarketData(): Promise<
|
||||||
return min([aStartDate, subYears(new Date(), 10)]);
|
AssetProfileIdentifier[]
|
||||||
}
|
> {
|
||||||
|
return (
|
||||||
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 = (
|
|
||||||
await this.prismaService.marketData.groupBy({
|
await this.prismaService.marketData.groupBy({
|
||||||
_count: true,
|
_count: true,
|
||||||
by: ['symbol'],
|
by: ['dataSource', 'symbol'],
|
||||||
orderBy: [{ symbol: 'asc' }],
|
orderBy: [{ symbol: 'asc' }],
|
||||||
where: {
|
where: {
|
||||||
date: { gt: startDate },
|
date: { gt: subDays(resetHours(new Date()), 7) },
|
||||||
state: 'CLOSE'
|
state: 'CLOSE'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.filter((group) => {
|
.filter(({ _count }) => {
|
||||||
return group._count >= 6;
|
return _count >= 6;
|
||||||
})
|
})
|
||||||
.map((group) => {
|
.map(({ dataSource, symbol }) => {
|
||||||
return group.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 }) => {
|
.filter(({ dataSource, scraperConfiguration, symbol }) => {
|
||||||
const manualDataSourceWithScraperConfiguration =
|
const manualDataSourceWithScraperConfiguration =
|
||||||
dataSource === 'MANUAL' && !isEmpty(scraperConfiguration);
|
dataSource === 'MANUAL' && !isEmpty(scraperConfiguration);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
!symbolsWithCompleteMarketData.includes(symbol) &&
|
!assetProfileIdentifiersWithCompleteMarketData.some((item) => {
|
||||||
|
return item.dataSource === dataSource && item.symbol === symbol;
|
||||||
|
}) &&
|
||||||
(dataSource !== 'MANUAL' || manualDataSourceWithScraperConfiguration)
|
(dataSource !== 'MANUAL' || manualDataSourceWithScraperConfiguration)
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.map((symbolProfile) => {
|
.map((symbolProfile) => {
|
||||||
return {
|
return {
|
||||||
...symbolProfile,
|
...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[]> {
|
private async getSymbolsMax(): Promise<IDataGatheringItem[]> {
|
||||||
|
@ -163,6 +163,10 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
|||||||
response.holdings = [];
|
response.holdings = [];
|
||||||
|
|
||||||
for (const { label, weight } of holdings?.topHoldings ?? []) {
|
for (const { label, weight } of holdings?.topHoldings ?? []) {
|
||||||
|
if (label?.toLowerCase() === 'other') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
response.holdings.push({
|
response.holdings.push({
|
||||||
weight,
|
weight,
|
||||||
name: label
|
name: label
|
||||||
|
@ -14,8 +14,13 @@ import {
|
|||||||
DERIVED_CURRENCIES,
|
DERIVED_CURRENCIES,
|
||||||
PROPERTY_DATA_SOURCE_MAPPING
|
PROPERTY_DATA_SOURCE_MAPPING
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { DATE_FORMAT, getStartOfUtcDate } from '@ghostfolio/common/helper';
|
import {
|
||||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
DATE_FORMAT,
|
||||||
|
getCurrencyFromSymbol,
|
||||||
|
getStartOfUtcDate,
|
||||||
|
isDerivedCurrency
|
||||||
|
} from '@ghostfolio/common/helper';
|
||||||
|
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
|
||||||
import type { Granularity, UserWithSettings } from '@ghostfolio/common/types';
|
import type { Granularity, UserWithSettings } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
@ -70,7 +75,7 @@ export class DataProviderService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAssetProfiles(items: UniqueAsset[]): Promise<{
|
public async getAssetProfiles(items: AssetProfileIdentifier[]): Promise<{
|
||||||
[symbol: string]: Partial<SymbolProfile>;
|
[symbol: string]: Partial<SymbolProfile>;
|
||||||
}> {
|
}> {
|
||||||
const response: {
|
const response: {
|
||||||
@ -168,7 +173,7 @@ export class DataProviderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getHistorical(
|
public async getHistorical(
|
||||||
aItems: UniqueAsset[],
|
aItems: AssetProfileIdentifier[],
|
||||||
aGranularity: Granularity = 'month',
|
aGranularity: Granularity = 'month',
|
||||||
from: Date,
|
from: Date,
|
||||||
to: Date
|
to: Date
|
||||||
@ -238,7 +243,7 @@ export class DataProviderService {
|
|||||||
from,
|
from,
|
||||||
to
|
to
|
||||||
}: {
|
}: {
|
||||||
dataGatheringItems: UniqueAsset[];
|
dataGatheringItems: AssetProfileIdentifier[];
|
||||||
from: Date;
|
from: Date;
|
||||||
to: Date;
|
to: Date;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
@ -345,7 +350,7 @@ export class DataProviderService {
|
|||||||
useCache = true,
|
useCache = true,
|
||||||
user
|
user
|
||||||
}: {
|
}: {
|
||||||
items: UniqueAsset[];
|
items: AssetProfileIdentifier[];
|
||||||
requestTimeout?: number;
|
requestTimeout?: number;
|
||||||
useCache?: boolean;
|
useCache?: boolean;
|
||||||
user?: UserWithSettings;
|
user?: UserWithSettings;
|
||||||
@ -371,7 +376,7 @@ export class DataProviderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get items from cache
|
// Get items from cache
|
||||||
const itemsToFetch: UniqueAsset[] = [];
|
const itemsToFetch: AssetProfileIdentifier[] = [];
|
||||||
|
|
||||||
for (const { dataSource, symbol } of items) {
|
for (const { dataSource, symbol } of items) {
|
||||||
if (useCache) {
|
if (useCache) {
|
||||||
@ -423,13 +428,18 @@ export class DataProviderService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const symbols = dataGatheringItems.map((dataGatheringItem) => {
|
const symbols = dataGatheringItems
|
||||||
return dataGatheringItem.symbol;
|
.filter(({ symbol }) => {
|
||||||
});
|
return !isDerivedCurrency(getCurrencyFromSymbol(symbol));
|
||||||
|
})
|
||||||
|
.map(({ symbol }) => {
|
||||||
|
return symbol;
|
||||||
|
});
|
||||||
|
|
||||||
const maximumNumberOfSymbolsPerRequest =
|
const maximumNumberOfSymbolsPerRequest =
|
||||||
dataProvider.getMaxNumberOfSymbolsPerRequest?.() ??
|
dataProvider.getMaxNumberOfSymbolsPerRequest?.() ??
|
||||||
Number.MAX_SAFE_INTEGER;
|
Number.MAX_SAFE_INTEGER;
|
||||||
|
|
||||||
for (
|
for (
|
||||||
let i = 0;
|
let i = 0;
|
||||||
i < symbols.length;
|
i < symbols.length;
|
||||||
@ -516,7 +526,8 @@ export class DataProviderService {
|
|||||||
.filter((symbol) => {
|
.filter((symbol) => {
|
||||||
return (
|
return (
|
||||||
isNumber(response[symbol].marketPrice) &&
|
isNumber(response[symbol].marketPrice) &&
|
||||||
response[symbol].marketPrice > 0
|
response[symbol].marketPrice > 0 &&
|
||||||
|
response[symbol].marketState === 'open'
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.map((symbol) => {
|
.map((symbol) => {
|
||||||
@ -622,7 +633,7 @@ export class DataProviderService {
|
|||||||
dataGatheringItems
|
dataGatheringItems
|
||||||
}: {
|
}: {
|
||||||
currency: string;
|
currency: string;
|
||||||
dataGatheringItems: UniqueAsset[];
|
dataGatheringItems: AssetProfileIdentifier[];
|
||||||
}) {
|
}) {
|
||||||
return dataGatheringItems.some(({ dataSource, symbol }) => {
|
return dataGatheringItems.some(({ dataSource, symbol }) => {
|
||||||
return (
|
return (
|
||||||
|
@ -246,7 +246,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
for (const { close, code, timestamp } of quotes) {
|
for (const { close, code, timestamp } of quotes) {
|
||||||
let currency: string;
|
let currency: string;
|
||||||
|
|
||||||
if (code.endsWith('.FOREX')) {
|
if (this.isForex(code)) {
|
||||||
currency = this.convertFromEodSymbol(code)?.replace(
|
currency = this.convertFromEodSymbol(code)?.replace(
|
||||||
DEFAULT_CURRENCY,
|
DEFAULT_CURRENCY,
|
||||||
''
|
''
|
||||||
@ -272,7 +272,10 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
currency,
|
currency,
|
||||||
dataSource: this.getName(),
|
dataSource: this.getName(),
|
||||||
marketPrice: close,
|
marketPrice: close,
|
||||||
marketState: isToday(new Date(timestamp * 1000)) ? 'open' : 'closed'
|
marketState:
|
||||||
|
this.isForex(code) || isToday(new Date(timestamp * 1000))
|
||||||
|
? 'open'
|
||||||
|
: 'closed'
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
Logger.error(
|
Logger.error(
|
||||||
@ -311,7 +314,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
items: searchResult
|
items: searchResult
|
||||||
.filter(({ currency, symbol }) => {
|
.filter(({ currency, symbol }) => {
|
||||||
// Remove 'NA' currency and exchange rates
|
// Remove 'NA' currency and exchange rates
|
||||||
return currency?.length === 3 && !symbol.endsWith('.FOREX');
|
return currency?.length === 3 && !this.isForex(symbol);
|
||||||
})
|
})
|
||||||
.map(
|
.map(
|
||||||
({
|
({
|
||||||
@ -349,7 +352,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
private convertFromEodSymbol(aEodSymbol: string) {
|
private convertFromEodSymbol(aEodSymbol: string) {
|
||||||
let symbol = aEodSymbol;
|
let symbol = aEodSymbol;
|
||||||
|
|
||||||
if (symbol.endsWith('.FOREX')) {
|
if (this.isForex(symbol)) {
|
||||||
symbol = symbol.replace('GBX', 'GBp');
|
symbol = symbol.replace('GBX', 'GBp');
|
||||||
symbol = symbol.replace('.FOREX', '');
|
symbol = symbol.replace('.FOREX', '');
|
||||||
}
|
}
|
||||||
@ -451,6 +454,10 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
return searchResult;
|
return searchResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isForex(aCode: string) {
|
||||||
|
return aCode?.endsWith('.FOREX') || false;
|
||||||
|
}
|
||||||
|
|
||||||
private parseAssetClass({
|
private parseAssetClass({
|
||||||
Exchange,
|
Exchange,
|
||||||
Type
|
Type
|
||||||
|
@ -167,9 +167,10 @@ export class ManualService implements DataProviderInterface {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const { currency, symbol } of symbolProfiles) {
|
for (const { currency, symbol } of symbolProfiles) {
|
||||||
let marketPrice = marketData.find((marketDataItem) => {
|
let marketPrice =
|
||||||
return marketDataItem.symbol === symbol;
|
marketData.find((marketDataItem) => {
|
||||||
})?.marketPrice;
|
return marketDataItem.symbol === symbol;
|
||||||
|
})?.marketPrice ?? 0;
|
||||||
|
|
||||||
response[symbol] = {
|
response[symbol] = {
|
||||||
currency,
|
currency,
|
||||||
@ -256,7 +257,7 @@ export class ManualService implements DataProviderInterface {
|
|||||||
signal: abortController.signal
|
signal: abortController.signal
|
||||||
});
|
});
|
||||||
|
|
||||||
if (headers['content-type'] === 'application/json') {
|
if (headers['content-type'].includes('application/json')) {
|
||||||
const data = JSON.parse(body);
|
const data = JSON.parse(body);
|
||||||
const value = String(
|
const value = String(
|
||||||
jsonpath.query(data, scraperConfiguration.selector)[0]
|
jsonpath.query(data, scraperConfiguration.selector)[0]
|
||||||
|
@ -361,13 +361,13 @@ export class ExchangeRateDataService {
|
|||||||
const symbol = `${currencyFrom}${currencyTo}`;
|
const symbol = `${currencyFrom}${currencyTo}`;
|
||||||
|
|
||||||
const marketData = await this.marketDataService.getRange({
|
const marketData = await this.marketDataService.getRange({
|
||||||
dateQuery: { gte: startDate, lt: endDate },
|
assetProfileIdentifiers: [
|
||||||
uniqueAssets: [
|
|
||||||
{
|
{
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
dateQuery: { gte: startDate, lt: endDate }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (marketData?.length > 0) {
|
if (marketData?.length > 0) {
|
||||||
@ -392,13 +392,13 @@ export class ExchangeRateDataService {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const marketData = await this.marketDataService.getRange({
|
const marketData = await this.marketDataService.getRange({
|
||||||
dateQuery: { gte: startDate, lt: endDate },
|
assetProfileIdentifiers: [
|
||||||
uniqueAssets: [
|
|
||||||
{
|
{
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol: `${DEFAULT_CURRENCY}${currencyFrom}`
|
symbol: `${DEFAULT_CURRENCY}${currencyFrom}`
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
dateQuery: { gte: startDate, lt: endDate }
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const { date, marketPrice } of marketData) {
|
for (const { date, marketPrice } of marketData) {
|
||||||
@ -415,16 +415,16 @@ export class ExchangeRateDataService {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const marketData = await this.marketDataService.getRange({
|
const marketData = await this.marketDataService.getRange({
|
||||||
dateQuery: {
|
assetProfileIdentifiers: [
|
||||||
gte: startDate,
|
|
||||||
lt: endDate
|
|
||||||
},
|
|
||||||
uniqueAssets: [
|
|
||||||
{
|
{
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol: `${DEFAULT_CURRENCY}${currencyTo}`
|
symbol: `${DEFAULT_CURRENCY}${currencyTo}`
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
dateQuery: {
|
||||||
|
gte: startDate,
|
||||||
|
lt: endDate
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const { date, marketPrice } of marketData) {
|
for (const { date, marketPrice } of marketData) {
|
||||||
|
@ -1,4 +1,7 @@
|
|||||||
import { DataProviderInfo, UniqueAsset } from '@ghostfolio/common/interfaces';
|
import {
|
||||||
|
AssetProfileIdentifier,
|
||||||
|
DataProviderInfo
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { MarketState } from '@ghostfolio/common/types';
|
import { MarketState } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -34,6 +37,6 @@ export interface IDataProviderResponse {
|
|||||||
marketState: MarketState;
|
marketState: MarketState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IDataGatheringItem extends UniqueAsset {
|
export interface IDataGatheringItem extends AssetProfileIdentifier {
|
||||||
date?: Date;
|
date?: Date;
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,7 @@ import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.i
|
|||||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { resetHours } from '@ghostfolio/common/helper';
|
import { resetHours } from '@ghostfolio/common/helper';
|
||||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
@ -17,7 +17,7 @@ import {
|
|||||||
export class MarketDataService {
|
export class MarketDataService {
|
||||||
public constructor(private readonly prismaService: PrismaService) {}
|
public constructor(private readonly prismaService: PrismaService) {}
|
||||||
|
|
||||||
public async deleteMany({ dataSource, symbol }: UniqueAsset) {
|
public async deleteMany({ dataSource, symbol }: AssetProfileIdentifier) {
|
||||||
return this.prismaService.marketData.deleteMany({
|
return this.prismaService.marketData.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
dataSource,
|
dataSource,
|
||||||
@ -40,7 +40,7 @@ export class MarketDataService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getMax({ dataSource, symbol }: UniqueAsset) {
|
public async getMax({ dataSource, symbol }: AssetProfileIdentifier) {
|
||||||
return this.prismaService.marketData.findFirst({
|
return this.prismaService.marketData.findFirst({
|
||||||
select: {
|
select: {
|
||||||
date: true,
|
date: true,
|
||||||
@ -59,11 +59,11 @@ export class MarketDataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getRange({
|
public async getRange({
|
||||||
dateQuery,
|
assetProfileIdentifiers,
|
||||||
uniqueAssets
|
dateQuery
|
||||||
}: {
|
}: {
|
||||||
|
assetProfileIdentifiers: AssetProfileIdentifier[];
|
||||||
dateQuery: DateQuery;
|
dateQuery: DateQuery;
|
||||||
uniqueAssets: UniqueAsset[];
|
|
||||||
}): Promise<MarketData[]> {
|
}): Promise<MarketData[]> {
|
||||||
return this.prismaService.marketData.findMany({
|
return this.prismaService.marketData.findMany({
|
||||||
orderBy: [
|
orderBy: [
|
||||||
@ -76,13 +76,13 @@ export class MarketDataService {
|
|||||||
],
|
],
|
||||||
where: {
|
where: {
|
||||||
dataSource: {
|
dataSource: {
|
||||||
in: uniqueAssets.map(({ dataSource }) => {
|
in: assetProfileIdentifiers.map(({ dataSource }) => {
|
||||||
return dataSource;
|
return dataSource;
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
date: dateQuery,
|
date: dateQuery,
|
||||||
symbol: {
|
symbol: {
|
||||||
in: uniqueAssets.map(({ symbol }) => {
|
in: assetProfileIdentifiers.map(({ symbol }) => {
|
||||||
return symbol;
|
return symbol;
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
|
AssetProfileIdentifier,
|
||||||
EnhancedSymbolProfile,
|
EnhancedSymbolProfile,
|
||||||
Holding,
|
Holding,
|
||||||
ScraperConfiguration,
|
ScraperConfiguration
|
||||||
UniqueAsset
|
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { Country } from '@ghostfolio/common/interfaces/country.interface';
|
import { Country } from '@ghostfolio/common/interfaces/country.interface';
|
||||||
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
|
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
|
||||||
@ -23,7 +23,7 @@ export class SymbolProfileService {
|
|||||||
return this.prismaService.symbolProfile.create({ data: assetProfile });
|
return this.prismaService.symbolProfile.create({ data: assetProfile });
|
||||||
}
|
}
|
||||||
|
|
||||||
public async delete({ dataSource, symbol }: UniqueAsset) {
|
public async delete({ dataSource, symbol }: AssetProfileIdentifier) {
|
||||||
return this.prismaService.symbolProfile.delete({
|
return this.prismaService.symbolProfile.delete({
|
||||||
where: { dataSource_symbol: { dataSource, symbol } }
|
where: { dataSource_symbol: { dataSource, symbol } }
|
||||||
});
|
});
|
||||||
@ -36,7 +36,7 @@ export class SymbolProfileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getSymbolProfiles(
|
public async getSymbolProfiles(
|
||||||
aUniqueAssets: UniqueAsset[]
|
aAssetProfileIdentifiers: AssetProfileIdentifier[]
|
||||||
): Promise<EnhancedSymbolProfile[]> {
|
): Promise<EnhancedSymbolProfile[]> {
|
||||||
return this.prismaService.symbolProfile
|
return this.prismaService.symbolProfile
|
||||||
.findMany({
|
.findMany({
|
||||||
@ -54,7 +54,7 @@ export class SymbolProfileService {
|
|||||||
SymbolProfileOverrides: true
|
SymbolProfileOverrides: true
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
OR: aUniqueAssets.map(({ dataSource, symbol }) => {
|
OR: aAssetProfileIdentifiers.map(({ dataSource, symbol }) => {
|
||||||
return {
|
return {
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
@ -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({
|
public updateSymbolProfile({
|
||||||
assetClass,
|
assetClass,
|
||||||
assetSubClass,
|
assetSubClass,
|
||||||
@ -106,7 +140,7 @@ export class SymbolProfileService {
|
|||||||
symbolMapping,
|
symbolMapping,
|
||||||
SymbolProfileOverrides,
|
SymbolProfileOverrides,
|
||||||
url
|
url
|
||||||
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
}: AssetProfileIdentifier & Prisma.SymbolProfileUpdateInput) {
|
||||||
return this.prismaService.symbolProfile.update({
|
return this.prismaService.symbolProfile.update({
|
||||||
data: {
|
data: {
|
||||||
assetClass,
|
assetClass,
|
||||||
@ -221,8 +255,9 @@ export class SymbolProfileService {
|
|||||||
const { name, weight } = holding as Prisma.JsonObject;
|
const { name, weight } = holding as Prisma.JsonObject;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
allocationInPercentage: weight as number,
|
||||||
name: (name as string) ?? UNKNOWN_KEY,
|
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);
|
await this.twitterClient.v2.tweet(status);
|
||||||
|
|
||||||
Logger.log(
|
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'
|
'TwitterBotService'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
44
apps/api/src/validators/is-currency-code.ts
Normal file
44
apps/api/src/validators/is-currency-code.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { DERIVED_CURRENCIES } from '@ghostfolio/common/config';
|
||||||
|
|
||||||
|
import {
|
||||||
|
registerDecorator,
|
||||||
|
ValidationOptions,
|
||||||
|
ValidatorConstraint,
|
||||||
|
ValidatorConstraintInterface,
|
||||||
|
ValidationArguments
|
||||||
|
} from 'class-validator';
|
||||||
|
import { isISO4217CurrencyCode } from 'class-validator';
|
||||||
|
|
||||||
|
export function IsCurrencyCode(validationOptions?: ValidationOptions) {
|
||||||
|
return function (object: Object, propertyName: string) {
|
||||||
|
registerDecorator({
|
||||||
|
propertyName,
|
||||||
|
constraints: [],
|
||||||
|
options: validationOptions,
|
||||||
|
target: object.constructor,
|
||||||
|
validator: IsExtendedCurrencyConstraint
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@ValidatorConstraint({ async: false })
|
||||||
|
export class IsExtendedCurrencyConstraint
|
||||||
|
implements ValidatorConstraintInterface
|
||||||
|
{
|
||||||
|
public defaultMessage(args: ValidationArguments) {
|
||||||
|
return '$value must be a valid ISO4217 currency code';
|
||||||
|
}
|
||||||
|
|
||||||
|
public validate(currency: any) {
|
||||||
|
// Return true if currency is a standard ISO 4217 code or a derived currency
|
||||||
|
return (
|
||||||
|
isISO4217CurrencyCode(currency) ||
|
||||||
|
[
|
||||||
|
...DERIVED_CURRENCIES.map((derivedCurrency) => {
|
||||||
|
return derivedCurrency.currency;
|
||||||
|
}),
|
||||||
|
'USX'
|
||||||
|
].includes(currency)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
18
apps/client/localhost.cert
Normal file
18
apps/client/localhost.cert
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIC5TCCAc2gAwIBAgIJAJAMHOFnJ6oyMA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNV
|
||||||
|
BAMMCWxvY2FsaG9zdDAeFw0yNDAyMjcxNTQ2MzFaFw0yNDAzMjgxNTQ2MzFaMBQx
|
||||||
|
EjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC
|
||||||
|
ggEBAJ0hRjViikEKVIyukXR4CfuYVvFEFzB6AwAQ9Jrz2MseqpLacLTXFFAS54mp
|
||||||
|
iDuqPBzs9ta40mlSrqSBuAWKikpW5kTNnmqUnDZ6iSJezbYWx9YyULGqqb1q3C4/
|
||||||
|
5pH9m6NHJ+2uaUNKlDxYNKbntqs3drQEdxH9yv672Z53nvogTcf9jz6zjutEQGSV
|
||||||
|
HgVkCTTQmzf3+3st+VJ5D8JeYFR+tpZ6yleqgXFaTMtPZRfKLvTkQ+KeyCJLnsUJ
|
||||||
|
BQvdCKI0PGsG6d6ygXFmSePolD9KW3VTKKDPCsndID89vAnRWDj9UhzvycxmKpcF
|
||||||
|
GrUPH5+Pis1PM1R7OnAvnFygnyECAwEAAaM6MDgwFAYDVR0RBA0wC4IJbG9jYWxo
|
||||||
|
b3N0MAsGA1UdDwQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDATANBgkqhkiG9w0B
|
||||||
|
AQsFAAOCAQEAOdzrY3RTAPnBVAd3/GKIEkielYyOgKPnvd+RcOB+je8cXZY+vaxX
|
||||||
|
uEFP0526G00+kRZ2Tx9t0SmjoSpwg3lHUPMko0dIxgRlqISDAohdrEptHpcVujsD
|
||||||
|
ak88LLnAurr60JNjWX2wbEoJ18KLtqGSnATdmCgKwDPIN5a7+ssp44BGyzH6VYCg
|
||||||
|
wV3VjJl0pp4C5M0Jfu0p1FrQjzIVhgqR7JFYmvqIogWrGwYAQK/3XRXq8t48J5X3
|
||||||
|
IsfWiLAA2ZdCoWAnZ6PAGBOoGivtkJm967pHjd/28qYY6mQo4sN2ksEOjx6/YslF
|
||||||
|
2mOJdLV/DzqoggUsTpPEG0dRhzQLTGHKDQ==
|
||||||
|
-----END CERTIFICATE-----
|
28
apps/client/localhost.pem
Normal file
28
apps/client/localhost.pem
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCdIUY1YopBClSM
|
||||||
|
rpF0eAn7mFbxRBcwegMAEPSa89jLHqqS2nC01xRQEueJqYg7qjwc7PbWuNJpUq6k
|
||||||
|
gbgFiopKVuZEzZ5qlJw2eokiXs22FsfWMlCxqqm9atwuP+aR/ZujRyftrmlDSpQ8
|
||||||
|
WDSm57arN3a0BHcR/cr+u9med576IE3H/Y8+s47rREBklR4FZAk00Js39/t7LflS
|
||||||
|
eQ/CXmBUfraWespXqoFxWkzLT2UXyi705EPinsgiS57FCQUL3QiiNDxrBunesoFx
|
||||||
|
Zknj6JQ/Slt1UyigzwrJ3SA/PbwJ0Vg4/VIc78nMZiqXBRq1Dx+fj4rNTzNUezpw
|
||||||
|
L5xcoJ8hAgMBAAECggEAPU5YOEgELTA8oM8TjV+wdWuQsH2ilpVkSkhTR4nQkh+a
|
||||||
|
6cU0qDoqgLt/fySYNL9MyPRjso9V+SX7YdAC3paZMjwJh9q57lehQ1g33SMkG+Fz
|
||||||
|
gs0K0ucFZxQkaB8idN+ANAp1N7UO+ORGRe0cTeqmSNNRCxea5XgiFZVxaPS/IFOR
|
||||||
|
vVdXS1DulgwHh4H0ljKmkj7jc9zPBSc9ccW5Ml2q4a26Atu4IC/Mlp/DF4GNELbD
|
||||||
|
ebi9ZOZG33ip2bdhj3WX7NW9GJaaViKtVUpcpR6u+6BfvTXQ6xrqdoxXk9fnPzzf
|
||||||
|
sSoLPTt8yO4RavP1zQU22PhgIcWudhCiy/2Nv+uLqQKBgQDMPh1/xwdFl8yES8dy
|
||||||
|
f0xJyCDWPiB+xzGwcOAC90FrNZaqG0WWs3VHE4cilaWjCflvdR6aAEDEY68sZJhl
|
||||||
|
h+ChT/g61QLMOI+VIDQ1kJXKYgfS/B+BE1PZ0EkuymKFOvbNO8agHodB8CqnZaQh
|
||||||
|
bLuZaDnZ0JLK4im3KPt70+aKYwKBgQDE8s6xl0SLu+yJLo3VklCRD67Z8/jlvx2t
|
||||||
|
h3DF6NG8pB3QmvKdJWFKuLAWLGflJwbUW9Pt3hXkc0649KQrtiIYC0ZMh9KMaVCk
|
||||||
|
WmjS/n2eIUQZ7ZUlwFesi4p4iGynVBavIY8TJ6Y+K3TvsJgXP3IZ96r689PQJo8E
|
||||||
|
KbSeyYzFqwKBgGQTS4EAlJ+U8bEhMGj51veQCAbyChoUoFRD+n95h6RwbZKMKlzd
|
||||||
|
MenRt7VKfg6VJJNoX8Y1uYaBEaQ+5i1Zlsdz1718AhLu4+u+C9bzMXIo9ox63TTx
|
||||||
|
s3RWioVSxVNiwOtvDrQGQWAdvcioFPQLwyA34aDIgiTHDImimxbhjWThAoGAWOqW
|
||||||
|
Tq9QjxWk0Lpn5ohMP3GpK1VuhasnJvUDARb/uf8ORuPtrOz3Y9jGBvy9W0OnXbCn
|
||||||
|
mbiugZldbTtl8yYjdl+AuYSIlkPl2I3IzZl/9Shnqp0MvSJ9crT9KzXMeC8Knr6z
|
||||||
|
7Z30/BR6ksxTngtS5E5grzPt6Qe/gc2ich3kpEkCgYBfBHUhVIKVrDW/8a7U2679
|
||||||
|
Wj4i9us/M3iPMxcTv7/ZAg08TEvNBQYtvVQLu0NfDKXx8iGKJJ6evIYgNXVm2sPq
|
||||||
|
VzSgwGCALi1X0443amZU+mnX+/9RnBqyM+PJU8570mM83jqKlY/BEsi0aqxTioFG
|
||||||
|
an3xbjjN+Rq9iKLzmPxIMg==
|
||||||
|
-----END PRIVATE KEY-----
|
@ -36,6 +36,10 @@
|
|||||||
"ngswConfigPath": "apps/client/ngsw-config.json"
|
"ngswConfigPath": "apps/client/ngsw-config.json"
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
|
"development-ca": {
|
||||||
|
"baseHref": "/ca/",
|
||||||
|
"localize": ["ca"]
|
||||||
|
},
|
||||||
"development-de": {
|
"development-de": {
|
||||||
"baseHref": "/de/",
|
"baseHref": "/de/",
|
||||||
"localize": ["de"]
|
"localize": ["de"]
|
||||||
@ -163,8 +167,11 @@
|
|||||||
"serve": {
|
"serve": {
|
||||||
"executor": "@nx/angular:dev-server",
|
"executor": "@nx/angular:dev-server",
|
||||||
"options": {
|
"options": {
|
||||||
|
"buildTarget": "client:build",
|
||||||
"proxyConfig": "apps/client/proxy.conf.json",
|
"proxyConfig": "apps/client/proxy.conf.json",
|
||||||
"buildTarget": "client:build"
|
"ssl": true,
|
||||||
|
"sslCert": "apps/client/localhost.cert",
|
||||||
|
"sslKey": "apps/client/localhost.pem"
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"development-de": {
|
"development-de": {
|
||||||
@ -209,6 +216,7 @@
|
|||||||
"includeContext": true,
|
"includeContext": true,
|
||||||
"outputPath": "src/locales",
|
"outputPath": "src/locales",
|
||||||
"targetFiles": [
|
"targetFiles": [
|
||||||
|
"messages.ca.xlf",
|
||||||
"messages.de.xlf",
|
"messages.de.xlf",
|
||||||
"messages.es.xlf",
|
"messages.es.xlf",
|
||||||
"messages.fr.xlf",
|
"messages.fr.xlf",
|
||||||
@ -237,6 +245,10 @@
|
|||||||
},
|
},
|
||||||
"i18n": {
|
"i18n": {
|
||||||
"locales": {
|
"locales": {
|
||||||
|
"ca": {
|
||||||
|
"baseHref": "/ca/",
|
||||||
|
"translation": "apps/client/src/locales/messages.ca.xlf"
|
||||||
|
},
|
||||||
"de": {
|
"de": {
|
||||||
"baseHref": "/de/",
|
"baseHref": "/de/",
|
||||||
"translation": "apps/client/src/locales/messages.de.xlf"
|
"translation": "apps/client/src/locales/messages.de.xlf"
|
||||||
|
@ -1,33 +1,31 @@
|
|||||||
<header>
|
<header>
|
||||||
<div
|
@if (canCreateAccount || user?.systemMessage) {
|
||||||
*ngIf="canCreateAccount || user?.systemMessage"
|
<div class="info-message-container">
|
||||||
class="info-message-container"
|
<div class="info-message-inner-container position-fixed w-100">
|
||||||
>
|
<div class="align-items-center d-flex h-100 justify-content-center">
|
||||||
<div class="info-message-inner-container position-fixed w-100">
|
@if (canCreateAccount) {
|
||||||
<div class="align-items-center d-flex h-100 justify-content-center">
|
<a class="text-center" [routerLink]="routerLinkRegister">
|
||||||
<a
|
<div
|
||||||
*ngIf="canCreateAccount"
|
class="cursor-pointer d-inline-block info-message"
|
||||||
class="text-center"
|
(click)="onCreateAccount()"
|
||||||
[routerLink]="routerLinkRegister"
|
>
|
||||||
>
|
<span i18n>You are using the Live Demo.</span>
|
||||||
<div
|
<span class="a ml-2" i18n>Create Account</span>
|
||||||
class="cursor-pointer d-inline-block info-message"
|
</div></a
|
||||||
(click)="onCreateAccount()"
|
>
|
||||||
>
|
}
|
||||||
<span i18n>You are using the Live Demo.</span>
|
@if (!canCreateAccount && user?.systemMessage) {
|
||||||
<span class="a ml-2" i18n>Create Account</span>
|
<div
|
||||||
</div></a
|
class="cursor-pointer d-inline-block info-message text-truncate"
|
||||||
>
|
(click)="onClickSystemMessage()"
|
||||||
<div
|
>
|
||||||
*ngIf="!canCreateAccount && user?.systemMessage"
|
{{ user.systemMessage.message }}
|
||||||
class="cursor-pointer d-inline-block info-message text-truncate"
|
</div>
|
||||||
(click)="onClickSystemMessage()"
|
}
|
||||||
>
|
|
||||||
{{ user.systemMessage.message }}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
|
|
||||||
<gf-header
|
<gf-header
|
||||||
class="position-fixed w-100"
|
class="position-fixed w-100"
|
||||||
@ -45,144 +43,164 @@
|
|||||||
<router-outlet></router-outlet>
|
<router-outlet></router-outlet>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer *ngIf="showFooter" class="d-flex justify-content-center py-4 w-100">
|
@if (showFooter) {
|
||||||
<div class="container">
|
<footer class="d-flex justify-content-center py-4 w-100">
|
||||||
<div class="mb-3 row">
|
<div class="container">
|
||||||
<div class="col-sm">
|
<div class="mb-3 row">
|
||||||
<a [routerLink]="['/']"><gf-logo /></a>
|
<div class="col-sm">
|
||||||
</div>
|
<a [routerLink]="['/']"><gf-logo /></a>
|
||||||
<div class="col-sm">
|
</div>
|
||||||
<div class="h6 mt-2" i18n>Personal Finance</div>
|
<div class="col-sm">
|
||||||
<ul class="list-unstyled">
|
<div class="h6 mt-2" i18n>Personal Finance</div>
|
||||||
<li *ngIf="hasPermissionToAccessFearAndGreedIndex">
|
<ul class="list-unstyled">
|
||||||
<a i18n [routerLink]="routerLinkMarkets">Markets</a>
|
@if (hasPermissionToAccessFearAndGreedIndex) {
|
||||||
</li>
|
<li>
|
||||||
<li><a i18n [routerLink]="routerLinkResources">Resources</a></li>
|
<a i18n [routerLink]="routerLinkMarkets">Markets</a>
|
||||||
</ul>
|
</li>
|
||||||
</div>
|
}
|
||||||
<div class="col-sm">
|
<li><a i18n [routerLink]="routerLinkResources">Resources</a></li>
|
||||||
<div class="h6 mt-2">Ghostfolio</div>
|
</ul>
|
||||||
<ul class="list-unstyled">
|
</div>
|
||||||
<li><a i18n [routerLink]="routerLinkAbout">About</a></li>
|
<div class="col-sm">
|
||||||
<li *ngIf="hasPermissionForSubscription">
|
<div class="h6 mt-2">Ghostfolio</div>
|
||||||
<a i18n [routerLink]="['/blog']">Blog</a>
|
<ul class="list-unstyled">
|
||||||
</li>
|
<li><a i18n [routerLink]="routerLinkAbout">About</a></li>
|
||||||
<li>
|
@if (hasPermissionForSubscription) {
|
||||||
<a i18n [routerLink]="routerLinkAboutChangelog">Changelog</a>
|
<li>
|
||||||
</li>
|
<a i18n [routerLink]="['/blog']">Blog</a>
|
||||||
<li><a i18n [routerLink]="routerLinkFeatures">Features</a></li>
|
</li>
|
||||||
<li *ngIf="hasPermissionForSubscription">
|
}
|
||||||
<a i18n [routerLink]="routerLinkFaq"
|
|
||||||
>Frequently Asked Questions (FAQ)</a
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a i18n [routerLink]="routerLinkAboutLicense">License</a>
|
|
||||||
</li>
|
|
||||||
<li *ngIf="hasPermissionForStatistics">
|
|
||||||
<a [routerLink]="['/open']">Open Startup</a>
|
|
||||||
</li>
|
|
||||||
<li *ngIf="hasPermissionForSubscription">
|
|
||||||
<a i18n [routerLink]="routerLinkPricing">Pricing</a>
|
|
||||||
</li>
|
|
||||||
<li *ngIf="hasPermissionForSubscription">
|
|
||||||
<a i18n [routerLink]="routerLinkAboutPrivacyPolicy"
|
|
||||||
>Privacy Policy</a
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
<li *ngIf="hasPermissionForSubscription">
|
|
||||||
<a
|
|
||||||
class="align-items-baseline d-flex"
|
|
||||||
href="https://status.ghostfol.io"
|
|
||||||
target="_blank"
|
|
||||||
title="Ghostfolio Status"
|
|
||||||
>Status<ion-icon class="ml-1" name="open-outline"
|
|
||||||
/></a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="col-sm">
|
|
||||||
<div class="h6 mt-2" i18n>Community</div>
|
|
||||||
<ul class="list-unstyled">
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
class="align-items-baseline d-flex"
|
|
||||||
href="https://github.com/ghostfolio/ghostfolio"
|
|
||||||
target="_blank"
|
|
||||||
title="Find Ghostfolio on GitHub"
|
|
||||||
>GitHub<ion-icon class="ml-1" name="open-outline"
|
|
||||||
/></a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
class="align-items-baseline d-flex"
|
|
||||||
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
|
||||||
target="_blank"
|
|
||||||
title="Join the Ghostfolio Slack community"
|
|
||||||
>Slack<ion-icon class="ml-1" name="open-outline"
|
|
||||||
/></a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
class="align-items-baseline d-flex"
|
|
||||||
href="https://twitter.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="../de" title="Ghostfolio in Deutsch">Deutsch</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="../en" title="Ghostfolio in English">English</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="../es" title="Ghostfolio in Español">Español</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="../fr" title="Ghostfolio en Français">Français</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="../it" title="Ghostfolio in Italiano">Italiano</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="../nl" title="Ghostfolio in Nederlands">Nederlands</a>
|
|
||||||
</li>
|
|
||||||
<!--
|
|
||||||
<li>
|
<li>
|
||||||
<a href="../pl" title="Ghostfolio in Polski">Polski</a>
|
<a i18n [routerLink]="routerLinkAboutChangelog">Changelog</a>
|
||||||
</li>
|
</li>
|
||||||
-->
|
<li><a i18n [routerLink]="routerLinkFeatures">Features</a></li>
|
||||||
<li>
|
@if (hasPermissionForSubscription) {
|
||||||
<a href="../pt" title="Ghostfolio in Português">Português</a>
|
<li>
|
||||||
</li>
|
<a i18n [routerLink]="routerLinkFaq"
|
||||||
<li>
|
>Frequently Asked Questions (FAQ)</a
|
||||||
<a href="../tr" title="Ghostfolio in Türkçe">Türkçe</a>
|
>
|
||||||
</li>
|
</li>
|
||||||
<!--
|
}
|
||||||
<li>
|
<li>
|
||||||
<a href="../zh" title="Ghostfolio in Chinese">Chinese</a>
|
<a i18n [routerLink]="routerLinkAboutLicense">License</a>
|
||||||
</li>
|
</li>
|
||||||
-->
|
@if (hasPermissionForStatistics) {
|
||||||
</ul>
|
<li>
|
||||||
|
<a [routerLink]="['/open']">Open Startup</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
@if (hasPermissionForSubscription) {
|
||||||
|
<li>
|
||||||
|
<a i18n [routerLink]="routerLinkPricing">Pricing</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
@if (hasPermissionForSubscription) {
|
||||||
|
<li>
|
||||||
|
<a i18n [routerLink]="routerLinkAboutPrivacyPolicy"
|
||||||
|
>Privacy Policy</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
@if (hasPermissionForSubscription) {
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="align-items-baseline d-flex"
|
||||||
|
href="https://status.ghostfol.io"
|
||||||
|
target="_blank"
|
||||||
|
title="Ghostfolio Status"
|
||||||
|
>Status<ion-icon class="ml-1" name="open-outline"
|
||||||
|
/></a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm">
|
||||||
|
<div class="h6 mt-2" i18n>Community</div>
|
||||||
|
<ul class="list-unstyled">
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="align-items-baseline d-flex"
|
||||||
|
href="https://github.com/ghostfolio/ghostfolio"
|
||||||
|
target="_blank"
|
||||||
|
title="Find Ghostfolio on GitHub"
|
||||||
|
>GitHub<ion-icon class="ml-1" name="open-outline"
|
||||||
|
/></a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="align-items-baseline d-flex"
|
||||||
|
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
||||||
|
target="_blank"
|
||||||
|
title="Join the Ghostfolio Slack community"
|
||||||
|
>Slack<ion-icon class="ml-1" name="open-outline"
|
||||||
|
/></a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="align-items-baseline d-flex"
|
||||||
|
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>
|
||||||
|
<li>
|
||||||
|
<a href="../en" title="Ghostfolio in English">English</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="../es" title="Ghostfolio in Español">Español</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="../fr" title="Ghostfolio en Français">Français</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="../it" title="Ghostfolio in Italiano">Italiano</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="../nl" title="Ghostfolio in Nederlands">Nederlands</a>
|
||||||
|
</li>
|
||||||
|
<!--
|
||||||
|
<li>
|
||||||
|
<a href="../pl" title="Ghostfolio in Polski">Polski</a>
|
||||||
|
</li>
|
||||||
|
-->
|
||||||
|
<li>
|
||||||
|
<a href="../pt" title="Ghostfolio in Português">Português</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="../tr" title="Ghostfolio in Türkçe">Türkçe</a>
|
||||||
|
</li>
|
||||||
|
<!--
|
||||||
|
<li>
|
||||||
|
<a href="../zh" title="Ghostfolio in Chinese">Chinese</a>
|
||||||
|
</li>
|
||||||
|
-->
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row text-center">
|
||||||
|
<div class="col">
|
||||||
|
© 2021 - {{ currentYear }}
|
||||||
|
<a href="https://ghostfol.io">Ghostfolio</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row text-center text-muted">
|
||||||
|
<div class="col">
|
||||||
|
<small i18n
|
||||||
|
>The risk of loss in trading can be substantial. It is not advisable
|
||||||
|
to invest money you may need in the short term.</small
|
||||||
|
>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</footer>
|
||||||
<div class="row text-center">
|
}
|
||||||
<div class="col">
|
|
||||||
© 2021 - {{ currentYear }} <a href="https://ghostfol.io">Ghostfolio</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row text-center text-muted">
|
|
||||||
<div class="col">
|
|
||||||
<small i18n
|
|
||||||
>The risk of loss in trading can be substantial. It is not advisable
|
|
||||||
to invest money you may need in the short term.</small
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
|
@ -255,10 +255,18 @@ export class AppComponent implements OnDestroy, OnInit {
|
|||||||
colorScheme: this.user?.settings?.colorScheme,
|
colorScheme: this.user?.settings?.colorScheme,
|
||||||
deviceType: this.deviceType,
|
deviceType: this.deviceType,
|
||||||
hasImpersonationId: this.hasImpersonationId,
|
hasImpersonationId: this.hasImpersonationId,
|
||||||
|
hasPermissionToCreateOrder:
|
||||||
|
!this.hasImpersonationId &&
|
||||||
|
hasPermission(this.user?.permissions, permissions.createOrder) &&
|
||||||
|
!this.user?.settings?.isRestrictedView,
|
||||||
hasPermissionToReportDataGlitch: hasPermission(
|
hasPermissionToReportDataGlitch: hasPermission(
|
||||||
this.user?.permissions,
|
this.user?.permissions,
|
||||||
permissions.reportDataGlitch
|
permissions.reportDataGlitch
|
||||||
),
|
),
|
||||||
|
hasPermissionToUpdateOrder:
|
||||||
|
!this.hasImpersonationId &&
|
||||||
|
hasPermission(this.user?.permissions, permissions.updateOrder) &&
|
||||||
|
!this.user?.settings?.isRestrictedView,
|
||||||
locale: this.user?.settings?.locale
|
locale: this.user?.settings?.locale
|
||||||
},
|
},
|
||||||
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||||
|
@ -1,7 +1,10 @@
|
|||||||
import { GfLogoComponent } from '@ghostfolio/ui/logo';
|
import { GfLogoComponent } from '@ghostfolio/ui/logo';
|
||||||
|
|
||||||
import { Platform } from '@angular/cdk/platform';
|
import { Platform } from '@angular/cdk/platform';
|
||||||
import { HttpClientModule } from '@angular/common/http';
|
import {
|
||||||
|
provideHttpClient,
|
||||||
|
withInterceptorsFromDi
|
||||||
|
} from '@angular/common/http';
|
||||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
||||||
import { MatChipsModule } from '@angular/material/chips';
|
import { MatChipsModule } from '@angular/material/chips';
|
||||||
@ -45,7 +48,6 @@ export function NgxStripeFactory(): string {
|
|||||||
GfHeaderModule,
|
GfHeaderModule,
|
||||||
GfLogoComponent,
|
GfLogoComponent,
|
||||||
GfSubscriptionInterstitialDialogModule,
|
GfSubscriptionInterstitialDialogModule,
|
||||||
HttpClientModule,
|
|
||||||
MarkdownModule.forRoot(),
|
MarkdownModule.forRoot(),
|
||||||
MatAutocompleteModule,
|
MatAutocompleteModule,
|
||||||
MatChipsModule,
|
MatChipsModule,
|
||||||
@ -63,6 +65,7 @@ export function NgxStripeFactory(): string {
|
|||||||
authInterceptorProviders,
|
authInterceptorProviders,
|
||||||
httpResponseInterceptorProviders,
|
httpResponseInterceptorProviders,
|
||||||
LanguageService,
|
LanguageService,
|
||||||
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
{
|
{
|
||||||
provide: DateAdapter,
|
provide: DateAdapter,
|
||||||
useClass: CustomDateAdapter,
|
useClass: CustomDateAdapter,
|
||||||
|
@ -1,67 +1,71 @@
|
|||||||
<table class="gf-table w-100" mat-table [dataSource]="dataSource">
|
<div class="overflow-x-auto">
|
||||||
<ng-container matColumnDef="alias">
|
<table class="gf-table w-100" mat-table [dataSource]="dataSource">
|
||||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Alias</th>
|
<ng-container matColumnDef="alias">
|
||||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Alias</th>
|
||||||
{{ element.alias }}
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
</td>
|
{{ element.alias }}
|
||||||
</ng-container>
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="grantee">
|
<ng-container matColumnDef="grantee">
|
||||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Grantee</th>
|
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Grantee</th>
|
||||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
{{ element.grantee }}
|
{{ element.grantee }}
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="type">
|
<ng-container matColumnDef="type">
|
||||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Permission</th>
|
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Permission</th>
|
||||||
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
|
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
|
||||||
<div class="align-items-center d-flex">
|
<div class="align-items-center d-flex">
|
||||||
@if (element.permissions.includes('READ')) {
|
@if (element.permissions.includes('READ')) {
|
||||||
<ion-icon class="mr-1" name="lock-open-outline" />
|
<ion-icon class="mr-1" name="lock-open-outline" />
|
||||||
<ng-container i18n>View</ng-container>
|
<ng-container i18n>View</ng-container>
|
||||||
} @else if (element.permissions.includes('READ_RESTRICTED')) {
|
} @else if (element.permissions.includes('READ_RESTRICTED')) {
|
||||||
<ion-icon class="mr-1" name="lock-closed-outline" />
|
<ion-icon class="mr-1" name="lock-closed-outline" />
|
||||||
<ng-container i18n>Restricted view</ng-container>
|
<ng-container i18n>Restricted view</ng-container>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="details">
|
||||||
|
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Details</th>
|
||||||
|
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
|
||||||
|
@if (element.type === 'PUBLIC') {
|
||||||
|
<div class="align-items-center d-flex">
|
||||||
|
<ion-icon class="mr-1" name="link-outline" />
|
||||||
|
<a
|
||||||
|
href="{{ baseUrl }}/{{ defaultLanguageCode }}/p/{{ element.id }}"
|
||||||
|
target="_blank"
|
||||||
|
>{{ baseUrl }}/{{ defaultLanguageCode }}/p/{{ element.id }}</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</td>
|
||||||
</td>
|
</ng-container>
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container matColumnDef="details">
|
<ng-container matColumnDef="actions" stickyEnd>
|
||||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Details</th>
|
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell></th>
|
||||||
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
|
|
||||||
<div *ngIf="element.type === 'PUBLIC'" class="align-items-center d-flex">
|
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
|
||||||
<ion-icon class="mr-1" name="link-outline" />
|
<button
|
||||||
<a
|
class="mx-1 no-min-width px-2"
|
||||||
href="{{ baseUrl }}/{{ defaultLanguageCode }}/p/{{ element.id }}"
|
mat-button
|
||||||
target="_blank"
|
[matMenuTriggerFor]="transactionMenu"
|
||||||
>{{ baseUrl }}/{{ defaultLanguageCode }}/p/{{ element.id }}</a
|
(click)="$event.stopPropagation()"
|
||||||
>
|
>
|
||||||
</div>
|
<ion-icon name="ellipsis-horizontal" />
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container matColumnDef="actions" stickyEnd>
|
|
||||||
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell></th>
|
|
||||||
|
|
||||||
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
|
|
||||||
<button
|
|
||||||
class="mx-1 no-min-width px-2"
|
|
||||||
mat-button
|
|
||||||
[matMenuTriggerFor]="transactionMenu"
|
|
||||||
(click)="$event.stopPropagation()"
|
|
||||||
>
|
|
||||||
<ion-icon name="ellipsis-horizontal" />
|
|
||||||
</button>
|
|
||||||
<mat-menu #transactionMenu="matMenu" xPosition="before">
|
|
||||||
<button mat-menu-item (click)="onDeleteAccess(element.id)">
|
|
||||||
<ng-container i18n>Revoke</ng-container>
|
|
||||||
</button>
|
</button>
|
||||||
</mat-menu>
|
<mat-menu #transactionMenu="matMenu" xPosition="before">
|
||||||
</td>
|
<button mat-menu-item (click)="onDeleteAccess(element.id)">
|
||||||
</ng-container>
|
<ng-container i18n>Revoke</ng-container>
|
||||||
|
</button>
|
||||||
|
</mat-menu>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
|
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
|
||||||
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
|
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
@ -23,6 +23,7 @@ import {
|
|||||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
import { Sort, SortDirection } from '@angular/material/sort';
|
import { Sort, SortDirection } from '@angular/material/sort';
|
||||||
import { MatTableDataSource } from '@angular/material/table';
|
import { MatTableDataSource } from '@angular/material/table';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
import { Big } from 'big.js';
|
import { Big } from 'big.js';
|
||||||
import { format, parseISO } from 'date-fns';
|
import { format, parseISO } from 'date-fns';
|
||||||
import { isNumber } from 'lodash';
|
import { isNumber } from 'lodash';
|
||||||
@ -66,6 +67,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
|||||||
@Inject(MAT_DIALOG_DATA) public data: AccountDetailDialogParams,
|
@Inject(MAT_DIALOG_DATA) public data: AccountDetailDialogParams,
|
||||||
private dataService: DataService,
|
private dataService: DataService,
|
||||||
public dialogRef: MatDialogRef<AccountDetailDialog>,
|
public dialogRef: MatDialogRef<AccountDetailDialog>,
|
||||||
|
private router: Router,
|
||||||
private userService: UserService
|
private userService: UserService
|
||||||
) {
|
) {
|
||||||
this.userService.stateChanged
|
this.userService.stateChanged
|
||||||
@ -92,6 +94,14 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
|||||||
this.fetchPortfolioPerformance();
|
this.fetchPortfolioPerformance();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onCloneActivity(aActivity: Activity) {
|
||||||
|
this.router.navigate(['/portfolio', 'activities'], {
|
||||||
|
queryParams: { activityId: aActivity.id, createDialog: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
this.dialogRef.close();
|
||||||
|
}
|
||||||
|
|
||||||
public onClose() {
|
public onClose() {
|
||||||
this.dialogRef.close();
|
this.dialogRef.close();
|
||||||
}
|
}
|
||||||
@ -147,6 +157,14 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
|||||||
this.fetchActivities();
|
this.fetchActivities();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onUpdateActivity(aActivity: Activity) {
|
||||||
|
this.router.navigate(['/portfolio', 'activities'], {
|
||||||
|
queryParams: { activityId: aActivity.id, editDialog: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
this.dialogRef.close();
|
||||||
|
}
|
||||||
|
|
||||||
private fetchAccount() {
|
private fetchAccount() {
|
||||||
this.dataService
|
this.dataService
|
||||||
.fetchAccount(this.data.accountId)
|
.fetchAccount(this.data.accountId)
|
||||||
|
@ -101,10 +101,17 @@
|
|||||||
[hasPermissionToFilter]="false"
|
[hasPermissionToFilter]="false"
|
||||||
[hasPermissionToOpenDetails]="false"
|
[hasPermissionToOpenDetails]="false"
|
||||||
[locale]="user?.settings?.locale"
|
[locale]="user?.settings?.locale"
|
||||||
[showActions]="false"
|
[showActions]="
|
||||||
|
!data.hasImpersonationId &&
|
||||||
|
data.hasPermissionToCreateOrder &&
|
||||||
|
user?.settings?.isExperimentalFeatures &&
|
||||||
|
!user?.settings?.isRestrictedView
|
||||||
|
"
|
||||||
[sortColumn]="sortColumn"
|
[sortColumn]="sortColumn"
|
||||||
[sortDirection]="sortDirection"
|
[sortDirection]="sortDirection"
|
||||||
[totalItems]="totalItems"
|
[totalItems]="totalItems"
|
||||||
|
(activityToClone)="onCloneActivity($event)"
|
||||||
|
(activityToUpdate)="onUpdateActivity($event)"
|
||||||
(export)="onExport()"
|
(export)="onExport()"
|
||||||
(sortChanged)="onSortChanged($event)"
|
(sortChanged)="onSortChanged($event)"
|
||||||
/>
|
/>
|
||||||
|
@ -2,4 +2,5 @@ export interface AccountDetailDialogParams {
|
|||||||
accountId: string;
|
accountId: string;
|
||||||
deviceType: string;
|
deviceType: string;
|
||||||
hasImpersonationId: boolean;
|
hasImpersonationId: boolean;
|
||||||
|
hasPermissionToCreateOrder: boolean;
|
||||||
}
|
}
|
||||||
|
@ -1,296 +1,322 @@
|
|||||||
<div *ngIf="showActions" class="d-flex justify-content-end">
|
@if (showActions) {
|
||||||
<button
|
<div class="d-flex justify-content-end">
|
||||||
class="align-items-center d-flex"
|
<button
|
||||||
mat-stroked-button
|
class="align-items-center d-flex"
|
||||||
[disabled]="dataSource?.data.length < 2"
|
mat-stroked-button
|
||||||
(click)="onTransferBalance()"
|
[disabled]="dataSource?.data.length < 2"
|
||||||
>
|
(click)="onTransferBalance()"
|
||||||
<ion-icon class="mr-2" name="arrow-redo-outline" />
|
>
|
||||||
<ng-container i18n>Transfer Cash Balance</ng-container>...
|
<ion-icon class="mr-2" name="arrow-redo-outline" />
|
||||||
</button>
|
<ng-container i18n>Transfer Cash Balance</ng-container>...
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="gf-table w-100" mat-table matSort [dataSource]="dataSource">
|
||||||
|
<ng-container matColumnDef="status">
|
||||||
|
<th
|
||||||
|
*matHeaderCellDef
|
||||||
|
class="d-none d-lg-table-cell px-1"
|
||||||
|
mat-header-cell
|
||||||
|
></th>
|
||||||
|
<td
|
||||||
|
*matCellDef="let element"
|
||||||
|
class="d-none d-lg-table-cell px-1"
|
||||||
|
mat-cell
|
||||||
|
>
|
||||||
|
<div class="d-flex justify-content-center">
|
||||||
|
@if (element.isExcluded) {
|
||||||
|
<ion-icon name="eye-off-outline" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
*matFooterCellDef
|
||||||
|
class="d-none d-lg-table-cell px-1"
|
||||||
|
mat-footer-cell
|
||||||
|
></td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="account">
|
||||||
|
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header="name">
|
||||||
|
<ng-container i18n>Name</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
|
@if (element.Platform?.url) {
|
||||||
|
<gf-asset-profile-icon
|
||||||
|
class="d-inline d-sm-none mr-1"
|
||||||
|
[tooltip]="element.Platform?.name"
|
||||||
|
[url]="element.Platform?.url"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
<span>{{ element.name }}</span>
|
||||||
|
</td>
|
||||||
|
<td *matFooterCellDef class="px-1" i18n mat-footer-cell>Total</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="currency">
|
||||||
|
<th
|
||||||
|
*matHeaderCellDef
|
||||||
|
class="d-none d-lg-table-cell px-1"
|
||||||
|
mat-header-cell
|
||||||
|
mat-sort-header
|
||||||
|
>
|
||||||
|
<ng-container i18n>Currency</ng-container>
|
||||||
|
</th>
|
||||||
|
<td
|
||||||
|
*matCellDef="let element"
|
||||||
|
class="d-none d-lg-table-cell px-1"
|
||||||
|
mat-cell
|
||||||
|
>
|
||||||
|
{{ element.currency }}
|
||||||
|
</td>
|
||||||
|
<td *matFooterCellDef class="d-none d-lg-table-cell px-1" mat-footer-cell>
|
||||||
|
{{ baseCurrency }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="platform">
|
||||||
|
<th
|
||||||
|
*matHeaderCellDef
|
||||||
|
class="d-none d-lg-table-cell px-1"
|
||||||
|
mat-header-cell
|
||||||
|
mat-sort-header="Platform.name"
|
||||||
|
>
|
||||||
|
<ng-container i18n>Platform</ng-container>
|
||||||
|
</th>
|
||||||
|
<td
|
||||||
|
*matCellDef="let element"
|
||||||
|
class="d-none d-lg-table-cell px-1"
|
||||||
|
mat-cell
|
||||||
|
>
|
||||||
|
<div class="d-flex">
|
||||||
|
@if (element.Platform?.url) {
|
||||||
|
<gf-asset-profile-icon
|
||||||
|
class="mr-1"
|
||||||
|
[tooltip]="element.Platform?.name"
|
||||||
|
[url]="element.Platform?.url"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
<span>{{ element.Platform?.name }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
*matFooterCellDef
|
||||||
|
class="d-none d-lg-table-cell px-1"
|
||||||
|
mat-footer-cell
|
||||||
|
></td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="transactions">
|
||||||
|
<th
|
||||||
|
*matHeaderCellDef
|
||||||
|
class="justify-content-end px-1"
|
||||||
|
mat-header-cell
|
||||||
|
mat-sort-header="transactionCount"
|
||||||
|
>
|
||||||
|
<span class="d-block d-sm-none">#</span>
|
||||||
|
<span class="d-none d-sm-block" i18n>Activities</span>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||||
|
{{ element.transactionCount }}
|
||||||
|
</td>
|
||||||
|
<td *matFooterCellDef class="px-1 text-right" mat-footer-cell>
|
||||||
|
{{ transactionCount }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="balance">
|
||||||
|
<th
|
||||||
|
*matHeaderCellDef
|
||||||
|
class="d-none d-lg-table-cell justify-content-end px-1"
|
||||||
|
mat-header-cell
|
||||||
|
mat-sort-header
|
||||||
|
>
|
||||||
|
<ng-container i18n>Cash Balance</ng-container>
|
||||||
|
</th>
|
||||||
|
<td
|
||||||
|
*matCellDef="let element"
|
||||||
|
class="d-none d-lg-table-cell px-1 text-right"
|
||||||
|
mat-cell
|
||||||
|
>
|
||||||
|
<gf-value
|
||||||
|
class="d-inline-block justify-content-end"
|
||||||
|
[isCurrency]="true"
|
||||||
|
[locale]="locale"
|
||||||
|
[value]="element.balance"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
*matFooterCellDef
|
||||||
|
class="d-none d-lg-table-cell px-1 text-right"
|
||||||
|
mat-footer-cell
|
||||||
|
>
|
||||||
|
<gf-value
|
||||||
|
class="d-inline-block justify-content-end"
|
||||||
|
[isCurrency]="true"
|
||||||
|
[locale]="locale"
|
||||||
|
[value]="totalBalanceInBaseCurrency"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="value">
|
||||||
|
<th
|
||||||
|
*matHeaderCellDef
|
||||||
|
class="d-none d-lg-table-cell justify-content-end px-1"
|
||||||
|
mat-header-cell
|
||||||
|
mat-sort-header
|
||||||
|
>
|
||||||
|
<ng-container i18n>Value</ng-container>
|
||||||
|
</th>
|
||||||
|
<td
|
||||||
|
*matCellDef="let element"
|
||||||
|
class="d-none d-lg-table-cell px-1 text-right"
|
||||||
|
mat-cell
|
||||||
|
>
|
||||||
|
<gf-value
|
||||||
|
class="d-inline-block justify-content-end"
|
||||||
|
[isCurrency]="true"
|
||||||
|
[locale]="locale"
|
||||||
|
[value]="element.value"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
*matFooterCellDef
|
||||||
|
class="d-none d-lg-table-cell px-1 text-right"
|
||||||
|
mat-footer-cell
|
||||||
|
>
|
||||||
|
<gf-value
|
||||||
|
class="d-inline-block justify-content-end"
|
||||||
|
[isCurrency]="true"
|
||||||
|
[locale]="locale"
|
||||||
|
[value]="totalValueInBaseCurrency"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="valueInBaseCurrency">
|
||||||
|
<th
|
||||||
|
*matHeaderCellDef
|
||||||
|
class="d-lg-none d-xl-none px-1 text-right"
|
||||||
|
mat-header-cell
|
||||||
|
mat-sort-header
|
||||||
|
>
|
||||||
|
<ng-container i18n>Value</ng-container>
|
||||||
|
</th>
|
||||||
|
<td
|
||||||
|
*matCellDef="let element"
|
||||||
|
class="d-lg-none d-xl-none px-1 text-right"
|
||||||
|
mat-cell
|
||||||
|
>
|
||||||
|
<gf-value
|
||||||
|
class="d-inline-block justify-content-end"
|
||||||
|
[isCurrency]="true"
|
||||||
|
[locale]="locale"
|
||||||
|
[value]="element.valueInBaseCurrency"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
*matFooterCellDef
|
||||||
|
class="d-lg-none d-xl-none px-1 text-right"
|
||||||
|
mat-footer-cell
|
||||||
|
>
|
||||||
|
<gf-value
|
||||||
|
class="d-inline-block justify-content-end"
|
||||||
|
[isCurrency]="true"
|
||||||
|
[locale]="locale"
|
||||||
|
[value]="totalValueInBaseCurrency"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="comment">
|
||||||
|
<th
|
||||||
|
*matHeaderCellDef
|
||||||
|
class="d-none d-lg-table-cell px-1"
|
||||||
|
mat-header-cell
|
||||||
|
></th>
|
||||||
|
<td
|
||||||
|
*matCellDef="let element"
|
||||||
|
class="d-none d-lg-table-cell px-1"
|
||||||
|
mat-cell
|
||||||
|
>
|
||||||
|
@if (element.comment) {
|
||||||
|
<button
|
||||||
|
class="mx-1 no-min-width px-2"
|
||||||
|
mat-button
|
||||||
|
title="Note"
|
||||||
|
(click)="onOpenComment(element.comment); $event.stopPropagation()"
|
||||||
|
>
|
||||||
|
<ion-icon name="document-text-outline" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
*matFooterCellDef
|
||||||
|
class="d-none d-lg-table-cell px-1"
|
||||||
|
mat-footer-cell
|
||||||
|
></td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="actions" stickyEnd>
|
||||||
|
<th *matHeaderCellDef class="px-1 text-center" i18n mat-header-cell></th>
|
||||||
|
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
|
||||||
|
<button
|
||||||
|
class="mx-1 no-min-width px-2"
|
||||||
|
mat-button
|
||||||
|
[matMenuTriggerFor]="accountMenu"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
>
|
||||||
|
<ion-icon name="ellipsis-horizontal" />
|
||||||
|
</button>
|
||||||
|
<mat-menu #accountMenu="matMenu" xPosition="before">
|
||||||
|
<button mat-menu-item (click)="onUpdateAccount(element)">
|
||||||
|
<span class="align-items-center d-flex">
|
||||||
|
<ion-icon class="mr-2" name="create-outline" />
|
||||||
|
<span i18n>Edit</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
mat-menu-item
|
||||||
|
[disabled]="element.transactionCount > 0"
|
||||||
|
(click)="onDeleteAccount(element.id)"
|
||||||
|
>
|
||||||
|
<span class="align-items-center d-flex">
|
||||||
|
<ion-icon class="mr-2" name="trash-outline" />
|
||||||
|
<span i18n>Delete</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</mat-menu>
|
||||||
|
</td>
|
||||||
|
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
|
||||||
|
<tr
|
||||||
|
*matRowDef="let row; columns: displayedColumns"
|
||||||
|
mat-row
|
||||||
|
[ngClass]="{
|
||||||
|
'cursor-pointer': hasPermissionToOpenDetails
|
||||||
|
}"
|
||||||
|
(click)="onOpenAccountDetailDialog(row.id)"
|
||||||
|
></tr>
|
||||||
|
<tr
|
||||||
|
*matFooterRowDef="displayedColumns"
|
||||||
|
mat-footer-row
|
||||||
|
[ngClass]="{ 'd-none': isLoading || !showFooter }"
|
||||||
|
></tr>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table class="gf-table w-100" mat-table matSort [dataSource]="dataSource">
|
@if (isLoading) {
|
||||||
<ng-container matColumnDef="status">
|
<ngx-skeleton-loader
|
||||||
<th
|
animation="pulse"
|
||||||
*matHeaderCellDef
|
class="px-4 py-3"
|
||||||
class="d-none d-lg-table-cell px-1"
|
[theme]="{
|
||||||
mat-header-cell
|
height: '1.5rem',
|
||||||
></th>
|
width: '100%'
|
||||||
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
|
|
||||||
<div class="d-flex justify-content-center">
|
|
||||||
<ion-icon *ngIf="element.isExcluded" name="eye-off-outline" />
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
*matFooterCellDef
|
|
||||||
class="d-none d-lg-table-cell px-1"
|
|
||||||
mat-footer-cell
|
|
||||||
></td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container matColumnDef="account">
|
|
||||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header="name">
|
|
||||||
<ng-container i18n>Name</ng-container>
|
|
||||||
</th>
|
|
||||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
|
||||||
<gf-asset-profile-icon
|
|
||||||
*ngIf="element.Platform?.url"
|
|
||||||
class="d-inline d-sm-none mr-1"
|
|
||||||
[tooltip]="element.Platform?.name"
|
|
||||||
[url]="element.Platform?.url"
|
|
||||||
/>
|
|
||||||
<span>{{ element.name }}</span>
|
|
||||||
</td>
|
|
||||||
<td *matFooterCellDef class="px-1" i18n mat-footer-cell>Total</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container matColumnDef="currency">
|
|
||||||
<th
|
|
||||||
*matHeaderCellDef
|
|
||||||
class="d-none d-lg-table-cell px-1"
|
|
||||||
mat-header-cell
|
|
||||||
mat-sort-header
|
|
||||||
>
|
|
||||||
<ng-container i18n>Currency</ng-container>
|
|
||||||
</th>
|
|
||||||
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
|
|
||||||
{{ element.currency }}
|
|
||||||
</td>
|
|
||||||
<td *matFooterCellDef class="d-none d-lg-table-cell px-1" mat-footer-cell>
|
|
||||||
{{ baseCurrency }}
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container matColumnDef="platform">
|
|
||||||
<th
|
|
||||||
*matHeaderCellDef
|
|
||||||
class="d-none d-lg-table-cell px-1"
|
|
||||||
mat-header-cell
|
|
||||||
mat-sort-header="Platform.name"
|
|
||||||
>
|
|
||||||
<ng-container i18n>Platform</ng-container>
|
|
||||||
</th>
|
|
||||||
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
|
|
||||||
<div class="d-flex">
|
|
||||||
<gf-asset-profile-icon
|
|
||||||
*ngIf="element.Platform?.url"
|
|
||||||
class="mr-1"
|
|
||||||
[tooltip]="element.Platform?.name"
|
|
||||||
[url]="element.Platform?.url"
|
|
||||||
/>
|
|
||||||
<span>{{ element.Platform?.name }}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
*matFooterCellDef
|
|
||||||
class="d-none d-lg-table-cell px-1"
|
|
||||||
mat-footer-cell
|
|
||||||
></td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container matColumnDef="transactions">
|
|
||||||
<th
|
|
||||||
*matHeaderCellDef
|
|
||||||
class="justify-content-end px-1"
|
|
||||||
mat-header-cell
|
|
||||||
mat-sort-header="transactionCount"
|
|
||||||
>
|
|
||||||
<span class="d-block d-sm-none">#</span>
|
|
||||||
<span class="d-none d-sm-block" i18n>Activities</span>
|
|
||||||
</th>
|
|
||||||
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
|
||||||
{{ element.transactionCount }}
|
|
||||||
</td>
|
|
||||||
<td *matFooterCellDef class="px-1 text-right" mat-footer-cell>
|
|
||||||
{{ transactionCount }}
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container matColumnDef="balance">
|
|
||||||
<th
|
|
||||||
*matHeaderCellDef
|
|
||||||
class="d-none d-lg-table-cell justify-content-end px-1"
|
|
||||||
mat-header-cell
|
|
||||||
mat-sort-header
|
|
||||||
>
|
|
||||||
<ng-container i18n>Cash Balance</ng-container>
|
|
||||||
</th>
|
|
||||||
<td
|
|
||||||
*matCellDef="let element"
|
|
||||||
class="d-none d-lg-table-cell px-1 text-right"
|
|
||||||
mat-cell
|
|
||||||
>
|
|
||||||
<gf-value
|
|
||||||
class="d-inline-block justify-content-end"
|
|
||||||
[isCurrency]="true"
|
|
||||||
[locale]="locale"
|
|
||||||
[value]="element.balance"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
*matFooterCellDef
|
|
||||||
class="d-none d-lg-table-cell px-1 text-right"
|
|
||||||
mat-footer-cell
|
|
||||||
>
|
|
||||||
<gf-value
|
|
||||||
class="d-inline-block justify-content-end"
|
|
||||||
[isCurrency]="true"
|
|
||||||
[locale]="locale"
|
|
||||||
[value]="totalBalanceInBaseCurrency"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container matColumnDef="value">
|
|
||||||
<th
|
|
||||||
*matHeaderCellDef
|
|
||||||
class="d-none d-lg-table-cell justify-content-end px-1"
|
|
||||||
mat-header-cell
|
|
||||||
mat-sort-header
|
|
||||||
>
|
|
||||||
<ng-container i18n>Value</ng-container>
|
|
||||||
</th>
|
|
||||||
<td
|
|
||||||
*matCellDef="let element"
|
|
||||||
class="d-none d-lg-table-cell px-1 text-right"
|
|
||||||
mat-cell
|
|
||||||
>
|
|
||||||
<gf-value
|
|
||||||
class="d-inline-block justify-content-end"
|
|
||||||
[isCurrency]="true"
|
|
||||||
[locale]="locale"
|
|
||||||
[value]="element.value"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
*matFooterCellDef
|
|
||||||
class="d-none d-lg-table-cell px-1 text-right"
|
|
||||||
mat-footer-cell
|
|
||||||
>
|
|
||||||
<gf-value
|
|
||||||
class="d-inline-block justify-content-end"
|
|
||||||
[isCurrency]="true"
|
|
||||||
[locale]="locale"
|
|
||||||
[value]="totalValueInBaseCurrency"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container matColumnDef="valueInBaseCurrency">
|
|
||||||
<th
|
|
||||||
*matHeaderCellDef
|
|
||||||
class="d-lg-none d-xl-none px-1 text-right"
|
|
||||||
mat-header-cell
|
|
||||||
mat-sort-header
|
|
||||||
>
|
|
||||||
<ng-container i18n>Value</ng-container>
|
|
||||||
</th>
|
|
||||||
<td
|
|
||||||
*matCellDef="let element"
|
|
||||||
class="d-lg-none d-xl-none px-1 text-right"
|
|
||||||
mat-cell
|
|
||||||
>
|
|
||||||
<gf-value
|
|
||||||
class="d-inline-block justify-content-end"
|
|
||||||
[isCurrency]="true"
|
|
||||||
[locale]="locale"
|
|
||||||
[value]="element.valueInBaseCurrency"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
*matFooterCellDef
|
|
||||||
class="d-lg-none d-xl-none px-1 text-right"
|
|
||||||
mat-footer-cell
|
|
||||||
>
|
|
||||||
<gf-value
|
|
||||||
class="d-inline-block justify-content-end"
|
|
||||||
[isCurrency]="true"
|
|
||||||
[locale]="locale"
|
|
||||||
[value]="totalValueInBaseCurrency"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container matColumnDef="comment">
|
|
||||||
<th
|
|
||||||
*matHeaderCellDef
|
|
||||||
class="d-none d-lg-table-cell px-1"
|
|
||||||
mat-header-cell
|
|
||||||
></th>
|
|
||||||
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
|
|
||||||
<button
|
|
||||||
*ngIf="element.comment"
|
|
||||||
class="mx-1 no-min-width px-2"
|
|
||||||
mat-button
|
|
||||||
title="Note"
|
|
||||||
(click)="onOpenComment(element.comment); $event.stopPropagation()"
|
|
||||||
>
|
|
||||||
<ion-icon name="document-text-outline" />
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
*matFooterCellDef
|
|
||||||
class="d-none d-lg-table-cell px-1"
|
|
||||||
mat-footer-cell
|
|
||||||
></td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container matColumnDef="actions" stickyEnd>
|
|
||||||
<th *matHeaderCellDef class="px-1 text-center" i18n mat-header-cell></th>
|
|
||||||
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
|
|
||||||
<button
|
|
||||||
class="mx-1 no-min-width px-2"
|
|
||||||
mat-button
|
|
||||||
[matMenuTriggerFor]="accountMenu"
|
|
||||||
(click)="$event.stopPropagation()"
|
|
||||||
>
|
|
||||||
<ion-icon name="ellipsis-horizontal" />
|
|
||||||
</button>
|
|
||||||
<mat-menu #accountMenu="matMenu" xPosition="before">
|
|
||||||
<button mat-menu-item (click)="onUpdateAccount(element)">
|
|
||||||
<span class="align-items-center d-flex">
|
|
||||||
<ion-icon class="mr-2" name="create-outline" />
|
|
||||||
<span i18n>Edit</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
mat-menu-item
|
|
||||||
[disabled]="element.transactionCount > 0"
|
|
||||||
(click)="onDeleteAccount(element.id)"
|
|
||||||
>
|
|
||||||
<span class="align-items-center d-flex">
|
|
||||||
<ion-icon class="mr-2" name="trash-outline" />
|
|
||||||
<span i18n>Delete</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</mat-menu>
|
|
||||||
</td>
|
|
||||||
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
|
|
||||||
<tr
|
|
||||||
*matRowDef="let row; columns: displayedColumns"
|
|
||||||
mat-row
|
|
||||||
[ngClass]="{
|
|
||||||
'cursor-pointer': hasPermissionToOpenDetails
|
|
||||||
}"
|
}"
|
||||||
(click)="onOpenAccountDetailDialog(row.id)"
|
/>
|
||||||
></tr>
|
}
|
||||||
<tr
|
|
||||||
*matFooterRowDef="displayedColumns"
|
|
||||||
mat-footer-row
|
|
||||||
[ngClass]="{ 'd-none': isLoading || !showFooter }"
|
|
||||||
></tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<ngx-skeleton-loader
|
|
||||||
*ngIf="isLoading"
|
|
||||||
animation="pulse"
|
|
||||||
class="px-4 py-3"
|
|
||||||
[theme]="{
|
|
||||||
height: '1.5rem',
|
|
||||||
width: '100%'
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
|
@ -5,11 +5,14 @@
|
|||||||
<mat-form-field appearance="outline" class="w-100 without-hint">
|
<mat-form-field appearance="outline" class="w-100 without-hint">
|
||||||
<mat-select formControlName="status">
|
<mat-select formControlName="status">
|
||||||
<mat-option />
|
<mat-option />
|
||||||
<mat-option
|
@for (
|
||||||
*ngFor="let statusFilterOption of statusFilterOptions"
|
statusFilterOption of statusFilterOptions;
|
||||||
[value]="statusFilterOption"
|
track statusFilterOption
|
||||||
>{{ statusFilterOption }}</mat-option
|
) {
|
||||||
>
|
<mat-option [value]="statusFilterOption">{{
|
||||||
|
statusFilterOption
|
||||||
|
}}</mat-option>
|
||||||
|
}
|
||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</form>
|
</form>
|
||||||
@ -28,15 +31,11 @@
|
|||||||
<ng-container i18n>Type</ng-container>
|
<ng-container i18n>Type</ng-container>
|
||||||
</th>
|
</th>
|
||||||
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
|
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
|
||||||
<ng-container *ngIf="element.name === 'GATHER_ASSET_PROFILE'" i18n>
|
@if (element.name === 'GATHER_ASSET_PROFILE') {
|
||||||
Asset Profile
|
<ng-container i18n>Asset Profile</ng-container>
|
||||||
</ng-container>
|
} @else if (element.name === 'GATHER_HISTORICAL_MARKET_DATA') {
|
||||||
<ng-container
|
<ng-container i18n>Historical Market Data</ng-container>
|
||||||
*ngIf="element.name === 'GATHER_HISTORICAL_MARKET_DATA'"
|
}
|
||||||
i18n
|
|
||||||
>
|
|
||||||
Historical Market Data
|
|
||||||
</ng-container>
|
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
@ -109,37 +108,29 @@
|
|||||||
<ng-container i18n>Status</ng-container>
|
<ng-container i18n>Status</ng-container>
|
||||||
</th>
|
</th>
|
||||||
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
|
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
|
||||||
<ion-icon
|
@if (element.state === 'active') {
|
||||||
*ngIf="element.state === 'active'"
|
<ion-icon class="h6 mb-0" name="play-outline" />
|
||||||
class="h6 mb-0"
|
} @else if (element.state === 'completed') {
|
||||||
name="play-outline"
|
<ion-icon
|
||||||
/>
|
class="h6 mb-0 text-success"
|
||||||
<ion-icon
|
name="checkmark-circle-outline"
|
||||||
*ngIf="element.state === 'completed'"
|
/>
|
||||||
class="h6 mb-0 text-success"
|
} @else if (element.state === 'delayed') {
|
||||||
name="checkmark-circle-outline"
|
<ion-icon
|
||||||
/>
|
class="h6 mb-0"
|
||||||
<ion-icon
|
name="time-outline"
|
||||||
*ngIf="element.state === 'delayed'"
|
[ngClass]="{ 'text-danger': element.stacktrace?.length > 0 }"
|
||||||
class="h6 mb-0"
|
/>
|
||||||
name="time-outline"
|
} @else if (element.state === 'failed') {
|
||||||
[ngClass]="{ 'text-danger': element.stacktrace?.length > 0 }"
|
<ion-icon
|
||||||
/>
|
class="h6 mb-0 text-danger"
|
||||||
<ion-icon
|
name="alert-circle-outline"
|
||||||
*ngIf="element.state === 'failed'"
|
/>
|
||||||
class="h6 mb-0 text-danger"
|
} @else if (element.state === 'paused') {
|
||||||
name="alert-circle-outline"
|
<ion-icon class="h6 mb-0" name="pause-outline" />
|
||||||
/>
|
} @else if (element.state === 'waiting') {
|
||||||
<ion-icon
|
<ion-icon class="h6 mb-0" name="cafe-outline" />
|
||||||
*ngIf="element.state === 'paused'"
|
}
|
||||||
class="h6 mb-0"
|
|
||||||
name="pause-outline"
|
|
||||||
/>
|
|
||||||
<ion-icon
|
|
||||||
*ngIf="element.state === 'waiting'"
|
|
||||||
class="h6 mb-0"
|
|
||||||
name="cafe-outline"
|
|
||||||
/>
|
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
|
@ -9,35 +9,38 @@
|
|||||||
[showYAxis]="true"
|
[showYAxis]="true"
|
||||||
[symbol]="symbol"
|
[symbol]="symbol"
|
||||||
/>
|
/>
|
||||||
<div *ngFor="let itemByMonth of marketDataByMonth | keyvalue" class="d-flex">
|
@for (itemByMonth of marketDataByMonth | keyvalue; track itemByMonth) {
|
||||||
<div class="date px-1 text-nowrap">{{ itemByMonth.key }}</div>
|
<div class="d-flex">
|
||||||
<div class="align-items-center d-flex flex-grow-1 px-1">
|
<div class="date px-1 text-nowrap">{{ itemByMonth.key }}</div>
|
||||||
<div
|
<div class="align-items-center d-flex flex-grow-1 px-1">
|
||||||
*ngFor="let dayItem of days; let i = index"
|
@for (dayItem of days; track dayItem; let i = $index) {
|
||||||
class="day"
|
<div
|
||||||
[ngClass]="{
|
class="day"
|
||||||
'cursor-pointer valid': isDateOfInterest(
|
[ngClass]="{
|
||||||
itemByMonth.key + '-' + (i + 1 < 10 ? '0' + (i + 1) : i + 1)
|
'cursor-pointer valid': isDateOfInterest(
|
||||||
),
|
itemByMonth.key + '-' + (i + 1 < 10 ? '0' + (i + 1) : i + 1)
|
||||||
available:
|
),
|
||||||
marketDataByMonth[itemByMonth.key][
|
available:
|
||||||
i + 1 < 10 ? '0' + (i + 1) : i + 1
|
marketDataByMonth[itemByMonth.key][
|
||||||
]?.marketPrice,
|
i + 1 < 10 ? '0' + (i + 1) : i + 1
|
||||||
today: isToday(
|
]?.marketPrice,
|
||||||
itemByMonth.key + '-' + (i + 1 < 10 ? '0' + (i + 1) : i + 1)
|
today: isToday(
|
||||||
)
|
itemByMonth.key + '-' + (i + 1 < 10 ? '0' + (i + 1) : i + 1)
|
||||||
}"
|
)
|
||||||
[title]="
|
}"
|
||||||
(itemByMonth.key + '-' + (i + 1 < 10 ? '0' + (i + 1) : i + 1)
|
[title]="
|
||||||
| date: defaultDateFormat) ?? ''
|
(itemByMonth.key + '-' + (i + 1 < 10 ? '0' + (i + 1) : i + 1)
|
||||||
"
|
| date: defaultDateFormat) ?? ''
|
||||||
(click)="
|
"
|
||||||
onOpenMarketDataDetail({
|
(click)="
|
||||||
day: i + 1 < 10 ? '0' + (i + 1) : i + 1,
|
onOpenMarketDataDetail({
|
||||||
yearMonth: itemByMonth.key
|
day: i + 1 < 10 ? '0' + (i + 1) : i + 1,
|
||||||
})
|
yearMonth: itemByMonth.key
|
||||||
"
|
})
|
||||||
></div>
|
"
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
</div>
|
</div>
|
||||||
|
@ -6,10 +6,17 @@ import {
|
|||||||
ghostfolioScraperApiSymbolPrefix
|
ghostfolioScraperApiSymbolPrefix
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { getDateFormatString } from '@ghostfolio/common/helper';
|
import { getDateFormatString } from '@ghostfolio/common/helper';
|
||||||
import { Filter, UniqueAsset, User } from '@ghostfolio/common/interfaces';
|
import {
|
||||||
|
AssetProfileIdentifier,
|
||||||
|
Filter,
|
||||||
|
InfoItem,
|
||||||
|
User
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
|
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
|
||||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import { translate } from '@ghostfolio/ui/i18n';
|
import { translate } from '@ghostfolio/ui/i18n';
|
||||||
|
|
||||||
|
import { SelectionModel } from '@angular/cdk/collections';
|
||||||
import {
|
import {
|
||||||
AfterViewInit,
|
AfterViewInit,
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
@ -68,6 +75,11 @@ export class AdminMarketDataComponent
|
|||||||
};
|
};
|
||||||
})
|
})
|
||||||
.concat([
|
.concat([
|
||||||
|
{
|
||||||
|
id: 'BENCHMARKS',
|
||||||
|
label: $localize`Benchmarks`,
|
||||||
|
type: <Filter['type']>'PRESET_ID'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'CURRENCIES',
|
id: 'CURRENCIES',
|
||||||
label: $localize`Currencies`,
|
label: $localize`Currencies`,
|
||||||
@ -91,32 +103,23 @@ export class AdminMarketDataComponent
|
|||||||
new MatTableDataSource();
|
new MatTableDataSource();
|
||||||
public defaultDateFormat: string;
|
public defaultDateFormat: string;
|
||||||
public deviceType: string;
|
public deviceType: string;
|
||||||
public displayedColumns = [
|
public displayedColumns: string[] = [];
|
||||||
'nameWithSymbol',
|
|
||||||
'dataSource',
|
|
||||||
'assetClass',
|
|
||||||
'assetSubClass',
|
|
||||||
'date',
|
|
||||||
'activitiesCount',
|
|
||||||
'marketDataItemCount',
|
|
||||||
'sectorsCount',
|
|
||||||
'countriesCount',
|
|
||||||
'comment',
|
|
||||||
'actions'
|
|
||||||
];
|
|
||||||
public filters$ = new Subject<Filter[]>();
|
public filters$ = new Subject<Filter[]>();
|
||||||
public ghostfolioScraperApiSymbolPrefix = ghostfolioScraperApiSymbolPrefix;
|
public ghostfolioScraperApiSymbolPrefix = ghostfolioScraperApiSymbolPrefix;
|
||||||
|
public hasPermissionForSubscription: boolean;
|
||||||
|
public info: InfoItem;
|
||||||
public isLoading = false;
|
public isLoading = false;
|
||||||
public isUUID = isUUID;
|
public isUUID = isUUID;
|
||||||
public placeholder = '';
|
public placeholder = '';
|
||||||
public pageSize = DEFAULT_PAGE_SIZE;
|
public pageSize = DEFAULT_PAGE_SIZE;
|
||||||
|
public selection: SelectionModel<Partial<SymbolProfile>>;
|
||||||
public totalItems = 0;
|
public totalItems = 0;
|
||||||
public user: User;
|
public user: User;
|
||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private adminMarketDataService: AdminMarketDataService,
|
public adminMarketDataService: AdminMarketDataService,
|
||||||
private adminService: AdminService,
|
private adminService: AdminService,
|
||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
private dataService: DataService,
|
private dataService: DataService,
|
||||||
@ -126,6 +129,33 @@ export class AdminMarketDataComponent
|
|||||||
private router: Router,
|
private router: Router,
|
||||||
private userService: UserService
|
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
|
this.route.queryParams
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((params) => {
|
.subscribe((params) => {
|
||||||
@ -183,6 +213,8 @@ export class AdminMarketDataComponent
|
|||||||
|
|
||||||
this.benchmarks = benchmarks;
|
this.benchmarks = benchmarks;
|
||||||
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||||
|
|
||||||
|
this.selection = new SelectionModel(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public onChangePage(page: PageEvent) {
|
public onChangePage(page: PageEvent) {
|
||||||
@ -193,8 +225,16 @@ export class AdminMarketDataComponent
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
public onDeleteAssetProfile({ dataSource, symbol }: AssetProfileIdentifier) {
|
||||||
this.adminMarketDataService.deleteProfileData({ dataSource, symbol });
|
this.adminMarketDataService.deleteAssetProfile({ dataSource, symbol });
|
||||||
|
}
|
||||||
|
|
||||||
|
public onDeleteAssetProfiles() {
|
||||||
|
this.adminMarketDataService.deleteAssetProfiles(
|
||||||
|
this.selection.selected.map(({ dataSource, symbol }) => {
|
||||||
|
return { dataSource, symbol };
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public onGather7Days() {
|
public onGather7Days() {
|
||||||
@ -226,21 +266,27 @@ export class AdminMarketDataComponent
|
|||||||
.subscribe(() => {});
|
.subscribe(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
public onGatherProfileDataBySymbol({ dataSource, symbol }: UniqueAsset) {
|
public onGatherProfileDataBySymbol({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}: AssetProfileIdentifier) {
|
||||||
this.adminService
|
this.adminService
|
||||||
.gatherProfileDataBySymbol({ dataSource, symbol })
|
.gatherProfileDataBySymbol({ dataSource, symbol })
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(() => {});
|
.subscribe(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
public onGatherSymbol({ dataSource, symbol }: UniqueAsset) {
|
public onGatherSymbol({ dataSource, symbol }: AssetProfileIdentifier) {
|
||||||
this.adminService
|
this.adminService
|
||||||
.gatherSymbol({ dataSource, symbol })
|
.gatherSymbol({ dataSource, symbol })
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(() => {});
|
.subscribe(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
public onOpenAssetProfileDialog({ dataSource, symbol }: UniqueAsset) {
|
public onOpenAssetProfileDialog({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}: AssetProfileIdentifier) {
|
||||||
this.router.navigate([], {
|
this.router.navigate([], {
|
||||||
queryParams: {
|
queryParams: {
|
||||||
dataSource,
|
dataSource,
|
||||||
@ -281,6 +327,8 @@ export class AdminMarketDataComponent
|
|||||||
this.placeholder =
|
this.placeholder =
|
||||||
this.activeFilters.length <= 0 ? $localize`Filter by...` : '';
|
this.activeFilters.length <= 0 ? $localize`Filter by...` : '';
|
||||||
|
|
||||||
|
this.selection.clear();
|
||||||
|
|
||||||
this.adminService
|
this.adminService
|
||||||
.fetchAdminMarketData({
|
.fetchAdminMarketData({
|
||||||
sortColumn,
|
sortColumn,
|
||||||
|
@ -11,206 +11,249 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<table
|
<div class="overflow-x-auto">
|
||||||
class="gf-table w-100"
|
<table
|
||||||
mat-table
|
class="gf-table w-100"
|
||||||
matSort
|
mat-table
|
||||||
matSortActive="symbol"
|
matSort
|
||||||
matSortDirection="asc"
|
matSortActive="symbol"
|
||||||
[dataSource]="dataSource"
|
matSortDirection="asc"
|
||||||
>
|
[dataSource]="dataSource"
|
||||||
<ng-container matColumnDef="symbol">
|
>
|
||||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
<ng-container matColumnDef="select">
|
||||||
<ng-container i18n>Symbol</ng-container>
|
<th *matHeaderCellDef class="px-1" mat-header-cell></th>
|
||||||
</th>
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
@if (
|
||||||
{{ element.symbol }}
|
adminMarketDataService.hasPermissionToDeleteAssetProfile({
|
||||||
</td>
|
activitiesCount: element.activitiesCount,
|
||||||
</ng-container>
|
isBenchmark: element.isBenchmark,
|
||||||
|
|
||||||
<ng-container matColumnDef="nameWithSymbol">
|
|
||||||
<th
|
|
||||||
*matHeaderCellDef
|
|
||||||
class="px-1"
|
|
||||||
mat-header-cell
|
|
||||||
mat-sort-header="symbol"
|
|
||||||
>
|
|
||||||
<ng-container i18n>Name</ng-container>
|
|
||||||
</th>
|
|
||||||
<td *matCellDef="let element" class="line-height-1 px-1" mat-cell>
|
|
||||||
<div class="text-truncate">{{ element.name }}</div>
|
|
||||||
<div *ngIf="!isUUID(element.symbol)">
|
|
||||||
<small class="text-muted">{{ element.symbol | gfSymbol }}</small>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container matColumnDef="dataSource">
|
|
||||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
|
||||||
<ng-container i18n>Data Source</ng-container>
|
|
||||||
</th>
|
|
||||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
|
||||||
{{ element.dataSource }}
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container matColumnDef="assetClass">
|
|
||||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
|
||||||
<ng-container i18n>Asset Class</ng-container>
|
|
||||||
</th>
|
|
||||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
|
||||||
{{ element.assetClass }}
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container matColumnDef="assetSubClass">
|
|
||||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
|
||||||
<ng-container i18n>Asset Sub Class</ng-container>
|
|
||||||
</th>
|
|
||||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
|
||||||
{{ element.assetSubClass }}
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container matColumnDef="date">
|
|
||||||
<th *matHeaderCellDef class="px-1" mat-header-cell>
|
|
||||||
<ng-container i18n>First Activity</ng-container>
|
|
||||||
</th>
|
|
||||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
|
||||||
{{ (element.date | date: defaultDateFormat) ?? '' }}
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container matColumnDef="activitiesCount">
|
|
||||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
|
||||||
<ng-container i18n>Activities Count</ng-container>
|
|
||||||
</th>
|
|
||||||
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
|
||||||
{{ element.activitiesCount }}
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container matColumnDef="marketDataItemCount">
|
|
||||||
<th *matHeaderCellDef class="px-1" mat-header-cell>
|
|
||||||
<ng-container i18n>Historical Data</ng-container>
|
|
||||||
</th>
|
|
||||||
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
|
||||||
{{ element.marketDataItemCount }}
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container matColumnDef="sectorsCount">
|
|
||||||
<th *matHeaderCellDef class="px-1" mat-header-cell>
|
|
||||||
<ng-container i18n>Sectors Count</ng-container>
|
|
||||||
</th>
|
|
||||||
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
|
||||||
{{ element.sectorsCount }}
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container matColumnDef="countriesCount">
|
|
||||||
<th *matHeaderCellDef class="px-1" mat-header-cell>
|
|
||||||
<ng-container i18n>Countries Count</ng-container>
|
|
||||||
</th>
|
|
||||||
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
|
||||||
{{ element.countriesCount }}
|
|
||||||
</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>
|
|
||||||
<ion-icon
|
|
||||||
*ngIf="element.comment"
|
|
||||||
class="d-block"
|
|
||||||
name="document-text-outline"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container matColumnDef="actions" stickyEnd>
|
|
||||||
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell>
|
|
||||||
<button
|
|
||||||
class="mx-1 no-min-width px-2"
|
|
||||||
mat-button
|
|
||||||
[matMenuTriggerFor]="assetProfilesActionsMenu"
|
|
||||||
(click)="$event.stopPropagation()"
|
|
||||||
>
|
|
||||||
<ion-icon name="ellipsis-vertical" />
|
|
||||||
</button>
|
|
||||||
<mat-menu #assetProfilesActionsMenu="matMenu" xPosition="before">
|
|
||||||
<button mat-menu-item (click)="onGather7Days()">
|
|
||||||
<ng-container i18n>Gather Recent Data</ng-container>
|
|
||||||
</button>
|
|
||||||
<button mat-menu-item (click)="onGatherMax()">
|
|
||||||
<ng-container i18n>Gather All Data</ng-container>
|
|
||||||
</button>
|
|
||||||
<button mat-menu-item (click)="onGatherProfileData()">
|
|
||||||
<ng-container i18n>Gather Profile Data</ng-container>
|
|
||||||
</button>
|
|
||||||
</mat-menu>
|
|
||||||
</th>
|
|
||||||
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
|
|
||||||
<button
|
|
||||||
class="mx-1 no-min-width px-2"
|
|
||||||
mat-button
|
|
||||||
[matMenuTriggerFor]="assetProfileActionsMenu"
|
|
||||||
(click)="$event.stopPropagation()"
|
|
||||||
>
|
|
||||||
<ion-icon name="ellipsis-horizontal" />
|
|
||||||
</button>
|
|
||||||
<mat-menu #assetProfileActionsMenu="matMenu" xPosition="before">
|
|
||||||
<a
|
|
||||||
mat-menu-item
|
|
||||||
[queryParams]="{
|
|
||||||
assetProfileDialog: true,
|
|
||||||
dataSource: element.dataSource,
|
|
||||||
symbol: element.symbol
|
symbol: element.symbol
|
||||||
}"
|
})
|
||||||
[routerLink]="[]"
|
) {
|
||||||
>
|
<mat-checkbox
|
||||||
<span class="align-items-center d-flex">
|
color="primary"
|
||||||
<ion-icon class="mr-2" name="create-outline" />
|
[checked]="selection.isSelected(element)"
|
||||||
<span i18n>Edit</span>
|
(change)="$event ? selection.toggle(element) : null"
|
||||||
</span>
|
(click)="$event.stopPropagation()"
|
||||||
</a>
|
>
|
||||||
|
</mat-checkbox>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="symbol">
|
||||||
|
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||||
|
<ng-container i18n>Symbol</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
|
{{ element.symbol }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="nameWithSymbol">
|
||||||
|
<th
|
||||||
|
*matHeaderCellDef
|
||||||
|
class="px-1"
|
||||||
|
mat-header-cell
|
||||||
|
mat-sort-header="symbol"
|
||||||
|
>
|
||||||
|
<ng-container i18n>Name</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="line-height-1 px-1" mat-cell>
|
||||||
|
<div class="text-truncate">{{ element.name }}</div>
|
||||||
|
@if (!isUUID(element.symbol)) {
|
||||||
|
<div>
|
||||||
|
<small class="text-muted">{{
|
||||||
|
element.symbol | gfSymbol
|
||||||
|
}}</small>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="dataSource">
|
||||||
|
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||||
|
<ng-container i18n>Data Source</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
|
{{ element.dataSource }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="assetClass">
|
||||||
|
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||||
|
<ng-container i18n>Asset Class</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
|
{{ element.assetClass }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="assetSubClass">
|
||||||
|
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||||
|
<ng-container i18n>Asset Sub Class</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
|
{{ element.assetSubClass }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="date">
|
||||||
|
<th *matHeaderCellDef class="px-1" mat-header-cell>
|
||||||
|
<ng-container i18n>First Activity</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
|
{{ (element.date | date: defaultDateFormat) ?? '' }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="activitiesCount">
|
||||||
|
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||||
|
<ng-container i18n>Activities Count</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||||
|
{{ element.activitiesCount }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="marketDataItemCount">
|
||||||
|
<th *matHeaderCellDef class="px-1" mat-header-cell>
|
||||||
|
<ng-container i18n>Historical Data</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||||
|
{{ element.marketDataItemCount }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="sectorsCount">
|
||||||
|
<th *matHeaderCellDef class="px-1" mat-header-cell>
|
||||||
|
<ng-container i18n>Sectors Count</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||||
|
{{ element.sectorsCount }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="countriesCount">
|
||||||
|
<th *matHeaderCellDef class="px-1" mat-header-cell>
|
||||||
|
<ng-container i18n>Countries Count</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||||
|
{{ element.countriesCount }}
|
||||||
|
</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>
|
||||||
|
@if (element.comment) {
|
||||||
|
<ion-icon class="d-block" name="document-text-outline" />
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="actions" stickyEnd>
|
||||||
|
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell>
|
||||||
<button
|
<button
|
||||||
mat-menu-item
|
class="mx-1 no-min-width px-2"
|
||||||
[disabled]="
|
mat-button
|
||||||
element.activitiesCount !== 0 ||
|
[matMenuTriggerFor]="assetProfilesActionsMenu"
|
||||||
element.isBenchmark ||
|
(click)="$event.stopPropagation()"
|
||||||
element.symbol.startsWith(ghostfolioScraperApiSymbolPrefix)
|
>
|
||||||
"
|
<ion-icon name="ellipsis-vertical" />
|
||||||
(click)="
|
</button>
|
||||||
onDeleteProfileData({
|
<mat-menu #assetProfilesActionsMenu="matMenu" xPosition="before">
|
||||||
|
<button mat-menu-item (click)="onGather7Days()">
|
||||||
|
<ng-container i18n>Gather Recent Data</ng-container>
|
||||||
|
</button>
|
||||||
|
<button mat-menu-item (click)="onGatherMax()">
|
||||||
|
<ng-container i18n>Gather All Data</ng-container>
|
||||||
|
</button>
|
||||||
|
<button mat-menu-item (click)="onGatherProfileData()">
|
||||||
|
<ng-container i18n>Gather Profile Data</ng-container>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
mat-menu-item
|
||||||
|
[disabled]="!selection.hasValue()"
|
||||||
|
(click)="onDeleteAssetProfiles()"
|
||||||
|
>
|
||||||
|
<ng-container i18n>Delete Profiles</ng-container>
|
||||||
|
</button>
|
||||||
|
</mat-menu>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
|
||||||
|
<button
|
||||||
|
class="mx-1 no-min-width px-2"
|
||||||
|
mat-button
|
||||||
|
[matMenuTriggerFor]="assetProfileActionsMenu"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
>
|
||||||
|
<ion-icon name="ellipsis-horizontal" />
|
||||||
|
</button>
|
||||||
|
<mat-menu #assetProfileActionsMenu="matMenu" xPosition="before">
|
||||||
|
<a
|
||||||
|
mat-menu-item
|
||||||
|
[queryParams]="{
|
||||||
|
assetProfileDialog: true,
|
||||||
dataSource: element.dataSource,
|
dataSource: element.dataSource,
|
||||||
symbol: element.symbol
|
symbol: element.symbol
|
||||||
})
|
}"
|
||||||
"
|
[routerLink]="[]"
|
||||||
>
|
>
|
||||||
<span class="align-items-center d-flex">
|
<span class="align-items-center d-flex">
|
||||||
<ion-icon class="mr-2" name="trash-outline" />
|
<ion-icon class="mr-2" name="create-outline" />
|
||||||
<span i18n>Delete</span>
|
<span i18n>Edit</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</a>
|
||||||
</mat-menu>
|
<button
|
||||||
</td>
|
mat-menu-item
|
||||||
</ng-container>
|
[disabled]="
|
||||||
|
!adminMarketDataService.hasPermissionToDeleteAssetProfile({
|
||||||
|
activitiesCount: element.activitiesCount,
|
||||||
|
isBenchmark: element.isBenchmark,
|
||||||
|
symbol: element.symbol
|
||||||
|
})
|
||||||
|
"
|
||||||
|
(click)="
|
||||||
|
onDeleteAssetProfile({
|
||||||
|
dataSource: element.dataSource,
|
||||||
|
symbol: element.symbol
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span class="align-items-center d-flex">
|
||||||
|
<ion-icon class="mr-2" name="trash-outline" />
|
||||||
|
<span i18n>Delete</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</mat-menu>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
|
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
|
||||||
<tr
|
<tr
|
||||||
*matRowDef="let row; columns: displayedColumns"
|
*matRowDef="let row; columns: displayedColumns"
|
||||||
class="cursor-pointer"
|
class="cursor-pointer"
|
||||||
mat-row
|
mat-row
|
||||||
(click)="
|
(click)="
|
||||||
onOpenAssetProfileDialog({
|
onOpenAssetProfileDialog({
|
||||||
dataSource: row.dataSource,
|
dataSource: row.dataSource,
|
||||||
symbol: row.symbol
|
symbol: row.symbol
|
||||||
})
|
})
|
||||||
"
|
"
|
||||||
></tr>
|
></tr>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<mat-paginator
|
<mat-paginator
|
||||||
[length]="totalItems"
|
[length]="totalItems"
|
||||||
@ -222,15 +265,16 @@
|
|||||||
(page)="onChangePage($event)"
|
(page)="onChangePage($event)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ngx-skeleton-loader
|
@if (isLoading && totalItems === 0) {
|
||||||
*ngIf="isLoading && totalItems === 0"
|
<ngx-skeleton-loader
|
||||||
animation="pulse"
|
animation="pulse"
|
||||||
class="px-4 py-3"
|
class="px-4 py-3"
|
||||||
[theme]="{
|
[theme]="{
|
||||||
height: '1.5rem',
|
height: '1.5rem',
|
||||||
width: '100%'
|
width: '100%'
|
||||||
}"
|
}"
|
||||||
/>
|
/>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
|
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
|
||||||
import { GfActivitiesFilterComponent } from '@ghostfolio/ui/activities-filter';
|
import { GfActivitiesFilterComponent } from '@ghostfolio/ui/activities-filter';
|
||||||
|
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
|
||||||
|
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||||
import { MatMenuModule } from '@angular/material/menu';
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
import { MatPaginatorModule } from '@angular/material/paginator';
|
import { MatPaginatorModule } from '@angular/material/paginator';
|
||||||
import { MatSortModule } from '@angular/material/sort';
|
import { MatSortModule } from '@angular/material/sort';
|
||||||
@ -23,8 +25,10 @@ import { GfCreateAssetProfileDialogModule } from './create-asset-profile-dialog/
|
|||||||
GfActivitiesFilterComponent,
|
GfActivitiesFilterComponent,
|
||||||
GfAssetProfileDialogModule,
|
GfAssetProfileDialogModule,
|
||||||
GfCreateAssetProfileDialogModule,
|
GfCreateAssetProfileDialogModule,
|
||||||
|
GfPremiumIndicatorComponent,
|
||||||
GfSymbolModule,
|
GfSymbolModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
|
MatCheckboxModule,
|
||||||
MatMenuModule,
|
MatMenuModule,
|
||||||
MatPaginatorModule,
|
MatPaginatorModule,
|
||||||
MatSortModule,
|
MatSortModule,
|
||||||
|
@ -1,14 +1,19 @@
|
|||||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
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 {
|
||||||
|
AssetProfileIdentifier,
|
||||||
|
AdminMarketDataItem
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { takeUntil } from 'rxjs';
|
import { EMPTY, catchError, finalize, forkJoin, takeUntil } from 'rxjs';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminMarketDataService {
|
export class AdminMarketDataService {
|
||||||
public constructor(private adminService: AdminService) {}
|
public constructor(private adminService: AdminService) {}
|
||||||
|
|
||||||
public deleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
public deleteAssetProfile({ dataSource, symbol }: AssetProfileIdentifier) {
|
||||||
const confirmation = confirm(
|
const confirmation = confirm(
|
||||||
$localize`Do you really want to delete this asset profile?`
|
$localize`Do you really want to delete this asset profile?`
|
||||||
);
|
);
|
||||||
@ -23,4 +28,48 @@ export class AdminMarketDataService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public deleteAssetProfiles(
|
||||||
|
aAssetProfileIdentifiers: AssetProfileIdentifier[]
|
||||||
|
) {
|
||||||
|
const confirmation = confirm(
|
||||||
|
$localize`Do you really want to delete these profiles?`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmation) {
|
||||||
|
const deleteRequests = aAssetProfileIdentifiers.map(
|
||||||
|
({ dataSource, symbol }) => {
|
||||||
|
return this.adminService.deleteProfileData({ dataSource, symbol });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
forkJoin(deleteRequests)
|
||||||
|
.pipe(
|
||||||
|
catchError(() => {
|
||||||
|
alert($localize`Oops! Could not delete profiles.`);
|
||||||
|
|
||||||
|
return EMPTY;
|
||||||
|
}),
|
||||||
|
finalize(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 300);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public hasPermissionToDeleteAssetProfile({
|
||||||
|
activitiesCount,
|
||||||
|
isBenchmark,
|
||||||
|
symbol
|
||||||
|
}: Pick<AdminMarketDataItem, 'activitiesCount' | 'isBenchmark' | 'symbol'>) {
|
||||||
|
return (
|
||||||
|
activitiesCount === 0 &&
|
||||||
|
!isBenchmark &&
|
||||||
|
!isCurrency(getCurrencyFromSymbol(symbol)) &&
|
||||||
|
!symbol.startsWith(ghostfolioScraperApiSymbolPrefix)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -8,7 +8,7 @@ import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config';
|
|||||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
AdminMarketDataDetails,
|
AdminMarketDataDetails,
|
||||||
UniqueAsset
|
AssetProfileIdentifier
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { translate } from '@ghostfolio/ui/i18n';
|
import { translate } from '@ghostfolio/ui/i18n';
|
||||||
|
|
||||||
@ -87,7 +87,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
|||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private adminMarketDataService: AdminMarketDataService,
|
public adminMarketDataService: AdminMarketDataService,
|
||||||
private adminService: AdminService,
|
private adminService: AdminService,
|
||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
@Inject(MAT_DIALOG_DATA) public data: AssetProfileDialogParams,
|
@Inject(MAT_DIALOG_DATA) public data: AssetProfileDialogParams,
|
||||||
@ -175,20 +175,23 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
|||||||
this.dialogRef.close();
|
this.dialogRef.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
public onDeleteProfileData({ dataSource, symbol }: AssetProfileIdentifier) {
|
||||||
this.adminMarketDataService.deleteProfileData({ dataSource, symbol });
|
this.adminMarketDataService.deleteAssetProfile({ dataSource, symbol });
|
||||||
|
|
||||||
this.dialogRef.close();
|
this.dialogRef.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
public onGatherProfileDataBySymbol({ dataSource, symbol }: UniqueAsset) {
|
public onGatherProfileDataBySymbol({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}: AssetProfileIdentifier) {
|
||||||
this.adminService
|
this.adminService
|
||||||
.gatherProfileDataBySymbol({ dataSource, symbol })
|
.gatherProfileDataBySymbol({ dataSource, symbol })
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(() => {});
|
.subscribe(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
public onGatherSymbol({ dataSource, symbol }: UniqueAsset) {
|
public onGatherSymbol({ dataSource, symbol }: AssetProfileIdentifier) {
|
||||||
this.adminService
|
this.adminService
|
||||||
.gatherSymbol({ dataSource, symbol })
|
.gatherSymbol({ dataSource, symbol })
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
@ -242,7 +245,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public onSetBenchmark({ dataSource, symbol }: UniqueAsset) {
|
public onSetBenchmark({ dataSource, symbol }: AssetProfileIdentifier) {
|
||||||
this.dataService
|
this.dataService
|
||||||
.postBenchmark({ dataSource, symbol })
|
.postBenchmark({ dataSource, symbol })
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
@ -342,7 +345,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public onUnsetBenchmark({ dataSource, symbol }: UniqueAsset) {
|
public onUnsetBenchmark({ dataSource, symbol }: AssetProfileIdentifier) {
|
||||||
this.dataService
|
this.dataService
|
||||||
.deleteBenchmark({ dataSource, symbol })
|
.deleteBenchmark({ dataSource, symbol })
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
@ -48,9 +48,11 @@
|
|||||||
mat-menu-item
|
mat-menu-item
|
||||||
type="button"
|
type="button"
|
||||||
[disabled]="
|
[disabled]="
|
||||||
assetProfile?.activitiesCount !== 0 ||
|
!adminMarketDataService.hasPermissionToDeleteAssetProfile({
|
||||||
isBenchmark ||
|
activitiesCount: assetProfile?.activitiesCount,
|
||||||
data.symbol.startsWith(ghostfolioScraperApiSymbolPrefix)
|
isBenchmark: isBenchmark,
|
||||||
|
symbol: data.symbol
|
||||||
|
})
|
||||||
"
|
"
|
||||||
(click)="
|
(click)="
|
||||||
onDeleteProfileData({
|
onDeleteProfileData({
|
||||||
@ -146,7 +148,7 @@
|
|||||||
i18n
|
i18n
|
||||||
size="medium"
|
size="medium"
|
||||||
[locale]="data.locale"
|
[locale]="data.locale"
|
||||||
[value]="assetProfile?.activitiesCount ?? 0"
|
[value]="assetProfile?.activitiesCount"
|
||||||
>Activities</gf-value
|
>Activities</gf-value
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@ -243,11 +245,11 @@
|
|||||||
<mat-label i18n>Asset Class</mat-label>
|
<mat-label i18n>Asset Class</mat-label>
|
||||||
<mat-select formControlName="assetClass">
|
<mat-select formControlName="assetClass">
|
||||||
<mat-option [value]="null" />
|
<mat-option [value]="null" />
|
||||||
<mat-option
|
@for (assetClass of assetClasses; track assetClass) {
|
||||||
*ngFor="let assetClass of assetClasses"
|
<mat-option [value]="assetClass.id">{{
|
||||||
[value]="assetClass.id"
|
assetClass.label
|
||||||
>{{ assetClass.label }}</mat-option
|
}}</mat-option>
|
||||||
>
|
}
|
||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
@ -256,11 +258,11 @@
|
|||||||
<mat-label i18n>Asset Sub Class</mat-label>
|
<mat-label i18n>Asset Sub Class</mat-label>
|
||||||
<mat-select formControlName="assetSubClass">
|
<mat-select formControlName="assetSubClass">
|
||||||
<mat-option [value]="null" />
|
<mat-option [value]="null" />
|
||||||
<mat-option
|
@for (assetSubClass of assetSubClasses; track assetSubClass) {
|
||||||
*ngFor="let assetSubClass of assetSubClasses"
|
<mat-option [value]="assetSubClass.id">{{
|
||||||
[value]="assetSubClass.id"
|
assetSubClass.label
|
||||||
>{{ assetSubClass.label }}</mat-option
|
}}</mat-option>
|
||||||
>
|
}
|
||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
|
@ -20,21 +20,24 @@
|
|||||||
</mat-radio-group>
|
</mat-radio-group>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div *ngIf="mode === 'auto'">
|
@if (mode === 'auto') {
|
||||||
<mat-form-field appearance="outline" class="w-100">
|
<div>
|
||||||
<mat-label i18n>Name, symbol or ISIN</mat-label>
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
<gf-symbol-autocomplete
|
<mat-label i18n>Name, symbol or ISIN</mat-label>
|
||||||
formControlName="searchSymbol"
|
<gf-symbol-autocomplete
|
||||||
[includeIndices]="true"
|
formControlName="searchSymbol"
|
||||||
/>
|
[includeIndices]="true"
|
||||||
</mat-form-field>
|
/>
|
||||||
</div>
|
</mat-form-field>
|
||||||
<div *ngIf="mode === 'manual'">
|
</div>
|
||||||
<mat-form-field appearance="outline" class="w-100">
|
} @else if (mode === 'manual') {
|
||||||
<mat-label i18n>Symbol</mat-label>
|
<div>
|
||||||
<input formControlName="addSymbol" matInput />
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
</mat-form-field>
|
<mat-label i18n>Symbol</mat-label>
|
||||||
</div>
|
<input formControlName="addSymbol" matInput />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex justify-content-end" mat-dialog-actions>
|
<div class="d-flex justify-content-end" mat-dialog-actions>
|
||||||
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button>
|
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button>
|
||||||
|
@ -12,11 +12,7 @@
|
|||||||
<div class="d-flex my-3">
|
<div class="d-flex my-3">
|
||||||
<div class="w-50" i18n>User Count</div>
|
<div class="w-50" i18n>User Count</div>
|
||||||
<div class="w-50">
|
<div class="w-50">
|
||||||
<gf-value
|
<gf-value [locale]="user?.settings?.locale" [value]="userCount" />
|
||||||
[locale]="user?.settings?.locale"
|
|
||||||
[precision]="0"
|
|
||||||
[value]="userCount"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex my-3">
|
<div class="d-flex my-3">
|
||||||
@ -24,75 +20,79 @@
|
|||||||
<div class="w-50">
|
<div class="w-50">
|
||||||
<gf-value
|
<gf-value
|
||||||
[locale]="user?.settings?.locale"
|
[locale]="user?.settings?.locale"
|
||||||
[precision]="0"
|
|
||||||
[value]="transactionCount"
|
[value]="transactionCount"
|
||||||
/>
|
/>
|
||||||
<div *ngIf="transactionCount && userCount">
|
@if (transactionCount && userCount) {
|
||||||
{{ transactionCount / userCount | number: '1.2-2' }}
|
<div>
|
||||||
<span i18n>per User</span>
|
{{ transactionCount / userCount | number: '1.2-2' }}
|
||||||
</div>
|
<span i18n>per User</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="align-items-start d-flex my-3">
|
<div class="align-items-start d-flex my-3">
|
||||||
<div class="w-50" i18n>Exchange Rates</div>
|
<div class="w-50" i18n>Exchange Rates</div>
|
||||||
<div class="w-50">
|
<div class="w-50">
|
||||||
<table>
|
<table>
|
||||||
<tr *ngFor="let exchangeRate of exchangeRates">
|
@for (exchangeRate of exchangeRates; track exchangeRate) {
|
||||||
<td>
|
<tr>
|
||||||
<gf-value [locale]="user?.settings?.locale" [value]="1" />
|
<td>
|
||||||
</td>
|
<gf-value [locale]="user?.settings?.locale" [value]="1" />
|
||||||
<td class="pl-1">{{ exchangeRate.label1 }}</td>
|
</td>
|
||||||
<td class="px-1">=</td>
|
<td class="pl-1">{{ exchangeRate.label1 }}</td>
|
||||||
<td align="right">
|
<td class="px-1">=</td>
|
||||||
<gf-value
|
<td align="right">
|
||||||
class="d-inline-block"
|
<gf-value
|
||||||
[locale]="user?.settings?.locale"
|
class="d-inline-block"
|
||||||
[precision]="4"
|
[locale]="user?.settings?.locale"
|
||||||
[value]="exchangeRate.value"
|
[precision]="4"
|
||||||
/>
|
[value]="exchangeRate.value"
|
||||||
</td>
|
/>
|
||||||
<td class="pl-1">{{ exchangeRate.label2 }}</td>
|
</td>
|
||||||
<td>
|
<td class="pl-1">{{ exchangeRate.label2 }}</td>
|
||||||
<button
|
<td>
|
||||||
class="mx-1 no-min-width px-2"
|
|
||||||
mat-button
|
|
||||||
[matMenuTriggerFor]="exchangeRateActionsMenu"
|
|
||||||
(click)="$event.stopPropagation()"
|
|
||||||
>
|
|
||||||
<ion-icon name="ellipsis-horizontal" />
|
|
||||||
</button>
|
|
||||||
<mat-menu
|
|
||||||
#exchangeRateActionsMenu="matMenu"
|
|
||||||
class="h-100 mx-1 no-min-width px-2"
|
|
||||||
xPosition="before"
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
mat-menu-item
|
|
||||||
[queryParams]="{
|
|
||||||
assetProfileDialog: true,
|
|
||||||
dataSource: exchangeRate.dataSource,
|
|
||||||
symbol: exchangeRate.symbol
|
|
||||||
}"
|
|
||||||
[routerLink]="['/admin', 'market-data']"
|
|
||||||
>
|
|
||||||
<span class="align-items-center d-flex">
|
|
||||||
<ion-icon class="mr-2" name="create-outline" />
|
|
||||||
<span i18n>Edit</span>
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
<button
|
<button
|
||||||
*ngIf="customCurrencies.includes(exchangeRate.label2)"
|
class="mx-1 no-min-width px-2"
|
||||||
mat-menu-item
|
mat-button
|
||||||
(click)="onDeleteCurrency(exchangeRate.label2)"
|
[matMenuTriggerFor]="exchangeRateActionsMenu"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
>
|
>
|
||||||
<span class="align-items-center d-flex">
|
<ion-icon name="ellipsis-horizontal" />
|
||||||
<ion-icon class="mr-2" name="trash-outline" />
|
|
||||||
<span i18n>Delete</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
</mat-menu>
|
<mat-menu
|
||||||
</td>
|
#exchangeRateActionsMenu="matMenu"
|
||||||
</tr>
|
class="h-100 mx-1 no-min-width px-2"
|
||||||
|
xPosition="before"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
mat-menu-item
|
||||||
|
[queryParams]="{
|
||||||
|
assetProfileDialog: true,
|
||||||
|
dataSource: exchangeRate.dataSource,
|
||||||
|
symbol: exchangeRate.symbol
|
||||||
|
}"
|
||||||
|
[routerLink]="['/admin', 'market-data']"
|
||||||
|
>
|
||||||
|
<span class="align-items-center d-flex">
|
||||||
|
<ion-icon class="mr-2" name="create-outline" />
|
||||||
|
<span i18n>Edit</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
@if (customCurrencies.includes(exchangeRate.label2)) {
|
||||||
|
<button
|
||||||
|
mat-menu-item
|
||||||
|
(click)="onDeleteCurrency(exchangeRate.label2)"
|
||||||
|
>
|
||||||
|
<span class="align-items-center d-flex">
|
||||||
|
<ion-icon class="mr-2" name="trash-outline" />
|
||||||
|
<span i18n>Delete</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</mat-menu>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
</table>
|
</table>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<button
|
<button
|
||||||
@ -119,17 +119,19 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="hasPermissionToToggleReadOnlyMode" class="d-flex my-3">
|
@if (hasPermissionToToggleReadOnlyMode) {
|
||||||
<div class="w-50" i18n>Read-only Mode</div>
|
<div class="d-flex my-3">
|
||||||
<div class="w-50">
|
<div class="w-50" i18n>Read-only Mode</div>
|
||||||
<mat-slide-toggle
|
<div class="w-50">
|
||||||
color="primary"
|
<mat-slide-toggle
|
||||||
hideIcon="true"
|
color="primary"
|
||||||
[checked]="info?.isReadOnlyMode"
|
hideIcon="true"
|
||||||
(change)="onReadOnlyModeChange($event)"
|
[checked]="info?.isReadOnlyMode"
|
||||||
/>
|
(change)="onReadOnlyModeChange($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
<div class="d-flex my-3">
|
<div class="d-flex my-3">
|
||||||
<div class="w-50" i18n>Data Gathering</div>
|
<div class="w-50" i18n>Data Gathering</div>
|
||||||
<div class="w-50">
|
<div class="w-50">
|
||||||
@ -141,99 +143,105 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="hasPermissionForSystemMessage" class="d-flex my-3">
|
@if (hasPermissionForSystemMessage) {
|
||||||
<div class="w-50" i18n>System Message</div>
|
<div class="d-flex my-3">
|
||||||
<div class="w-50">
|
<div class="w-50" i18n>System Message</div>
|
||||||
<div *ngIf="systemMessage" class="align-items-center d-flex">
|
<div class="w-50">
|
||||||
<div class="text-truncate">{{ systemMessage | json }}</div>
|
@if (systemMessage) {
|
||||||
<button
|
<div class="align-items-center d-flex">
|
||||||
class="h-100 mx-1 no-min-width px-2"
|
<div class="text-truncate">{{ systemMessage | json }}</div>
|
||||||
mat-button
|
|
||||||
(click)="onDeleteSystemMessage()"
|
|
||||||
>
|
|
||||||
<ion-icon name="trash-outline" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
*ngIf="!info?.systemMessage"
|
|
||||||
class="mt-2"
|
|
||||||
color="accent"
|
|
||||||
mat-flat-button
|
|
||||||
(click)="onSetSystemMessage()"
|
|
||||||
>
|
|
||||||
<ion-icon class="mr-1" name="information-circle-outline" />
|
|
||||||
<span i18n>Set Message</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
*ngIf="hasPermissionForSubscription"
|
|
||||||
class="d-flex my-3 subscription"
|
|
||||||
>
|
|
||||||
<div class="w-50" i18n>Coupons</div>
|
|
||||||
<div class="w-50">
|
|
||||||
<table>
|
|
||||||
<tr *ngFor="let coupon of coupons">
|
|
||||||
<td class="text-monospace">{{ coupon.code }}</td>
|
|
||||||
<td class="pl-2 text-right">{{ coupon.duration }}</td>
|
|
||||||
<td>
|
|
||||||
<button
|
<button
|
||||||
class="mx-1 no-min-width px-2"
|
|
||||||
mat-button
|
|
||||||
[matMenuTriggerFor]="couponActionsMenu"
|
|
||||||
(click)="$event.stopPropagation()"
|
|
||||||
>
|
|
||||||
<ion-icon name="ellipsis-horizontal" />
|
|
||||||
</button>
|
|
||||||
<mat-menu
|
|
||||||
#couponActionsMenu="matMenu"
|
|
||||||
class="h-100 mx-1 no-min-width px-2"
|
class="h-100 mx-1 no-min-width px-2"
|
||||||
xPosition="before"
|
mat-button
|
||||||
|
(click)="onDeleteSystemMessage()"
|
||||||
>
|
>
|
||||||
<button
|
<ion-icon name="trash-outline" />
|
||||||
mat-menu-item
|
</button>
|
||||||
(click)="onDeleteCoupon(coupon.code)"
|
</div>
|
||||||
>
|
}
|
||||||
<span class="align-items-center d-flex">
|
@if (!info?.systemMessage) {
|
||||||
<ion-icon class="mr-2" name="trash-outline" />
|
|
||||||
<span i18n>Delete</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</mat-menu>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<div class="mt-2">
|
|
||||||
<form #couponForm="ngForm" class="align-items-center d-flex">
|
|
||||||
<mat-form-field
|
|
||||||
appearance="outline"
|
|
||||||
class="mr-2 without-hint"
|
|
||||||
>
|
|
||||||
<mat-select
|
|
||||||
name="duration"
|
|
||||||
[value]="couponDuration"
|
|
||||||
(selectionChange)="onChangeCouponDuration($event.value)"
|
|
||||||
>
|
|
||||||
<mat-option value="7 days">7 Days</mat-option>
|
|
||||||
<mat-option value="14 days">14 Days</mat-option>
|
|
||||||
<mat-option value="30 days">30 Days</mat-option>
|
|
||||||
<mat-option value="90 days">90 Days</mat-option>
|
|
||||||
<mat-option value="180 days">180 Days</mat-option>
|
|
||||||
<mat-option value="1 year">1 Year</mat-option>
|
|
||||||
</mat-select>
|
|
||||||
</mat-form-field>
|
|
||||||
<button
|
<button
|
||||||
class="mt-1"
|
class="mt-2"
|
||||||
color="primary"
|
color="accent"
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
(click)="onAddCoupon()"
|
(click)="onSetSystemMessage()"
|
||||||
>
|
>
|
||||||
<span i18n>Add</span>
|
<ion-icon class="mr-1" name="information-circle-outline" />
|
||||||
|
<span i18n>Set Message</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
|
@if (hasPermissionForSubscription) {
|
||||||
|
<div class="d-flex my-3 subscription">
|
||||||
|
<div class="w-50" i18n>Coupons</div>
|
||||||
|
<div class="w-50">
|
||||||
|
<table>
|
||||||
|
@for (coupon of coupons; track coupon) {
|
||||||
|
<tr>
|
||||||
|
<td class="text-monospace">{{ coupon.code }}</td>
|
||||||
|
<td class="pl-2 text-right">{{ coupon.duration }}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
class="mx-1 no-min-width px-2"
|
||||||
|
mat-button
|
||||||
|
[matMenuTriggerFor]="couponActionsMenu"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
>
|
||||||
|
<ion-icon name="ellipsis-horizontal" />
|
||||||
|
</button>
|
||||||
|
<mat-menu
|
||||||
|
#couponActionsMenu="matMenu"
|
||||||
|
class="h-100 mx-1 no-min-width px-2"
|
||||||
|
xPosition="before"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
mat-menu-item
|
||||||
|
(click)="onDeleteCoupon(coupon.code)"
|
||||||
|
>
|
||||||
|
<span class="align-items-center d-flex">
|
||||||
|
<ion-icon class="mr-2" name="trash-outline" />
|
||||||
|
<span i18n>Delete</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</mat-menu>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</table>
|
||||||
|
<div class="mt-2">
|
||||||
|
<form #couponForm="ngForm" class="align-items-center d-flex">
|
||||||
|
<mat-form-field
|
||||||
|
appearance="outline"
|
||||||
|
class="mr-2 without-hint"
|
||||||
|
>
|
||||||
|
<mat-select
|
||||||
|
name="duration"
|
||||||
|
[value]="couponDuration"
|
||||||
|
(selectionChange)="onChangeCouponDuration($event.value)"
|
||||||
|
>
|
||||||
|
<mat-option value="7 days">7 Days</mat-option>
|
||||||
|
<mat-option value="14 days">14 Days</mat-option>
|
||||||
|
<mat-option value="30 days">30 Days</mat-option>
|
||||||
|
<mat-option value="90 days">90 Days</mat-option>
|
||||||
|
<mat-option value="180 days">180 Days</mat-option>
|
||||||
|
<mat-option value="1 year">1 Year</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<button
|
||||||
|
class="mt-1"
|
||||||
|
color="primary"
|
||||||
|
mat-flat-button
|
||||||
|
(click)="onAddCoupon()"
|
||||||
|
>
|
||||||
|
<span i18n>Add</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<div class="d-flex my-3">
|
<div class="d-flex my-3">
|
||||||
<div class="w-50" i18n>Housekeeping</div>
|
<div class="w-50" i18n>Housekeeping</div>
|
||||||
<div class="w-50">
|
<div class="w-50">
|
||||||
|
@ -30,12 +30,13 @@
|
|||||||
<ng-container i18n>Name</ng-container>
|
<ng-container i18n>Name</ng-container>
|
||||||
</th>
|
</th>
|
||||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
<gf-asset-profile-icon
|
@if (element.url) {
|
||||||
*ngIf="element.url"
|
<gf-asset-profile-icon
|
||||||
class="d-inline mr-1"
|
class="d-inline mr-1"
|
||||||
[tooltip]="element.name"
|
[tooltip]="element.name"
|
||||||
[url]="element.url"
|
[url]="element.url"
|
||||||
/>
|
/>
|
||||||
|
}
|
||||||
<span>{{ element.name }}</span>
|
<span>{{ element.name }}</span>
|
||||||
</td></ng-container
|
</td></ng-container
|
||||||
>
|
>
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user