Compare commits
161 Commits
Author | SHA1 | Date | |
---|---|---|---|
4973d0261d | |||
c4a62dfd68 | |||
4d6be0a507 | |||
b259ab7b0c | |||
e1ac5245c7 | |||
d4fea075af | |||
cef7fa79de | |||
ca05397dcd | |||
2a11977001 | |||
fb1a5c93ef | |||
77e9791e03 | |||
efd9e7a5c7 | |||
d9ced885e1 | |||
5fe07cb85f | |||
af008aa74f | |||
ca7bf27c20 | |||
0866587cab | |||
622bb8b0cf | |||
16b9fbe00e | |||
c9353d0a39 | |||
ea101dd3bd | |||
cd67ce82fa | |||
d5b3c52602 | |||
bdf72164b1 | |||
455a2d2e92 | |||
9c0f46b587 | |||
8533606177 | |||
6728e04ff7 | |||
2bf4f1237a | |||
4857b2e620 | |||
68a9a7f6f9 | |||
81ef95e13e | |||
b633132757 | |||
2b0f961370 | |||
30f1a3514a | |||
ed735e0b29 | |||
b89ccd2dde | |||
df6d39377f | |||
d5d14497d6 | |||
09c300661a | |||
92382e0b4d | |||
c25f532487 | |||
5d26d94586 | |||
73b6784e9f | |||
6159f48a62 | |||
7d34fba7c1 | |||
c434b730a8 | |||
2d23c566f1 | |||
ba220eaee9 | |||
09023214ce | |||
1ceabb6e6b | |||
421072c7fa | |||
0d421e7181 | |||
f5180ce88f | |||
aabf27dc96 | |||
421809ae95 | |||
d3234f9e77 | |||
a40be2f744 | |||
e62da06c5c | |||
b7f635bdfc | |||
0a465f125d | |||
c02e390bc1 | |||
f9bec0d793 | |||
2f44748f79 | |||
97504756be | |||
6a802a62a0 | |||
51ca26bb4d | |||
2ecc8dbc4e | |||
c0e0e2401e | |||
1a30c180bc | |||
39d4f80f36 | |||
3693091ad6 | |||
bf52f1137d | |||
54ea6c84b4 | |||
689e50ae1a | |||
677757fdf0 | |||
58d9816f01 | |||
5f3d445f1d | |||
fce6caebc2 | |||
d0a4f5c000 | |||
b5e2a3aa91 | |||
f47883fb0b | |||
2932744a68 | |||
73c0f02e06 | |||
382fe24f29 | |||
908876ca6e | |||
99cf9f8802 | |||
7444ff97fc | |||
834a48466e | |||
a9526430c2 | |||
fce3b2084e | |||
f5a50a95de | |||
06dfb91f82 | |||
be36050d76 | |||
7931e6950d | |||
04eb452e04 | |||
6f7e370fca | |||
b4a126280f | |||
2d009aacc4 | |||
9116443305 | |||
0adaf12a01 | |||
b6562b6e2c | |||
b0a4b09ef5 | |||
ad8b9ad333 | |||
809956f210 | |||
6077bfa754 | |||
09498bd804 | |||
fd84f4ec14 | |||
c711a11d6e | |||
8232b05f62 | |||
0ea66aebcb | |||
64087de3fc | |||
7082ff12f8 | |||
1c7d92e15e | |||
a53461d257 | |||
d630fb900d | |||
51e8555fa5 | |||
9db675b955 | |||
45bd8ed029 | |||
707fd31550 | |||
6e5f0086a1 | |||
97bcd8ff49 | |||
1809fc8a80 | |||
beb24f9bd4 | |||
ae57a188f5 | |||
23db85e940 | |||
bd8bb1a36a | |||
c48670ccdc | |||
fc019002e2 | |||
4282cb66b8 | |||
1d0ba5fe4b | |||
24cfb26c5b | |||
26a70aa208 | |||
ab7e050066 | |||
26b1fd6572 | |||
d7e682b65a | |||
f589ccb775 | |||
206b6567fd | |||
6857e0314f | |||
c8682a7393 | |||
144b6b2211 | |||
16a5ace4be | |||
b24ddc30c9 | |||
19333ab084 | |||
7529a7a26c | |||
21ebaae6ef | |||
3bc8b3c836 | |||
bb9415cc15 | |||
b3baeb8a5d | |||
1f393e78f6 | |||
215f5eafa6 | |||
1916e5343d | |||
fa9863fc54 | |||
7bf48ef351 | |||
faef3606fd | |||
d0ccd4d238 | |||
51e3650790 | |||
db29e2b666 | |||
655a68a847 | |||
86296b3591 | |||
73c127f10c |
3
.github/ISSUE_TEMPLATE/bug_report.md
vendored
3
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -6,7 +6,7 @@ labels: ''
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
The Issue tracker is **ONLY** used for reporting bugs. New features should be discussed on our [Slack channel](https://ghostfolio.slack.com) or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
|
||||
The Issue tracker is **ONLY** used for reporting bugs. New features should be discussed in our [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) community or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
|
||||
|
||||
**Bug Description**
|
||||
|
||||
@ -36,6 +36,7 @@ The Issue tracker is **ONLY** used for reporting bugs. New features should be di
|
||||
|
||||
<!-- Please complete the following information -->
|
||||
|
||||
- Cloud or Self-hosted
|
||||
- Ghostfolio Version X.Y.Z
|
||||
- Browser
|
||||
- OS
|
||||
|
2
.github/workflows/build-code.yml
vendored
2
.github/workflows/build-code.yml
vendored
@ -10,7 +10,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
node_version:
|
||||
- 16
|
||||
- 18
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
312
CHANGELOG.md
312
CHANGELOG.md
@ -5,6 +5,313 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## 1.295.0 - 2023-07-30
|
||||
|
||||
### Added
|
||||
|
||||
- Added a step by step introduction for new users
|
||||
|
||||
### Fixed
|
||||
|
||||
- Removed the _Stay signed in_ setting on _Sign in with fingerprint_ activation
|
||||
|
||||
## 1.294.0 - 2023-07-29
|
||||
|
||||
### Changed
|
||||
|
||||
- Extended the allocations by market chart on the allocations page by unavailable data
|
||||
|
||||
### Fixed
|
||||
|
||||
- Considered liabilities in the total account value calculation
|
||||
|
||||
## 1.293.0 - 2023-07-26
|
||||
|
||||
### Added
|
||||
|
||||
- Added error handling for the _Redis_ connections to keep the app running if the connection fails
|
||||
|
||||
### Changed
|
||||
|
||||
- Set the `lastmod` dates of `sitemap.xml` dynamically
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the missing values in the holdings table
|
||||
- Fixed the `no such file or directory` error caused by the missing `favicon.ico` file
|
||||
|
||||
## 1.292.0 - 2023-07-24
|
||||
|
||||
### Added
|
||||
|
||||
- Introduced the allocations by market chart on the allocations page
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgraded `yahoo-finance2` from version `2.4.2` to `2.4.3`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue in the public page
|
||||
|
||||
## 1.291.0 - 2023-07-23
|
||||
|
||||
### Added
|
||||
|
||||
- Broken down the emergency fund by cash and assets
|
||||
- Added support for account balance time series
|
||||
|
||||
### Changed
|
||||
|
||||
- Renamed queries to presets in the historical market data table of the admin control panel
|
||||
|
||||
## 1.290.0 - 2023-07-16
|
||||
|
||||
### Added
|
||||
|
||||
- Added hints to the activity types in the create or edit activity dialog
|
||||
- Added queries to the historical market data table of the admin control panel
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the usability of the login dialog
|
||||
- Disabled the caching in the health check endpoints for data providers
|
||||
- Improved the content of the Frequently Asked Questions (FAQ) page
|
||||
- Upgraded `prisma` from version `4.15.0` to `4.16.2`
|
||||
|
||||
## 1.289.0 - 2023-07-14
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgraded `yahoo-finance2` from version `2.4.1` to `2.4.2`
|
||||
|
||||
## 1.288.0 - 2023-07-12
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the loading state during filtering on the allocations page
|
||||
- Beautified the names with ampersand (`&`) in the asset profile
|
||||
- Improved the language localization for German (`de`)
|
||||
|
||||
## 1.287.0 - 2023-07-09
|
||||
|
||||
### Changed
|
||||
|
||||
- Hid the average buy price in the position detail chart if there is no holding
|
||||
- Improved the language localization for French (`fr`)
|
||||
- Refactored the blog articles to standalone components
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the sorting by currency in the activities table
|
||||
|
||||
## 1.286.0 - 2023-07-03
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the creation of (wealth) items and liabilities
|
||||
|
||||
## 1.285.0 - 2023-07-01
|
||||
|
||||
### Added
|
||||
|
||||
- Added a blog post: _Exploring the Path to Financial Independence and Retiring Early (FIRE)_
|
||||
- Added pagination to the historical market data table of the admin control panel
|
||||
- Added the attribute `headers` to the scraper configuration
|
||||
|
||||
### Changed
|
||||
|
||||
- Extended the asset profile details dialog in the admin control panel by the scraper configuration
|
||||
- Improved the language localization for German (`de`)
|
||||
|
||||
## 1.284.0 - 2023-06-27
|
||||
|
||||
### Added
|
||||
|
||||
- Added the currency to the cash balance in the create or update account dialog
|
||||
- Added the ability to add an index for benchmarks as an asset profile in the admin control panel
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgraded the _Internet Identity_ dependencies from version `0.15.1` to `0.15.7`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue with the clone functionality of a transaction caused by the symbol search component
|
||||
|
||||
## 1.283.5 - 2023-06-25
|
||||
|
||||
### Added
|
||||
|
||||
- Added the caching for current market prices
|
||||
- Added a loading indicator to the import dividends dialog
|
||||
- Set up the `helmet` middleware to protect the app from web vulnerabilities by setting HTTP headers
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the selected item of the holding selector in the import dividends dialog
|
||||
- Extended the symbol search component by asset sub classes
|
||||
|
||||
## 1.282.0 - 2023-06-19
|
||||
|
||||
### Added
|
||||
|
||||
- Added an icon to the external links in the footer navigation
|
||||
- Added the ability to add an asset profile in the admin control panel
|
||||
|
||||
### Changed
|
||||
|
||||
- Harmonized the use of permissions on the about page
|
||||
- Harmonized the use of permissions on the landing page
|
||||
- Improved the language localization for German (`de`)
|
||||
- Improved the language localization for Portuguese (`pt`)
|
||||
- Updated the binary targets of `linux-arm64-openssl` for `prisma`
|
||||
|
||||
## 1.281.0 - 2023-06-17
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the feature overview page by liabilities
|
||||
- Set up the language localization for Portuguese (`pt`)
|
||||
|
||||
### Changed
|
||||
|
||||
- Extracted the symbol search to a dedicated component
|
||||
- Improved the column headers in the holdings table for mobile
|
||||
- Upgraded `prisma` from version `4.14.1` to `4.15.0`
|
||||
|
||||
## 1.280.1 - 2023-06-10
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for liabilities
|
||||
|
||||
## 1.279.0 - 2023-06-10
|
||||
|
||||
### Added
|
||||
|
||||
- Supported a note for accounts
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the language localization for French (`fr`)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue with the value nullification related to the investment streaks
|
||||
- Fixed an issue in the public page related to the impersonation service
|
||||
|
||||
## 1.278.0 - 2023-06-09
|
||||
|
||||
### Changed
|
||||
|
||||
- Extended the clone functionality of a transaction by the quantity
|
||||
- Changed the direction of the ellipsis icon in various tables
|
||||
- Extracted the license to a dedicated tab on the about page
|
||||
- Displayed the link to the markets overview in the footer based on a permission
|
||||
- Improved the spacing in the benchmark comparator
|
||||
- Refreshed the cryptocurrencies list
|
||||
- Upgraded `Node.js` from version `16` to `18` (`Dockerfile`)
|
||||
|
||||
## 1.277.0 - 2023-06-07
|
||||
|
||||
### Added
|
||||
|
||||
- Added the investment streaks to the analysis page
|
||||
- Added support for a unit in the value component
|
||||
- Added a semantic list structure to the header navigation
|
||||
- Added a default value for the `includeHistoricalData` attribute in the symbol data endpoint
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue with the date format parsing in the activities import
|
||||
|
||||
## 1.276.0 - 2023-06-03
|
||||
|
||||
### Added
|
||||
|
||||
- Added tabs to the about page
|
||||
- Added the `changefreq` attribute to the sitemap
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the routes of the tabs
|
||||
- Enforced a stricter date format in the activities import: `dd-MM-yyyy` instead of `dd-MM-yy`
|
||||
- Updated the URL of the Ghostfolio Slack channel
|
||||
- Removed the _Ghostfolio in Numbers_ section from the about page
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue with the price when creating a `Subscription`
|
||||
|
||||
## 1.275.0 - 2023-05-30
|
||||
|
||||
### Changed
|
||||
|
||||
- Extended the footer navigation by the localized Ghostfolio versions
|
||||
- Improved the language localization for German (`de`)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the exchange rate service for a specific date (indirect calculation via base currency) used in activities with a manual currency
|
||||
|
||||
## 1.274.0 - 2023-05-29
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the footer by a navigation
|
||||
- Extended the testimonial section on the landing page
|
||||
- Added localized meta descriptions
|
||||
- Added support for localized routes in Spanish (`es`)
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the activities import dialog
|
||||
- Improved the language localization for German (`de`)
|
||||
|
||||
## 1.273.0 - 2023-05-28
|
||||
|
||||
### Added
|
||||
|
||||
- Added a stepper to the activities import dialog
|
||||
- Added a link to manage the benchmarks to the benchmark comparator
|
||||
- Added support for localized routes
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue in the data source transformation
|
||||
|
||||
## 1.272.0 - 2023-05-26
|
||||
|
||||
### Added
|
||||
|
||||
- Added support to set an asset profile as a benchmark
|
||||
|
||||
### Changed
|
||||
|
||||
- Decreased the density of the `@angular/material` tables
|
||||
- Improved the portfolio proportion chart component by supporting case insensitive names
|
||||
- Improved the breadcrumb navigation style in the blog post pages for mobile
|
||||
- Improved the error handling in the delete user endpoint
|
||||
- Improved the style of the _Changelog & License_ button on the about page
|
||||
- Upgraded `ionicons` from version `6.1.2` to `7.1.0`
|
||||
|
||||
## 1.271.0 - 2023-05-20
|
||||
|
||||
### Added
|
||||
|
||||
- Added the historical data and search functionality for the `FINANCIAL_MODELING_PREP` data source type
|
||||
- Added a blog post: _Unlock your Financial Potential with Ghostfolio_
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the local number formatting in the value component
|
||||
- Changed the uptime to the last 90 days on the _Open Startup_ (`/open`) page
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the vertical alignment in the toggle component
|
||||
|
||||
## 1.270.1 - 2023-05-19
|
||||
|
||||
### Added
|
||||
@ -247,7 +554,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- Changed the slide toggles to checkboxes on the account page
|
||||
- Changed the slide toggles to checkboxes in the admin control panel
|
||||
- Decreased the density of the theme
|
||||
- Increased the density of the theme
|
||||
- Migrated the style of various components to `@angular/material` `15` (mdc)
|
||||
- Upgraded `@angular/cdk` and `@angular/material` from version `15.2.5` to `15.2.6`
|
||||
- Upgraded `bull` from version `4.10.2` to `4.10.4`
|
||||
@ -667,7 +974,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Added support for the dividend timeline grouped by year
|
||||
- Added support for the investment timeline grouped by year
|
||||
- Set up the language localization for Français (`fr`)
|
||||
- Set up the language localization for Português (`pt`)
|
||||
|
||||
### Changed
|
||||
|
||||
@ -947,7 +1253,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- Added support to change the appearance (dark mode) in user settings
|
||||
- Added the total amount chart to the investment timeline
|
||||
- Setup the `prettier` plugin `prettier-plugin-organize-attributes`
|
||||
- Set up the `prettier` plugin `prettier-plugin-organize-attributes`
|
||||
|
||||
### Changed
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
FROM --platform=$BUILDPLATFORM node:16-slim as builder
|
||||
FROM --platform=$BUILDPLATFORM node:18-slim as builder
|
||||
|
||||
# Build application and add additional files
|
||||
WORKDIR /ghostfolio
|
||||
@ -50,7 +50,7 @@ COPY package.json /ghostfolio/dist/apps/api
|
||||
RUN yarn database:generate-typings
|
||||
|
||||
# Image to run, copy everything needed from builder
|
||||
FROM node:16-slim
|
||||
FROM node:18-slim
|
||||
RUN apt update && apt install -y \
|
||||
openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
@ -145,7 +145,7 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
|
||||
### Prerequisites
|
||||
|
||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||
- [Node.js](https://nodejs.org/en/download) (version 16)
|
||||
- [Node.js](https://nodejs.org/en/download) (version 18+)
|
||||
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||
- Create a local copy of this Git repository (clone)
|
||||
- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`)
|
||||
@ -263,13 +263,15 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
|
||||
|
||||
## Community Projects
|
||||
|
||||
- [ghostfolio-cli](https://github.com/DerAndereJohannes/ghostfolio-cli): Command-line interface to access your portfolio
|
||||
Discover a variety of community projects for Ghostfolio: https://github.com/topics/ghostfolio
|
||||
|
||||
Are you building your own project? Add the `ghostfolio` topic to your _GitHub_ repository to get listed as well. [Learn more →](https://docs.github.com/en/articles/classifying-your-repository-with-topics)
|
||||
|
||||
## Contributing
|
||||
|
||||
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 got some ideas. Please join the Ghostfolio [Slack channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) or tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_). We would love to hear from you.
|
||||
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_). 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).
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { AccountBalanceModule } from '@ghostfolio/api/services/account-balance/account-balance.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
@ -15,6 +16,7 @@ import { AccountService } from './account.service';
|
||||
controllers: [AccountController],
|
||||
exports: [AccountService],
|
||||
imports: [
|
||||
AccountBalanceModule,
|
||||
ConfigurationModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { Filter } from '@ghostfolio/common/interfaces';
|
||||
@ -11,16 +12,21 @@ import { CashDetails } from './interfaces/cash-details.interface';
|
||||
@Injectable()
|
||||
export class AccountService {
|
||||
public constructor(
|
||||
private readonly accountBalanceService: AccountBalanceService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly prismaService: PrismaService
|
||||
) {}
|
||||
|
||||
public async account(
|
||||
accountWhereUniqueInput: Prisma.AccountWhereUniqueInput
|
||||
): Promise<Account | null> {
|
||||
return this.prismaService.account.findUnique({
|
||||
where: accountWhereUniqueInput
|
||||
public async account({
|
||||
id_userId
|
||||
}: Prisma.AccountWhereUniqueInput): Promise<Account | null> {
|
||||
const { id, userId } = id_userId;
|
||||
|
||||
const [account] = await this.accounts({
|
||||
where: { id, userId }
|
||||
});
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
public async accountWithOrders(
|
||||
@ -50,9 +56,11 @@ export class AccountService {
|
||||
Platform?: Platform;
|
||||
})[]
|
||||
> {
|
||||
const { include, skip, take, cursor, where, orderBy } = params;
|
||||
const { include = {}, skip, take, cursor, where, orderBy } = params;
|
||||
|
||||
return this.prismaService.account.findMany({
|
||||
include.balances = { orderBy: { date: 'desc' }, take: 1 };
|
||||
|
||||
const accounts = await this.prismaService.account.findMany({
|
||||
cursor,
|
||||
include,
|
||||
orderBy,
|
||||
@ -60,15 +68,36 @@ export class AccountService {
|
||||
take,
|
||||
where
|
||||
});
|
||||
|
||||
return accounts.map((account) => {
|
||||
account = { ...account, balance: account.balances[0]?.value ?? 0 };
|
||||
|
||||
delete account.balances;
|
||||
|
||||
return account;
|
||||
});
|
||||
}
|
||||
|
||||
public async createAccount(
|
||||
data: Prisma.AccountCreateInput,
|
||||
aUserId: string
|
||||
): Promise<Account> {
|
||||
return this.prismaService.account.create({
|
||||
const account = await this.prismaService.account.create({
|
||||
data
|
||||
});
|
||||
|
||||
await this.prismaService.accountBalance.create({
|
||||
data: {
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: { id: account.id, userId: aUserId }
|
||||
}
|
||||
},
|
||||
value: data.balance
|
||||
}
|
||||
});
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
public async deleteAccount(
|
||||
@ -167,6 +196,18 @@ export class AccountService {
|
||||
aUserId: string
|
||||
): Promise<Account> {
|
||||
const { data, where } = params;
|
||||
|
||||
await this.prismaService.accountBalance.create({
|
||||
data: {
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: where.id_userId
|
||||
}
|
||||
},
|
||||
value: <number>data.balance
|
||||
}
|
||||
});
|
||||
|
||||
return this.prismaService.account.update({
|
||||
data,
|
||||
where
|
||||
@ -202,16 +243,17 @@ export class AccountService {
|
||||
);
|
||||
|
||||
if (amountInCurrencyOfAccount) {
|
||||
await this.prismaService.account.update({
|
||||
data: {
|
||||
balance: new Big(balance).plus(amountInCurrencyOfAccount).toNumber()
|
||||
},
|
||||
where: {
|
||||
id_userId: {
|
||||
userId,
|
||||
id: accountId
|
||||
await this.accountBalanceService.createAccountBalance({
|
||||
date,
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: {
|
||||
userId,
|
||||
id: accountId
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
value: new Big(balance).plus(amountInCurrencyOfAccount).toNumber()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { AccountType } from '@prisma/client';
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
@ -6,6 +7,7 @@ import {
|
||||
IsString,
|
||||
ValidateIf
|
||||
} from 'class-validator';
|
||||
import { isString } from 'lodash';
|
||||
|
||||
export class CreateAccountDto {
|
||||
@IsString()
|
||||
@ -14,6 +16,13 @@ export class CreateAccountDto {
|
||||
@IsNumber()
|
||||
balance: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Transform(({ value }: TransformFnParams) =>
|
||||
isString(value) ? value.trim() : value
|
||||
)
|
||||
comment?: string;
|
||||
|
||||
@IsString()
|
||||
currency: string;
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { AccountType } from '@prisma/client';
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
@ -6,6 +7,7 @@ import {
|
||||
IsString,
|
||||
ValidateIf
|
||||
} from 'class-validator';
|
||||
import { isString } from 'lodash';
|
||||
|
||||
export class UpdateAccountDto {
|
||||
@IsString()
|
||||
@ -14,6 +16,13 @@ export class UpdateAccountDto {
|
||||
@IsNumber()
|
||||
balance: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Transform(({ value }: TransformFnParams) =>
|
||||
isString(value) ? value.trim() : value
|
||||
)
|
||||
comment?: string;
|
||||
|
||||
@IsString()
|
||||
currency: string;
|
||||
|
||||
|
@ -1,7 +1,9 @@
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
||||
import {
|
||||
DEFAULT_PAGE_SIZE,
|
||||
GATHER_ASSET_PROFILE_PROCESS,
|
||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||
} from '@ghostfolio/common/config';
|
||||
@ -13,7 +15,10 @@ import {
|
||||
Filter
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import type {
|
||||
MarketDataPreset,
|
||||
RequestWithUser
|
||||
} from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@ -26,11 +31,12 @@ import {
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards
|
||||
UseGuards,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { DataSource, MarketData } from '@prisma/client';
|
||||
import { DataSource, MarketData, Prisma, SymbolProfile } from '@prisma/client';
|
||||
import { isDate } from 'date-fns';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
@ -245,7 +251,12 @@ export class AdminController {
|
||||
@Get('market-data')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getMarketData(
|
||||
@Query('assetSubClasses') filterByAssetSubClasses?: string
|
||||
@Query('assetSubClasses') filterByAssetSubClasses?: string,
|
||||
@Query('presetId') presetId?: MarketDataPreset,
|
||||
@Query('skip') skip?: number,
|
||||
@Query('sortColumn') sortColumn?: string,
|
||||
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
|
||||
@Query('take') take?: number
|
||||
): Promise<AdminMarketData> {
|
||||
if (
|
||||
!hasPermission(
|
||||
@ -270,7 +281,14 @@ export class AdminController {
|
||||
})
|
||||
];
|
||||
|
||||
return this.adminService.getMarketData(filters);
|
||||
return this.adminService.getMarketData({
|
||||
filters,
|
||||
presetId,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
skip: isNaN(skip) ? undefined : skip,
|
||||
take: isNaN(take) ? undefined : take
|
||||
});
|
||||
}
|
||||
|
||||
@Get('market-data/:dataSource/:symbol')
|
||||
@ -328,6 +346,28 @@ export class AdminController {
|
||||
});
|
||||
}
|
||||
|
||||
@Post('profile-data/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async addProfileData(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<SymbolProfile | never> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.adminService.addAssetProfile({ dataSource, symbol });
|
||||
}
|
||||
|
||||
@Delete('profile-data/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async deleteProfileData(
|
||||
|
@ -1,21 +1,25 @@
|
||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
|
||||
import {
|
||||
DEFAULT_PAGE_SIZE,
|
||||
PROPERTY_CURRENCIES
|
||||
} from '@ghostfolio/common/config';
|
||||
import {
|
||||
AdminData,
|
||||
AdminMarketData,
|
||||
AdminMarketDataDetails,
|
||||
AdminMarketDataItem,
|
||||
Filter,
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AssetSubClass, Prisma, Property } from '@prisma/client';
|
||||
import { MarketDataPreset } from '@ghostfolio/common/types';
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { AssetSubClass, Prisma, Property, SymbolProfile } from '@prisma/client';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { groupBy } from 'lodash';
|
||||
|
||||
@ -25,6 +29,7 @@ export class AdminService {
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly marketDataService: MarketDataService,
|
||||
private readonly prismaService: PrismaService,
|
||||
@ -35,6 +40,38 @@ export class AdminService {
|
||||
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
||||
}
|
||||
|
||||
public async addAssetProfile({
|
||||
dataSource,
|
||||
symbol
|
||||
}: UniqueAsset): Promise<SymbolProfile | never> {
|
||||
try {
|
||||
const assetProfiles = await this.dataProviderService.getAssetProfiles([
|
||||
{ dataSource, symbol }
|
||||
]);
|
||||
|
||||
if (!assetProfiles[symbol]?.currency) {
|
||||
throw new BadRequestException(
|
||||
`Asset profile not found for ${symbol} (${dataSource})`
|
||||
);
|
||||
}
|
||||
|
||||
return await this.symbolProfileService.add(
|
||||
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === 'P2002'
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`Asset profile of ${symbol} (${dataSource}) already exists`
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
||||
await this.marketDataService.deleteMany({ dataSource, symbol });
|
||||
await this.symbolProfileService.delete({ dataSource, symbol });
|
||||
@ -65,9 +102,32 @@ export class AdminService {
|
||||
};
|
||||
}
|
||||
|
||||
public async getMarketData(filters?: Filter[]): Promise<AdminMarketData> {
|
||||
public async getMarketData({
|
||||
filters,
|
||||
presetId,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
skip,
|
||||
take = Number.MAX_SAFE_INTEGER
|
||||
}: {
|
||||
filters?: Filter[];
|
||||
presetId?: MarketDataPreset;
|
||||
skip?: number;
|
||||
sortColumn?: string;
|
||||
sortDirection?: Prisma.SortOrder;
|
||||
take?: number;
|
||||
}): Promise<AdminMarketData> {
|
||||
let orderBy: Prisma.Enumerable<Prisma.SymbolProfileOrderByWithRelationInput> =
|
||||
[{ symbol: 'asc' }];
|
||||
const where: Prisma.SymbolProfileWhereInput = {};
|
||||
|
||||
if (
|
||||
presetId === 'ETF_WITHOUT_COUNTRIES' ||
|
||||
presetId === 'ETF_WITHOUT_SECTORS'
|
||||
) {
|
||||
filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }];
|
||||
}
|
||||
|
||||
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
|
||||
filters,
|
||||
(filter) => {
|
||||
@ -75,42 +135,33 @@ export class AdminService {
|
||||
}
|
||||
);
|
||||
|
||||
const marketData = await this.prismaService.marketData.groupBy({
|
||||
const marketDataItems = await this.prismaService.marketData.groupBy({
|
||||
_count: true,
|
||||
by: ['dataSource', 'symbol']
|
||||
});
|
||||
|
||||
let currencyPairsToGather: AdminMarketDataItem[] = [];
|
||||
|
||||
if (filtersByAssetSubClass) {
|
||||
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
|
||||
} else {
|
||||
currencyPairsToGather = this.exchangeRateDataService
|
||||
.getCurrencyPairs()
|
||||
.map(({ dataSource, symbol }) => {
|
||||
const marketDataItemCount =
|
||||
marketData.find((marketDataItem) => {
|
||||
return (
|
||||
marketDataItem.dataSource === dataSource &&
|
||||
marketDataItem.symbol === symbol
|
||||
);
|
||||
})?._count ?? 0;
|
||||
|
||||
return {
|
||||
dataSource,
|
||||
marketDataItemCount,
|
||||
symbol,
|
||||
assetClass: 'CASH',
|
||||
countriesCount: 0,
|
||||
sectorsCount: 0
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const symbolProfilesToGather: AdminMarketDataItem[] = (
|
||||
await this.prismaService.symbolProfile.findMany({
|
||||
if (sortColumn) {
|
||||
orderBy = [{ [sortColumn]: sortDirection }];
|
||||
|
||||
if (sortColumn === 'activitiesCount') {
|
||||
orderBy = {
|
||||
Order: {
|
||||
_count: sortDirection
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let [assetProfiles, count] = await Promise.all([
|
||||
this.prismaService.symbolProfile.findMany({
|
||||
orderBy,
|
||||
skip,
|
||||
take,
|
||||
where,
|
||||
orderBy: [{ symbol: 'asc' }],
|
||||
select: {
|
||||
_count: {
|
||||
select: { Order: true }
|
||||
@ -129,38 +180,64 @@ export class AdminService {
|
||||
sectors: true,
|
||||
symbol: true
|
||||
}
|
||||
})
|
||||
).map((symbolProfile) => {
|
||||
const countriesCount = symbolProfile.countries
|
||||
? Object.keys(symbolProfile.countries).length
|
||||
: 0;
|
||||
const marketDataItemCount =
|
||||
marketData.find((marketDataItem) => {
|
||||
return (
|
||||
marketDataItem.dataSource === symbolProfile.dataSource &&
|
||||
marketDataItem.symbol === symbolProfile.symbol
|
||||
);
|
||||
})?._count ?? 0;
|
||||
const sectorsCount = symbolProfile.sectors
|
||||
? Object.keys(symbolProfile.sectors).length
|
||||
: 0;
|
||||
}),
|
||||
this.prismaService.symbolProfile.count({ where })
|
||||
]);
|
||||
|
||||
return {
|
||||
countriesCount,
|
||||
marketDataItemCount,
|
||||
sectorsCount,
|
||||
activitiesCount: symbolProfile._count.Order,
|
||||
assetClass: symbolProfile.assetClass,
|
||||
assetSubClass: symbolProfile.assetSubClass,
|
||||
comment: symbolProfile.comment,
|
||||
dataSource: symbolProfile.dataSource,
|
||||
date: symbolProfile.Order?.[0]?.date,
|
||||
symbol: symbolProfile.symbol
|
||||
};
|
||||
});
|
||||
let marketData = assetProfiles.map(
|
||||
({
|
||||
_count,
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
comment,
|
||||
countries,
|
||||
dataSource,
|
||||
Order,
|
||||
sectors,
|
||||
symbol
|
||||
}) => {
|
||||
const countriesCount = countries ? Object.keys(countries).length : 0;
|
||||
const marketDataItemCount =
|
||||
marketDataItems.find((marketDataItem) => {
|
||||
return (
|
||||
marketDataItem.dataSource === dataSource &&
|
||||
marketDataItem.symbol === symbol
|
||||
);
|
||||
})?._count ?? 0;
|
||||
const sectorsCount = sectors ? Object.keys(sectors).length : 0;
|
||||
|
||||
return {
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
comment,
|
||||
countriesCount,
|
||||
dataSource,
|
||||
symbol,
|
||||
marketDataItemCount,
|
||||
sectorsCount,
|
||||
activitiesCount: _count.Order,
|
||||
date: Order?.[0]?.date
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
if (presetId) {
|
||||
if (presetId === 'ETF_WITHOUT_COUNTRIES') {
|
||||
marketData = marketData.filter(({ countriesCount }) => {
|
||||
return countriesCount === 0;
|
||||
});
|
||||
} else if (presetId === 'ETF_WITHOUT_SECTORS') {
|
||||
marketData = marketData.filter(({ sectorsCount }) => {
|
||||
return sectorsCount === 0;
|
||||
});
|
||||
}
|
||||
|
||||
count = marketData.length;
|
||||
}
|
||||
|
||||
return {
|
||||
marketData: [...currencyPairsToGather, ...symbolProfilesToGather]
|
||||
count,
|
||||
marketData
|
||||
};
|
||||
}
|
||||
|
||||
@ -198,12 +275,14 @@ export class AdminService {
|
||||
public async patchAssetProfileData({
|
||||
comment,
|
||||
dataSource,
|
||||
scraperConfiguration,
|
||||
symbol,
|
||||
symbolMapping
|
||||
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
||||
await this.symbolProfileService.updateSymbolProfile({
|
||||
comment,
|
||||
dataSource,
|
||||
scraperConfiguration,
|
||||
symbol,
|
||||
symbolMapping
|
||||
});
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { IsObject, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class UpdateAssetProfileDto {
|
||||
@ -5,6 +6,10 @@ export class UpdateAssetProfileDto {
|
||||
@IsOptional()
|
||||
comment?: string;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
scraperConfiguration?: Prisma.InputJsonObject;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
symbolMapping?: {
|
||||
|
@ -1,24 +1,36 @@
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||
import {
|
||||
import type {
|
||||
BenchmarkMarketDataDetails,
|
||||
BenchmarkResponse
|
||||
BenchmarkResponse,
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { BenchmarkService } from './benchmark.service';
|
||||
|
||||
@Controller('benchmark')
|
||||
export class BenchmarkController {
|
||||
public constructor(private readonly benchmarkService: BenchmarkService) {}
|
||||
public constructor(
|
||||
private readonly benchmarkService: BenchmarkService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@ -45,4 +57,41 @@ export class BenchmarkController {
|
||||
symbol
|
||||
});
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const benchmark = await this.benchmarkService.addBenchmark({
|
||||
dataSource,
|
||||
symbol
|
||||
});
|
||||
|
||||
if (!benchmark) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
return benchmark;
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||
StatusCodes.INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
@ -17,6 +18,7 @@ import { BenchmarkService } from './benchmark.service';
|
||||
ConfigurationModule,
|
||||
DataProviderModule,
|
||||
MarketDataModule,
|
||||
PrismaModule,
|
||||
PropertyModule,
|
||||
RedisCacheModule,
|
||||
SymbolModule,
|
||||
|
@ -4,7 +4,15 @@ describe('BenchmarkService', () => {
|
||||
let benchmarkService: BenchmarkService;
|
||||
|
||||
beforeAll(async () => {
|
||||
benchmarkService = new BenchmarkService(null, null, null, null, null, null);
|
||||
benchmarkService = new BenchmarkService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
it('calculateChangeInPercentage', async () => {
|
||||
|
@ -2,6 +2,7 @@ import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.s
|
||||
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||
import {
|
||||
@ -11,6 +12,7 @@ import {
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
BenchmarkMarketDataDetails,
|
||||
BenchmarkProperty,
|
||||
BenchmarkResponse,
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
@ -18,6 +20,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { SymbolProfile } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import { format } from 'date-fns';
|
||||
import { uniqBy } from 'lodash';
|
||||
import ms from 'ms';
|
||||
|
||||
@Injectable()
|
||||
@ -27,6 +30,7 @@ export class BenchmarkService {
|
||||
public constructor(
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly marketDataService: MarketDataService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly propertyService: PropertyService,
|
||||
private readonly redisCacheService: RedisCacheService,
|
||||
private readonly symbolProfileService: SymbolProfileService,
|
||||
@ -62,11 +66,11 @@ export class BenchmarkService {
|
||||
|
||||
const promises: Promise<number>[] = [];
|
||||
|
||||
const quotes = await this.dataProviderService.getQuotes(
|
||||
benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
||||
const quotes = await this.dataProviderService.getQuotes({
|
||||
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
||||
return { dataSource, symbol };
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
|
||||
promises.push(this.marketDataService.getMax({ dataSource, symbol }));
|
||||
@ -116,9 +120,9 @@ export class BenchmarkService {
|
||||
|
||||
public async getBenchmarkAssetProfiles(): Promise<Partial<SymbolProfile>[]> {
|
||||
const symbolProfileIds: string[] = (
|
||||
((await this.propertyService.getByKey(PROPERTY_BENCHMARKS)) as {
|
||||
symbolProfileId: string;
|
||||
}[]) ?? []
|
||||
((await this.propertyService.getByKey(
|
||||
PROPERTY_BENCHMARKS
|
||||
)) as BenchmarkProperty[]) ?? []
|
||||
).map(({ symbolProfileId }) => {
|
||||
return symbolProfileId;
|
||||
});
|
||||
@ -204,6 +208,43 @@ export class BenchmarkService {
|
||||
return response;
|
||||
}
|
||||
|
||||
public async addBenchmark({
|
||||
dataSource,
|
||||
symbol
|
||||
}: UniqueAsset): Promise<Partial<SymbolProfile>> {
|
||||
const assetProfile = await this.prismaService.symbolProfile.findFirst({
|
||||
where: {
|
||||
dataSource,
|
||||
symbol
|
||||
}
|
||||
});
|
||||
|
||||
if (!assetProfile) {
|
||||
return;
|
||||
}
|
||||
|
||||
let benchmarks =
|
||||
((await this.propertyService.getByKey(
|
||||
PROPERTY_BENCHMARKS
|
||||
)) as BenchmarkProperty[]) ?? [];
|
||||
|
||||
benchmarks.push({ symbolProfileId: assetProfile.id });
|
||||
|
||||
benchmarks = uniqBy(benchmarks, 'symbolProfileId');
|
||||
|
||||
await this.propertyService.put({
|
||||
key: PROPERTY_BENCHMARKS,
|
||||
value: JSON.stringify(benchmarks)
|
||||
});
|
||||
|
||||
return {
|
||||
dataSource,
|
||||
symbol,
|
||||
id: assetProfile.id,
|
||||
name: assetProfile.name
|
||||
};
|
||||
}
|
||||
|
||||
private getMarketCondition(aPerformanceInPercent: number) {
|
||||
return aPerformanceInPercent <= -0.2 ? 'BEAR_MARKET' : 'NEUTRAL_MARKET';
|
||||
}
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
|
||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ExportController } from './export.controller';
|
||||
@ -10,10 +11,11 @@ import { ExportService } from './export.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AccountModule,
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
PrismaModule,
|
||||
OrderModule,
|
||||
RedisCacheModule
|
||||
],
|
||||
controllers: [ExportController],
|
||||
|
@ -1,11 +1,15 @@
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { environment } from '@ghostfolio/api/environments/environment';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { Export } from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class ExportService {
|
||||
public constructor(private readonly prismaService: PrismaService) {}
|
||||
public constructor(
|
||||
private readonly accountService: AccountService,
|
||||
private readonly orderService: OrderService
|
||||
) {}
|
||||
|
||||
public async export({
|
||||
activityIds,
|
||||
@ -14,35 +18,40 @@ export class ExportService {
|
||||
activityIds?: string[];
|
||||
userId: string;
|
||||
}): Promise<Export> {
|
||||
const accounts = await this.prismaService.account.findMany({
|
||||
orderBy: {
|
||||
name: 'asc'
|
||||
},
|
||||
select: {
|
||||
accountType: true,
|
||||
balance: true,
|
||||
currency: true,
|
||||
id: true,
|
||||
isExcluded: true,
|
||||
name: true,
|
||||
platformId: true
|
||||
},
|
||||
where: { userId }
|
||||
});
|
||||
const accounts = (
|
||||
await this.accountService.accounts({
|
||||
orderBy: {
|
||||
name: 'asc'
|
||||
},
|
||||
where: { userId }
|
||||
})
|
||||
).map(
|
||||
({
|
||||
accountType,
|
||||
balance,
|
||||
comment,
|
||||
currency,
|
||||
id,
|
||||
isExcluded,
|
||||
name,
|
||||
platformId
|
||||
}) => {
|
||||
return {
|
||||
accountType,
|
||||
balance,
|
||||
comment,
|
||||
currency,
|
||||
id,
|
||||
isExcluded,
|
||||
name,
|
||||
platformId
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
let activities = await this.prismaService.order.findMany({
|
||||
let activities = await this.orderService.orders({
|
||||
include: { SymbolProfile: true },
|
||||
orderBy: { date: 'desc' },
|
||||
select: {
|
||||
accountId: true,
|
||||
comment: true,
|
||||
date: true,
|
||||
fee: true,
|
||||
id: true,
|
||||
quantity: true,
|
||||
SymbolProfile: true,
|
||||
type: true,
|
||||
unitPrice: true
|
||||
},
|
||||
where: { userId }
|
||||
});
|
||||
|
||||
|
@ -4,7 +4,7 @@ import * as path from 'path';
|
||||
import { environment } from '@ghostfolio/api/environments/environment';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { format } from 'date-fns';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
@ -18,6 +18,10 @@ export class FrontendMiddleware implements NestMiddleware {
|
||||
public indexHtmlIt = '';
|
||||
public indexHtmlNl = '';
|
||||
public indexHtmlPt = '';
|
||||
public sitemapXml = '';
|
||||
|
||||
private static readonly DEFAULT_DESCRIPTION =
|
||||
'Ghostfolio is a personal finance dashboard to keep track of your assets like stocks, ETFs or cryptocurrencies across multiple platforms.';
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService
|
||||
@ -51,6 +55,10 @@ export class FrontendMiddleware implements NestMiddleware {
|
||||
this.getPathOfIndexHtmlFile('pt'),
|
||||
'utf8'
|
||||
);
|
||||
this.sitemapXml = fs.readFileSync(
|
||||
path.join(__dirname, 'assets', 'sitemap.xml'),
|
||||
'utf8'
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@ -94,6 +102,18 @@ export class FrontendMiddleware implements NestMiddleware {
|
||||
) {
|
||||
featureGraphicPath = 'assets/images/blog/1000-stars-on-github.jpg';
|
||||
title = `Ghostfolio reaches 1’000 Stars on GitHub - ${title}`;
|
||||
} else if (
|
||||
request.path.startsWith(
|
||||
'/en/blog/2023/05/unlock-your-financial-potential-with-ghostfolio'
|
||||
)
|
||||
) {
|
||||
featureGraphicPath = 'assets/images/blog/20230520.jpg';
|
||||
title = `Unlock your Financial Potential with Ghostfolio - ${title}`;
|
||||
} else if (
|
||||
request.path.startsWith('/en/blog/2023/07/exploring-the-path-to-fire')
|
||||
) {
|
||||
featureGraphicPath = 'assets/images/blog/20230701.jpg';
|
||||
title = `Exploring the Path to FIRE - ${title}`;
|
||||
}
|
||||
|
||||
if (
|
||||
@ -103,12 +123,21 @@ export class FrontendMiddleware implements NestMiddleware {
|
||||
) {
|
||||
// Skip
|
||||
next();
|
||||
} else if (request.path === '/sitemap.xml') {
|
||||
response.setHeader('content-type', 'application/xml');
|
||||
response.send(
|
||||
this.interpolate(this.sitemapXml, {
|
||||
currentDate: format(getYesterday(), DATE_FORMAT)
|
||||
})
|
||||
);
|
||||
} else if (request.path === '/de' || request.path.startsWith('/de/')) {
|
||||
response.send(
|
||||
this.interpolate(this.indexHtmlDe, {
|
||||
currentDate,
|
||||
featureGraphicPath,
|
||||
title,
|
||||
description:
|
||||
'Mit dem Finanz-Dashboard Ghostfolio können Sie Ihr Vermögen in Form von Aktien, ETFs oder Kryptowährungen verteilt über mehrere Finanzinstitute überwachen.',
|
||||
languageCode: 'de',
|
||||
path: request.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
@ -120,6 +149,8 @@ export class FrontendMiddleware implements NestMiddleware {
|
||||
currentDate,
|
||||
featureGraphicPath,
|
||||
title,
|
||||
description:
|
||||
'Ghostfolio es un dashboard de finanzas personales para hacer un seguimiento de tus activos como acciones, ETFs o criptodivisas a través de múltiples plataformas.',
|
||||
languageCode: 'es',
|
||||
path: request.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
@ -128,7 +159,11 @@ export class FrontendMiddleware implements NestMiddleware {
|
||||
} else if (request.path === '/fr' || request.path.startsWith('/fr/')) {
|
||||
response.send(
|
||||
this.interpolate(this.indexHtmlFr, {
|
||||
currentDate,
|
||||
featureGraphicPath,
|
||||
title,
|
||||
description:
|
||||
'Ghostfolio est un dashboard de finances personnelles qui permet de suivre vos actifs comme les actions, les ETF ou les crypto-monnaies sur plusieurs plateformes.',
|
||||
languageCode: 'fr',
|
||||
path: request.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
@ -140,6 +175,8 @@ export class FrontendMiddleware implements NestMiddleware {
|
||||
currentDate,
|
||||
featureGraphicPath,
|
||||
title,
|
||||
description:
|
||||
'Ghostfolio è un dashboard di finanza personale per tenere traccia delle vostre attività come azioni, ETF o criptovalute su più piattaforme.',
|
||||
languageCode: 'it',
|
||||
path: request.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
@ -151,6 +188,8 @@ export class FrontendMiddleware implements NestMiddleware {
|
||||
currentDate,
|
||||
featureGraphicPath,
|
||||
title,
|
||||
description:
|
||||
'Ghostfolio is een persoonlijk financieel dashboard om uw activa zoals aandelen, ETF’s of cryptocurrencies over meerdere platforms bij te houden.',
|
||||
languageCode: 'nl',
|
||||
path: request.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
@ -159,7 +198,11 @@ export class FrontendMiddleware implements NestMiddleware {
|
||||
} else if (request.path === '/pt' || request.path.startsWith('/pt/')) {
|
||||
response.send(
|
||||
this.interpolate(this.indexHtmlPt, {
|
||||
currentDate,
|
||||
featureGraphicPath,
|
||||
title,
|
||||
description:
|
||||
'Ghostfolio é um dashboard de finanças pessoais para acompanhar os seus activos como acções, ETFs ou criptomoedas em múltiplas plataformas.',
|
||||
languageCode: 'pt',
|
||||
path: request.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
@ -171,6 +214,7 @@ export class FrontendMiddleware implements NestMiddleware {
|
||||
currentDate,
|
||||
featureGraphicPath,
|
||||
title,
|
||||
description: FrontendMiddleware.DEFAULT_DESCRIPTION,
|
||||
languageCode: DEFAULT_LANGUAGE_CODE,
|
||||
path: request.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
@ -196,7 +240,13 @@ export class FrontendMiddleware implements NestMiddleware {
|
||||
private isFileRequest(filename: string) {
|
||||
if (filename === '/assets/LICENSE') {
|
||||
return true;
|
||||
} else if (filename.includes('auth/ey')) {
|
||||
} else if (
|
||||
filename === '/sitemap.xml' ||
|
||||
filename.includes('auth/ey') ||
|
||||
filename.includes(
|
||||
'personal-finance-tools/open-source-alternative-to-markets.sh'
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -202,7 +202,7 @@ export class ImportService {
|
||||
|
||||
for (const activity of activitiesDto) {
|
||||
if (!activity.dataSource) {
|
||||
if (activity.type === 'ITEM') {
|
||||
if (activity.type === 'ITEM' || activity.type === 'LIABILITY') {
|
||||
activity.dataSource = DataSource.MANUAL;
|
||||
} else {
|
||||
activity.dataSource =
|
||||
|
@ -17,19 +17,22 @@ import {
|
||||
ghostfolioFearAndGreedIndexDataSource
|
||||
} from '@ghostfolio/common/config';
|
||||
import {
|
||||
DATE_FORMAT,
|
||||
encodeDataSource,
|
||||
extractNumberFromString
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { InfoItem } from '@ghostfolio/common/interfaces';
|
||||
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
|
||||
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
|
||||
import {
|
||||
InfoItem,
|
||||
Statistics,
|
||||
Subscription
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import { SubscriptionOffer } from '@ghostfolio/common/types';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bent from 'bent';
|
||||
import * as cheerio from 'cheerio';
|
||||
import { subDays } from 'date-fns';
|
||||
import { format, subDays } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
export class InfoService {
|
||||
@ -344,7 +347,10 @@ export class InfoService {
|
||||
)) as string;
|
||||
|
||||
const get = bent(
|
||||
`https://betteruptime.com/api/v2/monitors/${monitorId}/sla`,
|
||||
`https://betteruptime.com/api/v2/monitors/${monitorId}/sla?from=${format(
|
||||
subDays(new Date(), 90),
|
||||
DATE_FORMAT
|
||||
)}&to${format(new Date(), DATE_FORMAT)}`,
|
||||
'GET',
|
||||
'json',
|
||||
200,
|
||||
|
@ -2,6 +2,7 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
|
||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||
@ -31,6 +32,6 @@ import { OrderService } from './order.service';
|
||||
SymbolProfileModule,
|
||||
UserModule
|
||||
],
|
||||
providers: [AccountService, OrderService]
|
||||
providers: [AccountBalanceService, AccountService, OrderService]
|
||||
})
|
||||
export class OrderModule {}
|
||||
|
@ -96,7 +96,7 @@ export class OrderService {
|
||||
const updateAccountBalance = data.updateAccountBalance ?? false;
|
||||
const userId = data.userId;
|
||||
|
||||
if (data.type === 'ITEM') {
|
||||
if (data.type === 'ITEM' || data.type === 'LIABILITY') {
|
||||
const assetClass = data.assetClass;
|
||||
const assetSubClass = data.assetSubClass;
|
||||
currency = data.SymbolProfile.connectOrCreate.create.currency;
|
||||
@ -129,7 +129,10 @@ export class OrderService {
|
||||
}
|
||||
});
|
||||
|
||||
const isDraft = isAfter(data.date as Date, endOfToday());
|
||||
const isDraft =
|
||||
data.type === 'LIABILITY'
|
||||
? false
|
||||
: isAfter(data.date as Date, endOfToday());
|
||||
|
||||
if (!isDraft) {
|
||||
// Gather symbol data of order in the background, if not draft
|
||||
@ -201,7 +204,7 @@ export class OrderService {
|
||||
where
|
||||
});
|
||||
|
||||
if (order.type === 'ITEM') {
|
||||
if (order.type === 'ITEM' || order.type === 'LIABILITY') {
|
||||
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
||||
}
|
||||
|
||||
@ -320,7 +323,11 @@ export class OrderService {
|
||||
})
|
||||
)
|
||||
.filter((order) => {
|
||||
return withExcludedAccounts || order.Account?.isExcluded === false;
|
||||
return (
|
||||
withExcludedAccounts ||
|
||||
!order.Account ||
|
||||
order.Account?.isExcluded === false
|
||||
);
|
||||
})
|
||||
.map((order) => {
|
||||
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
|
||||
@ -368,7 +375,7 @@ export class OrderService {
|
||||
|
||||
let isDraft = false;
|
||||
|
||||
if (data.type === 'ITEM') {
|
||||
if (data.type === 'ITEM' || data.type === 'LIABILITY') {
|
||||
delete data.SymbolProfile.connect;
|
||||
} else {
|
||||
delete data.SymbolProfile.update;
|
||||
|
@ -98,7 +98,8 @@ describe('CurrentRateService', () => {
|
||||
[],
|
||||
null,
|
||||
null,
|
||||
propertyService
|
||||
propertyService,
|
||||
null
|
||||
);
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
|
@ -38,7 +38,7 @@ export class CurrentRateService {
|
||||
if (includeToday) {
|
||||
promises.push(
|
||||
this.dataProviderService
|
||||
.getQuotes(dataGatheringItems)
|
||||
.getQuotes({ items: dataGatheringItems })
|
||||
.then((dataResultProvider) => {
|
||||
const result: GetValueObject[] = [];
|
||||
for (const dataGatheringItem of dataGatheringItems) {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { DataSource, Type as TypeOfOrder } from '@prisma/client';
|
||||
import { DataSource, Tag, Type as TypeOfOrder } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
|
||||
export interface PortfolioOrder {
|
||||
@ -9,6 +9,7 @@ export interface PortfolioOrder {
|
||||
name: string;
|
||||
quantity: Big;
|
||||
symbol: string;
|
||||
tags?: Tag[];
|
||||
type: TypeOfOrder;
|
||||
unitPrice: Big;
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { DataSource, Tag } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
|
||||
export interface TransactionPointSymbol {
|
||||
@ -9,5 +9,6 @@ export interface TransactionPointSymbol {
|
||||
investment: Big;
|
||||
quantity: Big;
|
||||
symbol: string;
|
||||
tags?: Tag[];
|
||||
transactionCount: number;
|
||||
}
|
||||
|
@ -105,6 +105,40 @@ describe('PortfolioCalculator', () => {
|
||||
|
||||
expect(investmentsByMonth).toEqual([
|
||||
{ date: '2015-01-01', investment: new Big('640.86') },
|
||||
{ date: '2015-02-01', investment: new Big('0') },
|
||||
{ date: '2015-03-01', investment: new Big('0') },
|
||||
{ date: '2015-04-01', investment: new Big('0') },
|
||||
{ date: '2015-05-01', investment: new Big('0') },
|
||||
{ date: '2015-06-01', investment: new Big('0') },
|
||||
{ date: '2015-07-01', investment: new Big('0') },
|
||||
{ date: '2015-08-01', investment: new Big('0') },
|
||||
{ date: '2015-09-01', investment: new Big('0') },
|
||||
{ date: '2015-10-01', investment: new Big('0') },
|
||||
{ date: '2015-11-01', investment: new Big('0') },
|
||||
{ date: '2015-12-01', investment: new Big('0') },
|
||||
{ date: '2016-01-01', investment: new Big('0') },
|
||||
{ date: '2016-02-01', investment: new Big('0') },
|
||||
{ date: '2016-03-01', investment: new Big('0') },
|
||||
{ date: '2016-04-01', investment: new Big('0') },
|
||||
{ date: '2016-05-01', investment: new Big('0') },
|
||||
{ date: '2016-06-01', investment: new Big('0') },
|
||||
{ date: '2016-07-01', investment: new Big('0') },
|
||||
{ date: '2016-08-01', investment: new Big('0') },
|
||||
{ date: '2016-09-01', investment: new Big('0') },
|
||||
{ date: '2016-10-01', investment: new Big('0') },
|
||||
{ date: '2016-11-01', investment: new Big('0') },
|
||||
{ date: '2016-12-01', investment: new Big('0') },
|
||||
{ date: '2017-01-01', investment: new Big('0') },
|
||||
{ date: '2017-02-01', investment: new Big('0') },
|
||||
{ date: '2017-03-01', investment: new Big('0') },
|
||||
{ date: '2017-04-01', investment: new Big('0') },
|
||||
{ date: '2017-05-01', investment: new Big('0') },
|
||||
{ date: '2017-06-01', investment: new Big('0') },
|
||||
{ date: '2017-07-01', investment: new Big('0') },
|
||||
{ date: '2017-08-01', investment: new Big('0') },
|
||||
{ date: '2017-09-01', investment: new Big('0') },
|
||||
{ date: '2017-10-01', investment: new Big('0') },
|
||||
{ date: '2017-11-01', investment: new Big('0') },
|
||||
{ date: '2017-12-01', investment: new Big('-14156.4') }
|
||||
]);
|
||||
});
|
||||
|
@ -114,6 +114,7 @@ export class PortfolioCalculator {
|
||||
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
|
||||
quantity: newQuantity,
|
||||
symbol: order.symbol,
|
||||
tags: order.tags,
|
||||
transactionCount: oldAccumulatedSymbol.transactionCount + 1
|
||||
};
|
||||
} else {
|
||||
@ -125,6 +126,7 @@ export class PortfolioCalculator {
|
||||
investment: unitPrice.mul(order.quantity).mul(factor),
|
||||
quantity: order.quantity.mul(factor),
|
||||
symbol: order.symbol,
|
||||
tags: order.tags,
|
||||
transactionCount: 1
|
||||
};
|
||||
}
|
||||
@ -492,6 +494,7 @@ export class PortfolioCalculator {
|
||||
: null,
|
||||
quantity: item.quantity,
|
||||
symbol: item.symbol,
|
||||
tags: item.tags,
|
||||
transactionCount: item.transactionCount
|
||||
});
|
||||
|
||||
@ -544,7 +547,7 @@ export class PortfolioCalculator {
|
||||
return [];
|
||||
}
|
||||
|
||||
const investments = [];
|
||||
const investments: { date: string; investment: Big }[] = [];
|
||||
let currentDate: Date;
|
||||
let investmentByGroup = new Big(0);
|
||||
|
||||
@ -554,13 +557,11 @@ export class PortfolioCalculator {
|
||||
(groupBy === 'year' || isSameMonth(parseDate(order.date), currentDate))
|
||||
) {
|
||||
// Same group: Add up investments
|
||||
|
||||
investmentByGroup = investmentByGroup.plus(
|
||||
order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
|
||||
);
|
||||
} else {
|
||||
// New group: Store previous group and reset
|
||||
|
||||
if (currentDate) {
|
||||
investments.push({
|
||||
date: format(
|
||||
@ -595,7 +596,39 @@ export class PortfolioCalculator {
|
||||
}
|
||||
}
|
||||
|
||||
return investments;
|
||||
// Fill in the missing dates with investment = 0
|
||||
const startDate = parseDate(first(this.orders).date);
|
||||
const endDate = parseDate(last(this.orders).date);
|
||||
|
||||
const allDates: string[] = [];
|
||||
currentDate = startDate;
|
||||
|
||||
while (currentDate <= endDate) {
|
||||
allDates.push(
|
||||
format(
|
||||
set(currentDate, {
|
||||
date: 1,
|
||||
month: groupBy === 'year' ? 0 : currentDate.getMonth()
|
||||
}),
|
||||
DATE_FORMAT
|
||||
)
|
||||
);
|
||||
currentDate.setMonth(currentDate.getMonth() + 1);
|
||||
}
|
||||
|
||||
for (const date of allDates) {
|
||||
const existingInvestment = investments.find((investment) => {
|
||||
return investment.date === date;
|
||||
});
|
||||
|
||||
if (!existingInvestment) {
|
||||
investments.push({ date, investment: new Big(0) });
|
||||
}
|
||||
}
|
||||
|
||||
return sortBy(investments, (investment) => {
|
||||
return investment.date;
|
||||
});
|
||||
}
|
||||
|
||||
public async calculateTimeline(
|
||||
|
@ -134,7 +134,7 @@ export class PortfolioController {
|
||||
portfolioPosition.netPerformance = null;
|
||||
portfolioPosition.quantity = null;
|
||||
portfolioPosition.valueInPercentage =
|
||||
portfolioPosition.value / totalValue;
|
||||
portfolioPosition.valueInBaseCurrency / totalValue;
|
||||
}
|
||||
|
||||
for (const [name, { valueInBaseCurrency }] of Object.entries(accounts)) {
|
||||
@ -161,9 +161,12 @@ export class PortfolioController {
|
||||
'emergencyFund',
|
||||
'excludedAccountsAndActivities',
|
||||
'fees',
|
||||
'fireWealth',
|
||||
'items',
|
||||
'liabilities',
|
||||
'netWorth',
|
||||
'totalBuy',
|
||||
'totalInvestment',
|
||||
'totalSell'
|
||||
]);
|
||||
}
|
||||
@ -176,6 +179,9 @@ export class PortfolioController {
|
||||
countries: hasDetails ? portfolioPosition.countries : [],
|
||||
currency: hasDetails ? portfolioPosition.currency : undefined,
|
||||
markets: hasDetails ? portfolioPosition.markets : undefined,
|
||||
marketsAdvanced: hasDetails
|
||||
? portfolioPosition.marketsAdvanced
|
||||
: undefined,
|
||||
sectors: hasDetails ? portfolioPosition.sectors : []
|
||||
};
|
||||
}
|
||||
@ -258,11 +264,12 @@ export class PortfolioController {
|
||||
filterByTags
|
||||
});
|
||||
|
||||
let investments = await this.portfolioService.getInvestments({
|
||||
let { investments, streaks } = await this.portfolioService.getInvestments({
|
||||
dateRange,
|
||||
filters,
|
||||
groupBy,
|
||||
impersonationId
|
||||
impersonationId,
|
||||
savingsRate: this.request.user?.Settings?.settings.savingsRate
|
||||
});
|
||||
|
||||
if (
|
||||
@ -278,6 +285,11 @@ export class PortfolioController {
|
||||
date: item.date,
|
||||
investment: item.investment / maxInvestment
|
||||
}));
|
||||
|
||||
streaks = nullifyValuesInObject(streaks, [
|
||||
'currentStreak',
|
||||
'longestStreak'
|
||||
]);
|
||||
}
|
||||
|
||||
if (
|
||||
@ -287,9 +299,14 @@ export class PortfolioController {
|
||||
investments = investments.map((item) => {
|
||||
return nullifyValuesInObject(item, ['investment']);
|
||||
});
|
||||
|
||||
streaks = nullifyValuesInObject(streaks, [
|
||||
'currentStreak',
|
||||
'longestStreak'
|
||||
]);
|
||||
}
|
||||
|
||||
return { investments };
|
||||
return { investments, streaks };
|
||||
}
|
||||
|
||||
@Get('performance')
|
||||
@ -433,7 +450,8 @@ export class PortfolioController {
|
||||
|
||||
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||
portfolioPublicDetails.holdings[symbol] = {
|
||||
allocationInPercentage: portfolioPosition.value / totalValue,
|
||||
allocationInPercentage:
|
||||
portfolioPosition.valueInBaseCurrency / totalValue,
|
||||
countries: hasDetails ? portfolioPosition.countries : [],
|
||||
currency: hasDetails ? portfolioPosition.currency : undefined,
|
||||
dataSource: portfolioPosition.dataSource,
|
||||
@ -444,7 +462,7 @@ export class PortfolioController {
|
||||
sectors: hasDetails ? portfolioPosition.sectors : [],
|
||||
symbol: portfolioPosition.symbol,
|
||||
url: portfolioPosition.url,
|
||||
valueInPercentage: portfolioPosition.value / totalValue
|
||||
valueInPercentage: portfolioPosition.valueInBaseCurrency / totalValue
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -2,6 +2,7 @@ import { AccessModule } from '@ghostfolio/api/app/access/access.module';
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
|
||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||
@ -36,6 +37,7 @@ import { RulesService } from './rules.service';
|
||||
UserModule
|
||||
],
|
||||
providers: [
|
||||
AccountBalanceService,
|
||||
AccountService,
|
||||
CurrentRateService,
|
||||
PortfolioService,
|
||||
|
@ -28,6 +28,7 @@ import {
|
||||
Filter,
|
||||
HistoricalDataItem,
|
||||
PortfolioDetails,
|
||||
PortfolioInvestments,
|
||||
PortfolioPerformanceResponse,
|
||||
PortfolioPosition,
|
||||
PortfolioReport,
|
||||
@ -41,7 +42,6 @@ import type {
|
||||
AccountWithValue,
|
||||
DateRange,
|
||||
GroupBy,
|
||||
Market,
|
||||
OrderWithAccount,
|
||||
RequestWithUser,
|
||||
UserWithSettings
|
||||
@ -83,8 +83,10 @@ import {
|
||||
import { PortfolioCalculator } from './portfolio-calculator';
|
||||
import { RulesService } from './rules.service';
|
||||
|
||||
const asiaPacificMarkets = require('../../assets/countries/asia-pacific-markets.json');
|
||||
const developedMarkets = require('../../assets/countries/developed-markets.json');
|
||||
const emergingMarkets = require('../../assets/countries/emerging-markets.json');
|
||||
const europeMarkets = require('../../assets/countries/europe-markets.json');
|
||||
|
||||
@Injectable()
|
||||
export class PortfolioService {
|
||||
@ -252,13 +254,15 @@ export class PortfolioService {
|
||||
dateRange,
|
||||
filters,
|
||||
groupBy,
|
||||
impersonationId
|
||||
impersonationId,
|
||||
savingsRate
|
||||
}: {
|
||||
dateRange: DateRange;
|
||||
filters?: Filter[];
|
||||
groupBy?: GroupBy;
|
||||
impersonationId: string;
|
||||
}): Promise<InvestmentItem[]> {
|
||||
savingsRate: number;
|
||||
}): Promise<PortfolioInvestments> {
|
||||
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||
|
||||
const { portfolioOrders, transactionPoints } =
|
||||
@ -276,7 +280,10 @@ export class PortfolioService {
|
||||
|
||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||
if (transactionPoints.length === 0) {
|
||||
return [];
|
||||
return {
|
||||
investments: [],
|
||||
streaks: { currentStreak: 0, longestStreak: 0 }
|
||||
};
|
||||
}
|
||||
|
||||
let investments: InvestmentItem[];
|
||||
@ -346,9 +353,23 @@ export class PortfolioService {
|
||||
parseDate(investments[0]?.date)
|
||||
);
|
||||
|
||||
return investments.filter(({ date }) => {
|
||||
investments = investments.filter(({ date }) => {
|
||||
return !isBefore(parseDate(date), startDate);
|
||||
});
|
||||
|
||||
let streaks: PortfolioInvestments['streaks'];
|
||||
|
||||
if (savingsRate) {
|
||||
streaks = this.getStreaks({
|
||||
investments,
|
||||
savingsRate: groupBy === 'year' ? 12 * savingsRate : savingsRate
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
investments,
|
||||
streaks
|
||||
};
|
||||
}
|
||||
|
||||
public async getChart({
|
||||
@ -484,15 +505,17 @@ export class PortfolioService {
|
||||
);
|
||||
}
|
||||
|
||||
const dataGatheringItems = currentPositions.positions.map((position) => {
|
||||
return {
|
||||
dataSource: position.dataSource,
|
||||
symbol: position.symbol
|
||||
};
|
||||
});
|
||||
const dataGatheringItems = currentPositions.positions.map(
|
||||
({ dataSource, symbol }) => {
|
||||
return {
|
||||
dataSource,
|
||||
symbol
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const [dataProviderResponses, symbolProfiles] = await Promise.all([
|
||||
this.dataProviderService.getQuotes(dataGatheringItems),
|
||||
this.dataProviderService.getQuotes({ items: dataGatheringItems }),
|
||||
this.symbolProfileService.getSymbolProfiles(dataGatheringItems)
|
||||
]);
|
||||
|
||||
@ -516,30 +539,79 @@ export class PortfolioService {
|
||||
const symbolProfile = symbolProfileMap[item.symbol];
|
||||
const dataProviderResponse = dataProviderResponses[item.symbol];
|
||||
|
||||
const markets: { [key in Market]: number } = {
|
||||
const markets: PortfolioPosition['markets'] = {
|
||||
[UNKNOWN_KEY]: 0,
|
||||
developedMarkets: 0,
|
||||
emergingMarkets: 0,
|
||||
otherMarkets: 0
|
||||
};
|
||||
const marketsAdvanced: PortfolioPosition['marketsAdvanced'] = {
|
||||
[UNKNOWN_KEY]: 0,
|
||||
asiaPacific: 0,
|
||||
emergingMarkets: 0,
|
||||
europe: 0,
|
||||
japan: 0,
|
||||
northAmerica: 0,
|
||||
otherMarkets: 0
|
||||
};
|
||||
|
||||
for (const country of symbolProfile.countries) {
|
||||
if (developedMarkets.includes(country.code)) {
|
||||
markets.developedMarkets = new Big(markets.developedMarkets)
|
||||
.plus(country.weight)
|
||||
.toNumber();
|
||||
} else if (emergingMarkets.includes(country.code)) {
|
||||
markets.emergingMarkets = new Big(markets.emergingMarkets)
|
||||
.plus(country.weight)
|
||||
.toNumber();
|
||||
} else {
|
||||
markets.otherMarkets = new Big(markets.otherMarkets)
|
||||
.plus(country.weight)
|
||||
.toNumber();
|
||||
if (symbolProfile.countries.length > 0) {
|
||||
for (const country of symbolProfile.countries) {
|
||||
if (developedMarkets.includes(country.code)) {
|
||||
markets.developedMarkets = new Big(markets.developedMarkets)
|
||||
.plus(country.weight)
|
||||
.toNumber();
|
||||
} else if (emergingMarkets.includes(country.code)) {
|
||||
markets.emergingMarkets = new Big(markets.emergingMarkets)
|
||||
.plus(country.weight)
|
||||
.toNumber();
|
||||
} else {
|
||||
markets.otherMarkets = new Big(markets.otherMarkets)
|
||||
.plus(country.weight)
|
||||
.toNumber();
|
||||
}
|
||||
|
||||
if (country.code === 'JP') {
|
||||
marketsAdvanced.japan = new Big(marketsAdvanced.japan)
|
||||
.plus(country.weight)
|
||||
.toNumber();
|
||||
} else if (country.code === 'CA' || country.code === 'US') {
|
||||
marketsAdvanced.northAmerica = new Big(marketsAdvanced.northAmerica)
|
||||
.plus(country.weight)
|
||||
.toNumber();
|
||||
} else if (asiaPacificMarkets.includes(country.code)) {
|
||||
marketsAdvanced.asiaPacific = new Big(marketsAdvanced.asiaPacific)
|
||||
.plus(country.weight)
|
||||
.toNumber();
|
||||
} else if (emergingMarkets.includes(country.code)) {
|
||||
marketsAdvanced.emergingMarkets = new Big(
|
||||
marketsAdvanced.emergingMarkets
|
||||
)
|
||||
.plus(country.weight)
|
||||
.toNumber();
|
||||
} else if (europeMarkets.includes(country.code)) {
|
||||
marketsAdvanced.europe = new Big(marketsAdvanced.europe)
|
||||
.plus(country.weight)
|
||||
.toNumber();
|
||||
} else {
|
||||
marketsAdvanced.otherMarkets = new Big(marketsAdvanced.otherMarkets)
|
||||
.plus(country.weight)
|
||||
.toNumber();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
markets[UNKNOWN_KEY] = new Big(markets[UNKNOWN_KEY])
|
||||
.plus(value)
|
||||
.toNumber();
|
||||
|
||||
marketsAdvanced[UNKNOWN_KEY] = new Big(marketsAdvanced[UNKNOWN_KEY])
|
||||
.plus(value)
|
||||
.toNumber();
|
||||
}
|
||||
|
||||
holdings[item.symbol] = {
|
||||
markets,
|
||||
marketsAdvanced,
|
||||
allocationInPercentage: filteredValueInBaseCurrency.eq(0)
|
||||
? 0
|
||||
: value.div(filteredValueInBaseCurrency).toNumber(),
|
||||
@ -561,9 +633,10 @@ export class PortfolioService {
|
||||
quantity: item.quantity.toNumber(),
|
||||
sectors: symbolProfile.sectors,
|
||||
symbol: item.symbol,
|
||||
tags: item.tags,
|
||||
transactionCount: item.transactionCount,
|
||||
url: symbolProfile.url,
|
||||
value: value.toNumber()
|
||||
valueInBaseCurrency: value.toNumber()
|
||||
};
|
||||
}
|
||||
|
||||
@ -606,7 +679,7 @@ export class PortfolioService {
|
||||
const emergencyFundInCash = emergencyFund
|
||||
.minus(
|
||||
this.getEmergencyFundPositionsValueInBaseCurrency({
|
||||
activities: orders
|
||||
holdings
|
||||
})
|
||||
)
|
||||
.toNumber();
|
||||
@ -623,7 +696,7 @@ export class PortfolioService {
|
||||
holdings[userCurrency] = {
|
||||
...emergencyFundCashPositions[userCurrency],
|
||||
investment: emergencyFundInCash,
|
||||
value: emergencyFundInCash
|
||||
valueInBaseCurrency: emergencyFundInCash
|
||||
};
|
||||
}
|
||||
|
||||
@ -634,7 +707,7 @@ export class PortfolioService {
|
||||
balanceInBaseCurrency: cashDetails.balanceInBaseCurrency,
|
||||
emergencyFundPositionsValueInBaseCurrency:
|
||||
this.getEmergencyFundPositionsValueInBaseCurrency({
|
||||
activities: orders
|
||||
holdings
|
||||
})
|
||||
});
|
||||
|
||||
@ -720,6 +793,7 @@ export class PortfolioService {
|
||||
name: order.SymbolProfile?.name,
|
||||
quantity: new Big(order.quantity),
|
||||
symbol: order.SymbolProfile.symbol,
|
||||
tags: order.tags,
|
||||
type: order.type,
|
||||
unitPrice: new Big(order.unitPrice)
|
||||
}));
|
||||
@ -877,9 +951,9 @@ export class PortfolioService {
|
||||
)
|
||||
};
|
||||
} else {
|
||||
const currentData = await this.dataProviderService.getQuotes([
|
||||
{ dataSource: DataSource.YAHOO, symbol: aSymbol }
|
||||
]);
|
||||
const currentData = await this.dataProviderService.getQuotes({
|
||||
items: [{ dataSource: DataSource.YAHOO, symbol: aSymbol }]
|
||||
});
|
||||
const marketPrice = currentData[aSymbol]?.marketPrice;
|
||||
|
||||
let historicalData = await this.dataProviderService.getHistorical(
|
||||
@ -980,15 +1054,15 @@ export class PortfolioService {
|
||||
(item) => !item.quantity.eq(0)
|
||||
);
|
||||
|
||||
const dataGatheringItem = positions.map((position) => {
|
||||
const dataGatheringItems = positions.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
dataSource: position.dataSource,
|
||||
symbol: position.symbol
|
||||
dataSource,
|
||||
symbol
|
||||
};
|
||||
});
|
||||
|
||||
const [dataProviderResponses, symbolProfiles] = await Promise.all([
|
||||
this.dataProviderService.getQuotes(dataGatheringItem),
|
||||
this.dataProviderService.getQuotes({ items: dataGatheringItems }),
|
||||
this.symbolProfileService.getSymbolProfiles(
|
||||
positions.map(({ dataSource, symbol }) => {
|
||||
return { dataSource, symbol };
|
||||
@ -1256,7 +1330,7 @@ export class PortfolioService {
|
||||
|
||||
if (cashPositions[account.currency]) {
|
||||
cashPositions[account.currency].investment += convertedBalance;
|
||||
cashPositions[account.currency].value += convertedBalance;
|
||||
cashPositions[account.currency].valueInBaseCurrency += convertedBalance;
|
||||
} else {
|
||||
cashPositions[account.currency] = this.getInitialCashPosition({
|
||||
balance: convertedBalance,
|
||||
@ -1268,7 +1342,9 @@ export class PortfolioService {
|
||||
for (const symbol of Object.keys(cashPositions)) {
|
||||
// Calculate allocations for each currency
|
||||
cashPositions[symbol].allocationInPercentage = value.gt(0)
|
||||
? new Big(cashPositions[symbol].value).div(value).toNumber()
|
||||
? new Big(cashPositions[symbol].valueInBaseCurrency)
|
||||
.div(value)
|
||||
.toNumber()
|
||||
: 0;
|
||||
}
|
||||
|
||||
@ -1282,12 +1358,11 @@ export class PortfolioService {
|
||||
}: {
|
||||
activities: OrderWithAccount[];
|
||||
date?: Date;
|
||||
|
||||
userCurrency: string;
|
||||
}) {
|
||||
return activities
|
||||
.filter((activity) => {
|
||||
// Filter out all activities before given date and type dividend
|
||||
// Filter out all activities before given date (drafts) and type dividend
|
||||
return (
|
||||
isBefore(date, new Date(activity.date)) &&
|
||||
activity.type === TypeOfOrder.DIVIDEND
|
||||
@ -1369,13 +1444,13 @@ export class PortfolioService {
|
||||
}
|
||||
|
||||
private getEmergencyFundPositionsValueInBaseCurrency({
|
||||
activities
|
||||
holdings
|
||||
}: {
|
||||
activities: Activity[];
|
||||
holdings: PortfolioDetails['holdings'];
|
||||
}) {
|
||||
const emergencyFundOrders = activities.filter((activity) => {
|
||||
const emergencyFundHoldings = Object.values(holdings).filter(({ tags }) => {
|
||||
return (
|
||||
activity.tags?.some(({ id }) => {
|
||||
tags?.some(({ id }) => {
|
||||
return id === EMERGENCY_FUND_TAG_ID;
|
||||
}) ?? false
|
||||
);
|
||||
@ -1383,18 +1458,9 @@ export class PortfolioService {
|
||||
|
||||
let valueInBaseCurrencyOfEmergencyFundPositions = new Big(0);
|
||||
|
||||
for (const order of emergencyFundOrders) {
|
||||
if (order.type === 'BUY') {
|
||||
valueInBaseCurrencyOfEmergencyFundPositions =
|
||||
valueInBaseCurrencyOfEmergencyFundPositions.plus(
|
||||
order.valueInBaseCurrency
|
||||
);
|
||||
} else if (order.type === 'SELL') {
|
||||
valueInBaseCurrencyOfEmergencyFundPositions =
|
||||
valueInBaseCurrencyOfEmergencyFundPositions.minus(
|
||||
order.valueInBaseCurrency
|
||||
);
|
||||
}
|
||||
for (const { valueInBaseCurrency } of emergencyFundHoldings) {
|
||||
valueInBaseCurrencyOfEmergencyFundPositions =
|
||||
valueInBaseCurrencyOfEmergencyFundPositions.plus(valueInBaseCurrency);
|
||||
}
|
||||
|
||||
return valueInBaseCurrencyOfEmergencyFundPositions.toNumber();
|
||||
@ -1411,7 +1477,7 @@ export class PortfolioService {
|
||||
}) {
|
||||
return activities
|
||||
.filter((activity) => {
|
||||
// Filter out all activities before given date
|
||||
// Filter out all activities before given date (drafts)
|
||||
return isBefore(date, new Date(activity.date));
|
||||
})
|
||||
.map(({ fee, SymbolProfile }) => {
|
||||
@ -1453,24 +1519,25 @@ export class PortfolioService {
|
||||
quantity: 0,
|
||||
sectors: [],
|
||||
symbol: currency,
|
||||
tags: [],
|
||||
transactionCount: 0,
|
||||
value: balance
|
||||
valueInBaseCurrency: balance
|
||||
};
|
||||
}
|
||||
|
||||
private getItems(orders: OrderWithAccount[], date = new Date(0)) {
|
||||
return orders
|
||||
.filter((order) => {
|
||||
// Filter out all orders before given date and type item
|
||||
private getItems(activities: OrderWithAccount[], date = new Date(0)) {
|
||||
return activities
|
||||
.filter((activity) => {
|
||||
// Filter out all activities before given date (drafts) and type item
|
||||
return (
|
||||
isBefore(date, new Date(order.date)) &&
|
||||
order.type === TypeOfOrder.ITEM
|
||||
isBefore(date, new Date(activity.date)) &&
|
||||
activity.type === TypeOfOrder.ITEM
|
||||
);
|
||||
})
|
||||
.map((order) => {
|
||||
.map(({ quantity, SymbolProfile, unitPrice }) => {
|
||||
return this.exchangeRateDataService.toCurrency(
|
||||
new Big(order.quantity).mul(order.unitPrice).toNumber(),
|
||||
order.SymbolProfile.currency,
|
||||
new Big(quantity).mul(unitPrice).toNumber(),
|
||||
SymbolProfile.currency,
|
||||
this.request.user.Settings.settings.baseCurrency
|
||||
);
|
||||
})
|
||||
@ -1480,6 +1547,30 @@ export class PortfolioService {
|
||||
);
|
||||
}
|
||||
|
||||
private getLiabilities({
|
||||
activities,
|
||||
userCurrency
|
||||
}: {
|
||||
activities: OrderWithAccount[];
|
||||
userCurrency: string;
|
||||
}) {
|
||||
return activities
|
||||
.filter(({ type }) => {
|
||||
return type === TypeOfOrder.LIABILITY;
|
||||
})
|
||||
.map(({ quantity, SymbolProfile, unitPrice }) => {
|
||||
return this.exchangeRateDataService.toCurrency(
|
||||
new Big(quantity).mul(unitPrice).toNumber(),
|
||||
SymbolProfile.currency,
|
||||
userCurrency
|
||||
);
|
||||
})
|
||||
.reduce(
|
||||
(previous, current) => new Big(previous).plus(current),
|
||||
new Big(0)
|
||||
);
|
||||
}
|
||||
|
||||
private getStartDate(aDateRange: DateRange, portfolioStart: Date) {
|
||||
switch (aDateRange) {
|
||||
case '1d':
|
||||
@ -1510,6 +1601,28 @@ export class PortfolioService {
|
||||
return portfolioStart;
|
||||
}
|
||||
|
||||
private getStreaks({
|
||||
investments,
|
||||
savingsRate
|
||||
}: {
|
||||
investments: InvestmentItem[];
|
||||
savingsRate: number;
|
||||
}) {
|
||||
let currentStreak = 0;
|
||||
let longestStreak = 0;
|
||||
|
||||
for (const { investment } of investments) {
|
||||
if (investment >= savingsRate) {
|
||||
currentStreak++;
|
||||
longestStreak = Math.max(longestStreak, currentStreak);
|
||||
} else {
|
||||
currentStreak = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return { currentStreak, longestStreak };
|
||||
}
|
||||
|
||||
private async getSummary({
|
||||
balanceInBaseCurrency,
|
||||
emergencyFundPositionsValueInBaseCurrency,
|
||||
@ -1559,6 +1672,10 @@ export class PortfolioService {
|
||||
const fees = this.getFees({ activities, userCurrency }).toNumber();
|
||||
const firstOrderDate = activities[0]?.date;
|
||||
const items = this.getItems(activities).toNumber();
|
||||
const liabilities = this.getLiabilities({
|
||||
activities,
|
||||
userCurrency
|
||||
}).toNumber();
|
||||
|
||||
const totalBuy = this.getTotalByType(activities, userCurrency, 'BUY');
|
||||
const totalSell = this.getTotalByType(activities, userCurrency, 'SELL');
|
||||
@ -1591,6 +1708,7 @@ export class PortfolioService {
|
||||
.plus(performanceInformation.performance.currentValue)
|
||||
.plus(items)
|
||||
.plus(excludedAccountsAndActivities)
|
||||
.minus(liabilities)
|
||||
.toNumber();
|
||||
|
||||
const daysInMarket = differenceInDays(new Date(), firstOrderDate);
|
||||
@ -1617,11 +1735,21 @@ export class PortfolioService {
|
||||
fees,
|
||||
firstOrderDate,
|
||||
items,
|
||||
liabilities,
|
||||
netWorth,
|
||||
totalBuy,
|
||||
totalSell,
|
||||
committedFunds: committedFunds.toNumber(),
|
||||
emergencyFund: emergencyFund.toNumber(),
|
||||
emergencyFund: {
|
||||
assets: emergencyFundPositionsValueInBaseCurrency,
|
||||
cash: emergencyFund
|
||||
.minus(emergencyFundPositionsValueInBaseCurrency)
|
||||
.toNumber(),
|
||||
total: emergencyFund.toNumber()
|
||||
},
|
||||
fireWealth: new Big(performanceInformation.performance.currentValue)
|
||||
.minus(emergencyFundPositionsValueInBaseCurrency)
|
||||
.toNumber(),
|
||||
ordersCount: activities.filter(({ type }) => {
|
||||
return type === 'BUY' || type === 'SELL';
|
||||
}).length
|
||||
@ -1673,6 +1801,7 @@ export class PortfolioService {
|
||||
name: order.SymbolProfile?.name,
|
||||
quantity: new Big(order.quantity),
|
||||
symbol: order.SymbolProfile.symbol,
|
||||
tags: order.tags,
|
||||
type: order.type,
|
||||
unitPrice: new Big(
|
||||
this.exchangeRateDataService.toCurrency(
|
||||
@ -1713,12 +1842,12 @@ export class PortfolioService {
|
||||
userId: string;
|
||||
withExcludedAccounts?: boolean;
|
||||
}) {
|
||||
const ordersOfTypeItem = await this.orderService.getOrders({
|
||||
const ordersOfTypeItemOrLiability = await this.orderService.getOrders({
|
||||
filters,
|
||||
userCurrency,
|
||||
userId,
|
||||
withExcludedAccounts,
|
||||
types: ['ITEM']
|
||||
types: ['ITEM', 'LIABILITY']
|
||||
});
|
||||
|
||||
const accounts: PortfolioDetails['accounts'] = {};
|
||||
@ -1758,13 +1887,14 @@ export class PortfolioService {
|
||||
return accountId === account.id;
|
||||
});
|
||||
|
||||
const ordersOfTypeItemByAccount = ordersOfTypeItem.filter(
|
||||
({ accountId }) => {
|
||||
const ordersOfTypeItemOrLiabilityByAccount =
|
||||
ordersOfTypeItemOrLiability.filter(({ accountId }) => {
|
||||
return accountId === account.id;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
ordersByAccount = ordersByAccount.concat(ordersOfTypeItemByAccount);
|
||||
ordersByAccount = ordersByAccount.concat(
|
||||
ordersOfTypeItemOrLiabilityByAccount
|
||||
);
|
||||
|
||||
accounts[account.id] = {
|
||||
balance: account.balance,
|
||||
@ -1804,7 +1934,7 @@ export class PortfolioService {
|
||||
order.unitPrice ??
|
||||
0);
|
||||
|
||||
if (order.type === 'SELL') {
|
||||
if (order.type === 'LIABILITY' || order.type === 'SELL') {
|
||||
currentValueOfSymbolInBaseCurrency *= -1;
|
||||
}
|
||||
|
||||
@ -1841,13 +1971,6 @@ export class PortfolioService {
|
||||
return { accounts, platforms };
|
||||
}
|
||||
|
||||
private async getUserId(aImpersonationId: string, aUserId: string) {
|
||||
const impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(aImpersonationId);
|
||||
|
||||
return impersonationUserId || aUserId;
|
||||
}
|
||||
|
||||
private getTotalByType(
|
||||
orders: OrderWithAccount[],
|
||||
currency: string,
|
||||
@ -1874,4 +1997,11 @@ export class PortfolioService {
|
||||
this.baseCurrency
|
||||
);
|
||||
}
|
||||
|
||||
private async getUserId(aImpersonationId: string, aUserId: string) {
|
||||
const impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(aImpersonationId);
|
||||
|
||||
return impersonationUserId || aUserId;
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,7 @@
|
||||
import { Cache } from 'cache-manager';
|
||||
|
||||
import type { RedisStore } from './redis-store.interface';
|
||||
|
||||
export interface RedisCache extends Cache {
|
||||
store: RedisStore;
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
import { Store } from 'cache-manager';
|
||||
import { RedisClient } from 'redis';
|
||||
|
||||
export interface RedisStore extends Store {
|
||||
getClient: () => RedisClient;
|
||||
isCacheableValue: (value: any) => boolean;
|
||||
name: 'redis';
|
||||
}
|
@ -1,18 +1,30 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { CACHE_MANAGER, Inject, Injectable } from '@nestjs/common';
|
||||
import { Cache } from 'cache-manager';
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import { CACHE_MANAGER, Inject, Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import type { RedisCache } from './interfaces/redis-cache.interface';
|
||||
|
||||
@Injectable()
|
||||
export class RedisCacheService {
|
||||
public constructor(
|
||||
@Inject(CACHE_MANAGER) private readonly cache: Cache,
|
||||
@Inject(CACHE_MANAGER) private readonly cache: RedisCache,
|
||||
private readonly configurationService: ConfigurationService
|
||||
) {}
|
||||
) {
|
||||
const client = cache.store.getClient();
|
||||
|
||||
client.on('error', (error) => {
|
||||
Logger.error(error, 'RedisCacheService');
|
||||
});
|
||||
}
|
||||
|
||||
public async get(key: string): Promise<string> {
|
||||
return await this.cache.get(key);
|
||||
}
|
||||
|
||||
public getQuoteKey({ dataSource, symbol }: UniqueAsset) {
|
||||
return `quote-${dataSource}-${symbol}`;
|
||||
}
|
||||
|
||||
public async remove(key: string) {
|
||||
await this.cache.del(key);
|
||||
}
|
||||
|
@ -1,10 +1,6 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import {
|
||||
DEFAULT_LANGUAGE_CODE,
|
||||
PROPERTY_STRIPE_CONFIG
|
||||
} from '@ghostfolio/common/config';
|
||||
import { Subscription as SubscriptionInterface } from '@ghostfolio/common/interfaces/subscription.interface';
|
||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||
import { UserWithSettings } from '@ghostfolio/common/types';
|
||||
import { SubscriptionType } from '@ghostfolio/common/types/subscription-type.type';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
@ -101,19 +97,8 @@ export class SubscriptionService {
|
||||
aCheckoutSessionId
|
||||
);
|
||||
|
||||
let subscriptions: SubscriptionInterface[] = [];
|
||||
|
||||
const stripeConfig = (await this.prismaService.property.findUnique({
|
||||
where: { key: PROPERTY_STRIPE_CONFIG }
|
||||
})) ?? { value: '{}' };
|
||||
|
||||
subscriptions = [JSON.parse(stripeConfig.value)];
|
||||
|
||||
const coupon = subscriptions[0]?.coupon ?? 0;
|
||||
const price = subscriptions[0]?.price ?? 0;
|
||||
|
||||
await this.createSubscription({
|
||||
price: price - coupon,
|
||||
price: session.amount_total / 100,
|
||||
userId: session.client_reference_id
|
||||
});
|
||||
|
||||
|
@ -36,10 +36,12 @@ export class SymbolController {
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async lookupSymbol(
|
||||
@Query() { query = '' }
|
||||
@Query('includeIndices') includeIndices: boolean = false,
|
||||
@Query('query') query = ''
|
||||
): Promise<{ items: LookupItem[] }> {
|
||||
try {
|
||||
return this.symbolService.lookup({
|
||||
includeIndices,
|
||||
query: query.toLowerCase(),
|
||||
user: this.request.user
|
||||
});
|
||||
@ -60,7 +62,7 @@ export class SymbolController {
|
||||
public async getSymbolData(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string,
|
||||
@Query('includeHistoricalData') includeHistoricalData?: number
|
||||
@Query('includeHistoricalData') includeHistoricalData = 0
|
||||
): Promise<SymbolItem> {
|
||||
if (!DataSource[dataSource]) {
|
||||
throw new HttpException(
|
||||
|
@ -27,9 +27,9 @@ export class SymbolService {
|
||||
dataGatheringItem: IDataGatheringItem;
|
||||
includeHistoricalData?: number;
|
||||
}): Promise<SymbolItem> {
|
||||
const quotes = await this.dataProviderService.getQuotes([
|
||||
dataGatheringItem
|
||||
]);
|
||||
const quotes = await this.dataProviderService.getQuotes({
|
||||
items: [dataGatheringItem]
|
||||
});
|
||||
const { currency, marketPrice } = quotes[dataGatheringItem.symbol] ?? {};
|
||||
|
||||
if (dataGatheringItem.dataSource && marketPrice >= 0) {
|
||||
@ -81,9 +81,11 @@ export class SymbolService {
|
||||
}
|
||||
|
||||
public async lookup({
|
||||
includeIndices = false,
|
||||
query,
|
||||
user
|
||||
}: {
|
||||
includeIndices?: boolean;
|
||||
query: string;
|
||||
user: UserWithSettings;
|
||||
}): Promise<{ items: LookupItem[] }> {
|
||||
@ -95,6 +97,7 @@ export class SymbolService {
|
||||
|
||||
try {
|
||||
const { items } = await this.dataProviderService.search({
|
||||
includeIndices,
|
||||
query,
|
||||
user
|
||||
});
|
||||
|
@ -14,6 +14,7 @@ import {
|
||||
import { UserWithSettings } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma, Role, User } from '@prisma/client';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { sortBy } from 'lodash';
|
||||
|
||||
const crypto = require('crypto');
|
||||
@ -123,7 +124,7 @@ export class UserService {
|
||||
id,
|
||||
provider,
|
||||
role,
|
||||
Settings,
|
||||
Settings: Settings as UserWithSettings['Settings'],
|
||||
thirdPartyId,
|
||||
updatedAt,
|
||||
activityCount: Analytics?.activityCount
|
||||
@ -165,11 +166,26 @@ export class UserService {
|
||||
user.subscription =
|
||||
this.subscriptionService.getSubscription(Subscription);
|
||||
|
||||
if (
|
||||
Analytics?.activityCount % 20 === 0 &&
|
||||
user.subscription?.type === 'Basic'
|
||||
) {
|
||||
currentPermissions.push(permissions.enableSubscriptionInterstitial);
|
||||
if (user.subscription?.type === 'Basic') {
|
||||
const daysSinceRegistration = differenceInDays(
|
||||
new Date(),
|
||||
user.createdAt
|
||||
);
|
||||
let frequency = 20;
|
||||
|
||||
if (daysSinceRegistration > 180) {
|
||||
frequency = 3;
|
||||
} else if (daysSinceRegistration > 60) {
|
||||
frequency = 5;
|
||||
} else if (daysSinceRegistration > 30) {
|
||||
frequency = 10;
|
||||
} else if (daysSinceRegistration > 15) {
|
||||
frequency = 15;
|
||||
}
|
||||
|
||||
if (Analytics?.activityCount % frequency === 1) {
|
||||
currentPermissions.push(permissions.enableSubscriptionInterstitial);
|
||||
}
|
||||
}
|
||||
|
||||
if (user.subscription?.type === 'Premium') {
|
||||
@ -304,21 +320,29 @@ export class UserService {
|
||||
}
|
||||
|
||||
public async deleteUser(where: Prisma.UserWhereUniqueInput): Promise<User> {
|
||||
await this.prismaService.access.deleteMany({
|
||||
where: { OR: [{ granteeUserId: where.id }, { userId: where.id }] }
|
||||
});
|
||||
try {
|
||||
await this.prismaService.access.deleteMany({
|
||||
where: { OR: [{ granteeUserId: where.id }, { userId: where.id }] }
|
||||
});
|
||||
} catch {}
|
||||
|
||||
await this.prismaService.account.deleteMany({
|
||||
where: { userId: where.id }
|
||||
});
|
||||
try {
|
||||
await this.prismaService.account.deleteMany({
|
||||
where: { userId: where.id }
|
||||
});
|
||||
} catch {}
|
||||
|
||||
await this.prismaService.analytics.delete({
|
||||
where: { userId: where.id }
|
||||
});
|
||||
try {
|
||||
await this.prismaService.analytics.delete({
|
||||
where: { userId: where.id }
|
||||
});
|
||||
} catch {}
|
||||
|
||||
await this.prismaService.order.deleteMany({
|
||||
where: { userId: where.id }
|
||||
});
|
||||
try {
|
||||
await this.prismaService.order.deleteMany({
|
||||
where: { userId: where.id }
|
||||
});
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
await this.prismaService.settings.delete({
|
||||
|
1
apps/api/src/assets/countries/asia-pacific-markets.json
Normal file
1
apps/api/src/assets/countries/asia-pacific-markets.json
Normal file
@ -0,0 +1 @@
|
||||
["AU", "HK", "NZ", "SG"]
|
19
apps/api/src/assets/countries/europe-markets.json
Normal file
19
apps/api/src/assets/countries/europe-markets.json
Normal file
@ -0,0 +1,19 @@
|
||||
[
|
||||
"AT",
|
||||
"BE",
|
||||
"CH",
|
||||
"DE",
|
||||
"DK",
|
||||
"ES",
|
||||
"FI",
|
||||
"FR",
|
||||
"GB",
|
||||
"IE",
|
||||
"IL",
|
||||
"IT",
|
||||
"LU",
|
||||
"NL",
|
||||
"NO",
|
||||
"PT",
|
||||
"SE"
|
||||
]
|
File diff suppressed because it is too large
Load Diff
519
apps/api/src/assets/sitemap.xml
Normal file
519
apps/api/src/assets/sitemap.xml
Normal file
@ -0,0 +1,519 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset
|
||||
xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
|
||||
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/blog</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/blog/2021/07/hallo-ghostfolio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/features</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/haeufig-gestellte-fragen</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/maerkte</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/open</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/preise</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/registrierung</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ueber-uns</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ueber-uns/changelog</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ueber-uns/datenschutzbestimmungen</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ueber-uns/lizenz</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/about</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/about/changelog</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/about/license</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2021/07/hello-ghostfolio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2022/01/ghostfolio-first-months-in-open-source</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2022/07/ghostfolio-meets-internet-identity</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2022/07/how-do-i-get-my-finances-in-order</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2022/08/500-stars-on-github</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2022/10/hacktoberfest-2022</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2022/11/black-friday-2022</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2022/12/the-importance-of-tracking-your-personal-finances</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2023/02/ghostfolio-meets-umbrel</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2023/03/ghostfolio-reaches-1000-stars-on-github</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2023/05/unlock-your-financial-potential-with-ghostfolio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2023/07/exploring-the-path-to-fire</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/faq</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/features</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/markets</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/open</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/pricing</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/register</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools</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-copilot-money</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-delta</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-divvydiary</loc>
|
||||
<lastmod>${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-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-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-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-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-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-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-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-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-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-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-yeekatee</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/es</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/es/funcionalidades</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/es/mercados</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/es/open</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/es/precios</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/es/preguntas-mas-frecuentes</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/es/recursos</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/es/registro</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/es/sobre</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/es/sobre/changelog</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/es/sobre/licencia</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/es/sobre/politica-de-privacidad</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/fr</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/fr/a-propos</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/fr/a-propos/changelog</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/fr/a-propos/licence</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/fr/a-propos/politique-de-confidentialite</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/fr/enregistrement</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/fr/fonctionnalites</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/fr/foire-aux-questions</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/fr/marches</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/fr/open</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/fr/prix</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/fr/ressources</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/domande-piu-frequenti</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/funzionalita</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/informazioni-su</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/informazioni-su/changelog</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/informazioni-su/licenza</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/informazioni-su/informativa-sulla-privacy</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/iscrizione</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/mercati</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/open</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/prezzi</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/kenmerken</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/markten</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/open</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/over</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/over/changelog</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/over/licentie</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/over/privacybeleid</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/prijzen</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/registratie</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/vaak-gestelde-vragen</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/blog</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/funcionalidades</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/mercados</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/open</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/perguntas-mais-frequentes</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/precos</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/recursos</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/registo</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/sobre</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/sobre/changelog</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/sobre/licenca</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/sobre/politica-de-privacidade</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
</urlset>
|
@ -16,9 +16,11 @@ export function hasNotDefinedValuesInObject(aObject: Object): boolean {
|
||||
export function nullifyValuesInObject<T>(aObject: T, keys: string[]): T {
|
||||
const object = cloneDeep(aObject);
|
||||
|
||||
keys.forEach((key) => {
|
||||
object[key] = null;
|
||||
});
|
||||
if (object) {
|
||||
keys.forEach((key) => {
|
||||
object[key] = null;
|
||||
});
|
||||
}
|
||||
|
||||
return object;
|
||||
}
|
||||
|
@ -1,7 +1,9 @@
|
||||
import { Logger, ValidationPipe, VersioningType } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import * as bodyParser from 'body-parser';
|
||||
import helmet from 'helmet';
|
||||
|
||||
import { AppModule } from './app/app.module';
|
||||
import { environment } from './environments/environment';
|
||||
@ -10,11 +12,12 @@ async function bootstrap() {
|
||||
const configApp = await NestFactory.create(AppModule);
|
||||
const configService = configApp.get<ConfigService>(ConfigService);
|
||||
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
logger: environment.production
|
||||
? ['error', 'log', 'warn']
|
||||
: ['debug', 'error', 'log', 'verbose', 'warn']
|
||||
});
|
||||
|
||||
app.enableCors();
|
||||
app.enableVersioning({
|
||||
defaultVersion: '1',
|
||||
@ -32,6 +35,22 @@ async function bootstrap() {
|
||||
// Support 10mb csv/json files for importing activities
|
||||
app.use(bodyParser.json({ limit: '10mb' }));
|
||||
|
||||
if (configService.get<string>('ENABLE_FEATURE_SUBSCRIPTION') === 'true') {
|
||||
app.use(
|
||||
helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
frameSrc: ["'self'", 'https://js.stripe.com'], // Allow loading frames from Stripe
|
||||
scriptSrc: ["'self'", "'unsafe-inline'", 'https://js.stripe.com'], // Allow inline scripts and scripts from Stripe
|
||||
scriptSrcAttr: ["'self'", "'unsafe-inline'"], // Allow inline event handlers
|
||||
styleSrc: ["'self'", "'unsafe-inline'"] // Allow inline styles
|
||||
}
|
||||
},
|
||||
crossOriginOpenerPolicy: false // Disable Cross-Origin-Opener-Policy header (for Internet Identity)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const BASE_CURRENCY = configService.get<string>('BASE_CURRENCY');
|
||||
const HOST = configService.get<string>('HOST') || '0.0.0.0';
|
||||
const PORT = configService.get<number>('PORT') || 3333;
|
||||
|
@ -0,0 +1,10 @@
|
||||
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
@Module({
|
||||
exports: [AccountBalanceService],
|
||||
imports: [PrismaModule],
|
||||
providers: [AccountBalanceService]
|
||||
})
|
||||
export class AccountBalanceModule {}
|
@ -0,0 +1,16 @@
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AccountBalance, Prisma } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class AccountBalanceService {
|
||||
public constructor(private readonly prismaService: PrismaService) {}
|
||||
|
||||
public async createAccountBalance(
|
||||
data: Prisma.AccountBalanceCreateInput
|
||||
): Promise<AccountBalance> {
|
||||
return this.prismaService.accountBalance.create({
|
||||
data
|
||||
});
|
||||
}
|
||||
}
|
@ -16,6 +16,7 @@ export class ConfigurationService {
|
||||
default: 'USD'
|
||||
}),
|
||||
BETTER_UPTIME_API_KEY: str({ default: '' }),
|
||||
CACHE_QUOTES_TTL: num({ default: 1 }),
|
||||
CACHE_TTL: num({ default: 1 }),
|
||||
DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }),
|
||||
DATA_SOURCE_IMPORT: str({ default: DataSource.YAHOO }),
|
||||
|
@ -114,8 +114,14 @@ export class AlphaVantageService implements DataProviderInterface {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||
const result = await this.alphaVantage.data.search(aQuery);
|
||||
public async search({
|
||||
includeIndices = false,
|
||||
query
|
||||
}: {
|
||||
includeIndices?: boolean;
|
||||
query: string;
|
||||
}): Promise<{ items: LookupItem[] }> {
|
||||
const result = await this.alphaVantage.data.search(query);
|
||||
|
||||
return {
|
||||
items: result?.bestMatches?.map((bestMatch) => {
|
||||
|
@ -164,16 +164,17 @@ export class CoinGeckoService implements DataProviderInterface {
|
||||
return 'bitcoin';
|
||||
}
|
||||
|
||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||
public async search({
|
||||
includeIndices = false,
|
||||
query
|
||||
}: {
|
||||
includeIndices?: boolean;
|
||||
query: string;
|
||||
}): Promise<{ items: LookupItem[] }> {
|
||||
let items: LookupItem[] = [];
|
||||
|
||||
try {
|
||||
const get = bent(
|
||||
`${this.URL}/search?query=${aQuery}`,
|
||||
'GET',
|
||||
'json',
|
||||
200
|
||||
);
|
||||
const get = bent(`${this.URL}/search?query=${query}`, 'GET', 'json', 200);
|
||||
const { coins } = await get();
|
||||
|
||||
items = coins.map(({ id: symbol, name }) => {
|
||||
|
@ -135,6 +135,8 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
|
||||
let name = longName;
|
||||
|
||||
if (name) {
|
||||
name = name.replace('&', '&');
|
||||
|
||||
name = name.replace('Amundi Index Solutions - ', '');
|
||||
name = name.replace('iShares ETF (CH) - ', '');
|
||||
name = name.replace('iShares III Public Limited Company - ', '');
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
|
||||
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
||||
@ -26,6 +27,7 @@ import { DataProviderService } from './data-provider.service';
|
||||
MarketDataModule,
|
||||
PrismaModule,
|
||||
PropertyModule,
|
||||
RedisCacheModule,
|
||||
SymbolProfileModule
|
||||
],
|
||||
providers: [
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
||||
import {
|
||||
IDataGatheringItem,
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
@ -11,8 +11,8 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { PROPERTY_DATA_SOURCE_MAPPING } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, getStartOfUtcDate } from '@ghostfolio/common/helper';
|
||||
import { UserWithSettings } from '@ghostfolio/common/types';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import type { Granularity, UserWithSettings } from '@ghostfolio/common/types';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource, MarketData, SymbolProfile } from '@prisma/client';
|
||||
import { format, isValid } from 'date-fns';
|
||||
@ -28,7 +28,8 @@ export class DataProviderService {
|
||||
private readonly dataProviderInterfaces: DataProviderInterface[],
|
||||
private readonly marketDataService: MarketDataService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly propertyService: PropertyService
|
||||
private readonly propertyService: PropertyService,
|
||||
private readonly redisCacheService: RedisCacheService
|
||||
) {
|
||||
this.initialize();
|
||||
}
|
||||
@ -44,12 +45,15 @@ export class DataProviderService {
|
||||
const dataProvider = this.getDataProvider(dataSource);
|
||||
const symbol = dataProvider.getTestSymbol();
|
||||
|
||||
const quotes = await this.getQuotes([
|
||||
{
|
||||
dataSource,
|
||||
symbol
|
||||
}
|
||||
]);
|
||||
const quotes = await this.getQuotes({
|
||||
items: [
|
||||
{
|
||||
dataSource,
|
||||
symbol
|
||||
}
|
||||
],
|
||||
useCache: false
|
||||
});
|
||||
|
||||
if (quotes[symbol]?.marketPrice > 0) {
|
||||
return true;
|
||||
@ -58,14 +62,16 @@ export class DataProviderService {
|
||||
return false;
|
||||
}
|
||||
|
||||
public async getAssetProfiles(items: IDataGatheringItem[]): Promise<{
|
||||
public async getAssetProfiles(items: UniqueAsset[]): Promise<{
|
||||
[symbol: string]: Partial<SymbolProfile>;
|
||||
}> {
|
||||
const response: {
|
||||
[symbol: string]: Partial<SymbolProfile>;
|
||||
} = {};
|
||||
|
||||
const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource);
|
||||
const itemsGroupedByDataSource = groupBy(items, ({ dataSource }) => {
|
||||
return dataSource;
|
||||
});
|
||||
|
||||
const promises = [];
|
||||
|
||||
@ -126,7 +132,7 @@ export class DataProviderService {
|
||||
}
|
||||
|
||||
public async getHistorical(
|
||||
aItems: IDataGatheringItem[],
|
||||
aItems: UniqueAsset[],
|
||||
aGranularity: Granularity = 'month',
|
||||
from: Date,
|
||||
to: Date
|
||||
@ -154,11 +160,11 @@ export class DataProviderService {
|
||||
)}'`
|
||||
: '';
|
||||
|
||||
const dataSources = aItems.map((item) => {
|
||||
return item.dataSource;
|
||||
const dataSources = aItems.map(({ dataSource }) => {
|
||||
return dataSource;
|
||||
});
|
||||
const symbols = aItems.map((item) => {
|
||||
return item.symbol;
|
||||
const symbols = aItems.map(({ symbol }) => {
|
||||
return symbol;
|
||||
});
|
||||
|
||||
try {
|
||||
@ -191,7 +197,7 @@ export class DataProviderService {
|
||||
}
|
||||
|
||||
public async getHistoricalRaw(
|
||||
aDataGatheringItems: IDataGatheringItem[],
|
||||
aDataGatheringItems: UniqueAsset[],
|
||||
from: Date,
|
||||
to: Date
|
||||
): Promise<{
|
||||
@ -228,7 +234,13 @@ export class DataProviderService {
|
||||
return result;
|
||||
}
|
||||
|
||||
public async getQuotes(items: IDataGatheringItem[]): Promise<{
|
||||
public async getQuotes({
|
||||
items,
|
||||
useCache = true
|
||||
}: {
|
||||
items: UniqueAsset[];
|
||||
useCache?: boolean;
|
||||
}): Promise<{
|
||||
[symbol: string]: IDataProviderResponse;
|
||||
}> {
|
||||
const response: {
|
||||
@ -236,9 +248,44 @@ export class DataProviderService {
|
||||
} = {};
|
||||
const startTimeTotal = performance.now();
|
||||
|
||||
const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource);
|
||||
// Get items from cache
|
||||
const itemsToFetch: UniqueAsset[] = [];
|
||||
|
||||
const promises = [];
|
||||
for (const { dataSource, symbol } of items) {
|
||||
if (useCache) {
|
||||
const quoteString = await this.redisCacheService.get(
|
||||
this.redisCacheService.getQuoteKey({ dataSource, symbol })
|
||||
);
|
||||
|
||||
if (quoteString) {
|
||||
try {
|
||||
const cachedDataProviderResponse = JSON.parse(quoteString);
|
||||
response[symbol] = cachedDataProviderResponse;
|
||||
continue;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
itemsToFetch.push({ dataSource, symbol });
|
||||
}
|
||||
|
||||
const numberOfItemsInCache = Object.keys(response)?.length;
|
||||
|
||||
if (numberOfItemsInCache) {
|
||||
Logger.debug(
|
||||
`Fetched ${numberOfItemsInCache} quote${
|
||||
numberOfItemsInCache > 1 ? 's' : ''
|
||||
} from cache in ${((performance.now() - startTimeTotal) / 1000).toFixed(
|
||||
3
|
||||
)} seconds`
|
||||
);
|
||||
}
|
||||
|
||||
const itemsGroupedByDataSource = groupBy(itemsToFetch, ({ dataSource }) => {
|
||||
return dataSource;
|
||||
});
|
||||
|
||||
const promises: Promise<any>[] = [];
|
||||
|
||||
for (const [dataSource, dataGatheringItems] of Object.entries(
|
||||
itemsGroupedByDataSource
|
||||
@ -272,6 +319,15 @@ export class DataProviderService {
|
||||
result
|
||||
)) {
|
||||
response[symbol] = dataProviderResponse;
|
||||
|
||||
this.redisCacheService.set(
|
||||
this.redisCacheService.getQuoteKey({
|
||||
dataSource: DataSource[dataSource],
|
||||
symbol
|
||||
}),
|
||||
JSON.stringify(dataProviderResponse),
|
||||
this.configurationService.get('CACHE_QUOTES_TTL')
|
||||
);
|
||||
}
|
||||
|
||||
Logger.debug(
|
||||
@ -284,7 +340,7 @@ export class DataProviderService {
|
||||
);
|
||||
|
||||
try {
|
||||
await this.marketDataService.updateMany({
|
||||
this.marketDataService.updateMany({
|
||||
data: Object.keys(response)
|
||||
.filter((symbol) => {
|
||||
return (
|
||||
@ -323,9 +379,11 @@ export class DataProviderService {
|
||||
}
|
||||
|
||||
public async search({
|
||||
includeIndices = false,
|
||||
query,
|
||||
user
|
||||
}: {
|
||||
includeIndices?: boolean;
|
||||
query: string;
|
||||
user: UserWithSettings;
|
||||
}): Promise<{ items: LookupItem[] }> {
|
||||
@ -348,7 +406,12 @@ export class DataProviderService {
|
||||
}
|
||||
|
||||
for (const dataSource of dataSources) {
|
||||
promises.push(this.getDataProvider(DataSource[dataSource]).search(query));
|
||||
promises.push(
|
||||
this.getDataProvider(DataSource[dataSource]).search({
|
||||
includeIndices,
|
||||
query
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const searchResults = await Promise.all(promises);
|
||||
|
@ -156,7 +156,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
return !symbol.endsWith('.FOREX');
|
||||
})
|
||||
.map((symbol) => {
|
||||
return this.search(symbol);
|
||||
return this.search({ query: symbol });
|
||||
})
|
||||
);
|
||||
|
||||
@ -219,8 +219,14 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
return 'AAPL.US';
|
||||
}
|
||||
|
||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||
const searchResult = await this.getSearchResult(aQuery);
|
||||
public async search({
|
||||
includeIndices = false,
|
||||
query
|
||||
}: {
|
||||
includeIndices?: boolean;
|
||||
query: string;
|
||||
}): Promise<{ items: LookupItem[] }> {
|
||||
const searchResult = await this.getSearchResult(query);
|
||||
|
||||
return {
|
||||
items: searchResult
|
||||
|
@ -5,11 +5,13 @@ import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
||||
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||
import bent from 'bent';
|
||||
import { format, isAfter, isBefore, isSameDay } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
export class FinancialModelingPrepService implements DataProviderInterface {
|
||||
@ -61,9 +63,42 @@ export class FinancialModelingPrepService implements DataProviderInterface {
|
||||
): Promise<{
|
||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||
}> {
|
||||
return {
|
||||
[aSymbol]: {}
|
||||
};
|
||||
try {
|
||||
const get = bent(
|
||||
`${this.URL}/historical-price-full/${aSymbol}?apikey=${this.apiKey}`,
|
||||
'GET',
|
||||
'json',
|
||||
200
|
||||
);
|
||||
const { historical } = await get();
|
||||
|
||||
const result: {
|
||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||
} = {
|
||||
[aSymbol]: {}
|
||||
};
|
||||
|
||||
for (const { close, date } of historical) {
|
||||
if (
|
||||
(isSameDay(parseDate(date), from) ||
|
||||
isAfter(parseDate(date), from)) &&
|
||||
isBefore(parseDate(date), to)
|
||||
) {
|
||||
result[aSymbol][date] = {
|
||||
marketPrice: close
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
|
||||
from,
|
||||
DATE_FORMAT
|
||||
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public getName(): DataSource {
|
||||
@ -108,8 +143,39 @@ export class FinancialModelingPrepService implements DataProviderInterface {
|
||||
return 'AAPL';
|
||||
}
|
||||
|
||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||
return { items: [] };
|
||||
public async search({
|
||||
includeIndices = false,
|
||||
query
|
||||
}: {
|
||||
includeIndices?: boolean;
|
||||
query: string;
|
||||
}): Promise<{ items: LookupItem[] }> {
|
||||
let items: LookupItem[] = [];
|
||||
|
||||
try {
|
||||
const get = bent(
|
||||
`${this.URL}/search?query=${query}&apikey=${this.apiKey}`,
|
||||
'GET',
|
||||
'json',
|
||||
200
|
||||
);
|
||||
const result = await get();
|
||||
|
||||
items = result.map(({ currency, name, symbol }) => {
|
||||
return {
|
||||
// TODO: Add assetClass
|
||||
// TODO: Add assetSubClass
|
||||
currency,
|
||||
name,
|
||||
symbol,
|
||||
dataSource: this.getName()
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.error(error, 'FinancialModelingPrepService');
|
||||
}
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
private getDataProviderInfo(): DataProviderInfo {
|
||||
|
@ -153,7 +153,13 @@ export class GoogleSheetsService implements DataProviderInterface {
|
||||
return 'INDEXSP:.INX';
|
||||
}
|
||||
|
||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||
public async search({
|
||||
includeIndices = false,
|
||||
query
|
||||
}: {
|
||||
includeIndices?: boolean;
|
||||
query: string;
|
||||
}): Promise<{ items: LookupItem[] }> {
|
||||
const items = await this.prismaService.symbolProfile.findMany({
|
||||
select: {
|
||||
assetClass: true,
|
||||
@ -169,14 +175,14 @@ export class GoogleSheetsService implements DataProviderInterface {
|
||||
dataSource: this.getName(),
|
||||
name: {
|
||||
mode: 'insensitive',
|
||||
startsWith: aQuery
|
||||
startsWith: query
|
||||
}
|
||||
},
|
||||
{
|
||||
dataSource: this.getName(),
|
||||
symbol: {
|
||||
mode: 'insensitive',
|
||||
startsWith: aQuery
|
||||
startsWith: query
|
||||
}
|
||||
}
|
||||
]
|
||||
|
@ -42,5 +42,11 @@ export interface DataProviderInterface {
|
||||
|
||||
getTestSymbol(): string;
|
||||
|
||||
search(aQuery: string): Promise<{ items: LookupItem[] }>;
|
||||
search({
|
||||
includeIndices,
|
||||
query
|
||||
}: {
|
||||
includeIndices?: boolean;
|
||||
query: string;
|
||||
}): Promise<{ items: LookupItem[] }>;
|
||||
}
|
||||
|
@ -67,8 +67,12 @@ export class ManualService implements DataProviderInterface {
|
||||
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
|
||||
[{ symbol, dataSource: this.getName() }]
|
||||
);
|
||||
const { defaultMarketPrice, selector, url } =
|
||||
symbolProfile.scraperConfiguration ?? {};
|
||||
const {
|
||||
defaultMarketPrice,
|
||||
headers = {},
|
||||
selector,
|
||||
url
|
||||
} = symbolProfile.scraperConfiguration ?? {};
|
||||
|
||||
if (defaultMarketPrice) {
|
||||
const historical: {
|
||||
@ -91,7 +95,7 @@ export class ManualService implements DataProviderInterface {
|
||||
return {};
|
||||
}
|
||||
|
||||
const get = bent(url, 'GET', 'string', 200, {});
|
||||
const get = bent(url, 'GET', 'string', 200, headers);
|
||||
|
||||
const html = await get();
|
||||
const $ = cheerio.load(html);
|
||||
@ -171,7 +175,13 @@ export class ManualService implements DataProviderInterface {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||
public async search({
|
||||
includeIndices = false,
|
||||
query
|
||||
}: {
|
||||
includeIndices?: boolean;
|
||||
query: string;
|
||||
}): Promise<{ items: LookupItem[] }> {
|
||||
let items = await this.prismaService.symbolProfile.findMany({
|
||||
select: {
|
||||
assetClass: true,
|
||||
@ -187,14 +197,14 @@ export class ManualService implements DataProviderInterface {
|
||||
dataSource: this.getName(),
|
||||
name: {
|
||||
mode: 'insensitive',
|
||||
startsWith: aQuery
|
||||
startsWith: query
|
||||
}
|
||||
},
|
||||
{
|
||||
dataSource: this.getName(),
|
||||
symbol: {
|
||||
mode: 'insensitive',
|
||||
startsWith: aQuery
|
||||
startsWith: query
|
||||
}
|
||||
}
|
||||
]
|
||||
|
@ -117,7 +117,13 @@ export class RapidApiService implements DataProviderInterface {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||
public async search({
|
||||
includeIndices = false,
|
||||
query
|
||||
}: {
|
||||
includeIndices?: boolean;
|
||||
query: string;
|
||||
}): Promise<{ items: LookupItem[] }> {
|
||||
return { items: [] };
|
||||
}
|
||||
|
||||
|
@ -138,8 +138,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
marketPrice: this.getConvertedValue({
|
||||
symbol: aSymbol,
|
||||
value: historicalItem.close
|
||||
}),
|
||||
performance: historicalItem.open - historicalItem.close
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
@ -276,11 +275,23 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
return 'AAPL';
|
||||
}
|
||||
|
||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||
public async search({
|
||||
includeIndices = false,
|
||||
query
|
||||
}: {
|
||||
includeIndices?: boolean;
|
||||
query: string;
|
||||
}): Promise<{ items: LookupItem[] }> {
|
||||
const items: LookupItem[] = [];
|
||||
|
||||
try {
|
||||
const searchResult = await yahooFinance.search(aQuery);
|
||||
const quoteTypes = ['EQUITY', 'ETF', 'FUTURE', 'MUTUALFUND'];
|
||||
|
||||
if (includeIndices) {
|
||||
quoteTypes.push('INDEX');
|
||||
}
|
||||
|
||||
const searchResult = await yahooFinance.search(query);
|
||||
|
||||
const quotes = searchResult.quotes
|
||||
.filter((quote) => {
|
||||
@ -296,7 +307,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
this.baseCurrency
|
||||
)
|
||||
)) ||
|
||||
['EQUITY', 'ETF', 'FUTURE', 'MUTUALFUND'].includes(quoteType)
|
||||
quoteTypes.includes(quoteType)
|
||||
);
|
||||
})
|
||||
.filter(({ quoteType, symbol }) => {
|
||||
|
@ -64,11 +64,11 @@ export class ExchangeRateDataService {
|
||||
if (Object.keys(result).length !== this.currencyPairs.length) {
|
||||
// Load currencies directly from data provider as a fallback
|
||||
// if historical data is not fully available
|
||||
const quotes = await this.dataProviderService.getQuotes(
|
||||
this.currencyPairs.map(({ dataSource, symbol }) => {
|
||||
const quotes = await this.dataProviderService.getQuotes({
|
||||
items: this.currencyPairs.map(({ dataSource, symbol }) => {
|
||||
return { dataSource, symbol };
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
for (const symbol of Object.keys(quotes)) {
|
||||
if (isNumber(quotes[symbol].marketPrice)) {
|
||||
@ -125,9 +125,11 @@ export class ExchangeRateDataService {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let factor = 1;
|
||||
let factor: number;
|
||||
|
||||
if (aFromCurrency !== aToCurrency) {
|
||||
if (aFromCurrency === aToCurrency) {
|
||||
factor = 1;
|
||||
} else {
|
||||
if (this.exchangeRates[`${aFromCurrency}${aToCurrency}`]) {
|
||||
factor = this.exchangeRates[`${aFromCurrency}${aToCurrency}`];
|
||||
} else {
|
||||
@ -171,7 +173,9 @@ export class ExchangeRateDataService {
|
||||
|
||||
let factor: number;
|
||||
|
||||
if (aFromCurrency !== aToCurrency) {
|
||||
if (aFromCurrency === aToCurrency) {
|
||||
factor = 1;
|
||||
} else {
|
||||
const dataSource =
|
||||
this.dataProviderService.getDataSourceForExchangeRates();
|
||||
const symbol = `${aFromCurrency}${aToCurrency}`;
|
||||
@ -186,28 +190,42 @@ export class ExchangeRateDataService {
|
||||
factor = marketData?.marketPrice;
|
||||
} else {
|
||||
// Calculate indirectly via base currency
|
||||
try {
|
||||
const [
|
||||
{ marketPrice: marketPriceBaseCurrencyFromCurrency },
|
||||
{ marketPrice: marketPriceBaseCurrencyToCurrency }
|
||||
] = await Promise.all([
|
||||
this.marketDataService.get({
|
||||
dataSource,
|
||||
date: aDate,
|
||||
symbol: `${this.baseCurrency}${aFromCurrency}`
|
||||
}),
|
||||
this.marketDataService.get({
|
||||
dataSource,
|
||||
date: aDate,
|
||||
symbol: `${this.baseCurrency}${aToCurrency}`
|
||||
})
|
||||
]);
|
||||
|
||||
// Calculate the opposite direction
|
||||
factor =
|
||||
(1 / marketPriceBaseCurrencyFromCurrency) *
|
||||
marketPriceBaseCurrencyToCurrency;
|
||||
let marketPriceBaseCurrencyFromCurrency: number;
|
||||
let marketPriceBaseCurrencyToCurrency: number;
|
||||
|
||||
try {
|
||||
if (this.baseCurrency === aFromCurrency) {
|
||||
marketPriceBaseCurrencyFromCurrency = 1;
|
||||
} else {
|
||||
marketPriceBaseCurrencyFromCurrency = (
|
||||
await this.marketDataService.get({
|
||||
dataSource,
|
||||
date: aDate,
|
||||
symbol: `${this.baseCurrency}${aFromCurrency}`
|
||||
})
|
||||
)?.marketPrice;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
if (this.baseCurrency === aToCurrency) {
|
||||
marketPriceBaseCurrencyToCurrency = 1;
|
||||
} else {
|
||||
marketPriceBaseCurrencyToCurrency = (
|
||||
await this.marketDataService.get({
|
||||
dataSource,
|
||||
date: aDate,
|
||||
symbol: `${this.baseCurrency}${aToCurrency}`
|
||||
})
|
||||
)?.marketPrice;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Calculate the opposite direction
|
||||
factor =
|
||||
(1 / marketPriceBaseCurrencyFromCurrency) *
|
||||
marketPriceBaseCurrencyToCurrency;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -12,22 +12,36 @@ export class ImpersonationService {
|
||||
) {}
|
||||
|
||||
public async validateImpersonationId(aId = '') {
|
||||
const accessObject = await this.prismaService.access.findFirst({
|
||||
where: {
|
||||
GranteeUser: { id: this.request.user.id },
|
||||
id: aId
|
||||
}
|
||||
});
|
||||
if (this.request.user) {
|
||||
const accessObject = await this.prismaService.access.findFirst({
|
||||
where: {
|
||||
GranteeUser: { id: this.request.user.id },
|
||||
id: aId
|
||||
}
|
||||
});
|
||||
|
||||
if (accessObject?.userId) {
|
||||
return accessObject?.userId;
|
||||
} else if (
|
||||
hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.impersonateAllUsers
|
||||
)
|
||||
) {
|
||||
return aId;
|
||||
if (accessObject?.userId) {
|
||||
return accessObject.userId;
|
||||
} else if (
|
||||
hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.impersonateAllUsers
|
||||
)
|
||||
) {
|
||||
return aId;
|
||||
}
|
||||
} else {
|
||||
// Public access
|
||||
const accessObject = await this.prismaService.access.findFirst({
|
||||
where: {
|
||||
GranteeUser: null,
|
||||
User: { id: aId }
|
||||
}
|
||||
});
|
||||
|
||||
if (accessObject?.userId) {
|
||||
return accessObject.userId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
|
@ -5,6 +5,7 @@ export interface Environment extends CleanedEnvAccessors {
|
||||
ALPHA_VANTAGE_API_KEY: string;
|
||||
BASE_CURRENCY: string;
|
||||
BETTER_UPTIME_API_KEY: string;
|
||||
CACHE_QUOTES_TTL: number;
|
||||
CACHE_TTL: number;
|
||||
DATA_SOURCE_EXCHANGE_RATES: string;
|
||||
DATA_SOURCE_IMPORT: string;
|
||||
|
@ -23,7 +23,6 @@ export interface IOrder {
|
||||
|
||||
export interface IDataProviderHistoricalResponse {
|
||||
marketPrice: number;
|
||||
performance?: number;
|
||||
}
|
||||
|
||||
export interface IDataProviderResponse {
|
||||
|
@ -15,6 +15,12 @@ import { continents, countries } from 'countries-list';
|
||||
export class SymbolProfileService {
|
||||
public constructor(private readonly prismaService: PrismaService) {}
|
||||
|
||||
public async add(
|
||||
assetProfile: Prisma.SymbolProfileCreateInput
|
||||
): Promise<SymbolProfile | never> {
|
||||
return this.prismaService.symbolProfile.create({ data: assetProfile });
|
||||
}
|
||||
|
||||
public async delete({ dataSource, symbol }: UniqueAsset) {
|
||||
return this.prismaService.symbolProfile.delete({
|
||||
where: { dataSource_symbol: { dataSource, symbol } }
|
||||
@ -90,11 +96,12 @@ export class SymbolProfileService {
|
||||
public updateSymbolProfile({
|
||||
comment,
|
||||
dataSource,
|
||||
scraperConfiguration,
|
||||
symbol,
|
||||
symbolMapping
|
||||
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
||||
return this.prismaService.symbolProfile.update({
|
||||
data: { comment, symbolMapping },
|
||||
data: { comment, scraperConfiguration, symbolMapping },
|
||||
where: { dataSource_symbol: { dataSource, symbol } }
|
||||
});
|
||||
}
|
||||
@ -189,6 +196,8 @@ export class SymbolProfileService {
|
||||
if (scraperConfiguration) {
|
||||
return {
|
||||
defaultMarketPrice: scraperConfiguration.defaultMarketPrice as number,
|
||||
headers:
|
||||
scraperConfiguration.headers as ScraperConfiguration['headers'],
|
||||
selector: scraperConfiguration.selector as string,
|
||||
url: scraperConfiguration.url as string
|
||||
};
|
||||
|
@ -29,6 +29,11 @@
|
||||
"input": "",
|
||||
"output": "./../assets"
|
||||
},
|
||||
{
|
||||
"glob": "favicon.ico",
|
||||
"input": "apps/client/src/assets",
|
||||
"output": "./../"
|
||||
},
|
||||
{
|
||||
"glob": "LICENSE",
|
||||
"input": "",
|
||||
|
@ -5,25 +5,19 @@ import { PageTitleStrategy } from '@ghostfolio/client/services/page-title.strate
|
||||
import { ModulePreloadService } from './core/module-preload.service';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: 'about',
|
||||
...[
|
||||
'about',
|
||||
/////
|
||||
'a-propos',
|
||||
'informazioni-su',
|
||||
'over',
|
||||
'sobre',
|
||||
'ueber-uns'
|
||||
].map((path) => ({
|
||||
path,
|
||||
loadChildren: () =>
|
||||
import('./pages/about/about-page.module').then((m) => m.AboutPageModule)
|
||||
},
|
||||
{
|
||||
path: 'about/changelog',
|
||||
loadChildren: () =>
|
||||
import('./pages/about/changelog/changelog-page.module').then(
|
||||
(m) => m.ChangelogPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'about/privacy-policy',
|
||||
loadChildren: () =>
|
||||
import('./pages/about/privacy-policy/privacy-policy-page.module').then(
|
||||
(m) => m.PrivacyPolicyPageModule
|
||||
)
|
||||
},
|
||||
})),
|
||||
{
|
||||
path: 'account',
|
||||
loadChildren: () =>
|
||||
@ -48,124 +42,64 @@ const routes: Routes = [
|
||||
loadChildren: () =>
|
||||
import('./pages/auth/auth-page.module').then((m) => m.AuthPageModule)
|
||||
},
|
||||
{
|
||||
path: 'blog',
|
||||
...['blog'].map((path) => ({
|
||||
path,
|
||||
loadChildren: () =>
|
||||
import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule)
|
||||
},
|
||||
{
|
||||
path: 'blog/2021/07/hallo-ghostfolio',
|
||||
loadChildren: () =>
|
||||
import(
|
||||
'./pages/blog/2021/07/hallo-ghostfolio/hallo-ghostfolio-page.module'
|
||||
).then((m) => m.HalloGhostfolioPageModule)
|
||||
},
|
||||
{
|
||||
path: 'blog/2021/07/hello-ghostfolio',
|
||||
loadChildren: () =>
|
||||
import(
|
||||
'./pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.module'
|
||||
).then((m) => m.HelloGhostfolioPageModule)
|
||||
},
|
||||
{
|
||||
path: 'blog/2022/01/ghostfolio-first-months-in-open-source',
|
||||
loadChildren: () =>
|
||||
import(
|
||||
'./pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.module'
|
||||
).then((m) => m.FirstMonthsInOpenSourcePageModule)
|
||||
},
|
||||
{
|
||||
path: 'blog/2022/07/ghostfolio-meets-internet-identity',
|
||||
loadChildren: () =>
|
||||
import(
|
||||
'./pages/blog/2022/07/ghostfolio-meets-internet-identity/ghostfolio-meets-internet-identity-page.module'
|
||||
).then((m) => m.GhostfolioMeetsInternetIdentityPageModule)
|
||||
},
|
||||
{
|
||||
path: 'blog/2022/07/how-do-i-get-my-finances-in-order',
|
||||
loadChildren: () =>
|
||||
import(
|
||||
'./pages/blog/2022/07/how-do-i-get-my-finances-in-order/how-do-i-get-my-finances-in-order-page.module'
|
||||
).then((m) => m.HowDoIGetMyFinancesInOrderPageModule)
|
||||
},
|
||||
{
|
||||
path: 'blog/2022/08/500-stars-on-github',
|
||||
loadChildren: () =>
|
||||
import(
|
||||
'./pages/blog/2022/08/500-stars-on-github/500-stars-on-github-page.module'
|
||||
).then((m) => m.FiveHundredStarsOnGitHubPageModule)
|
||||
},
|
||||
{
|
||||
path: 'blog/2022/10/hacktoberfest-2022',
|
||||
loadChildren: () =>
|
||||
import(
|
||||
'./pages/blog/2022/10/hacktoberfest-2022/hacktoberfest-2022-page.module'
|
||||
).then((m) => m.Hacktoberfest2022PageModule)
|
||||
},
|
||||
{
|
||||
path: 'blog/2022/11/black-friday-2022',
|
||||
loadChildren: () =>
|
||||
import(
|
||||
'./pages/blog/2022/11/black-friday-2022/black-friday-2022-page.module'
|
||||
).then((m) => m.BlackFriday2022PageModule)
|
||||
},
|
||||
{
|
||||
path: 'blog/2022/12/the-importance-of-tracking-your-personal-finances',
|
||||
loadChildren: () =>
|
||||
import(
|
||||
'./pages/blog/2022/12/the-importance-of-tracking-your-personal-finances/the-importance-of-tracking-your-personal-finances-page.module'
|
||||
).then((m) => m.TheImportanceOfTrackingYourPersonalFinancesPageModule)
|
||||
},
|
||||
{
|
||||
path: 'blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt',
|
||||
loadChildren: () =>
|
||||
import(
|
||||
'./pages/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt/ghostfolio-auf-sackgeld-vorgestellt-page.module'
|
||||
).then((m) => m.GhostfolioAufSackgeldVorgestelltPageModule)
|
||||
},
|
||||
{
|
||||
path: 'blog/2023/02/ghostfolio-meets-umbrel',
|
||||
loadChildren: () =>
|
||||
import(
|
||||
'./pages/blog/2023/02/ghostfolio-meets-umbrel/ghostfolio-meets-umbrel-page.module'
|
||||
).then((m) => m.GhostfolioMeetsUmbrelPageModule)
|
||||
},
|
||||
{
|
||||
path: 'blog/2023/03/ghostfolio-reaches-1000-stars-on-github',
|
||||
loadChildren: () =>
|
||||
import(
|
||||
'./pages/blog/2023/03/1000-stars-on-github/1000-stars-on-github-page.module'
|
||||
).then((m) => m.ThousandStarsOnGitHubPageModule)
|
||||
},
|
||||
})),
|
||||
{
|
||||
path: 'demo',
|
||||
loadChildren: () =>
|
||||
import('./pages/demo/demo-page.module').then((m) => m.DemoPageModule)
|
||||
},
|
||||
{
|
||||
path: 'faq',
|
||||
...[
|
||||
'faq',
|
||||
/////
|
||||
'domande-piu-frequenti',
|
||||
'foire-aux-questions',
|
||||
'haeufig-gestellte-fragen',
|
||||
'perguntas-mais-frequentes',
|
||||
'preguntas-mas-frecuentes',
|
||||
'vaak-gestelde-vragen'
|
||||
].map((path) => ({
|
||||
path,
|
||||
loadChildren: () =>
|
||||
import('./pages/faq/faq-page.module').then((m) => m.FaqPageModule)
|
||||
},
|
||||
{
|
||||
path: 'features',
|
||||
})),
|
||||
...[
|
||||
'features',
|
||||
/////
|
||||
'fonctionnalites',
|
||||
'funcionalidades',
|
||||
'funzionalita',
|
||||
'kenmerken'
|
||||
].map((path) => ({
|
||||
path,
|
||||
loadChildren: () =>
|
||||
import('./pages/features/features-page.module').then(
|
||||
(m) => m.FeaturesPageModule
|
||||
)
|
||||
},
|
||||
})),
|
||||
{
|
||||
path: 'home',
|
||||
loadChildren: () =>
|
||||
import('./pages/home/home-page.module').then((m) => m.HomePageModule)
|
||||
},
|
||||
{
|
||||
path: 'markets',
|
||||
...[
|
||||
'markets',
|
||||
/////
|
||||
'maerkte',
|
||||
'marches',
|
||||
'markten',
|
||||
'mercados',
|
||||
'mercati'
|
||||
].map((path) => ({
|
||||
path,
|
||||
loadChildren: () =>
|
||||
import('./pages/markets/markets-page.module').then(
|
||||
(m) => m.MarketsPageModule
|
||||
)
|
||||
},
|
||||
})),
|
||||
{
|
||||
path: 'open',
|
||||
loadChildren: () =>
|
||||
@ -185,27 +119,53 @@ const routes: Routes = [
|
||||
(m) => m.PortfolioPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'pricing',
|
||||
...[
|
||||
'pricing',
|
||||
/////
|
||||
'precios',
|
||||
'precos',
|
||||
'preise',
|
||||
'prezzi',
|
||||
'prijzen',
|
||||
'prix'
|
||||
].map((path) => ({
|
||||
path,
|
||||
loadChildren: () =>
|
||||
import('./pages/pricing/pricing-page.module').then(
|
||||
(m) => m.PricingPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'register',
|
||||
})),
|
||||
...[
|
||||
'register',
|
||||
/////
|
||||
'enregistrement',
|
||||
'iscrizione',
|
||||
'registo',
|
||||
'registratie',
|
||||
'registrierung',
|
||||
'registro'
|
||||
].map((path) => ({
|
||||
path,
|
||||
loadChildren: () =>
|
||||
import('./pages/register/register-page.module').then(
|
||||
(m) => m.RegisterPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'resources',
|
||||
})),
|
||||
...[
|
||||
'resources',
|
||||
/////
|
||||
'bronnen',
|
||||
'recursos',
|
||||
'ressourcen',
|
||||
'ressources',
|
||||
'risorse'
|
||||
].map((path) => ({
|
||||
path,
|
||||
loadChildren: () =>
|
||||
import('./pages/resources/resources-page.module').then(
|
||||
(m) => m.ResourcesPageModule
|
||||
)
|
||||
},
|
||||
})),
|
||||
{
|
||||
path: 'start',
|
||||
loadChildren: () =>
|
||||
|
@ -44,19 +44,143 @@
|
||||
</main>
|
||||
|
||||
<footer
|
||||
*ngIf="currentRoute === 'start'"
|
||||
class="footer d-flex justify-content-center w-100"
|
||||
*ngIf="
|
||||
(currentRoute === 'blog' ||
|
||||
currentRoute === 'faq' ||
|
||||
currentRoute === 'features' ||
|
||||
currentRoute === 'markets' ||
|
||||
currentRoute === 'open' ||
|
||||
currentRoute === 'pricing' ||
|
||||
currentRoute === 'resources' ||
|
||||
currentRoute === 'register' ||
|
||||
currentRoute === 'start') &&
|
||||
deviceType !== 'mobile'
|
||||
"
|
||||
class="d-flex justify-content-center py-4 w-100"
|
||||
>
|
||||
<div class="container text-center">
|
||||
<div>
|
||||
© 2021 - {{ currentYear }} <a href="https://ghostfol.io">Ghostfolio</a>
|
||||
{{ version }}
|
||||
<div class="container">
|
||||
<div class="mb-3 row">
|
||||
<div class="col-sm">
|
||||
<a [routerLink]="['/']"><gf-logo /></a>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<div class="h6 mt-2" i18n>Personal Finance</div>
|
||||
<ul class="list-unstyled">
|
||||
<li *ngIf="hasPermissionToAccessFearAndGreedIndex">
|
||||
<a i18n [routerLink]="['/markets']">Markets</a>
|
||||
</li>
|
||||
<li><a i18n [routerLink]="['/resources']">Resources</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<div class="h6 mt-2">Ghostfolio</div>
|
||||
<ul class="list-unstyled">
|
||||
<li><a i18n [routerLink]="['/about']">About</a></li>
|
||||
<li *ngIf="hasPermissionForBlog">
|
||||
<a i18n [routerLink]="['/blog']">Blog</a>
|
||||
</li>
|
||||
<li>
|
||||
<a i18n [routerLink]="['/about', 'changelog']">Changelog</a>
|
||||
</li>
|
||||
<li><a i18n [routerLink]="['/features']">Features</a></li>
|
||||
<li *ngIf="hasPermissionForSubscription">
|
||||
<a i18n [routerLink]="['/faq']">Frequently Asked Questions (FAQ)</a>
|
||||
</li>
|
||||
<li>
|
||||
<a i18n [routerLink]="['/about', 'license']">License</a>
|
||||
</li>
|
||||
<li *ngIf="hasPermissionForStatistics">
|
||||
<a [routerLink]="['/open']">Open Startup</a>
|
||||
</li>
|
||||
<li *ngIf="hasPermissionForSubscription">
|
||||
<a i18n [routerLink]="['/pricing']">Pricing</a>
|
||||
</li>
|
||||
<li *ngIf="hasPermissionForSubscription">
|
||||
<a i18n [routerLink]="['/about', 'privacy-policy']"
|
||||
>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"></ion-icon
|
||||
></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"></ion-icon
|
||||
></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"></ion-icon
|
||||
></a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
class="align-items-baseline d-flex"
|
||||
href="https://twitter.com/ghostfolio_"
|
||||
target="_blank"
|
||||
title="Follow Ghostfolio on Twitter"
|
||||
>Twitter<ion-icon class="ml-1" name="open-outline"></ion-icon
|
||||
></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>
|
||||
<a href="../pt" title="Ghostfolio in Português">Português</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="py-2 text-muted">
|
||||
<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 class="row text-center">
|
||||
<div class="col">
|
||||
© 2021 - {{ currentYear }} <a href="https://ghostfol.io">Ghostfolio</a>
|
||||
{{ version }}
|
||||
</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>
|
||||
|
@ -4,6 +4,11 @@
|
||||
display: block;
|
||||
min-height: 100vh;
|
||||
|
||||
footer {
|
||||
background-color: rgba(var(--palette-foreground-text), 0.05);
|
||||
font-size: 90%;
|
||||
}
|
||||
|
||||
main {
|
||||
min-height: 100vh;
|
||||
padding-top: 5rem;
|
||||
@ -25,14 +30,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
height: 5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(.is-dark-theme) {
|
||||
footer {
|
||||
background-color: rgba(var(--palette-foreground-text-dark), 0.05);
|
||||
}
|
||||
|
||||
main {
|
||||
.info-message-container {
|
||||
.info-message {
|
||||
|
@ -32,6 +32,10 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
public currentRoute: string;
|
||||
public currentYear = new Date().getFullYear();
|
||||
public deviceType: string;
|
||||
public hasPermissionForBlog: boolean;
|
||||
public hasPermissionForStatistics: boolean;
|
||||
public hasPermissionForSubscription: boolean;
|
||||
public hasPermissionToAccessFearAndGreedIndex: boolean;
|
||||
public info: InfoItem;
|
||||
public pageTitle: string;
|
||||
public user: User;
|
||||
@ -55,6 +59,27 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
|
||||
public ngOnInit() {
|
||||
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||
this.info = this.dataService.fetchInfo();
|
||||
|
||||
this.hasPermissionForBlog = hasPermission(
|
||||
this.info?.globalPermissions,
|
||||
permissions.enableBlog
|
||||
);
|
||||
|
||||
this.hasPermissionForSubscription = hasPermission(
|
||||
this.info?.globalPermissions,
|
||||
permissions.enableSubscription
|
||||
);
|
||||
|
||||
this.hasPermissionForStatistics = hasPermission(
|
||||
this.info?.globalPermissions,
|
||||
permissions.enableStatistics
|
||||
);
|
||||
|
||||
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
|
||||
this.info?.globalPermissions,
|
||||
permissions.enableFearAndGreedIndex
|
||||
);
|
||||
|
||||
this.router.events
|
||||
.pipe(filter((event) => event instanceof NavigationEnd))
|
||||
@ -64,8 +89,6 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
const urlSegments = urlSegmentGroup.segments;
|
||||
this.currentRoute = urlSegments[0].path;
|
||||
|
||||
this.info = this.dataService.fetchInfo();
|
||||
|
||||
if (this.deviceType === 'mobile') {
|
||||
setTimeout(() => {
|
||||
const index = this.title.getTitle().indexOf('–');
|
||||
|
@ -14,6 +14,7 @@ import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { ServiceWorkerModule } from '@angular/service-worker';
|
||||
import { GfLogoModule } from '@ghostfolio/ui/logo';
|
||||
import { MarkdownModule } from 'ngx-markdown';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
import { NgxStripeModule, STRIPE_PUBLISHABLE_KEY } from 'ngx-stripe';
|
||||
@ -40,6 +41,7 @@ export function NgxStripeFactory(): string {
|
||||
BrowserAnimationsModule,
|
||||
BrowserModule,
|
||||
GfHeaderModule,
|
||||
GfLogoModule,
|
||||
GfSubscriptionInterstitialDialogModule,
|
||||
HttpClientModule,
|
||||
MarkdownModule.forRoot(),
|
||||
|
@ -47,7 +47,7 @@
|
||||
[matMenuTriggerFor]="transactionMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
||||
</button>
|
||||
<mat-menu #transactionMenu="matMenu" xPosition="before">
|
||||
<button mat-menu-item (click)="onDeleteAccess(element.id)">
|
||||
@ -57,6 +57,6 @@
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
||||
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
|
||||
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
|
||||
</table>
|
||||
|
@ -12,8 +12,9 @@
|
||||
<div class="col-12 d-flex justify-content-center mb-3">
|
||||
<gf-value
|
||||
size="large"
|
||||
[currency]="user?.settings?.baseCurrency"
|
||||
[isCurrency]="true"
|
||||
[locale]="user?.settings?.locale"
|
||||
[unit]="user?.settings?.baseCurrency"
|
||||
[value]="valueInBaseCurrency"
|
||||
></gf-value>
|
||||
</div>
|
||||
@ -24,8 +25,9 @@
|
||||
<gf-value
|
||||
i18n
|
||||
size="medium"
|
||||
[currency]="currency"
|
||||
[isCurrency]="true"
|
||||
[locale]="user?.settings?.locale"
|
||||
[unit]="currency"
|
||||
[value]="balance"
|
||||
>Cash Balance</gf-value
|
||||
>
|
||||
@ -34,8 +36,9 @@
|
||||
<gf-value
|
||||
i18n
|
||||
size="medium"
|
||||
[currency]="currency"
|
||||
[isCurrency]="true"
|
||||
[locale]="user?.settings?.locale"
|
||||
[unit]="currency"
|
||||
[value]="equity"
|
||||
>Equity</gf-value
|
||||
>
|
||||
|
@ -207,6 +207,30 @@
|
||||
</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"></ion-icon>
|
||||
</button>
|
||||
</td>
|
||||
<td
|
||||
*matFooterCellDef
|
||||
class="d-none d-lg-table-cell px-1"
|
||||
mat-footer-cell
|
||||
></td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<th *matHeaderCellDef class="px-1 text-center" i18n mat-header-cell></th>
|
||||
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
|
||||
@ -216,7 +240,7 @@
|
||||
[matMenuTriggerFor]="accountMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
||||
</button>
|
||||
<mat-menu #accountMenu="matMenu" xPosition="before">
|
||||
<button mat-menu-item (click)="onUpdateAccount(element)">
|
||||
|
@ -58,7 +58,8 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
|
||||
'balance',
|
||||
'value',
|
||||
'currency',
|
||||
'valueInBaseCurrency'
|
||||
'valueInBaseCurrency',
|
||||
'comment'
|
||||
];
|
||||
|
||||
if (this.showActions) {
|
||||
@ -92,6 +93,10 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
public onOpenComment(aComment: string) {
|
||||
alert(aComment);
|
||||
}
|
||||
|
||||
public onUpdateAccount(aAccount: AccountModel) {
|
||||
this.accountToUpdate.emit(aAccount);
|
||||
}
|
||||
|
@ -108,7 +108,7 @@
|
||||
[matMenuTriggerFor]="jobActionsMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
||||
</button>
|
||||
<mat-menu #jobActionsMenu="matMenu" xPosition="before">
|
||||
<button mat-menu-item (click)="onViewData(job.data)">
|
||||
|
@ -1,4 +1,5 @@
|
||||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
@ -7,23 +8,26 @@ import {
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatSort } from '@angular/material/sort';
|
||||
import { MatPaginator, PageEvent } from '@angular/material/paginator';
|
||||
import { MatSort, Sort } from '@angular/material/sort';
|
||||
import { MatTableDataSource } from '@angular/material/table';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
|
||||
import { getDateFormatString } from '@ghostfolio/common/helper';
|
||||
import { Filter, UniqueAsset, User } from '@ghostfolio/common/interfaces';
|
||||
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
import { AssetSubClass, DataSource } from '@prisma/client';
|
||||
import { AssetSubClass, DataSource, Prisma } from '@prisma/client';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { Subject } from 'rxjs';
|
||||
import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
|
||||
|
||||
import { AssetProfileDialog } from './asset-profile-dialog/asset-profile-dialog.component';
|
||||
import { AssetProfileDialogParams } from './asset-profile-dialog/interfaces/interfaces';
|
||||
import { CreateAssetProfileDialog } from './create-asset-profile-dialog/create-asset-profile-dialog.component';
|
||||
import { CreateAssetProfileDialogParams } from './create-asset-profile-dialog/interfaces/interfaces';
|
||||
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
@ -31,7 +35,10 @@ import { AssetProfileDialogParams } from './asset-profile-dialog/interfaces/inte
|
||||
styleUrls: ['./admin-market-data.scss'],
|
||||
templateUrl: './admin-market-data.html'
|
||||
})
|
||||
export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
||||
export class AdminMarketDataComponent
|
||||
implements AfterViewInit, OnDestroy, OnInit
|
||||
{
|
||||
@ViewChild(MatPaginator) paginator: MatPaginator;
|
||||
@ViewChild(MatSort) sort: MatSort;
|
||||
|
||||
public activeFilters: Filter[] = [];
|
||||
@ -44,13 +51,26 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
||||
AssetSubClass.PRECIOUS_METAL,
|
||||
AssetSubClass.PRIVATE_EQUITY,
|
||||
AssetSubClass.STOCK
|
||||
].map((assetSubClass) => {
|
||||
return {
|
||||
id: assetSubClass,
|
||||
label: translate(assetSubClass),
|
||||
type: 'ASSET_SUB_CLASS'
|
||||
};
|
||||
});
|
||||
]
|
||||
.map((assetSubClass) => {
|
||||
return {
|
||||
id: assetSubClass.toString(),
|
||||
label: translate(assetSubClass),
|
||||
type: <Filter['type']>'ASSET_SUB_CLASS'
|
||||
};
|
||||
})
|
||||
.concat([
|
||||
{
|
||||
id: 'ETF_WITHOUT_COUNTRIES',
|
||||
label: $localize`ETFs without Countries`,
|
||||
type: <Filter['type']>'PRESET_ID'
|
||||
},
|
||||
{
|
||||
id: 'ETF_WITHOUT_SECTORS',
|
||||
label: $localize`ETFs without Sectors`,
|
||||
type: <Filter['type']>'PRESET_ID'
|
||||
}
|
||||
]);
|
||||
public currentDataSource: DataSource;
|
||||
public currentSymbol: string;
|
||||
public dataSource: MatTableDataSource<AdminMarketDataItem> =
|
||||
@ -73,6 +93,8 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
||||
public filters$ = new Subject<Filter[]>();
|
||||
public isLoading = false;
|
||||
public placeholder = '';
|
||||
public pageSize = DEFAULT_PAGE_SIZE;
|
||||
public totalItems = 0;
|
||||
public user: User;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
@ -80,7 +102,6 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
||||
public constructor(
|
||||
private adminService: AdminService,
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
private deviceService: DeviceDetectorService,
|
||||
private dialog: MatDialog,
|
||||
private route: ActivatedRoute,
|
||||
@ -99,6 +120,8 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
||||
dataSource: params['dataSource'],
|
||||
symbol: params['symbol']
|
||||
});
|
||||
} else if (params['createAssetProfileDialog']) {
|
||||
this.openCreateAssetProfileDialog();
|
||||
}
|
||||
});
|
||||
|
||||
@ -113,34 +136,40 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
this.filters$
|
||||
.pipe(distinctUntilChanged(), takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((filters) => {
|
||||
this.activeFilters = filters;
|
||||
|
||||
this.loadData();
|
||||
});
|
||||
}
|
||||
|
||||
public ngAfterViewInit() {
|
||||
this.sort.sortChange.subscribe(
|
||||
({ active: sortColumn, direction }: Sort) => {
|
||||
this.paginator.pageIndex = 0;
|
||||
|
||||
this.loadData({
|
||||
sortColumn,
|
||||
sortDirection: <Prisma.SortOrder>direction,
|
||||
pageIndex: this.paginator.pageIndex
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public ngOnInit() {
|
||||
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||
}
|
||||
|
||||
this.filters$
|
||||
.pipe(
|
||||
distinctUntilChanged(),
|
||||
switchMap((filters) => {
|
||||
this.isLoading = true;
|
||||
this.activeFilters = filters;
|
||||
this.placeholder =
|
||||
this.activeFilters.length <= 0 ? $localize`Filter by...` : '';
|
||||
|
||||
return this.dataService.fetchAdminMarketData({
|
||||
filters: this.activeFilters
|
||||
});
|
||||
}),
|
||||
takeUntil(this.unsubscribeSubject)
|
||||
)
|
||||
.subscribe(({ marketData }) => {
|
||||
this.dataSource = new MatTableDataSource(marketData);
|
||||
this.dataSource.sort = this.sort;
|
||||
|
||||
this.isLoading = false;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
public onChangePage(page: PageEvent) {
|
||||
this.loadData({
|
||||
pageIndex: page.pageIndex,
|
||||
sortColumn: this.sort.active,
|
||||
sortDirection: <Prisma.SortOrder>this.sort.direction
|
||||
});
|
||||
}
|
||||
|
||||
public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
||||
@ -208,6 +237,53 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
|
||||
private loadData(
|
||||
{
|
||||
pageIndex,
|
||||
sortColumn,
|
||||
sortDirection
|
||||
}: {
|
||||
pageIndex: number;
|
||||
sortColumn?: string;
|
||||
sortDirection?: Prisma.SortOrder;
|
||||
} = { pageIndex: 0 }
|
||||
) {
|
||||
this.isLoading = true;
|
||||
|
||||
this.pageSize =
|
||||
this.activeFilters.length === 1 &&
|
||||
this.activeFilters[0].type === 'PRESET_ID'
|
||||
? undefined
|
||||
: DEFAULT_PAGE_SIZE;
|
||||
|
||||
if (pageIndex === 0 && this.paginator) {
|
||||
this.paginator.pageIndex = 0;
|
||||
}
|
||||
|
||||
this.placeholder =
|
||||
this.activeFilters.length <= 0 ? $localize`Filter by...` : '';
|
||||
|
||||
this.adminService
|
||||
.fetchAdminMarketData({
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
filters: this.activeFilters,
|
||||
skip: pageIndex * this.pageSize,
|
||||
take: this.pageSize
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ count, marketData }) => {
|
||||
this.totalItems = count;
|
||||
|
||||
this.dataSource = new MatTableDataSource(marketData);
|
||||
this.dataSource.sort = this.sort;
|
||||
|
||||
this.isLoading = false;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
private openAssetProfileDialog({
|
||||
dataSource,
|
||||
symbol
|
||||
@ -241,4 +317,53 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private openCreateAssetProfileDialog() {
|
||||
this.userService
|
||||
.get()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((user) => {
|
||||
this.user = user;
|
||||
|
||||
const dialogRef = this.dialog.open(CreateAssetProfileDialog, {
|
||||
autoFocus: false,
|
||||
data: <CreateAssetProfileDialogParams>{
|
||||
deviceType: this.deviceType,
|
||||
locale: this.user?.settings?.locale
|
||||
},
|
||||
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||
});
|
||||
|
||||
dialogRef
|
||||
.afterClosed()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ dataSource, symbol }) => {
|
||||
if (dataSource && symbol) {
|
||||
this.adminService
|
||||
.addAssetProfile({ dataSource, symbol })
|
||||
.pipe(
|
||||
switchMap(() => {
|
||||
this.isLoading = true;
|
||||
this.changeDetectorRef.markForCheck();
|
||||
|
||||
return this.adminService.fetchAdminMarketData({
|
||||
filters: this.activeFilters,
|
||||
take: this.pageSize
|
||||
});
|
||||
}),
|
||||
takeUntil(this.unsubscribeSubject)
|
||||
)
|
||||
.subscribe(({ marketData }) => {
|
||||
this.dataSource = new MatTableDataSource(marketData);
|
||||
this.dataSource.sort = this.sort;
|
||||
this.isLoading = false;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
this.router.navigate(['.'], { relativeTo: this.route });
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -56,7 +56,7 @@
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="date">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||
<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>
|
||||
@ -74,7 +74,7 @@
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="marketDataItemCount">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||
<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>
|
||||
@ -83,7 +83,7 @@
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="sectorsCount">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||
<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>
|
||||
@ -92,7 +92,7 @@
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="countriesCount">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||
<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>
|
||||
@ -140,21 +140,9 @@
|
||||
[matMenuTriggerFor]="assetProfileActionsMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
||||
</button>
|
||||
<mat-menu #assetProfileActionsMenu="matMenu" xPosition="before">
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="onGatherSymbol({dataSource: element.dataSource, symbol: element.symbol})"
|
||||
>
|
||||
<ng-container i18n>Gather Historical Data</ng-container>
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="onGatherProfileDataBySymbol({dataSource: element.dataSource, symbol: element.symbol})"
|
||||
>
|
||||
<ng-container i18n>Gather Profile Data</ng-container>
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
[disabled]="element.activitiesCount !== 0"
|
||||
@ -174,6 +162,40 @@
|
||||
(click)="onOpenAssetProfileDialog({ dataSource: row.dataSource, symbol: row.symbol })"
|
||||
></tr>
|
||||
</table>
|
||||
|
||||
<mat-paginator
|
||||
[length]="totalItems"
|
||||
[ngClass]="{
|
||||
'd-none':
|
||||
(isLoading && totalItems === 0) ||
|
||||
totalItems <= pageSize
|
||||
}"
|
||||
[pageSize]="pageSize"
|
||||
[showFirstLastButtons]="true"
|
||||
(page)="onChangePage($event)"
|
||||
></mat-paginator>
|
||||
|
||||
<ngx-skeleton-loader
|
||||
*ngIf="isLoading && totalItems === 0"
|
||||
animation="pulse"
|
||||
class="px-4 py-3"
|
||||
[theme]="{
|
||||
height: '1.5rem',
|
||||
width: '100%'
|
||||
}"
|
||||
></ngx-skeleton-loader>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fab-container">
|
||||
<a
|
||||
class="align-items-center d-flex justify-content-center"
|
||||
color="primary"
|
||||
mat-fab
|
||||
[queryParams]="{ createAssetProfileDialog: true }"
|
||||
[routerLink]="[]"
|
||||
>
|
||||
<ion-icon name="add-outline" size="large"></ion-icon>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -2,12 +2,16 @@ import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatPaginatorModule } from '@angular/material/paginator';
|
||||
import { MatSortModule } from '@angular/material/sort';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
|
||||
import { AdminMarketDataComponent } from './admin-market-data.component';
|
||||
import { GfAssetProfileDialogModule } from './asset-profile-dialog/asset-profile-dialog.module';
|
||||
import { GfCreateAssetProfileDialogModule } from './create-asset-profile-dialog/create-asset-profile-dialog.module';
|
||||
|
||||
@NgModule({
|
||||
declarations: [AdminMarketDataComponent],
|
||||
@ -15,10 +19,14 @@ import { GfAssetProfileDialogModule } from './asset-profile-dialog/asset-profile
|
||||
CommonModule,
|
||||
GfActivitiesFilterModule,
|
||||
GfAssetProfileDialogModule,
|
||||
GfCreateAssetProfileDialogModule,
|
||||
MatButtonModule,
|
||||
MatMenuModule,
|
||||
MatPaginatorModule,
|
||||
MatSortModule,
|
||||
MatTableModule
|
||||
MatTableModule,
|
||||
NgxSkeletonLoaderModule,
|
||||
RouterModule
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
|
@ -2,4 +2,11 @@
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
.fab-container {
|
||||
bottom: 2rem;
|
||||
position: fixed;
|
||||
right: 2rem;
|
||||
z-index: 999;
|
||||
}
|
||||
}
|
||||
|
@ -10,13 +10,14 @@ import { FormBuilder } from '@angular/forms';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
|
||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import {
|
||||
AdminMarketDataDetails,
|
||||
EnhancedSymbolProfile,
|
||||
ScraperConfiguration,
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
import { MarketData } from '@prisma/client';
|
||||
import { MarketData, SymbolProfile } from '@prisma/client';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
@ -34,12 +35,15 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
public assetProfile: AdminMarketDataDetails['assetProfile'];
|
||||
public assetProfileForm = this.formBuilder.group({
|
||||
comment: '',
|
||||
scraperConfiguration: '',
|
||||
symbolMapping: ''
|
||||
});
|
||||
public assetSubClass: string;
|
||||
public benchmarks: Partial<SymbolProfile>[];
|
||||
public countries: {
|
||||
[code: string]: { name: string; value: number };
|
||||
};
|
||||
public isBenchmark = false;
|
||||
public marketDataDetails: MarketData[] = [];
|
||||
public sectors: {
|
||||
[name: string]: { name: string; value: number };
|
||||
@ -51,11 +55,14 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
private adminService: AdminService,
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
@Inject(MAT_DIALOG_DATA) public data: AssetProfileDialogParams,
|
||||
private dataService: DataService,
|
||||
public dialogRef: MatDialogRef<AssetProfileDialog>,
|
||||
private formBuilder: FormBuilder
|
||||
) {}
|
||||
|
||||
public ngOnInit(): void {
|
||||
this.benchmarks = this.dataService.fetchInfo().benchmarks;
|
||||
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
@ -72,6 +79,9 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
this.assetClass = translate(this.assetProfile?.assetClass);
|
||||
this.assetSubClass = translate(this.assetProfile?.assetSubClass);
|
||||
this.countries = {};
|
||||
this.isBenchmark = this.benchmarks.some(({ id }) => {
|
||||
return id === this.assetProfile.id;
|
||||
});
|
||||
this.marketDataDetails = marketData;
|
||||
this.sectors = {};
|
||||
|
||||
@ -95,6 +105,9 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
|
||||
this.assetProfileForm.setValue({
|
||||
comment: this.assetProfile?.comment ?? '',
|
||||
scraperConfiguration: JSON.stringify(
|
||||
this.assetProfile?.scraperConfiguration ?? {}
|
||||
),
|
||||
symbolMapping: JSON.stringify(this.assetProfile?.symbolMapping ?? {})
|
||||
});
|
||||
|
||||
@ -128,9 +141,27 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
public onSetBenchmark({ dataSource, symbol }: UniqueAsset) {
|
||||
this.dataService
|
||||
.postBenchmark({ dataSource, symbol })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
|
||||
public onSubmit() {
|
||||
let scraperConfiguration = {};
|
||||
let symbolMapping = {};
|
||||
|
||||
try {
|
||||
scraperConfiguration = JSON.parse(
|
||||
this.assetProfileForm.controls['scraperConfiguration'].value
|
||||
);
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
symbolMapping = JSON.parse(
|
||||
this.assetProfileForm.controls['symbolMapping'].value
|
||||
@ -138,6 +169,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
} catch {}
|
||||
|
||||
const assetProfileData: UpdateAssetProfileDto = {
|
||||
scraperConfiguration,
|
||||
symbolMapping,
|
||||
comment: this.assetProfileForm.controls['comment'].value ?? null
|
||||
};
|
||||
|
@ -37,6 +37,13 @@
|
||||
>
|
||||
<ng-container i18n>Gather Profile Data</ng-container>
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
[disabled]="isBenchmark"
|
||||
(click)="onSetBenchmark({dataSource: data.dataSource, symbol: data.symbol})"
|
||||
>
|
||||
<ng-container i18n>Set as Benchmark</ng-container>
|
||||
</button>
|
||||
</mat-menu>
|
||||
</div>
|
||||
|
||||
@ -155,6 +162,17 @@
|
||||
></textarea>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div *ngIf="assetProfile?.dataSource === 'MANUAL'">
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Scraper Configuration</mat-label>
|
||||
<textarea
|
||||
cdkTextareaAutosize
|
||||
formControlName="scraperConfiguration"
|
||||
matInput
|
||||
type="text"
|
||||
></textarea>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Note</mat-label>
|
||||
|
@ -0,0 +1,53 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
Inject,
|
||||
OnDestroy,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import {
|
||||
FormBuilder,
|
||||
FormControl,
|
||||
FormGroup,
|
||||
Validators
|
||||
} from '@angular/forms';
|
||||
import { MatDialogRef } from '@angular/material/dialog';
|
||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: { class: 'h-100' },
|
||||
selector: 'gf-create-asset-profile-dialog',
|
||||
templateUrl: 'create-asset-profile-dialog.html'
|
||||
})
|
||||
export class CreateAssetProfileDialog implements OnInit, OnDestroy {
|
||||
public createAssetProfileForm: FormGroup;
|
||||
|
||||
public constructor(
|
||||
public readonly adminService: AdminService,
|
||||
public readonly changeDetectorRef: ChangeDetectorRef,
|
||||
public readonly dialogRef: MatDialogRef<CreateAssetProfileDialog>,
|
||||
public readonly formBuilder: FormBuilder
|
||||
) {}
|
||||
|
||||
public ngOnInit() {
|
||||
this.createAssetProfileForm = this.formBuilder.group({
|
||||
searchSymbol: new FormControl(null, [Validators.required])
|
||||
});
|
||||
}
|
||||
|
||||
public onCancel() {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
public onSubmit() {
|
||||
this.dialogRef.close({
|
||||
dataSource:
|
||||
this.createAssetProfileForm.controls['searchSymbol'].value.dataSource,
|
||||
symbol: this.createAssetProfileForm.controls['searchSymbol'].value.symbol
|
||||
});
|
||||
}
|
||||
|
||||
public ngOnDestroy() {}
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
<form
|
||||
class="d-flex flex-column h-100"
|
||||
[formGroup]="createAssetProfileForm"
|
||||
(keyup.enter)="createAssetProfileForm.valid && onSubmit()"
|
||||
(ngSubmit)="onSubmit()"
|
||||
>
|
||||
<h1 i18n mat-dialog-title>Add Asset Profile</h1>
|
||||
<div class="flex-grow-1 py-3" mat-dialog-content>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Name, symbol or ISIN</mat-label>
|
||||
<gf-symbol-autocomplete
|
||||
formControlName="searchSymbol"
|
||||
[includeIndices]="true"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end" mat-dialog-actions>
|
||||
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button>
|
||||
<button
|
||||
color="primary"
|
||||
mat-flat-button
|
||||
type="submit"
|
||||
[disabled]="!createAssetProfileForm.valid"
|
||||
>
|
||||
<ng-container i18n>Save</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
@ -0,0 +1,24 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { GfSymbolAutocompleteModule } from '@ghostfolio/ui/symbol-autocomplete';
|
||||
|
||||
import { CreateAssetProfileDialog } from './create-asset-profile-dialog.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [CreateAssetProfileDialog],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
GfSymbolAutocompleteModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
ReactiveFormsModule
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
export class GfCreateAssetProfileDialogModule {}
|
@ -0,0 +1,4 @@
|
||||
export interface CreateAssetProfileDialogParams {
|
||||
deviceType: string;
|
||||
locale: string;
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { MatCheckboxChange } from '@angular/material/checkbox';
|
||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||
import { CacheService } from '@ghostfolio/client/services/cache.service';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
@ -45,6 +46,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
public constructor(
|
||||
private adminService: AdminService,
|
||||
private cacheService: CacheService,
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
@ -197,7 +199,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
private fetchAdminData() {
|
||||
this.dataService
|
||||
this.adminService
|
||||
.fetchAdminData()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ exchangeRates, settings, transactionCount, userCount }) => {
|
||||
|
@ -72,19 +72,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
*ngIf="info?.benchmarks?.length > 0"
|
||||
class="align-items-start d-flex my-3"
|
||||
>
|
||||
<div class="w-50" i18n>Benchmarks</div>
|
||||
<div class="w-50">
|
||||
<table>
|
||||
<tr *ngFor="let benchmark of info.benchmarks">
|
||||
<td class="pl-1">{{ benchmark.symbol }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
*ngIf="info?.tags?.length > 0"
|
||||
class="align-items-start d-flex my-3"
|
||||
|
@ -82,7 +82,7 @@
|
||||
[matMenuTriggerFor]="platformMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
||||
</button>
|
||||
<mat-menu #platformMenu="matMenu" xPosition="before">
|
||||
<button mat-menu-item (click)="onUpdatePlatform(element)">
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
@ -30,6 +31,7 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
public constructor(
|
||||
private adminService: AdminService,
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
private impersonationStorageService: ImpersonationStorageService,
|
||||
@ -112,7 +114,7 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
private fetchAdminData() {
|
||||
this.dataService
|
||||
this.adminService
|
||||
.fetchAdminData()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ users }) => {
|
||||
|
@ -109,7 +109,7 @@
|
||||
[matMenuTriggerFor]="userMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
||||
</button>
|
||||
<mat-menu #userMenu="matMenu" xPosition="before">
|
||||
<button
|
||||
|
@ -1,6 +1,8 @@
|
||||
<div class="mb-2 row">
|
||||
<div class="col-md-6 col-xs-12 d-flex">
|
||||
<div class="align-items-center d-flex flex-grow-1 h5 mb-0 text-truncate">
|
||||
<div
|
||||
class="align-items-center d-flex flex-grow-1 h5 mb-0 py-2 text-truncate"
|
||||
>
|
||||
<span i18n>Performance</span>
|
||||
<gf-premium-indicator
|
||||
*ngIf="user?.subscription?.type === 'Basic'"
|
||||
@ -13,7 +15,6 @@
|
||||
appearance="outline"
|
||||
class="w-100 without-hint"
|
||||
color="accent"
|
||||
[hidden]="benchmarks?.length === 0"
|
||||
>
|
||||
<mat-label i18n>Compare with...</mat-label>
|
||||
<mat-select
|
||||
@ -28,6 +29,12 @@
|
||||
[value]="symbolProfile.id"
|
||||
>{{ symbolProfile.name }}</mat-option
|
||||
>
|
||||
<mat-option
|
||||
*ngIf="hasPermissionToAccessAdminControl"
|
||||
i18n
|
||||
[routerLink]="['/admin', 'market-data']"
|
||||
>Manage Benchmarks</mat-option
|
||||
>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
@ -23,6 +23,7 @@ import {
|
||||
parseDate
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { LineChartItem, User } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { ColorScheme } from '@ghostfolio/common/types';
|
||||
import { SymbolProfile } from '@prisma/client';
|
||||
import {
|
||||
@ -59,6 +60,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
|
||||
@ViewChild('chartCanvas') chartCanvas;
|
||||
|
||||
public chart: Chart<'line'>;
|
||||
public hasPermissionToAccessAdminControl: boolean;
|
||||
|
||||
public constructor() {
|
||||
Chart.register(
|
||||
@ -76,6 +78,11 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
|
||||
}
|
||||
|
||||
public ngOnChanges() {
|
||||
this.hasPermissionToAccessAdminControl = hasPermission(
|
||||
this.user?.permissions,
|
||||
permissions.accessAdminControl
|
||||
);
|
||||
|
||||
if (this.performanceDataItems) {
|
||||
this.initialize();
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
|
||||
@ -16,7 +17,8 @@ import { BenchmarkComparatorComponent } from './benchmark-comparator.component';
|
||||
GfPremiumIndicatorModule,
|
||||
MatSelectModule,
|
||||
NgxSkeletonLoaderModule,
|
||||
ReactiveFormsModule
|
||||
ReactiveFormsModule,
|
||||
RouterModule
|
||||
]
|
||||
})
|
||||
export class GfBenchmarkComparatorModule {}
|
||||
|
@ -8,216 +8,238 @@
|
||||
<gf-logo [label]="pageTitle"></gf-logo>
|
||||
</a>
|
||||
<span class="spacer"></span>
|
||||
<a
|
||||
class="d-none d-sm-block"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'home' || currentRoute === 'zen',
|
||||
'text-decoration-underline':
|
||||
currentRoute === 'home' || currentRoute === 'zen'
|
||||
}"
|
||||
[routerLink]="['/']"
|
||||
>Overview</a
|
||||
>
|
||||
<a
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'portfolio',
|
||||
'text-decoration-underline': currentRoute === 'portfolio'
|
||||
}"
|
||||
[routerLink]="['/portfolio']"
|
||||
>Portfolio</a
|
||||
>
|
||||
<a
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'accounts',
|
||||
'text-decoration-underline': currentRoute === 'accounts'
|
||||
}"
|
||||
[routerLink]="['/accounts']"
|
||||
>Accounts</a
|
||||
>
|
||||
<a
|
||||
*ngIf="hasPermissionToAccessAdminControl"
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'admin',
|
||||
'text-decoration-underline': currentRoute === 'admin'
|
||||
}"
|
||||
[routerLink]="['/admin']"
|
||||
>Admin Control</a
|
||||
>
|
||||
<a
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'resources',
|
||||
'text-decoration-underline': currentRoute === 'resources'
|
||||
}"
|
||||
[routerLink]="['/resources']"
|
||||
>Resources</a
|
||||
>
|
||||
<a
|
||||
*ngIf="
|
||||
hasPermissionForSubscription && user?.subscription?.type === 'Basic'
|
||||
"
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'pricing',
|
||||
'text-decoration-underline': currentRoute === 'pricing'
|
||||
}"
|
||||
[routerLink]="['/pricing']"
|
||||
>Pricing</a
|
||||
>
|
||||
<a
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'about',
|
||||
'text-decoration-underline': currentRoute === 'about'
|
||||
}"
|
||||
[routerLink]="['/about']"
|
||||
>About</a
|
||||
>
|
||||
<button
|
||||
class="no-min-width px-1"
|
||||
mat-flat-button
|
||||
[matMenuTriggerFor]="accountMenu"
|
||||
(menuClosed)="onMenuClosed()"
|
||||
(menuOpened)="onMenuOpened()"
|
||||
>
|
||||
<ion-icon
|
||||
class="d-none d-sm-block"
|
||||
name="person-circle-outline"
|
||||
size="large"
|
||||
></ion-icon>
|
||||
<ion-icon
|
||||
class="d-block d-sm-none"
|
||||
size="large"
|
||||
[name]="isMenuOpen ? 'close-outline' : 'menu-outline'"
|
||||
></ion-icon>
|
||||
</button>
|
||||
<mat-menu #accountMenu="matMenu" xPosition="before">
|
||||
<ng-container *ngIf="user?.access?.length > 0">
|
||||
<button mat-menu-item (click)="impersonateAccount(null)">
|
||||
<ion-icon
|
||||
*ngIf="user?.access?.length > 0"
|
||||
class="mr-2"
|
||||
[name]="
|
||||
impersonationId
|
||||
? 'radio-button-off-outline'
|
||||
: 'radio-button-on-outline'
|
||||
"
|
||||
></ion-icon>
|
||||
<span i18n>Me</span>
|
||||
</button>
|
||||
<button
|
||||
*ngFor="let accessItem of user?.access"
|
||||
mat-menu-item
|
||||
(click)="impersonateAccount(accessItem.id)"
|
||||
<ul class="alig-items-center d-flex list-inline m-0">
|
||||
<li class="list-inline-item">
|
||||
<a
|
||||
class="d-none d-sm-block"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold':
|
||||
currentRoute === 'home' || currentRoute === 'zen',
|
||||
'text-decoration-underline':
|
||||
currentRoute === 'home' || currentRoute === 'zen'
|
||||
}"
|
||||
[routerLink]="['/']"
|
||||
>Overview</a
|
||||
>
|
||||
<ion-icon
|
||||
class="mr-2"
|
||||
name="square-outline"
|
||||
[name]="
|
||||
accessItem.id === impersonationId
|
||||
? 'radio-button-on-outline'
|
||||
: 'radio-button-off-outline'
|
||||
"
|
||||
></ion-icon>
|
||||
<span *ngIf="accessItem.alias">{{ accessItem.alias }}</span>
|
||||
<span *ngIf="!accessItem.alias" i18n>User</span>
|
||||
</button>
|
||||
<hr class="m-0" />
|
||||
</ng-container>
|
||||
<a
|
||||
class="d-flex d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'home' || currentRoute === 'zen'
|
||||
}"
|
||||
[routerLink]="['/']"
|
||||
>Overview</a
|
||||
>
|
||||
<a
|
||||
class="d-flex d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'portfolio'
|
||||
}"
|
||||
[routerLink]="['/portfolio']"
|
||||
>Portfolio</a
|
||||
>
|
||||
<a
|
||||
class="d-flex d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'accounts' }"
|
||||
[routerLink]="['/accounts']"
|
||||
>Accounts</a
|
||||
>
|
||||
<a
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'account' }"
|
||||
[routerLink]="['/account']"
|
||||
>My Ghostfolio</a
|
||||
>
|
||||
<a
|
||||
*ngIf="hasPermissionToAccessAdminControl"
|
||||
class="d-flex d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'admin' }"
|
||||
[routerLink]="['/admin']"
|
||||
>Admin Control</a
|
||||
>
|
||||
<hr class="m-0" />
|
||||
<a
|
||||
class="d-flex d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'resources'
|
||||
}"
|
||||
[routerLink]="['/resources']"
|
||||
>Resources</a
|
||||
>
|
||||
<a
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<a
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'portfolio',
|
||||
'text-decoration-underline': currentRoute === 'portfolio'
|
||||
}"
|
||||
[routerLink]="['/portfolio']"
|
||||
>Portfolio</a
|
||||
>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<a
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'accounts',
|
||||
'text-decoration-underline': currentRoute === 'accounts'
|
||||
}"
|
||||
[routerLink]="['/accounts']"
|
||||
>Accounts</a
|
||||
>
|
||||
</li>
|
||||
<li *ngIf="hasPermissionToAccessAdminControl" class="list-inline-item">
|
||||
<a
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'admin',
|
||||
'text-decoration-underline': currentRoute === 'admin'
|
||||
}"
|
||||
[routerLink]="['/admin']"
|
||||
>Admin Control</a
|
||||
>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<a
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'resources',
|
||||
'text-decoration-underline': currentRoute === 'resources'
|
||||
}"
|
||||
[routerLink]="['/resources']"
|
||||
>Resources</a
|
||||
>
|
||||
</li>
|
||||
<li
|
||||
*ngIf="
|
||||
hasPermissionForSubscription && user?.subscription?.type === 'Basic'
|
||||
"
|
||||
class="d-flex d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'pricing' }"
|
||||
[routerLink]="['/pricing']"
|
||||
>Pricing</a
|
||||
class="list-inline-item"
|
||||
>
|
||||
<a
|
||||
class="d-flex d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'about' }"
|
||||
[routerLink]="['/about']"
|
||||
>About Ghostfolio</a
|
||||
>
|
||||
<hr class="d-flex d-sm-none m-0" />
|
||||
<button mat-menu-item (click)="onSignOut()">Logout</button>
|
||||
</mat-menu>
|
||||
<a
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'pricing',
|
||||
'text-decoration-underline': currentRoute === 'pricing'
|
||||
}"
|
||||
[routerLink]="['/pricing']"
|
||||
>Pricing</a
|
||||
>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<a
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'about',
|
||||
'text-decoration-underline': currentRoute === 'about'
|
||||
}"
|
||||
[routerLink]="['/about']"
|
||||
>About</a
|
||||
>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<button
|
||||
class="no-min-width px-1"
|
||||
mat-flat-button
|
||||
[matMenuTriggerFor]="accountMenu"
|
||||
(menuClosed)="onMenuClosed()"
|
||||
(menuOpened)="onMenuOpened()"
|
||||
>
|
||||
<ion-icon
|
||||
class="d-none d-sm-block"
|
||||
name="person-circle-outline"
|
||||
size="large"
|
||||
></ion-icon>
|
||||
<ion-icon
|
||||
class="d-block d-sm-none"
|
||||
size="large"
|
||||
[name]="isMenuOpen ? 'close-outline' : 'menu-outline'"
|
||||
></ion-icon>
|
||||
</button>
|
||||
<mat-menu #accountMenu="matMenu" xPosition="before">
|
||||
<ng-container *ngIf="user?.access?.length > 0">
|
||||
<button mat-menu-item (click)="impersonateAccount(null)">
|
||||
<ion-icon
|
||||
*ngIf="user?.access?.length > 0"
|
||||
class="mr-2"
|
||||
[name]="
|
||||
impersonationId
|
||||
? 'radio-button-off-outline'
|
||||
: 'radio-button-on-outline'
|
||||
"
|
||||
></ion-icon>
|
||||
<span i18n>Me</span>
|
||||
</button>
|
||||
<button
|
||||
*ngFor="let accessItem of user?.access"
|
||||
mat-menu-item
|
||||
(click)="impersonateAccount(accessItem.id)"
|
||||
>
|
||||
<ion-icon
|
||||
class="mr-2"
|
||||
name="square-outline"
|
||||
[name]="
|
||||
accessItem.id === impersonationId
|
||||
? 'radio-button-on-outline'
|
||||
: 'radio-button-off-outline'
|
||||
"
|
||||
></ion-icon>
|
||||
<span *ngIf="accessItem.alias">{{ accessItem.alias }}</span>
|
||||
<span *ngIf="!accessItem.alias" i18n>User</span>
|
||||
</button>
|
||||
<hr class="m-0" />
|
||||
</ng-container>
|
||||
<a
|
||||
class="d-flex d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{
|
||||
'font-weight-bold':
|
||||
currentRoute === 'home' || currentRoute === 'zen'
|
||||
}"
|
||||
[routerLink]="['/']"
|
||||
>Overview</a
|
||||
>
|
||||
<a
|
||||
class="d-flex d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'portfolio'
|
||||
}"
|
||||
[routerLink]="['/portfolio']"
|
||||
>Portfolio</a
|
||||
>
|
||||
<a
|
||||
class="d-flex d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'accounts' }"
|
||||
[routerLink]="['/accounts']"
|
||||
>Accounts</a
|
||||
>
|
||||
<a
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'account' }"
|
||||
[routerLink]="['/account']"
|
||||
>My Ghostfolio</a
|
||||
>
|
||||
<a
|
||||
*ngIf="hasPermissionToAccessAdminControl"
|
||||
class="d-flex d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'admin' }"
|
||||
[routerLink]="['/admin']"
|
||||
>Admin Control</a
|
||||
>
|
||||
<hr class="m-0" />
|
||||
<a
|
||||
class="d-flex d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'resources'
|
||||
}"
|
||||
[routerLink]="['/resources']"
|
||||
>Resources</a
|
||||
>
|
||||
<a
|
||||
*ngIf="
|
||||
hasPermissionForSubscription &&
|
||||
user?.subscription?.type === 'Basic'
|
||||
"
|
||||
class="d-flex d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'pricing' }"
|
||||
[routerLink]="['/pricing']"
|
||||
>Pricing</a
|
||||
>
|
||||
<a
|
||||
class="d-flex d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'about' }"
|
||||
[routerLink]="['/about']"
|
||||
>About Ghostfolio</a
|
||||
>
|
||||
<hr class="d-flex d-sm-none m-0" />
|
||||
<button mat-menu-item (click)="onSignOut()">Logout</button>
|
||||
</mat-menu>
|
||||
</li>
|
||||
</ul>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="user === null">
|
||||
<a
|
||||
@ -231,67 +253,86 @@
|
||||
></gf-logo>
|
||||
</a>
|
||||
<span class="spacer"></span>
|
||||
<a
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'features',
|
||||
'text-decoration-underline': currentRoute === 'features'
|
||||
}"
|
||||
[routerLink]="['/features']"
|
||||
>Features</a
|
||||
>
|
||||
<a
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'about',
|
||||
'text-decoration-underline': currentRoute === 'about'
|
||||
}"
|
||||
[routerLink]="['/about']"
|
||||
>About</a
|
||||
>
|
||||
<a
|
||||
*ngIf="hasPermissionForSubscription"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'pricing',
|
||||
'text-decoration-underline': currentRoute === 'pricing'
|
||||
}"
|
||||
[routerLink]="['/pricing']"
|
||||
>Pricing</a
|
||||
>
|
||||
<a
|
||||
*ngIf="hasPermissionToAccessFearAndGreedIndex"
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'markets',
|
||||
'text-decoration-underline': currentRoute === 'markets'
|
||||
}"
|
||||
[routerLink]="['/markets']"
|
||||
>Markets</a
|
||||
>
|
||||
<a
|
||||
class="d-none d-sm-block no-min-width"
|
||||
href="https://github.com/ghostfolio/ghostfolio"
|
||||
mat-icon-button
|
||||
><ion-icon name="logo-github"></ion-icon
|
||||
></a>
|
||||
<button class="mx-1" mat-flat-button (click)="openLoginDialog()">
|
||||
<ng-container i18n>Sign in</ng-container>
|
||||
</button>
|
||||
<a
|
||||
*ngIf="currentRoute !== 'register' && hasPermissionToCreateUser"
|
||||
class="d-none d-sm-block"
|
||||
color="primary"
|
||||
mat-flat-button
|
||||
[routerLink]="['/register']"
|
||||
><ng-container i18n>Get started</ng-container>
|
||||
</a>
|
||||
<ul class="alig-items-center d-flex list-inline m-0">
|
||||
<li class="list-inline-item">
|
||||
<a
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'features',
|
||||
'text-decoration-underline': currentRoute === 'features'
|
||||
}"
|
||||
[routerLink]="['/features']"
|
||||
>Features</a
|
||||
>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<a
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'about',
|
||||
'text-decoration-underline': currentRoute === 'about'
|
||||
}"
|
||||
[routerLink]="['/about']"
|
||||
>About</a
|
||||
>
|
||||
</li>
|
||||
<li *ngIf="hasPermissionForSubscription" class="list-inline-item">
|
||||
<a
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'pricing',
|
||||
'text-decoration-underline': currentRoute === 'pricing'
|
||||
}"
|
||||
[routerLink]="['/pricing']"
|
||||
>Pricing</a
|
||||
>
|
||||
</li>
|
||||
<li
|
||||
*ngIf="hasPermissionToAccessFearAndGreedIndex"
|
||||
class="list-inline-item"
|
||||
>
|
||||
<a
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'markets',
|
||||
'text-decoration-underline': currentRoute === 'markets'
|
||||
}"
|
||||
[routerLink]="['/markets']"
|
||||
>Markets</a
|
||||
>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<a
|
||||
class="d-none d-sm-block no-min-width p-1"
|
||||
href="https://github.com/ghostfolio/ghostfolio"
|
||||
mat-flat-button
|
||||
><ion-icon name="logo-github"></ion-icon
|
||||
></a>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<button class="mx-1" mat-flat-button (click)="openLoginDialog()">
|
||||
<ng-container i18n>Sign in</ng-container>
|
||||
</button>
|
||||
</li>
|
||||
<li
|
||||
*ngIf="currentRoute !== 'register' && hasPermissionToCreateUser"
|
||||
class="list-inline-item"
|
||||
>
|
||||
<a
|
||||
class="d-none d-sm-block"
|
||||
color="primary"
|
||||
mat-flat-button
|
||||
[routerLink]="['/register']"
|
||||
><ng-container i18n>Get started</ng-container>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</ng-container>
|
||||
</mat-toolbar>
|
||||
|
@ -1,57 +1,110 @@
|
||||
<div
|
||||
class="align-items-center container d-flex flex-column h-100 justify-content-center overview p-0 position-relative"
|
||||
>
|
||||
<div class="row w-100">
|
||||
<div class="col p-0">
|
||||
<div class="chart-container mx-auto position-relative">
|
||||
<div
|
||||
*ngIf="hasPermissionToCreateOrder && historicalDataItems?.length === 0"
|
||||
class="align-items-center d-flex h-100 justify-content-center w-100"
|
||||
<div
|
||||
*ngIf="hasPermissionToCreateOrder && historicalDataItems?.length === 0; else isUserActive"
|
||||
class="justify-content-center row w-100"
|
||||
>
|
||||
<div class="col introduction">
|
||||
<h4 i18n>Welcome to Ghostfolio</h4>
|
||||
<p i18n>Ready to take control of your personal finances?</p>
|
||||
<ol class="font-weight-bold">
|
||||
<li
|
||||
class="mb-2"
|
||||
[ngClass]="{ 'text-muted': user?.accounts?.length > 1 }"
|
||||
>
|
||||
<div class="d-flex justify-content-center">
|
||||
<gf-no-transactions-info-indicator></gf-no-transactions-info-indicator>
|
||||
</div>
|
||||
<a class="d-block" [routerLink]="['/accounts']"
|
||||
><span i18n>Setup your accounts</span><br />
|
||||
<span class="font-weight-normal" i18n
|
||||
>Get a comprehensive financial overview by adding your bank and
|
||||
brokerage accounts.</span
|
||||
></a
|
||||
>
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<a class="d-block" [routerLink]="['/portfolio', 'activities']">
|
||||
<span i18n>Capture your activities</span><br />
|
||||
<span class="font-weight-normal" i18n
|
||||
>Record your investment activities to keep your portfolio up to
|
||||
date.</span
|
||||
></a
|
||||
>
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<a class="d-block" [routerLink]="['/portfolio']">
|
||||
<span i18n>Monitor and analyze your portfolio</span><br />
|
||||
<span class="font-weight-normal" i18n
|
||||
>Track your progress in real-time with comprehensive analysis and
|
||||
insights.</span
|
||||
>
|
||||
</a>
|
||||
</li>
|
||||
</ol>
|
||||
<div class="d-flex justify-content-center">
|
||||
<a
|
||||
*ngIf="user?.accounts?.length === 1"
|
||||
color="primary"
|
||||
mat-flat-button
|
||||
[routerLink]="['/accounts']"
|
||||
>
|
||||
<ng-container i18n>Setup accounts</ng-container>
|
||||
</a>
|
||||
<a
|
||||
*ngIf="user?.accounts?.length > 1"
|
||||
color="primary"
|
||||
mat-flat-button
|
||||
[routerLink]="['/portfolio', 'activities']"
|
||||
>
|
||||
<ng-container i18n>Add activity</ng-container>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ng-template #isUserActive>
|
||||
<div class="row w-100">
|
||||
<div class="col p-0">
|
||||
<div class="chart-container mx-auto position-relative">
|
||||
<gf-line-chart
|
||||
class="position-absolute"
|
||||
symbol="Performance"
|
||||
unit="%"
|
||||
[colorScheme]="user?.settings?.colorScheme"
|
||||
[hidden]="historicalDataItems?.length === 0"
|
||||
[historicalDataItems]="historicalDataItems"
|
||||
[isAnimated]="user?.settings?.dateRange === '1d' ? false : true"
|
||||
[locale]="user?.settings?.locale"
|
||||
[ngClass]="{ 'pr-3': deviceType === 'mobile' }"
|
||||
[showGradient]="true"
|
||||
[showLoader]="false"
|
||||
[showXAxis]="false"
|
||||
[showYAxis]="false"
|
||||
></gf-line-chart>
|
||||
</div>
|
||||
<gf-line-chart
|
||||
class="position-absolute"
|
||||
symbol="Performance"
|
||||
unit="%"
|
||||
[colorScheme]="user?.settings?.colorScheme"
|
||||
[hidden]="historicalDataItems?.length === 0"
|
||||
[historicalDataItems]="historicalDataItems"
|
||||
[isAnimated]="user?.settings?.dateRange === '1d' ? false : true"
|
||||
[locale]="user?.settings?.locale"
|
||||
[ngClass]="{ 'pr-3': deviceType === 'mobile' }"
|
||||
[showGradient]="true"
|
||||
[showLoader]="false"
|
||||
[showXAxis]="false"
|
||||
[showYAxis]="false"
|
||||
></gf-line-chart>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overview-container row mt-1">
|
||||
<div class="col">
|
||||
<gf-portfolio-performance
|
||||
class="pb-4"
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[deviceType]="deviceType"
|
||||
[errors]="errors"
|
||||
[isAllTimeHigh]="isAllTimeHigh"
|
||||
[isAllTimeLow]="isAllTimeLow"
|
||||
[isLoading]="isLoadingPerformance"
|
||||
[locale]="user?.settings?.locale"
|
||||
[performance]="performance"
|
||||
[showDetails]="showDetails"
|
||||
></gf-portfolio-performance>
|
||||
<div *ngIf="showDetails" class="text-center">
|
||||
<gf-toggle
|
||||
[defaultValue]="user?.settings?.dateRange"
|
||||
<div class="overview-container row mt-1">
|
||||
<div class="col">
|
||||
<gf-portfolio-performance
|
||||
class="pb-4"
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[deviceType]="deviceType"
|
||||
[errors]="errors"
|
||||
[isAllTimeHigh]="isAllTimeHigh"
|
||||
[isAllTimeLow]="isAllTimeLow"
|
||||
[isLoading]="isLoadingPerformance"
|
||||
[options]="dateRangeOptions"
|
||||
(change)="onChangeDateRange($event.value)"
|
||||
></gf-toggle>
|
||||
[locale]="user?.settings?.locale"
|
||||
[performance]="performance"
|
||||
[showDetails]="showDetails"
|
||||
></gf-portfolio-performance>
|
||||
<div *ngIf="showDetails" class="text-center">
|
||||
<gf-toggle
|
||||
[defaultValue]="user?.settings?.dateRange"
|
||||
[isLoading]="isLoadingPerformance"
|
||||
[options]="dateRangeOptions"
|
||||
(change)="onChangeDateRange($event.value)"
|
||||
></gf-toggle>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { GfPortfolioPerformanceModule } from '@ghostfolio/client/components/portfolio-performance/portfolio-performance.module';
|
||||
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
|
||||
@ -16,6 +17,7 @@ import { HomeOverviewComponent } from './home-overview.component';
|
||||
GfNoTransactionsInfoModule,
|
||||
GfPortfolioPerformanceModule,
|
||||
GfToggleModule,
|
||||
MatButtonModule,
|
||||
RouterModule
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
|
@ -31,4 +31,8 @@
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.introduction {
|
||||
max-width: 50rem;
|
||||
}
|
||||
}
|
||||
|
@ -16,6 +16,8 @@ import { TokenStorageService } from '@ghostfolio/client/services/token-storage.s
|
||||
templateUrl: 'login-with-access-token-dialog.html'
|
||||
})
|
||||
export class LoginWithAccessTokenDialog {
|
||||
public isAccessTokenHidden = true;
|
||||
|
||||
public constructor(
|
||||
@Inject(MAT_DIALOG_DATA) public data: any,
|
||||
public dialogRef: MatDialogRef<LoginWithAccessTokenDialog>,
|
||||
@ -38,6 +40,12 @@ export class LoginWithAccessTokenDialog {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
public onLoginWithAccessToken() {
|
||||
if (this.data.accessToken) {
|
||||
this.dialogRef.close(this.data);
|
||||
}
|
||||
}
|
||||
|
||||
public async onLoginWithInternetIdentity() {
|
||||
try {
|
||||
const { authToken } = await this.internetIdentityService.login();
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user