Compare commits
62 Commits
Author | SHA1 | Date | |
---|---|---|---|
69ac3408f1 | |||
e1806b4bd8 | |||
6aae0cc1e4 | |||
5d8a50a80d | |||
662231e830 | |||
4d84459b5b | |||
efba7429c1 | |||
9cae5a3e79 | |||
c2ed0a436f | |||
8486c02575 | |||
5122ef3456 | |||
579b86665e | |||
52b3ad6dc3 | |||
bf9b60aa74 | |||
6cd51fb044 | |||
271001f523 | |||
a7e513a6d1 | |||
b5f256be95 | |||
a834ef6b4c | |||
e5bd0d1bfa | |||
7fa6eda45d | |||
f47e4d3b04 | |||
0300c6f3b7 | |||
4865c45fd4 | |||
2beceb36cf | |||
cd64601482 | |||
efac39eb51 | |||
4da8a547ca | |||
9e8a9e4670 | |||
bb99141e9c | |||
d147c2313f | |||
0878941c4f | |||
69a9e77820 | |||
104cca069f | |||
7ad58b1a62 | |||
e88dbb0181 | |||
152fd4fdf8 | |||
6b022b8de8 | |||
7ab699e5fe | |||
a7e5a316be | |||
3f2d3a2da9 | |||
0208bd0923 | |||
aeba6e1f03 | |||
1b899da9ff | |||
90a7a84ac5 | |||
fc8e23a9c8 | |||
f3c8ec27cb | |||
38474f54b0 | |||
18d25fb6c2 | |||
a850e8ca22 | |||
b5f565c054 | |||
aa6d0a4533 | |||
25e9028a41 | |||
925d38703e | |||
158bb00b8a | |||
b17111e6f1 | |||
c4765e31cd | |||
d321d56dee | |||
07dd22f7fe | |||
eb4d088a80 | |||
0509f0101f | |||
8818e09be8 |
2
.github/workflows/build-code.yml
vendored
2
.github/workflows/build-code.yml
vendored
@ -10,7 +10,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
node_version:
|
node_version:
|
||||||
- 16
|
- 18
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
127
CHANGELOG.md
127
CHANGELOG.md
@ -5,6 +5,133 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## 1.230.0 - 2023-01-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added an interstitial for the subscription
|
||||||
|
- Added _SourceForge_ to the _As seen in_ section on the landing page
|
||||||
|
- Added a quote to the blog post _Ghostfolio auf Sackgeld.com vorgestellt_
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the unit format (`%`) in the global heat map component of the public page
|
||||||
|
- Improved the pricing page
|
||||||
|
- Upgraded `Node.js` from version `16` to `18` (`Dockerfile`)
|
||||||
|
- Upgraded `prisma` from version `4.8.0` to `4.9.0`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the click of unknown accounts in the portfolio proportion chart component
|
||||||
|
- Fixed an issue with `value` in the value redaction interceptor for the impersonation mode
|
||||||
|
|
||||||
|
## 1.229.0 - 2023-01-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a blog post: _Ghostfolio auf Sackgeld.com vorgestellt_
|
||||||
|
- Added _Sackgeld.com_ to the _As seen in_ section on the landing page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Removed the toggle _Original Shares_ vs. _Current Shares_ on the allocations page
|
||||||
|
- Hid error messages related to no current investment in the client
|
||||||
|
- Refactored the value redaction interceptor for the impersonation mode
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the value of the active (emergency fund) filter in percentage on the allocations page
|
||||||
|
|
||||||
|
## 1.228.1 - 2023-01-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the hints in user settings
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the date formatting in the tooltip of the dividend timeline grouped by month / year
|
||||||
|
- Improved the date formatting in the tooltip of the investment timeline grouped by month / year
|
||||||
|
- Reduced the execution interval of the data gathering to every 4 hours
|
||||||
|
- Removed emergency fund as an asset class
|
||||||
|
|
||||||
|
## 1.227.1 - 2023-01-14
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the create or edit activity dialog
|
||||||
|
|
||||||
|
## 1.227.0 - 2023-01-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for assets other than cash in emergency fund (affecting buying power)
|
||||||
|
- Added support for translated tags
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the logo alignment
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the grouping by month / year of the dividend and investment timeline
|
||||||
|
|
||||||
|
## 1.226.0 - 2023-01-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the language localization for Français (`fr`)
|
||||||
|
- Extended the landing page by a global heat map of subscribers
|
||||||
|
- Added support for the thousand separator in the global heat map component
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the form of the import dividends dialog (disable while loading)
|
||||||
|
- Removed the deprecated `~` in _Sass_ imports
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an exception in the _X-ray_ section
|
||||||
|
|
||||||
|
## 1.225.0 - 2023-01-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for importing dividends from a data provider
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Extended the Frequently Asked Questions (FAQ) page
|
||||||
|
|
||||||
|
## 1.224.0 - 2023-01-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
- Improved the language localization for Dutch (`nl`)
|
||||||
|
|
||||||
|
## 1.223.0 - 2023-01-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a student discount to the pricing page
|
||||||
|
- Added a prefix to the codes of the coupon system
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Optimized the page titles in the header for mobile
|
||||||
|
- Extended the asset profile details dialog in the admin control panel
|
||||||
|
|
||||||
## 1.222.0 - 2022-12-29
|
## 1.222.0 - 2022-12-29
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
@ -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
|
# Build application and add additional files
|
||||||
WORKDIR /ghostfolio
|
WORKDIR /ghostfolio
|
||||||
@ -50,7 +50,7 @@ COPY package.json /ghostfolio/dist/apps/api
|
|||||||
RUN yarn database:generate-typings
|
RUN yarn database:generate-typings
|
||||||
|
|
||||||
# Image to run, copy everything needed from builder
|
# Image to run, copy everything needed from builder
|
||||||
FROM node:16-slim
|
FROM node:18-slim
|
||||||
RUN apt update && apt install -y \
|
RUN apt update && apt install -y \
|
||||||
openssl \
|
openssl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
79
README.md
79
README.md
@ -1,34 +1,26 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<a href="https://ghostfol.io">
|
|
||||||
<img
|
|
||||||
alt="Ghostfolio Logo"
|
|
||||||
src="https://avatars.githubusercontent.com/u/82473144?s=200"
|
|
||||||
width="100"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<h1>Ghostfolio</h1>
|
[<img src="https://avatars.githubusercontent.com/u/82473144?s=200" width="100" alt="Ghostfolio logo">](https://ghostfol.io)
|
||||||
<p>
|
|
||||||
<strong>Open Source Wealth Management Software</strong>
|
# Ghostfolio
|
||||||
</p>
|
|
||||||
<p>
|
**Open Source Wealth Management Software**
|
||||||
<a href="https://ghostfol.io"><strong>Ghostfol.io</strong></a> | <a href="https://ghostfol.io/en/demo"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/en/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/en/faq"><strong>FAQ</strong></a> | <a href="https://ghostfol.io/en/blog"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
|
|
||||||
</p>
|
[**Ghostfol.io**](https://ghostfol.io) | [**Live Demo**](https://ghostfol.io/en/demo) | [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) | [**FAQ**](https://ghostfol.io/en/faq) |
|
||||||
<p>
|
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**Twitter**](https://twitter.com/ghostfolio_)
|
||||||
<a href="https://www.buymeacoffee.com/ghostfolio">
|
|
||||||
<img src="https://img.shields.io/badge/Buy%20me%20a%20coffee-Support-yellow?logo=buymeacoffee"/></a>
|
[](https://www.buymeacoffee.com/ghostfolio)
|
||||||
<a href="#contributing">
|
[](#contributing)
|
||||||
<img src="https://img.shields.io/badge/Contributions-Welcome-orange.svg"/></a>
|
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||||
<a href="https://www.gnu.org/licenses/agpl-3.0" rel="nofollow">
|
|
||||||
<img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3"/></a>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. The software is designed for personal use in continuous operation.
|
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. The software is designed for personal use in continuous operation.
|
||||||
|
|
||||||
<div align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
|
<div align="center">
|
||||||
<a href="https://www.youtube.com/watch?v=yY6ObSQVJZk">
|
|
||||||
<img src="./apps/client/src/assets/images/video-preview.jpg" width="600"></a>
|
[<img src="./apps/client/src/assets/images/video-preview.jpg" width="600" alt="Preview image of the Ghostfolio video trailer">](https://www.youtube.com/watch?v=yY6ObSQVJZk)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## Ghostfolio Premium
|
## Ghostfolio Premium
|
||||||
@ -48,7 +40,7 @@ Ghostfolio is for you if you are...
|
|||||||
- 🧘 into minimalism
|
- 🧘 into minimalism
|
||||||
- 🧺 caring about diversifying your financial resources
|
- 🧺 caring about diversifying your financial resources
|
||||||
- 🆓 interested in financial independence
|
- 🆓 interested in financial independence
|
||||||
- 🙅 saying no to spreadsheets in 2022
|
- 🙅 saying no to spreadsheets in 2023
|
||||||
- 😎 still reading this list
|
- 😎 still reading this list
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
@ -63,8 +55,10 @@ Ghostfolio is for you if you are...
|
|||||||
- ✅ Zen Mode
|
- ✅ Zen Mode
|
||||||
- ✅ Progressive Web App (PWA) with a mobile-first design
|
- ✅ Progressive Web App (PWA) with a mobile-first design
|
||||||
|
|
||||||
<div align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
|
<div align="center">
|
||||||
<img src="./apps/client/src/assets/images/screenshot.png" width="300">
|
|
||||||
|
<img src="./apps/client/src/assets/images/screenshot.png" width="300" alt="Image of a phone showing the Ghostfolio app open">
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## Technology Stack
|
## Technology Stack
|
||||||
@ -84,13 +78,9 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
|
|||||||
We provide official container images hosted on [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio) for `linux/amd64` and `linux/arm64`.
|
We provide official container images hosted on [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio) for `linux/amd64` and `linux/arm64`.
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<a href="https://www.buymeacoffee.com/ghostfolio">
|
|
||||||
<img
|
[<img src="./apps/client/src/assets/images/button-buy-me-a-coffee.png" width="150" alt="Buy me a coffee button"/>](https://www.buymeacoffee.com/ghostfolio)
|
||||||
alt="Buy me a coffee button"
|
|
||||||
src="./apps/client/src/assets/images/button-buy-me-a-coffee.png"
|
|
||||||
width="150"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
### Supported Environment Variables
|
### Supported Environment Variables
|
||||||
@ -158,7 +148,7 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
|
|||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||||
- [Node.js](https://nodejs.org/en/download) (version 16+)
|
- [Node.js](https://nodejs.org/en/download) (version 16)
|
||||||
- [Yarn](https://yarnpkg.com/en/docs/install)
|
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||||
- A local copy of this Git repository (clone)
|
- A local copy of this Git repository (clone)
|
||||||
|
|
||||||
@ -175,10 +165,13 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
|
|||||||
|
|
||||||
### Start Server
|
### Start Server
|
||||||
|
|
||||||
<ol type="a">
|
#### Debug
|
||||||
<li>Debug: Run <code>yarn watch:server</code> and click "Launch Program" in <a href="https://code.visualstudio.com">Visual Studio Code</a></li>
|
|
||||||
<li>Serve: Run <code>yarn start:server</code></li>
|
Run `yarn watch:server` and click _Launch Program_ in [Visual Studio Code](https://code.visualstudio.com)
|
||||||
</ol>
|
|
||||||
|
#### Serve
|
||||||
|
|
||||||
|
Run `yarn start:server`
|
||||||
|
|
||||||
### Start Client
|
### Start Client
|
||||||
|
|
||||||
@ -276,12 +269,12 @@ You can get the _Bearer Token_ via `GET http://localhost:3333/api/v1/auth/anonym
|
|||||||
|
|
||||||
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
|
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
|
||||||
|
|
||||||
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg), tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from 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.
|
||||||
|
|
||||||
If you like to support this project, get **[Ghostfolio Premium](https://ghostfol.io/en/pricing)** or **[Buy me a coffee](https://www.buymeacoffee.com/ghostfolio)**.
|
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
© 2022 [Ghostfolio](https://ghostfol.io)
|
© 2023 [Ghostfolio](https://ghostfol.io)
|
||||||
|
|
||||||
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).
|
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).
|
||||||
|
@ -1,9 +1,6 @@
|
|||||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
import {
|
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||||
nullifyValuesInObject,
|
|
||||||
nullifyValuesInObjects
|
|
||||||
} from '@ghostfolio/api/helper/object.helper';
|
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||||
import { Accounts } from '@ghostfolio/common/interfaces';
|
import { Accounts } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
@ -22,7 +19,8 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
UseGuards
|
UseGuards,
|
||||||
|
UseInterceptors
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
@ -85,6 +83,7 @@ export class AccountController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
public async getAllAccounts(
|
public async getAllAccounts(
|
||||||
@Headers('impersonation-id') impersonationId
|
@Headers('impersonation-id') impersonationId
|
||||||
): Promise<Accounts> {
|
): Promise<Accounts> {
|
||||||
@ -94,39 +93,15 @@ export class AccountController {
|
|||||||
this.request.user.id
|
this.request.user.id
|
||||||
);
|
);
|
||||||
|
|
||||||
let accountsWithAggregations =
|
return this.portfolioService.getAccountsWithAggregations({
|
||||||
await this.portfolioService.getAccountsWithAggregations({
|
|
||||||
userId: impersonationUserId || this.request.user.id,
|
userId: impersonationUserId || this.request.user.id,
|
||||||
withExcludedAccounts: true
|
withExcludedAccounts: true
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
|
||||||
impersonationUserId ||
|
|
||||||
this.userService.isRestrictedView(this.request.user)
|
|
||||||
) {
|
|
||||||
accountsWithAggregations = {
|
|
||||||
...nullifyValuesInObject(accountsWithAggregations, [
|
|
||||||
'totalBalanceInBaseCurrency',
|
|
||||||
'totalValueInBaseCurrency'
|
|
||||||
]),
|
|
||||||
accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [
|
|
||||||
'balance',
|
|
||||||
'balanceInBaseCurrency',
|
|
||||||
'convertedBalance',
|
|
||||||
'fee',
|
|
||||||
'quantity',
|
|
||||||
'unitPrice',
|
|
||||||
'value',
|
|
||||||
'valueInBaseCurrency'
|
|
||||||
])
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return accountsWithAggregations;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
public async getAccountById(
|
public async getAccountById(
|
||||||
@Headers('impersonation-id') impersonationId,
|
@Headers('impersonation-id') impersonationId,
|
||||||
@Param('id') id: string
|
@Param('id') id: string
|
||||||
@ -137,35 +112,13 @@ export class AccountController {
|
|||||||
this.request.user.id
|
this.request.user.id
|
||||||
);
|
);
|
||||||
|
|
||||||
let accountsWithAggregations =
|
const accountsWithAggregations =
|
||||||
await this.portfolioService.getAccountsWithAggregations({
|
await this.portfolioService.getAccountsWithAggregations({
|
||||||
filters: [{ id, type: 'ACCOUNT' }],
|
filters: [{ id, type: 'ACCOUNT' }],
|
||||||
userId: impersonationUserId || this.request.user.id,
|
userId: impersonationUserId || this.request.user.id,
|
||||||
withExcludedAccounts: true
|
withExcludedAccounts: true
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
|
||||||
impersonationUserId ||
|
|
||||||
this.userService.isRestrictedView(this.request.user)
|
|
||||||
) {
|
|
||||||
accountsWithAggregations = {
|
|
||||||
...nullifyValuesInObject(accountsWithAggregations, [
|
|
||||||
'totalBalanceInBaseCurrency',
|
|
||||||
'totalValueInBaseCurrency'
|
|
||||||
]),
|
|
||||||
accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [
|
|
||||||
'balance',
|
|
||||||
'balanceInBaseCurrency',
|
|
||||||
'convertedBalance',
|
|
||||||
'fee',
|
|
||||||
'quantity',
|
|
||||||
'unitPrice',
|
|
||||||
'value',
|
|
||||||
'valueInBaseCurrency'
|
|
||||||
])
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return accountsWithAggregations.accounts[0];
|
return accountsWithAggregations.accounts[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -14,8 +14,10 @@ export class FrontendMiddleware implements NestMiddleware {
|
|||||||
public indexHtmlDe = '';
|
public indexHtmlDe = '';
|
||||||
public indexHtmlEn = '';
|
public indexHtmlEn = '';
|
||||||
public indexHtmlEs = '';
|
public indexHtmlEs = '';
|
||||||
|
public indexHtmlFr = '';
|
||||||
public indexHtmlIt = '';
|
public indexHtmlIt = '';
|
||||||
public indexHtmlNl = '';
|
public indexHtmlNl = '';
|
||||||
|
public indexHtmlPt = '';
|
||||||
public isProduction: boolean;
|
public isProduction: boolean;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
@ -41,6 +43,10 @@ export class FrontendMiddleware implements NestMiddleware {
|
|||||||
this.getPathOfIndexHtmlFile('es'),
|
this.getPathOfIndexHtmlFile('es'),
|
||||||
'utf8'
|
'utf8'
|
||||||
);
|
);
|
||||||
|
this.indexHtmlFr = fs.readFileSync(
|
||||||
|
this.getPathOfIndexHtmlFile('fr'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
this.indexHtmlIt = fs.readFileSync(
|
this.indexHtmlIt = fs.readFileSync(
|
||||||
this.getPathOfIndexHtmlFile('it'),
|
this.getPathOfIndexHtmlFile('it'),
|
||||||
'utf8'
|
'utf8'
|
||||||
@ -49,6 +55,10 @@ export class FrontendMiddleware implements NestMiddleware {
|
|||||||
this.getPathOfIndexHtmlFile('nl'),
|
this.getPathOfIndexHtmlFile('nl'),
|
||||||
'utf8'
|
'utf8'
|
||||||
);
|
);
|
||||||
|
this.indexHtmlPt = fs.readFileSync(
|
||||||
|
this.getPathOfIndexHtmlFile('pt'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -73,6 +83,13 @@ export class FrontendMiddleware implements NestMiddleware {
|
|||||||
) {
|
) {
|
||||||
featureGraphicPath = 'assets/images/blog/20221226.jpg';
|
featureGraphicPath = 'assets/images/blog/20221226.jpg';
|
||||||
title = `The importance of tracking your personal finances - ${title}`;
|
title = `The importance of tracking your personal finances - ${title}`;
|
||||||
|
} else if (
|
||||||
|
request.path.startsWith(
|
||||||
|
'/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt'
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
featureGraphicPath = 'assets/images/blog/ghostfolio-x-sackgeld.png';
|
||||||
|
title = `Ghostfolio auf Sackgeld.com vorgestellt - ${title}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -104,6 +121,15 @@ export class FrontendMiddleware implements NestMiddleware {
|
|||||||
rootUrl: this.configurationService.get('ROOT_URL')
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
} else if (request.path === '/fr' || request.path.startsWith('/fr/')) {
|
||||||
|
response.send(
|
||||||
|
this.interpolate(this.indexHtmlFr, {
|
||||||
|
featureGraphicPath,
|
||||||
|
languageCode: 'fr',
|
||||||
|
path: request.path,
|
||||||
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
|
})
|
||||||
|
);
|
||||||
} else if (request.path === '/it' || request.path.startsWith('/it/')) {
|
} else if (request.path === '/it' || request.path.startsWith('/it/')) {
|
||||||
response.send(
|
response.send(
|
||||||
this.interpolate(this.indexHtmlIt, {
|
this.interpolate(this.indexHtmlIt, {
|
||||||
@ -126,6 +152,15 @@ export class FrontendMiddleware implements NestMiddleware {
|
|||||||
rootUrl: this.configurationService.get('ROOT_URL')
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
} else if (request.path === '/pt' || request.path.startsWith('/pt/')) {
|
||||||
|
response.send(
|
||||||
|
this.interpolate(this.indexHtmlPt, {
|
||||||
|
featureGraphicPath,
|
||||||
|
languageCode: 'pt',
|
||||||
|
path: request.path,
|
||||||
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
|
})
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
response.send(
|
response.send(
|
||||||
this.interpolate(this.indexHtmlEn, {
|
this.interpolate(this.indexHtmlEn, {
|
||||||
|
@ -1,18 +1,24 @@
|
|||||||
|
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 { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { ImportResponse } from '@ghostfolio/common/interfaces';
|
import { ImportResponse } from '@ghostfolio/common/interfaces';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
Get,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
Logger,
|
Logger,
|
||||||
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
UseGuards
|
UseGuards,
|
||||||
|
UseInterceptors
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { ImportDataDto } from './import-data.dto';
|
import { ImportDataDto } from './import-data.dto';
|
||||||
@ -74,4 +80,23 @@ export class ImportController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('dividends/:dataSource/:symbol')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
|
public async gatherDividends(
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
): Promise<ImportResponse> {
|
||||||
|
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||||
|
|
||||||
|
const activities = await this.importService.getDividends({
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
userCurrency
|
||||||
|
});
|
||||||
|
|
||||||
|
return { activities };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,12 +1,14 @@
|
|||||||
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
|
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
|
||||||
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
||||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||||
|
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { ImportController } from './import.controller';
|
import { ImportController } from './import.controller';
|
||||||
@ -22,8 +24,10 @@ import { ImportService } from './import.service';
|
|||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
OrderModule,
|
OrderModule,
|
||||||
|
PortfolioModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
RedisCacheModule
|
RedisCacheModule,
|
||||||
|
SymbolProfileModule
|
||||||
],
|
],
|
||||||
providers: [ImportService]
|
providers: [ImportService]
|
||||||
})
|
})
|
||||||
|
@ -2,9 +2,16 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
|||||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||||
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
|
import {
|
||||||
|
AccountWithPlatform,
|
||||||
|
OrderWithAccount
|
||||||
|
} from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { SymbolProfile } from '@prisma/client';
|
import { SymbolProfile } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
@ -17,9 +24,81 @@ export class ImportService {
|
|||||||
private readonly accountService: AccountService,
|
private readonly accountService: AccountService,
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly orderService: OrderService
|
private readonly orderService: OrderService,
|
||||||
|
private readonly portfolioService: PortfolioService,
|
||||||
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
public async getDividends({
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
userCurrency
|
||||||
|
}: UniqueAsset & { userCurrency: string }): Promise<Activity[]> {
|
||||||
|
try {
|
||||||
|
const { firstBuyDate, historicalData, orders } =
|
||||||
|
await this.portfolioService.getPosition(dataSource, undefined, symbol);
|
||||||
|
|
||||||
|
const [[assetProfile], dividends] = await Promise.all([
|
||||||
|
this.symbolProfileService.getSymbolProfiles([
|
||||||
|
{
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}
|
||||||
|
]),
|
||||||
|
await this.dataProviderService.getDividends({
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
from: parseDate(firstBuyDate),
|
||||||
|
granularity: 'day',
|
||||||
|
to: new Date()
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
const accounts = orders.map((order) => {
|
||||||
|
return order.Account;
|
||||||
|
});
|
||||||
|
|
||||||
|
const Account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;
|
||||||
|
|
||||||
|
return Object.entries(dividends).map(([dateString, { marketPrice }]) => {
|
||||||
|
const quantity =
|
||||||
|
historicalData.find((historicalDataItem) => {
|
||||||
|
return historicalDataItem.date === dateString;
|
||||||
|
})?.quantity ?? 0;
|
||||||
|
|
||||||
|
const value = new Big(quantity).mul(marketPrice).toNumber();
|
||||||
|
|
||||||
|
return {
|
||||||
|
Account,
|
||||||
|
quantity,
|
||||||
|
value,
|
||||||
|
accountId: Account?.id,
|
||||||
|
accountUserId: undefined,
|
||||||
|
comment: undefined,
|
||||||
|
createdAt: undefined,
|
||||||
|
date: parseDate(dateString),
|
||||||
|
fee: 0,
|
||||||
|
feeInBaseCurrency: 0,
|
||||||
|
id: assetProfile.id,
|
||||||
|
isDraft: false,
|
||||||
|
SymbolProfile: <SymbolProfile>(<unknown>assetProfile),
|
||||||
|
symbolProfileId: assetProfile.id,
|
||||||
|
type: 'DIVIDEND',
|
||||||
|
unitPrice: marketPrice,
|
||||||
|
updatedAt: undefined,
|
||||||
|
userId: Account?.userId,
|
||||||
|
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
|
value,
|
||||||
|
assetProfile.currency,
|
||||||
|
userCurrency
|
||||||
|
)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async import({
|
public async import({
|
||||||
activitiesDto,
|
activitiesDto,
|
||||||
isDryRun = false,
|
isDryRun = false,
|
||||||
@ -161,6 +240,16 @@ export class ImportService {
|
|||||||
return activities;
|
return activities;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isUniqueAccount(accounts: AccountWithPlatform[]) {
|
||||||
|
const uniqueAccountIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const account of accounts) {
|
||||||
|
uniqueAccountIds.add(account.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return uniqueAccountIds.size === 1;
|
||||||
|
}
|
||||||
|
|
||||||
private async validateActivities({
|
private async validateActivities({
|
||||||
activitiesDto,
|
activitiesDto,
|
||||||
maxActivitiesToImport,
|
maxActivitiesToImport,
|
||||||
|
@ -7,6 +7,7 @@ import { PropertyService } from '@ghostfolio/api/services/property/property.serv
|
|||||||
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
||||||
import {
|
import {
|
||||||
DEMO_USER_ID,
|
DEMO_USER_ID,
|
||||||
|
PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
|
||||||
PROPERTY_IS_READ_ONLY_MODE,
|
PROPERTY_IS_READ_ONLY_MODE,
|
||||||
PROPERTY_SLACK_COMMUNITY_USERS,
|
PROPERTY_SLACK_COMMUNITY_USERS,
|
||||||
PROPERTY_STRIPE_CONFIG,
|
PROPERTY_STRIPE_CONFIG,
|
||||||
@ -92,6 +93,10 @@ export class InfoService {
|
|||||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
globalPermissions.push(permissions.enableSubscription);
|
globalPermissions.push(permissions.enableSubscription);
|
||||||
|
|
||||||
|
info.countriesOfSubscribers =
|
||||||
|
((await this.propertyService.getByKey(
|
||||||
|
PROPERTY_COUNTRIES_OF_SUBSCRIBERS
|
||||||
|
)) as string[]) ?? [];
|
||||||
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
|
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -64,7 +64,8 @@ describe('PortfolioCalculator', () => {
|
|||||||
|
|
||||||
const investments = portfolioCalculator.getInvestments();
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
const investmentsByMonth =
|
||||||
|
portfolioCalculator.getInvestmentsByGroup('month');
|
||||||
|
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
|
@ -53,7 +53,8 @@ describe('PortfolioCalculator', () => {
|
|||||||
|
|
||||||
const investments = portfolioCalculator.getInvestments();
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
const investmentsByMonth =
|
||||||
|
portfolioCalculator.getInvestmentsByGroup('month');
|
||||||
|
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
|
@ -64,7 +64,8 @@ describe('PortfolioCalculator', () => {
|
|||||||
|
|
||||||
const investments = portfolioCalculator.getInvestments();
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
const investmentsByMonth =
|
||||||
|
portfolioCalculator.getInvestmentsByGroup('month');
|
||||||
|
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
|
@ -41,7 +41,8 @@ describe('PortfolioCalculator', () => {
|
|||||||
|
|
||||||
const investments = portfolioCalculator.getInvestments();
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
const investmentsByMonth =
|
||||||
|
portfolioCalculator.getInvestmentsByGroup('month');
|
||||||
|
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
|
@ -64,7 +64,8 @@ describe('PortfolioCalculator', () => {
|
|||||||
|
|
||||||
const investments = portfolioCalculator.getInvestments();
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
const investmentsByMonth =
|
||||||
|
portfolioCalculator.getInvestmentsByGroup('month');
|
||||||
|
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
|
@ -68,7 +68,8 @@ describe('PortfolioCalculator', () => {
|
|||||||
|
|
||||||
const investments = portfolioCalculator.getInvestments();
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
const investmentsByMonth =
|
||||||
|
portfolioCalculator.getInvestmentsByGroup('month');
|
||||||
|
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
|
@ -2,6 +2,7 @@ import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/
|
|||||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||||
import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
|
import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
|
||||||
|
import { GroupBy } from '@ghostfolio/common/types';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { Type as TypeOfOrder } from '@prisma/client';
|
import { Type as TypeOfOrder } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
@ -446,7 +447,7 @@ export class PortfolioCalculator {
|
|||||||
transactionCount: item.transactionCount
|
transactionCount: item.transactionCount
|
||||||
});
|
});
|
||||||
|
|
||||||
if (hasErrors) {
|
if (hasErrors && item.investment.gt(0)) {
|
||||||
errors.push({ dataSource: item.dataSource, symbol: item.symbol });
|
errors.push({ dataSource: item.dataSource, symbol: item.symbol });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -478,46 +479,60 @@ export class PortfolioCalculator {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public getInvestmentsByMonth(): { date: string; investment: Big }[] {
|
public getInvestmentsByGroup(
|
||||||
|
groupBy: GroupBy
|
||||||
|
): { date: string; investment: Big }[] {
|
||||||
if (this.orders.length === 0) {
|
if (this.orders.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const investments = [];
|
const investments = [];
|
||||||
let currentDate: Date;
|
let currentDate: Date;
|
||||||
let investmentByMonth = new Big(0);
|
let investmentByGroup = new Big(0);
|
||||||
|
|
||||||
for (const [index, order] of this.orders.entries()) {
|
for (const [index, order] of this.orders.entries()) {
|
||||||
if (
|
if (
|
||||||
isSameMonth(parseDate(order.date), currentDate) &&
|
isSameYear(parseDate(order.date), currentDate) &&
|
||||||
isSameYear(parseDate(order.date), currentDate)
|
(groupBy === 'year' || isSameMonth(parseDate(order.date), currentDate))
|
||||||
) {
|
) {
|
||||||
// Same month: Add up investments
|
// Same group: Add up investments
|
||||||
|
|
||||||
investmentByMonth = investmentByMonth.plus(
|
investmentByGroup = investmentByGroup.plus(
|
||||||
order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
|
order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// New month: Store previous month and reset
|
// New group: Store previous group and reset
|
||||||
|
|
||||||
if (currentDate) {
|
if (currentDate) {
|
||||||
investments.push({
|
investments.push({
|
||||||
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
|
date: format(
|
||||||
investment: investmentByMonth
|
set(currentDate, {
|
||||||
|
date: 1,
|
||||||
|
month: groupBy === 'year' ? 0 : currentDate.getMonth()
|
||||||
|
}),
|
||||||
|
DATE_FORMAT
|
||||||
|
),
|
||||||
|
investment: investmentByGroup
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
currentDate = parseDate(order.date);
|
currentDate = parseDate(order.date);
|
||||||
investmentByMonth = order.quantity
|
investmentByGroup = order.quantity
|
||||||
.mul(order.unitPrice)
|
.mul(order.unitPrice)
|
||||||
.mul(this.getFactor(order.type));
|
.mul(this.getFactor(order.type));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (index === this.orders.length - 1) {
|
if (index === this.orders.length - 1) {
|
||||||
// Store current month (latest order)
|
// Store current group (latest order)
|
||||||
investments.push({
|
investments.push({
|
||||||
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
|
date: format(
|
||||||
investment: investmentByMonth
|
set(currentDate, {
|
||||||
|
date: 1,
|
||||||
|
month: groupBy === 'year' ? 0 : currentDate.getMonth()
|
||||||
|
}),
|
||||||
|
DATE_FORMAT
|
||||||
|
),
|
||||||
|
investment: investmentByGroup
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -131,7 +131,8 @@ export class PortfolioController {
|
|||||||
portfolioPosition.investment / totalInvestment;
|
portfolioPosition.investment / totalInvestment;
|
||||||
portfolioPosition.netPerformance = null;
|
portfolioPosition.netPerformance = null;
|
||||||
portfolioPosition.quantity = null;
|
portfolioPosition.quantity = null;
|
||||||
portfolioPosition.value = portfolioPosition.value / totalValue;
|
portfolioPosition.valueInPercentage =
|
||||||
|
portfolioPosition.value / totalValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [name, { current, original }] of Object.entries(accounts)) {
|
for (const [name, { current, original }] of Object.entries(accounts)) {
|
||||||
@ -322,7 +323,7 @@ export class PortfolioController {
|
|||||||
totalInvestment: new Big(totalInvestment)
|
totalInvestment: new Big(totalInvestment)
|
||||||
.div(performanceInformation.performance.totalInvestment)
|
.div(performanceInformation.performance.totalInvestment)
|
||||||
.toNumber(),
|
.toNumber(),
|
||||||
value: new Big(value)
|
valueInPercentage: new Big(value)
|
||||||
.div(performanceInformation.performance.currentValue)
|
.div(performanceInformation.performance.currentValue)
|
||||||
.toNumber()
|
.toNumber()
|
||||||
};
|
};
|
||||||
@ -356,6 +357,7 @@ export class PortfolioController {
|
|||||||
|
|
||||||
@Get('positions')
|
@Get('positions')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getPositions(
|
public async getPositions(
|
||||||
@Headers('impersonation-id') impersonationId: string,
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
@ -370,27 +372,11 @@ export class PortfolioController {
|
|||||||
filterByTags
|
filterByTags
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await this.portfolioService.getPositions({
|
return this.portfolioService.getPositions({
|
||||||
dateRange,
|
dateRange,
|
||||||
filters,
|
filters,
|
||||||
impersonationId
|
impersonationId
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
|
||||||
impersonationId ||
|
|
||||||
this.userService.isRestrictedView(this.request.user)
|
|
||||||
) {
|
|
||||||
result.positions = result.positions.map((position) => {
|
|
||||||
return nullifyValuesInObject(position, [
|
|
||||||
'grossPerformance',
|
|
||||||
'investment',
|
|
||||||
'netPerformance',
|
|
||||||
'quantity'
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('public/:accessId')
|
@Get('public/:accessId')
|
||||||
@ -441,7 +427,7 @@ export class PortfolioController {
|
|||||||
|
|
||||||
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||||
portfolioPublicDetails.holdings[symbol] = {
|
portfolioPublicDetails.holdings[symbol] = {
|
||||||
allocationCurrent: portfolioPosition.value / totalValue,
|
allocationInPercentage: portfolioPosition.value / totalValue,
|
||||||
countries: hasDetails ? portfolioPosition.countries : [],
|
countries: hasDetails ? portfolioPosition.countries : [],
|
||||||
currency: hasDetails ? portfolioPosition.currency : undefined,
|
currency: hasDetails ? portfolioPosition.currency : undefined,
|
||||||
dataSource: portfolioPosition.dataSource,
|
dataSource: portfolioPosition.dataSource,
|
||||||
@ -452,7 +438,7 @@ export class PortfolioController {
|
|||||||
sectors: hasDetails ? portfolioPosition.sectors : [],
|
sectors: hasDetails ? portfolioPosition.sectors : [],
|
||||||
symbol: portfolioPosition.symbol,
|
symbol: portfolioPosition.symbol,
|
||||||
url: portfolioPosition.url,
|
url: portfolioPosition.url,
|
||||||
value: portfolioPosition.value / totalValue
|
valueInPercentage: portfolioPosition.value / totalValue
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -460,6 +446,7 @@ export class PortfolioController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('position/:dataSource/:symbol')
|
@Get('position/:dataSource/:symbol')
|
||||||
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@ -468,27 +455,13 @@ export class PortfolioController {
|
|||||||
@Param('dataSource') dataSource,
|
@Param('dataSource') dataSource,
|
||||||
@Param('symbol') symbol
|
@Param('symbol') symbol
|
||||||
): Promise<PortfolioPositionDetail> {
|
): Promise<PortfolioPositionDetail> {
|
||||||
let position = await this.portfolioService.getPosition(
|
const position = await this.portfolioService.getPosition(
|
||||||
dataSource,
|
dataSource,
|
||||||
impersonationId,
|
impersonationId,
|
||||||
symbol
|
symbol
|
||||||
);
|
);
|
||||||
|
|
||||||
if (position) {
|
if (position) {
|
||||||
if (
|
|
||||||
impersonationId ||
|
|
||||||
this.userService.isRestrictedView(this.request.user)
|
|
||||||
) {
|
|
||||||
position = nullifyValuesInObject(position, [
|
|
||||||
'grossPerformance',
|
|
||||||
'investment',
|
|
||||||
'netPerformance',
|
|
||||||
'orders',
|
|
||||||
'quantity',
|
|
||||||
'value'
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return position;
|
return position;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
|
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
|
||||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface';
|
import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface';
|
||||||
@ -19,7 +20,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
|
|||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
import {
|
import {
|
||||||
ASSET_SUB_CLASS_EMERGENCY_FUND,
|
EMERGENCY_FUND_TAG_ID,
|
||||||
MAX_CHART_ITEMS,
|
MAX_CHART_ITEMS,
|
||||||
UNKNOWN_KEY
|
UNKNOWN_KEY
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
@ -235,8 +236,8 @@ export class PortfolioService {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
if (groupBy === 'month') {
|
if (groupBy) {
|
||||||
dividends = this.getDividendsByMonth(dividends);
|
dividends = this.getDividendsByGroup({ dividends, groupBy });
|
||||||
}
|
}
|
||||||
|
|
||||||
const startDate = this.getStartDate(
|
const startDate = this.getStartDate(
|
||||||
@ -282,26 +283,31 @@ export class PortfolioService {
|
|||||||
|
|
||||||
let investments: InvestmentItem[];
|
let investments: InvestmentItem[];
|
||||||
|
|
||||||
if (groupBy === 'month') {
|
if (groupBy) {
|
||||||
investments = portfolioCalculator.getInvestmentsByMonth().map((item) => {
|
investments = portfolioCalculator
|
||||||
|
.getInvestmentsByGroup(groupBy)
|
||||||
|
.map((item) => {
|
||||||
return {
|
return {
|
||||||
date: item.date,
|
date: item.date,
|
||||||
investment: item.investment.toNumber()
|
investment: item.investment.toNumber()
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add investment of current month
|
// Add investment of current group
|
||||||
const dateOfCurrentMonth = format(
|
const dateOfCurrentGroup = format(
|
||||||
set(new Date(), { date: 1 }),
|
set(new Date(), {
|
||||||
|
date: 1,
|
||||||
|
month: groupBy === 'year' ? 0 : new Date().getMonth()
|
||||||
|
}),
|
||||||
DATE_FORMAT
|
DATE_FORMAT
|
||||||
);
|
);
|
||||||
const investmentOfCurrentMonth = investments.filter(({ date }) => {
|
const investmentOfCurrentGroup = investments.filter(({ date }) => {
|
||||||
return date === dateOfCurrentMonth;
|
return date === dateOfCurrentGroup;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (investmentOfCurrentMonth.length <= 0) {
|
if (investmentOfCurrentGroup.length <= 0) {
|
||||||
investments.push({
|
investments.push({
|
||||||
date: dateOfCurrentMonth,
|
date: dateOfCurrentGroup,
|
||||||
investment: 0
|
investment: 0
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -531,12 +537,9 @@ export class PortfolioService {
|
|||||||
|
|
||||||
holdings[item.symbol] = {
|
holdings[item.symbol] = {
|
||||||
markets,
|
markets,
|
||||||
allocationCurrent: filteredValueInBaseCurrency.eq(0)
|
allocationInPercentage: filteredValueInBaseCurrency.eq(0)
|
||||||
? 0
|
? 0
|
||||||
: value.div(filteredValueInBaseCurrency).toNumber(),
|
: value.div(filteredValueInBaseCurrency).toNumber(),
|
||||||
allocationInvestment: item.investment
|
|
||||||
.div(totalInvestmentInBaseCurrency)
|
|
||||||
.toNumber(),
|
|
||||||
assetClass: symbolProfile.assetClass,
|
assetClass: symbolProfile.assetClass,
|
||||||
assetSubClass: symbolProfile.assetSubClass,
|
assetSubClass: symbolProfile.assetSubClass,
|
||||||
countries: symbolProfile.countries,
|
countries: symbolProfile.countries,
|
||||||
@ -569,7 +572,6 @@ export class PortfolioService {
|
|||||||
) {
|
) {
|
||||||
const cashPositions = await this.getCashPositions({
|
const cashPositions = await this.getCashPositions({
|
||||||
cashDetails,
|
cashDetails,
|
||||||
emergencyFund,
|
|
||||||
userCurrency,
|
userCurrency,
|
||||||
investment: totalInvestmentInBaseCurrency,
|
investment: totalInvestmentInBaseCurrency,
|
||||||
value: filteredValueInBaseCurrency
|
value: filteredValueInBaseCurrency
|
||||||
@ -589,10 +591,52 @@ export class PortfolioService {
|
|||||||
withExcludedAccounts
|
withExcludedAccounts
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
filters?.length === 1 &&
|
||||||
|
filters[0].id === EMERGENCY_FUND_TAG_ID &&
|
||||||
|
filters[0].type === 'TAG'
|
||||||
|
) {
|
||||||
|
const cashPositions = await this.getCashPositions({
|
||||||
|
cashDetails,
|
||||||
|
userCurrency,
|
||||||
|
investment: totalInvestmentInBaseCurrency,
|
||||||
|
value: filteredValueInBaseCurrency
|
||||||
|
});
|
||||||
|
|
||||||
|
const emergencyFundInCash = emergencyFund
|
||||||
|
.minus(
|
||||||
|
this.getEmergencyFundPositionsValueInBaseCurrency({
|
||||||
|
activities: orders
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.toNumber();
|
||||||
|
|
||||||
|
filteredValueInBaseCurrency = emergencyFund;
|
||||||
|
|
||||||
|
accounts[UNKNOWN_KEY] = {
|
||||||
|
balance: 0,
|
||||||
|
currency: userCurrency,
|
||||||
|
current: emergencyFundInCash,
|
||||||
|
name: UNKNOWN_KEY,
|
||||||
|
original: emergencyFundInCash
|
||||||
|
};
|
||||||
|
|
||||||
|
holdings[userCurrency] = {
|
||||||
|
...cashPositions[userCurrency],
|
||||||
|
investment: emergencyFundInCash,
|
||||||
|
value: emergencyFundInCash
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const summary = await this.getSummary({
|
const summary = await this.getSummary({
|
||||||
impersonationId,
|
impersonationId,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
userId
|
userId,
|
||||||
|
balanceInBaseCurrency: cashDetails.balanceInBaseCurrency,
|
||||||
|
emergencyFundPositionsValueInBaseCurrency:
|
||||||
|
this.getEmergencyFundPositionsValueInBaseCurrency({
|
||||||
|
activities: orders
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -655,8 +699,9 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const positionCurrency = orders[0].SymbolProfile.currency;
|
const positionCurrency = orders[0].SymbolProfile.currency;
|
||||||
const [SymbolProfile] =
|
const [SymbolProfile] = await this.symbolProfileService.getSymbolProfiles([
|
||||||
await this.symbolProfileService.getSymbolProfilesBySymbols([aSymbol]);
|
{ dataSource: aDataSource, symbol: aSymbol }
|
||||||
|
]);
|
||||||
|
|
||||||
const portfolioOrders: PortfolioOrder[] = orders
|
const portfolioOrders: PortfolioOrder[] = orders
|
||||||
.filter((order) => {
|
.filter((order) => {
|
||||||
@ -740,7 +785,8 @@ export class PortfolioService {
|
|||||||
historicalDataArray.push({
|
historicalDataArray.push({
|
||||||
averagePrice: orders[0].unitPrice,
|
averagePrice: orders[0].unitPrice,
|
||||||
date: firstBuyDate,
|
date: firstBuyDate,
|
||||||
value: orders[0].unitPrice
|
marketPrice: orders[0].unitPrice,
|
||||||
|
quantity: orders[0].quantity
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -756,6 +802,7 @@ export class PortfolioService {
|
|||||||
j++;
|
j++;
|
||||||
}
|
}
|
||||||
let currentAveragePrice = 0;
|
let currentAveragePrice = 0;
|
||||||
|
let currentQuantity = 0;
|
||||||
const currentSymbol = transactionPoints[j].items.find(
|
const currentSymbol = transactionPoints[j].items.find(
|
||||||
(item) => item.symbol === aSymbol
|
(item) => item.symbol === aSymbol
|
||||||
);
|
);
|
||||||
@ -763,12 +810,14 @@ export class PortfolioService {
|
|||||||
currentAveragePrice = currentSymbol.quantity.eq(0)
|
currentAveragePrice = currentSymbol.quantity.eq(0)
|
||||||
? 0
|
? 0
|
||||||
: currentSymbol.investment.div(currentSymbol.quantity).toNumber();
|
: currentSymbol.investment.div(currentSymbol.quantity).toNumber();
|
||||||
|
currentQuantity = currentSymbol.quantity.toNumber();
|
||||||
}
|
}
|
||||||
|
|
||||||
historicalDataArray.push({
|
historicalDataArray.push({
|
||||||
date,
|
date,
|
||||||
|
marketPrice,
|
||||||
averagePrice: currentAveragePrice,
|
averagePrice: currentAveragePrice,
|
||||||
value: marketPrice
|
quantity: currentQuantity
|
||||||
});
|
});
|
||||||
|
|
||||||
maxPrice = Math.max(marketPrice ?? 0, maxPrice);
|
maxPrice = Math.max(marketPrice ?? 0, maxPrice);
|
||||||
@ -900,12 +949,14 @@ export class PortfolioService {
|
|||||||
const positions = currentPositions.positions.filter(
|
const positions = currentPositions.positions.filter(
|
||||||
(item) => !item.quantity.eq(0)
|
(item) => !item.quantity.eq(0)
|
||||||
);
|
);
|
||||||
|
|
||||||
const dataGatheringItem = positions.map((position) => {
|
const dataGatheringItem = positions.map((position) => {
|
||||||
return {
|
return {
|
||||||
dataSource: position.dataSource,
|
dataSource: position.dataSource,
|
||||||
symbol: position.symbol
|
symbol: position.symbol
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const symbols = positions.map((position) => position.symbol);
|
const symbols = positions.map((position) => position.symbol);
|
||||||
|
|
||||||
const [dataProviderResponses, symbolProfiles] = await Promise.all([
|
const [dataProviderResponses, symbolProfiles] = await Promise.all([
|
||||||
@ -988,29 +1039,21 @@ export class PortfolioService {
|
|||||||
|
|
||||||
const portfolioStart = parseDate(transactionPoints[0].date);
|
const portfolioStart = parseDate(transactionPoints[0].date);
|
||||||
const startDate = this.getStartDate(dateRange, portfolioStart);
|
const startDate = this.getStartDate(dateRange, portfolioStart);
|
||||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
const {
|
||||||
startDate
|
currentValue,
|
||||||
);
|
errors,
|
||||||
|
grossPerformance,
|
||||||
|
grossPerformancePercentage,
|
||||||
|
hasErrors,
|
||||||
|
netPerformance,
|
||||||
|
netPerformancePercentage,
|
||||||
|
totalInvestment
|
||||||
|
} = await portfolioCalculator.getCurrentPositions(startDate);
|
||||||
|
|
||||||
const hasErrors = currentPositions.hasErrors;
|
const currentGrossPerformance = grossPerformance;
|
||||||
const currentValue = currentPositions.currentValue.toNumber();
|
const currentGrossPerformancePercent = grossPerformancePercentage;
|
||||||
const currentGrossPerformance = currentPositions.grossPerformance;
|
let currentNetPerformance = netPerformance;
|
||||||
const currentGrossPerformancePercent =
|
let currentNetPerformancePercent = netPerformancePercentage;
|
||||||
currentPositions.grossPerformancePercentage;
|
|
||||||
let currentNetPerformance = currentPositions.netPerformance;
|
|
||||||
let currentNetPerformancePercent =
|
|
||||||
currentPositions.netPerformancePercentage;
|
|
||||||
const totalInvestment = currentPositions.totalInvestment;
|
|
||||||
|
|
||||||
// if (currentGrossPerformance.mul(currentGrossPerformancePercent).lt(0)) {
|
|
||||||
// // If algebraic sign is different, harmonize it
|
|
||||||
// currentGrossPerformancePercent = currentGrossPerformancePercent.mul(-1);
|
|
||||||
// }
|
|
||||||
|
|
||||||
// if (currentNetPerformance.mul(currentNetPerformancePercent).lt(0)) {
|
|
||||||
// // If algebraic sign is different, harmonize it
|
|
||||||
// currentNetPerformancePercent = currentNetPerformancePercent.mul(-1);
|
|
||||||
// }
|
|
||||||
|
|
||||||
const historicalDataContainer = await this.getChart({
|
const historicalDataContainer = await this.getChart({
|
||||||
dateRange,
|
dateRange,
|
||||||
@ -1032,28 +1075,28 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
errors,
|
||||||
|
hasErrors,
|
||||||
chart: historicalDataContainer.items.map(
|
chart: historicalDataContainer.items.map(
|
||||||
({
|
({
|
||||||
date,
|
date,
|
||||||
netPerformance,
|
netPerformance: netPerformanceOfItem,
|
||||||
netPerformanceInPercentage,
|
netPerformanceInPercentage,
|
||||||
totalInvestment,
|
totalInvestment: totalInvestmentOfItem,
|
||||||
value
|
value
|
||||||
}) => {
|
}) => {
|
||||||
return {
|
return {
|
||||||
date,
|
date,
|
||||||
netPerformance,
|
|
||||||
netPerformanceInPercentage,
|
netPerformanceInPercentage,
|
||||||
totalInvestment,
|
value,
|
||||||
value
|
netPerformance: netPerformanceOfItem,
|
||||||
|
totalInvestment: totalInvestmentOfItem
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
errors: currentPositions.errors,
|
|
||||||
firstOrderDate: parseDate(historicalDataContainer.items[0]?.date),
|
firstOrderDate: parseDate(historicalDataContainer.items[0]?.date),
|
||||||
hasErrors: currentPositions.hasErrors || hasErrors,
|
|
||||||
performance: {
|
performance: {
|
||||||
currentValue,
|
currentValue: currentValue.toNumber(),
|
||||||
currentGrossPerformance: currentGrossPerformance.toNumber(),
|
currentGrossPerformance: currentGrossPerformance.toNumber(),
|
||||||
currentGrossPerformancePercent:
|
currentGrossPerformancePercent:
|
||||||
currentGrossPerformancePercent.toNumber(),
|
currentGrossPerformancePercent.toNumber(),
|
||||||
@ -1093,16 +1136,23 @@ export class PortfolioService {
|
|||||||
portfolioStart
|
portfolioStart
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const positions = currentPositions.positions.filter(
|
||||||
|
(item) => !item.quantity.eq(0)
|
||||||
|
);
|
||||||
|
|
||||||
const portfolioItemsNow: { [symbol: string]: TimelinePosition } = {};
|
const portfolioItemsNow: { [symbol: string]: TimelinePosition } = {};
|
||||||
for (const position of currentPositions.positions) {
|
|
||||||
|
for (const position of positions) {
|
||||||
portfolioItemsNow[position.symbol] = position;
|
portfolioItemsNow[position.symbol] = position;
|
||||||
}
|
}
|
||||||
|
|
||||||
const accounts = await this.getValueOfAccounts({
|
const accounts = await this.getValueOfAccounts({
|
||||||
orders,
|
orders,
|
||||||
portfolioItemsNow,
|
portfolioItemsNow,
|
||||||
userId,
|
userCurrency,
|
||||||
userCurrency
|
userId
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rules: {
|
rules: {
|
||||||
accountClusterRisk: await this.rulesService.evaluate(
|
accountClusterRisk: await this.rulesService.evaluate(
|
||||||
@ -1126,19 +1176,19 @@ export class PortfolioService {
|
|||||||
[
|
[
|
||||||
new CurrencyClusterRiskBaseCurrencyInitialInvestment(
|
new CurrencyClusterRiskBaseCurrencyInitialInvestment(
|
||||||
this.exchangeRateDataService,
|
this.exchangeRateDataService,
|
||||||
currentPositions
|
positions
|
||||||
),
|
),
|
||||||
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
|
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
|
||||||
this.exchangeRateDataService,
|
this.exchangeRateDataService,
|
||||||
currentPositions
|
positions
|
||||||
),
|
),
|
||||||
new CurrencyClusterRiskInitialInvestment(
|
new CurrencyClusterRiskInitialInvestment(
|
||||||
this.exchangeRateDataService,
|
this.exchangeRateDataService,
|
||||||
currentPositions
|
positions
|
||||||
),
|
),
|
||||||
new CurrencyClusterRiskCurrentInvestment(
|
new CurrencyClusterRiskCurrentInvestment(
|
||||||
this.exchangeRateDataService,
|
this.exchangeRateDataService,
|
||||||
currentPositions
|
positions
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
<UserSettings>this.request.user.Settings.settings
|
<UserSettings>this.request.user.Settings.settings
|
||||||
@ -1148,7 +1198,7 @@ export class PortfolioService {
|
|||||||
new FeeRatioInitialInvestment(
|
new FeeRatioInitialInvestment(
|
||||||
this.exchangeRateDataService,
|
this.exchangeRateDataService,
|
||||||
currentPositions.totalInvestment.toNumber(),
|
currentPositions.totalInvestment.toNumber(),
|
||||||
this.getFees({ orders, userCurrency }).toNumber()
|
this.getFees({ userCurrency, activities: orders }).toNumber()
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
<UserSettings>this.request.user.Settings.settings
|
<UserSettings>this.request.user.Settings.settings
|
||||||
@ -1159,16 +1209,14 @@ export class PortfolioService {
|
|||||||
|
|
||||||
private async getCashPositions({
|
private async getCashPositions({
|
||||||
cashDetails,
|
cashDetails,
|
||||||
emergencyFund,
|
|
||||||
investment,
|
investment,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
value
|
value
|
||||||
}: {
|
}: {
|
||||||
cashDetails: CashDetails;
|
cashDetails: CashDetails;
|
||||||
emergencyFund: Big;
|
|
||||||
investment: Big;
|
investment: Big;
|
||||||
value: Big;
|
|
||||||
userCurrency: string;
|
userCurrency: string;
|
||||||
|
value: Big;
|
||||||
}) {
|
}) {
|
||||||
const cashPositions: PortfolioDetails['holdings'] = {
|
const cashPositions: PortfolioDetails['holdings'] = {
|
||||||
[userCurrency]: this.getInitialCashPosition({
|
[userCurrency]: this.getInitialCashPosition({
|
||||||
@ -1199,62 +1247,38 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (emergencyFund.gt(0)) {
|
|
||||||
cashPositions[ASSET_SUB_CLASS_EMERGENCY_FUND] = {
|
|
||||||
...cashPositions[userCurrency],
|
|
||||||
assetSubClass: ASSET_SUB_CLASS_EMERGENCY_FUND,
|
|
||||||
investment: emergencyFund.toNumber(),
|
|
||||||
name: ASSET_SUB_CLASS_EMERGENCY_FUND,
|
|
||||||
symbol: ASSET_SUB_CLASS_EMERGENCY_FUND,
|
|
||||||
value: emergencyFund.toNumber()
|
|
||||||
};
|
|
||||||
|
|
||||||
cashPositions[userCurrency].investment = new Big(
|
|
||||||
cashPositions[userCurrency].investment
|
|
||||||
)
|
|
||||||
.minus(emergencyFund)
|
|
||||||
.toNumber();
|
|
||||||
cashPositions[userCurrency].value = new Big(
|
|
||||||
cashPositions[userCurrency].value
|
|
||||||
)
|
|
||||||
.minus(emergencyFund)
|
|
||||||
.toNumber();
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const symbol of Object.keys(cashPositions)) {
|
for (const symbol of Object.keys(cashPositions)) {
|
||||||
// Calculate allocations for each currency
|
// Calculate allocations for each currency
|
||||||
cashPositions[symbol].allocationCurrent = value.gt(0)
|
cashPositions[symbol].allocationInPercentage = value.gt(0)
|
||||||
? new Big(cashPositions[symbol].value).div(value).toNumber()
|
? new Big(cashPositions[symbol].value).div(value).toNumber()
|
||||||
: 0;
|
: 0;
|
||||||
cashPositions[symbol].allocationInvestment = investment.gt(0)
|
|
||||||
? new Big(cashPositions[symbol].investment).div(investment).toNumber()
|
|
||||||
: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return cashPositions;
|
return cashPositions;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getDividend({
|
private getDividend({
|
||||||
|
activities,
|
||||||
date = new Date(0),
|
date = new Date(0),
|
||||||
orders,
|
|
||||||
userCurrency
|
userCurrency
|
||||||
}: {
|
}: {
|
||||||
|
activities: OrderWithAccount[];
|
||||||
date?: Date;
|
date?: Date;
|
||||||
orders: OrderWithAccount[];
|
|
||||||
userCurrency: string;
|
userCurrency: string;
|
||||||
}) {
|
}) {
|
||||||
return orders
|
return activities
|
||||||
.filter((order) => {
|
.filter((activity) => {
|
||||||
// Filter out all orders before given date and type dividend
|
// Filter out all activities before given date and type dividend
|
||||||
return (
|
return (
|
||||||
isBefore(date, new Date(order.date)) &&
|
isBefore(date, new Date(activity.date)) &&
|
||||||
order.type === TypeOfOrder.DIVIDEND
|
activity.type === TypeOfOrder.DIVIDEND
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.map((order) => {
|
.map(({ quantity, SymbolProfile, unitPrice }) => {
|
||||||
return this.exchangeRateDataService.toCurrency(
|
return this.exchangeRateDataService.toCurrency(
|
||||||
new Big(order.quantity).mul(order.unitPrice).toNumber(),
|
new Big(quantity).mul(unitPrice).toNumber(),
|
||||||
order.SymbolProfile.currency,
|
SymbolProfile.currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@ -1264,67 +1288,118 @@ export class PortfolioService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getDividendsByMonth(aDividends: InvestmentItem[]): InvestmentItem[] {
|
private getDividendsByGroup({
|
||||||
if (aDividends.length === 0) {
|
dividends,
|
||||||
|
groupBy
|
||||||
|
}: {
|
||||||
|
dividends: InvestmentItem[];
|
||||||
|
groupBy: GroupBy;
|
||||||
|
}): InvestmentItem[] {
|
||||||
|
if (dividends.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const dividends = [];
|
const dividendsByGroup: InvestmentItem[] = [];
|
||||||
let currentDate: Date;
|
let currentDate: Date;
|
||||||
let investmentByMonth = new Big(0);
|
let investmentByGroup = new Big(0);
|
||||||
|
|
||||||
for (const [index, dividend] of aDividends.entries()) {
|
for (const [index, dividend] of dividends.entries()) {
|
||||||
if (
|
if (
|
||||||
isSameMonth(parseDate(dividend.date), currentDate) &&
|
isSameYear(parseDate(dividend.date), currentDate) &&
|
||||||
isSameYear(parseDate(dividend.date), currentDate)
|
(groupBy === 'year' ||
|
||||||
|
isSameMonth(parseDate(dividend.date), currentDate))
|
||||||
) {
|
) {
|
||||||
// Same month: Add up divididends
|
// Same group: Add up dividends
|
||||||
|
|
||||||
investmentByMonth = investmentByMonth.plus(dividend.investment);
|
investmentByGroup = investmentByGroup.plus(dividend.investment);
|
||||||
} else {
|
} else {
|
||||||
// New month: Store previous month and reset
|
// New group: Store previous group and reset
|
||||||
|
|
||||||
if (currentDate) {
|
if (currentDate) {
|
||||||
dividends.push({
|
dividendsByGroup.push({
|
||||||
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
|
date: format(
|
||||||
investment: investmentByMonth
|
set(currentDate, {
|
||||||
|
date: 1,
|
||||||
|
month: groupBy === 'year' ? 0 : currentDate.getMonth()
|
||||||
|
}),
|
||||||
|
DATE_FORMAT
|
||||||
|
),
|
||||||
|
investment: investmentByGroup.toNumber()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
currentDate = parseDate(dividend.date);
|
currentDate = parseDate(dividend.date);
|
||||||
investmentByMonth = new Big(dividend.investment);
|
investmentByGroup = new Big(dividend.investment);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (index === aDividends.length - 1) {
|
if (index === dividends.length - 1) {
|
||||||
// Store current month (latest order)
|
// Store current month (latest order)
|
||||||
dividends.push({
|
dividendsByGroup.push({
|
||||||
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
|
date: format(
|
||||||
investment: investmentByMonth
|
set(currentDate, {
|
||||||
|
date: 1,
|
||||||
|
month: groupBy === 'year' ? 0 : currentDate.getMonth()
|
||||||
|
}),
|
||||||
|
DATE_FORMAT
|
||||||
|
),
|
||||||
|
investment: investmentByGroup.toNumber()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return dividends;
|
return dividendsByGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getEmergencyFundPositionsValueInBaseCurrency({
|
||||||
|
activities
|
||||||
|
}: {
|
||||||
|
activities: Activity[];
|
||||||
|
}) {
|
||||||
|
const emergencyFundOrders = activities.filter((activity) => {
|
||||||
|
return (
|
||||||
|
activity.tags?.some(({ id }) => {
|
||||||
|
return id === EMERGENCY_FUND_TAG_ID;
|
||||||
|
}) ?? false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return valueInBaseCurrencyOfEmergencyFundPositions.toNumber();
|
||||||
}
|
}
|
||||||
|
|
||||||
private getFees({
|
private getFees({
|
||||||
|
activities,
|
||||||
date = new Date(0),
|
date = new Date(0),
|
||||||
orders,
|
|
||||||
userCurrency
|
userCurrency
|
||||||
}: {
|
}: {
|
||||||
|
activities: OrderWithAccount[];
|
||||||
date?: Date;
|
date?: Date;
|
||||||
orders: OrderWithAccount[];
|
|
||||||
userCurrency: string;
|
userCurrency: string;
|
||||||
}) {
|
}) {
|
||||||
return orders
|
return activities
|
||||||
.filter((order) => {
|
.filter((activity) => {
|
||||||
// Filter out all orders before given date
|
// Filter out all activities before given date
|
||||||
return isBefore(date, new Date(order.date));
|
return isBefore(date, new Date(activity.date));
|
||||||
})
|
})
|
||||||
.map((order) => {
|
.map(({ fee, SymbolProfile }) => {
|
||||||
return this.exchangeRateDataService.toCurrency(
|
return this.exchangeRateDataService.toCurrency(
|
||||||
order.fee,
|
fee,
|
||||||
order.SymbolProfile.currency,
|
SymbolProfile.currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@ -1343,8 +1418,7 @@ export class PortfolioService {
|
|||||||
}): PortfolioPosition {
|
}): PortfolioPosition {
|
||||||
return {
|
return {
|
||||||
currency,
|
currency,
|
||||||
allocationCurrent: 0,
|
allocationInPercentage: 0,
|
||||||
allocationInvestment: 0,
|
|
||||||
assetClass: AssetClass.CASH,
|
assetClass: AssetClass.CASH,
|
||||||
assetSubClass: AssetClass.CASH,
|
assetSubClass: AssetClass.CASH,
|
||||||
countries: [],
|
countries: [],
|
||||||
@ -1391,26 +1465,42 @@ export class PortfolioService {
|
|||||||
private getStartDate(aDateRange: DateRange, portfolioStart: Date) {
|
private getStartDate(aDateRange: DateRange, portfolioStart: Date) {
|
||||||
switch (aDateRange) {
|
switch (aDateRange) {
|
||||||
case '1d':
|
case '1d':
|
||||||
portfolioStart = max([portfolioStart, subDays(new Date(), 1)]);
|
portfolioStart = max([
|
||||||
|
portfolioStart,
|
||||||
|
subDays(new Date().setHours(0, 0, 0, 0), 1)
|
||||||
|
]);
|
||||||
break;
|
break;
|
||||||
case 'ytd':
|
case 'ytd':
|
||||||
portfolioStart = max([portfolioStart, setDayOfYear(new Date(), 1)]);
|
portfolioStart = max([
|
||||||
|
portfolioStart,
|
||||||
|
setDayOfYear(new Date().setHours(0, 0, 0, 0), 1)
|
||||||
|
]);
|
||||||
break;
|
break;
|
||||||
case '1y':
|
case '1y':
|
||||||
portfolioStart = max([portfolioStart, subYears(new Date(), 1)]);
|
portfolioStart = max([
|
||||||
|
portfolioStart,
|
||||||
|
subYears(new Date().setHours(0, 0, 0, 0), 1)
|
||||||
|
]);
|
||||||
break;
|
break;
|
||||||
case '5y':
|
case '5y':
|
||||||
portfolioStart = max([portfolioStart, subYears(new Date(), 5)]);
|
portfolioStart = max([
|
||||||
|
portfolioStart,
|
||||||
|
subYears(new Date().setHours(0, 0, 0, 0), 5)
|
||||||
|
]);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
return portfolioStart;
|
return portfolioStart;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getSummary({
|
private async getSummary({
|
||||||
|
balanceInBaseCurrency,
|
||||||
|
emergencyFundPositionsValueInBaseCurrency,
|
||||||
impersonationId,
|
impersonationId,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
userId
|
userId
|
||||||
}: {
|
}: {
|
||||||
|
balanceInBaseCurrency: number;
|
||||||
|
emergencyFundPositionsValueInBaseCurrency: number;
|
||||||
impersonationId: string;
|
impersonationId: string;
|
||||||
userCurrency: string;
|
userCurrency: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
@ -1423,11 +1513,7 @@ export class PortfolioService {
|
|||||||
userId
|
userId
|
||||||
});
|
});
|
||||||
|
|
||||||
const { balanceInBaseCurrency } = await this.accountService.getCashDetails({
|
const activities = await this.orderService.getOrders({
|
||||||
userId,
|
|
||||||
currency: userCurrency
|
|
||||||
});
|
|
||||||
const orders = await this.orderService.getOrders({
|
|
||||||
userCurrency,
|
userCurrency,
|
||||||
userId
|
userId
|
||||||
});
|
});
|
||||||
@ -1442,18 +1528,24 @@ export class PortfolioService {
|
|||||||
return account?.isExcluded ?? false;
|
return account?.isExcluded ?? false;
|
||||||
});
|
});
|
||||||
|
|
||||||
const dividend = this.getDividend({ orders, userCurrency }).toNumber();
|
const dividend = this.getDividend({
|
||||||
|
activities,
|
||||||
|
userCurrency
|
||||||
|
}).toNumber();
|
||||||
const emergencyFund = new Big(
|
const emergencyFund = new Big(
|
||||||
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
|
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
|
||||||
);
|
);
|
||||||
const fees = this.getFees({ orders, userCurrency }).toNumber();
|
const fees = this.getFees({ activities, userCurrency }).toNumber();
|
||||||
const firstOrderDate = orders[0]?.date;
|
const firstOrderDate = activities[0]?.date;
|
||||||
const items = this.getItems(orders).toNumber();
|
const items = this.getItems(activities).toNumber();
|
||||||
|
|
||||||
const totalBuy = this.getTotalByType(orders, userCurrency, 'BUY');
|
const totalBuy = this.getTotalByType(activities, userCurrency, 'BUY');
|
||||||
const totalSell = this.getTotalByType(orders, userCurrency, 'SELL');
|
const totalSell = this.getTotalByType(activities, userCurrency, 'SELL');
|
||||||
|
|
||||||
const cash = new Big(balanceInBaseCurrency).minus(emergencyFund).toNumber();
|
const cash = new Big(balanceInBaseCurrency)
|
||||||
|
.minus(emergencyFund)
|
||||||
|
.plus(emergencyFundPositionsValueInBaseCurrency)
|
||||||
|
.toNumber();
|
||||||
const committedFunds = new Big(totalBuy).minus(totalSell);
|
const committedFunds = new Big(totalBuy).minus(totalSell);
|
||||||
const totalOfExcludedActivities = new Big(
|
const totalOfExcludedActivities = new Big(
|
||||||
this.getTotalByType(excludedActivities, userCurrency, 'BUY')
|
this.getTotalByType(excludedActivities, userCurrency, 'BUY')
|
||||||
@ -1509,8 +1601,8 @@ export class PortfolioService {
|
|||||||
totalSell,
|
totalSell,
|
||||||
committedFunds: committedFunds.toNumber(),
|
committedFunds: committedFunds.toNumber(),
|
||||||
emergencyFund: emergencyFund.toNumber(),
|
emergencyFund: emergencyFund.toNumber(),
|
||||||
ordersCount: orders.filter((order) => {
|
ordersCount: activities.filter(({ type }) => {
|
||||||
return order.type === 'BUY' || order.type === 'SELL';
|
return type === 'BUY' || type === 'SELL';
|
||||||
}).length
|
}).length
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -1527,7 +1619,7 @@ export class PortfolioService {
|
|||||||
withExcludedAccounts?: boolean;
|
withExcludedAccounts?: boolean;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
transactionPoints: TransactionPoint[];
|
transactionPoints: TransactionPoint[];
|
||||||
orders: OrderWithAccount[];
|
orders: Activity[];
|
||||||
portfolioOrders: PortfolioOrder[];
|
portfolioOrders: PortfolioOrder[];
|
||||||
}> {
|
}> {
|
||||||
const userCurrency =
|
const userCurrency =
|
||||||
@ -1653,7 +1745,7 @@ export class PortfolioService {
|
|||||||
for (const order of ordersByAccount) {
|
for (const order of ordersByAccount) {
|
||||||
let currentValueOfSymbolInBaseCurrency =
|
let currentValueOfSymbolInBaseCurrency =
|
||||||
order.quantity *
|
order.quantity *
|
||||||
portfolioItemsNow[order.SymbolProfile.symbol].marketPrice;
|
portfolioItemsNow[order.SymbolProfile.symbol]?.marketPrice ?? 0;
|
||||||
let originalValueOfSymbolInBaseCurrency =
|
let originalValueOfSymbolInBaseCurrency =
|
||||||
this.exchangeRateDataService.toCurrency(
|
this.exchangeRateDataService.toCurrency(
|
||||||
order.quantity * order.unitPrice,
|
order.quantity * order.unitPrice,
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import { PROPERTY_IS_USER_SIGNUP_ENABLED } from '@ghostfolio/common/config';
|
|
||||||
import { User, UserSettings } from '@ghostfolio/common/interfaces';
|
import { User, UserSettings } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
@ -31,7 +29,6 @@ import { UserService } from './user.service';
|
|||||||
@Controller('user')
|
@Controller('user')
|
||||||
export class UserController {
|
export class UserController {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly propertyService: PropertyService,
|
private readonly propertyService: PropertyService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||||
|
@ -97,6 +97,7 @@ export class UserService {
|
|||||||
const {
|
const {
|
||||||
accessToken,
|
accessToken,
|
||||||
Account,
|
Account,
|
||||||
|
Analytics,
|
||||||
authChallenge,
|
authChallenge,
|
||||||
createdAt,
|
createdAt,
|
||||||
id,
|
id,
|
||||||
@ -107,7 +108,12 @@ export class UserService {
|
|||||||
thirdPartyId,
|
thirdPartyId,
|
||||||
updatedAt
|
updatedAt
|
||||||
} = await this.prismaService.user.findUnique({
|
} = await this.prismaService.user.findUnique({
|
||||||
include: { Account: true, Settings: true, Subscription: true },
|
include: {
|
||||||
|
Account: true,
|
||||||
|
Analytics: true,
|
||||||
|
Settings: true,
|
||||||
|
Subscription: true
|
||||||
|
},
|
||||||
where: userWhereUniqueInput
|
where: userWhereUniqueInput
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -121,7 +127,8 @@ export class UserService {
|
|||||||
role,
|
role,
|
||||||
Settings,
|
Settings,
|
||||||
thirdPartyId,
|
thirdPartyId,
|
||||||
updatedAt
|
updatedAt,
|
||||||
|
activityCount: Analytics?.activityCount
|
||||||
};
|
};
|
||||||
|
|
||||||
if (user?.Settings) {
|
if (user?.Settings) {
|
||||||
@ -154,16 +161,23 @@ export class UserService {
|
|||||||
(user.Settings.settings as UserSettings).viewMode = 'DEFAULT';
|
(user.Settings.settings as UserSettings).viewMode = 'DEFAULT';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let currentPermissions = getPermissions(user.role);
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
user.subscription =
|
user.subscription =
|
||||||
this.subscriptionService.getSubscription(Subscription);
|
this.subscriptionService.getSubscription(Subscription);
|
||||||
}
|
|
||||||
|
|
||||||
let currentPermissions = getPermissions(user.role);
|
if (
|
||||||
|
Analytics?.activityCount % 25 === 0 &&
|
||||||
|
user.subscription?.type === 'Basic'
|
||||||
|
) {
|
||||||
|
currentPermissions.push(permissions.enableSubscriptionInterstitial);
|
||||||
|
}
|
||||||
|
|
||||||
if (user.subscription?.type === 'Premium') {
|
if (user.subscription?.type === 'Premium') {
|
||||||
currentPermissions.push(permissions.reportDataGlitch);
|
currentPermissions.push(permissions.reportDataGlitch);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
|
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
|
||||||
if (hasRole(user, Role.ADMIN)) {
|
if (hasRole(user, Role.ADMIN)) {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { cloneDeep, isObject } from 'lodash';
|
import { cloneDeep, isArray, isObject } from 'lodash';
|
||||||
|
|
||||||
export function hasNotDefinedValuesInObject(aObject: Object): boolean {
|
export function hasNotDefinedValuesInObject(aObject: Object): boolean {
|
||||||
for (const key in aObject) {
|
for (const key in aObject) {
|
||||||
@ -27,3 +27,48 @@ export function nullifyValuesInObjects<T>(aObjects: T[], keys: string[]): T[] {
|
|||||||
return nullifyValuesInObject(object, keys);
|
return nullifyValuesInObject(object, keys);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function redactAttributes({
|
||||||
|
object,
|
||||||
|
options
|
||||||
|
}: {
|
||||||
|
object: any;
|
||||||
|
options: { attribute: string; valueMap: { [key: string]: any } }[];
|
||||||
|
}): any {
|
||||||
|
if (!object || !options || !options.length) {
|
||||||
|
return object;
|
||||||
|
}
|
||||||
|
|
||||||
|
const redactedObject = cloneDeep(object);
|
||||||
|
|
||||||
|
for (const option of options) {
|
||||||
|
if (redactedObject.hasOwnProperty(option.attribute)) {
|
||||||
|
if (option.valueMap['*'] || option.valueMap['*'] === null) {
|
||||||
|
redactedObject[option.attribute] = option.valueMap['*'];
|
||||||
|
} else if (option.valueMap[redactedObject[option.attribute]]) {
|
||||||
|
redactedObject[option.attribute] =
|
||||||
|
option.valueMap[redactedObject[option.attribute]];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If the attribute is not present on the current object,
|
||||||
|
// check if it exists on any nested objects
|
||||||
|
for (const property in redactedObject) {
|
||||||
|
if (isArray(redactedObject[property])) {
|
||||||
|
redactedObject[property] = redactedObject[property].map(
|
||||||
|
(currentObject) => {
|
||||||
|
return redactAttributes({ options, object: currentObject });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else if (isObject(redactedObject[property])) {
|
||||||
|
// Recursively call the function on the nested object
|
||||||
|
redactedObject[property] = redactAttributes({
|
||||||
|
options,
|
||||||
|
object: redactedObject[property]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return redactedObject;
|
||||||
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
|
||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
|
import { redactAttributes } from '@ghostfolio/api/helper/object.helper';
|
||||||
import {
|
import {
|
||||||
CallHandler,
|
CallHandler,
|
||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
@ -28,61 +28,37 @@ export class RedactValuesInResponseInterceptor<T>
|
|||||||
hasImpersonationId ||
|
hasImpersonationId ||
|
||||||
this.userService.isRestrictedView(request.user)
|
this.userService.isRestrictedView(request.user)
|
||||||
) {
|
) {
|
||||||
if (data.accounts) {
|
data = redactAttributes({
|
||||||
for (const accountId of Object.keys(data.accounts)) {
|
object: data,
|
||||||
if (data.accounts[accountId]?.balance !== undefined) {
|
options: [
|
||||||
data.accounts[accountId].balance = null;
|
'balance',
|
||||||
|
'balanceInBaseCurrency',
|
||||||
|
'comment',
|
||||||
|
'convertedBalance',
|
||||||
|
'fee',
|
||||||
|
'feeInBaseCurrency',
|
||||||
|
'filteredValueInBaseCurrency',
|
||||||
|
'grossPerformance',
|
||||||
|
'investment',
|
||||||
|
'netPerformance',
|
||||||
|
'quantity',
|
||||||
|
'symbolMapping',
|
||||||
|
'totalBalanceInBaseCurrency',
|
||||||
|
'totalValueInBaseCurrency',
|
||||||
|
'unitPrice',
|
||||||
|
'value',
|
||||||
|
'valueInBaseCurrency'
|
||||||
|
].map((attribute) => {
|
||||||
|
return {
|
||||||
|
attribute,
|
||||||
|
valueMap: {
|
||||||
|
'*': null
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
}
|
})
|
||||||
|
|
||||||
if (data.activities) {
|
|
||||||
data.activities = data.activities.map((activity: Activity) => {
|
|
||||||
if (activity.Account?.balance !== undefined) {
|
|
||||||
activity.Account.balance = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activity.comment !== undefined) {
|
|
||||||
activity.comment = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activity.fee !== undefined) {
|
|
||||||
activity.fee = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activity.feeInBaseCurrency !== undefined) {
|
|
||||||
activity.feeInBaseCurrency = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activity.quantity !== undefined) {
|
|
||||||
activity.quantity = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activity.unitPrice !== undefined) {
|
|
||||||
activity.unitPrice = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activity.value !== undefined) {
|
|
||||||
activity.value = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activity.valueInBaseCurrency !== undefined) {
|
|
||||||
activity.valueInBaseCurrency = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return activity;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.filteredValueInBaseCurrency) {
|
|
||||||
data.filteredValueInBaseCurrency = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.totalValueInBaseCurrency) {
|
|
||||||
data.totalValueInBaseCurrency = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { redactAttributes } from '@ghostfolio/api/helper/object.helper';
|
||||||
import { encodeDataSource } from '@ghostfolio/common/helper';
|
import { encodeDataSource } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
CallHandler,
|
CallHandler,
|
||||||
@ -5,7 +6,7 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NestInterceptor
|
NestInterceptor
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { isArray } from 'lodash';
|
import { DataSource } from '@prisma/client';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { map } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
|
|
||||||
@ -28,65 +29,25 @@ export class TransformDataSourceInResponseInterceptor<T>
|
|||||||
if (
|
if (
|
||||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true
|
||||||
) {
|
) {
|
||||||
if (data.activities) {
|
data = redactAttributes({
|
||||||
data.activities.map((activity) => {
|
options: [
|
||||||
activity.SymbolProfile.dataSource = encodeDataSource(
|
{
|
||||||
activity.SymbolProfile.dataSource
|
attribute: 'dataSource',
|
||||||
|
valueMap: Object.keys(DataSource).reduce(
|
||||||
|
(valueMap, dataSource) => {
|
||||||
|
valueMap[dataSource] = encodeDataSource(
|
||||||
|
DataSource[dataSource]
|
||||||
);
|
);
|
||||||
return activity;
|
return valueMap;
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
object: data
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isArray(data.benchmarks)) {
|
|
||||||
data.benchmarks.map((benchmark) => {
|
|
||||||
benchmark.dataSource = encodeDataSource(benchmark.dataSource);
|
|
||||||
return benchmark;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.dataSource) {
|
|
||||||
data.dataSource = encodeDataSource(data.dataSource);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.errors) {
|
|
||||||
for (const error of data.errors) {
|
|
||||||
if (error.dataSource) {
|
|
||||||
error.dataSource = encodeDataSource(error.dataSource);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.holdings) {
|
|
||||||
for (const symbol of Object.keys(data.holdings)) {
|
|
||||||
if (data.holdings[symbol].dataSource) {
|
|
||||||
data.holdings[symbol].dataSource = encodeDataSource(
|
|
||||||
data.holdings[symbol].dataSource
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.items) {
|
|
||||||
data.items.map((item) => {
|
|
||||||
item.dataSource = encodeDataSource(item.dataSource);
|
|
||||||
return item;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.positions) {
|
|
||||||
data.positions.map((position) => {
|
|
||||||
position.dataSource = encodeDataSource(position.dataSource);
|
|
||||||
return position;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.SymbolProfile) {
|
|
||||||
data.SymbolProfile.dataSource = encodeDataSource(
|
|
||||||
data.SymbolProfile.dataSource
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -1,14 +1,13 @@
|
|||||||
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
|
|
||||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { UserSettings } from '@ghostfolio/common/interfaces';
|
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
import { Rule } from '../../rule';
|
||||||
|
|
||||||
export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Settings> {
|
export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Settings> {
|
||||||
public constructor(
|
public constructor(
|
||||||
protected exchangeRateDataService: ExchangeRateDataService,
|
protected exchangeRateDataService: ExchangeRateDataService,
|
||||||
private currentPositions: CurrentPositions
|
private positions: TimelinePosition[]
|
||||||
) {
|
) {
|
||||||
super(exchangeRateDataService, {
|
super(exchangeRateDataService, {
|
||||||
name: 'Current Investment: Base Currency'
|
name: 'Current Investment: Base Currency'
|
||||||
@ -17,7 +16,7 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
|
|||||||
|
|
||||||
public evaluate(ruleSettings: Settings) {
|
public evaluate(ruleSettings: Settings) {
|
||||||
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
|
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
|
||||||
this.currentPositions.positions,
|
this.positions,
|
||||||
'currency',
|
'currency',
|
||||||
ruleSettings.baseCurrency
|
ruleSettings.baseCurrency
|
||||||
);
|
);
|
||||||
|
@ -1,14 +1,13 @@
|
|||||||
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
|
|
||||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { UserSettings } from '@ghostfolio/common/interfaces';
|
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
import { Rule } from '../../rule';
|
||||||
|
|
||||||
export class CurrencyClusterRiskBaseCurrencyInitialInvestment extends Rule<Settings> {
|
export class CurrencyClusterRiskBaseCurrencyInitialInvestment extends Rule<Settings> {
|
||||||
public constructor(
|
public constructor(
|
||||||
protected exchangeRateDataService: ExchangeRateDataService,
|
protected exchangeRateDataService: ExchangeRateDataService,
|
||||||
private currentPositions: CurrentPositions
|
private positions: TimelinePosition[]
|
||||||
) {
|
) {
|
||||||
super(exchangeRateDataService, {
|
super(exchangeRateDataService, {
|
||||||
name: 'Initial Investment: Base Currency'
|
name: 'Initial Investment: Base Currency'
|
||||||
@ -17,7 +16,7 @@ export class CurrencyClusterRiskBaseCurrencyInitialInvestment extends Rule<Setti
|
|||||||
|
|
||||||
public evaluate(ruleSettings: Settings) {
|
public evaluate(ruleSettings: Settings) {
|
||||||
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
|
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
|
||||||
this.currentPositions.positions,
|
this.positions,
|
||||||
'currency',
|
'currency',
|
||||||
ruleSettings.baseCurrency
|
ruleSettings.baseCurrency
|
||||||
);
|
);
|
||||||
|
@ -1,14 +1,13 @@
|
|||||||
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
|
|
||||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { UserSettings } from '@ghostfolio/common/interfaces';
|
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
import { Rule } from '../../rule';
|
||||||
|
|
||||||
export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||||
public constructor(
|
public constructor(
|
||||||
public exchangeRateDataService: ExchangeRateDataService,
|
protected exchangeRateDataService: ExchangeRateDataService,
|
||||||
private currentPositions: CurrentPositions
|
private positions: TimelinePosition[]
|
||||||
) {
|
) {
|
||||||
super(exchangeRateDataService, {
|
super(exchangeRateDataService, {
|
||||||
name: 'Current Investment'
|
name: 'Current Investment'
|
||||||
@ -17,7 +16,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
|||||||
|
|
||||||
public evaluate(ruleSettings: Settings) {
|
public evaluate(ruleSettings: Settings) {
|
||||||
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
|
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
|
||||||
this.currentPositions.positions,
|
this.positions,
|
||||||
'currency',
|
'currency',
|
||||||
ruleSettings.baseCurrency
|
ruleSettings.baseCurrency
|
||||||
);
|
);
|
||||||
|
@ -1,14 +1,13 @@
|
|||||||
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
|
|
||||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { UserSettings } from '@ghostfolio/common/interfaces';
|
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
import { Rule } from '../../rule';
|
||||||
|
|
||||||
export class CurrencyClusterRiskInitialInvestment extends Rule<Settings> {
|
export class CurrencyClusterRiskInitialInvestment extends Rule<Settings> {
|
||||||
public constructor(
|
public constructor(
|
||||||
protected exchangeRateDataService: ExchangeRateDataService,
|
protected exchangeRateDataService: ExchangeRateDataService,
|
||||||
private currentPositions: CurrentPositions
|
private positions: TimelinePosition[]
|
||||||
) {
|
) {
|
||||||
super(exchangeRateDataService, {
|
super(exchangeRateDataService, {
|
||||||
name: 'Initial Investment'
|
name: 'Initial Investment'
|
||||||
@ -17,7 +16,7 @@ export class CurrencyClusterRiskInitialInvestment extends Rule<Settings> {
|
|||||||
|
|
||||||
public evaluate(ruleSettings: Settings) {
|
public evaluate(ruleSettings: Settings) {
|
||||||
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
|
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
|
||||||
this.currentPositions.positions,
|
this.positions,
|
||||||
'currency',
|
'currency',
|
||||||
ruleSettings.baseCurrency
|
ruleSettings.baseCurrency
|
||||||
);
|
);
|
||||||
|
@ -19,8 +19,8 @@ export class CronService {
|
|||||||
private readonly twitterBotService: TwitterBotService
|
private readonly twitterBotService: TwitterBotService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Cron(CronExpression.EVERY_HOUR)
|
@Cron(CronExpression.EVERY_4_HOURS)
|
||||||
public async runEveryHour() {
|
public async runEveryFourHours() {
|
||||||
await this.dataGatheringService.gather7Days();
|
await this.dataGatheringService.gather7Days();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -37,6 +37,20 @@ export class AlphaVantageService implements DataProviderInterface {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getDividends({
|
||||||
|
from,
|
||||||
|
granularity = 'day',
|
||||||
|
symbol,
|
||||||
|
to
|
||||||
|
}: {
|
||||||
|
from: Date;
|
||||||
|
granularity: Granularity;
|
||||||
|
symbol: string;
|
||||||
|
to: Date;
|
||||||
|
}) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
public async getHistorical(
|
public async getHistorical(
|
||||||
aSymbol: string,
|
aSymbol: string,
|
||||||
aGranularity: Granularity = 'day',
|
aGranularity: Granularity = 'day',
|
||||||
|
@ -59,6 +59,10 @@ import { DataProviderService } from './data-provider.service';
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
exports: [DataProviderService, GhostfolioScraperApiService]
|
exports: [
|
||||||
|
DataProviderService,
|
||||||
|
GhostfolioScraperApiService,
|
||||||
|
YahooFinanceService
|
||||||
|
]
|
||||||
})
|
})
|
||||||
export class DataProviderModule {}
|
export class DataProviderModule {}
|
||||||
|
@ -23,6 +23,27 @@ export class DataProviderService {
|
|||||||
private readonly prismaService: PrismaService
|
private readonly prismaService: PrismaService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
public async getDividends({
|
||||||
|
dataSource,
|
||||||
|
from,
|
||||||
|
granularity = 'day',
|
||||||
|
symbol,
|
||||||
|
to
|
||||||
|
}: {
|
||||||
|
dataSource: DataSource;
|
||||||
|
from: Date;
|
||||||
|
granularity: Granularity;
|
||||||
|
symbol: string;
|
||||||
|
to: Date;
|
||||||
|
}) {
|
||||||
|
return this.getDataProvider(DataSource[dataSource]).getDividends({
|
||||||
|
from,
|
||||||
|
granularity,
|
||||||
|
symbol,
|
||||||
|
to
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public async getHistorical(
|
public async getHistorical(
|
||||||
aItems: IDataGatheringItem[],
|
aItems: IDataGatheringItem[],
|
||||||
aGranularity: Granularity = 'month',
|
aGranularity: Granularity = 'month',
|
||||||
|
@ -37,6 +37,20 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getDividends({
|
||||||
|
from,
|
||||||
|
granularity = 'day',
|
||||||
|
symbol,
|
||||||
|
to
|
||||||
|
}: {
|
||||||
|
from: Date;
|
||||||
|
granularity: Granularity;
|
||||||
|
symbol: string;
|
||||||
|
to: Date;
|
||||||
|
}) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
public async getHistorical(
|
public async getHistorical(
|
||||||
aSymbol: string,
|
aSymbol: string,
|
||||||
aGranularity: Granularity = 'day',
|
aGranularity: Granularity = 'day',
|
||||||
|
@ -37,6 +37,20 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getDividends({
|
||||||
|
from,
|
||||||
|
granularity = 'day',
|
||||||
|
symbol,
|
||||||
|
to
|
||||||
|
}: {
|
||||||
|
from: Date;
|
||||||
|
granularity: Granularity;
|
||||||
|
symbol: string;
|
||||||
|
to: Date;
|
||||||
|
}) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
public async getHistorical(
|
public async getHistorical(
|
||||||
aSymbol: string,
|
aSymbol: string,
|
||||||
aGranularity: Granularity = 'day',
|
aGranularity: Granularity = 'day',
|
||||||
|
@ -34,6 +34,20 @@ export class GoogleSheetsService implements DataProviderInterface {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getDividends({
|
||||||
|
from,
|
||||||
|
granularity = 'day',
|
||||||
|
symbol,
|
||||||
|
to
|
||||||
|
}: {
|
||||||
|
from: Date;
|
||||||
|
granularity: Granularity;
|
||||||
|
symbol: string;
|
||||||
|
to: Date;
|
||||||
|
}) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
public async getHistorical(
|
public async getHistorical(
|
||||||
aSymbol: string,
|
aSymbol: string,
|
||||||
aGranularity: Granularity = 'day',
|
aGranularity: Granularity = 'day',
|
||||||
|
@ -11,6 +11,18 @@ export interface DataProviderInterface {
|
|||||||
|
|
||||||
getAssetProfile(aSymbol: string): Promise<Partial<SymbolProfile>>;
|
getAssetProfile(aSymbol: string): Promise<Partial<SymbolProfile>>;
|
||||||
|
|
||||||
|
getDividends({
|
||||||
|
from,
|
||||||
|
granularity,
|
||||||
|
symbol,
|
||||||
|
to
|
||||||
|
}: {
|
||||||
|
from: Date;
|
||||||
|
granularity: Granularity;
|
||||||
|
symbol: string;
|
||||||
|
to: Date;
|
||||||
|
}): Promise<{ [date: string]: IDataProviderHistoricalResponse }>;
|
||||||
|
|
||||||
getHistorical(
|
getHistorical(
|
||||||
aSymbol: string,
|
aSymbol: string,
|
||||||
aGranularity: Granularity,
|
aGranularity: Granularity,
|
||||||
|
@ -29,6 +29,20 @@ export class ManualService implements DataProviderInterface {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getDividends({
|
||||||
|
from,
|
||||||
|
granularity = 'day',
|
||||||
|
symbol,
|
||||||
|
to
|
||||||
|
}: {
|
||||||
|
from: Date;
|
||||||
|
granularity: Granularity;
|
||||||
|
symbol: string;
|
||||||
|
to: Date;
|
||||||
|
}) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
public async getHistorical(
|
public async getHistorical(
|
||||||
aSymbol: string,
|
aSymbol: string,
|
||||||
aGranularity: Granularity = 'day',
|
aGranularity: Granularity = 'day',
|
||||||
|
@ -31,6 +31,20 @@ export class RapidApiService implements DataProviderInterface {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getDividends({
|
||||||
|
from,
|
||||||
|
granularity = 'day',
|
||||||
|
symbol,
|
||||||
|
to
|
||||||
|
}: {
|
||||||
|
from: Date;
|
||||||
|
granularity: Granularity;
|
||||||
|
symbol: string;
|
||||||
|
to: Date;
|
||||||
|
}) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
public async getHistorical(
|
public async getHistorical(
|
||||||
aSymbol: string,
|
aSymbol: string,
|
||||||
aGranularity: Granularity = 'day',
|
aGranularity: Granularity = 'day',
|
||||||
|
@ -160,6 +160,59 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getDividends({
|
||||||
|
from,
|
||||||
|
granularity = 'day',
|
||||||
|
symbol,
|
||||||
|
to
|
||||||
|
}: {
|
||||||
|
from: Date;
|
||||||
|
granularity: Granularity;
|
||||||
|
symbol: string;
|
||||||
|
to: Date;
|
||||||
|
}) {
|
||||||
|
if (isSameDay(from, to)) {
|
||||||
|
to = addDays(to, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const historicalResult = await yahooFinance.historical(
|
||||||
|
this.convertToYahooFinanceSymbol(symbol),
|
||||||
|
{
|
||||||
|
events: 'dividends',
|
||||||
|
interval: granularity === 'month' ? '1mo' : '1d',
|
||||||
|
period1: format(from, DATE_FORMAT),
|
||||||
|
period2: format(to, DATE_FORMAT)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const response: {
|
||||||
|
[date: string]: IDataProviderHistoricalResponse;
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
for (const historicalItem of historicalResult) {
|
||||||
|
response[format(historicalItem.date, DATE_FORMAT)] = {
|
||||||
|
marketPrice: this.getConvertedValue({
|
||||||
|
symbol,
|
||||||
|
value: historicalItem.dividends
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(
|
||||||
|
`Could not get dividends for ${symbol} (${this.getName()}) from ${format(
|
||||||
|
from,
|
||||||
|
DATE_FORMAT
|
||||||
|
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`,
|
||||||
|
'YahooFinanceService'
|
||||||
|
);
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async getHistorical(
|
public async getHistorical(
|
||||||
aSymbol: string,
|
aSymbol: string,
|
||||||
aGranularity: Granularity = 'day',
|
aGranularity: Granularity = 'day',
|
||||||
@ -172,11 +225,9 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
to = addDays(to, 1);
|
to = addDays(to, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const yahooFinanceSymbol = this.convertToYahooFinanceSymbol(aSymbol);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const historicalResult = await yahooFinance.historical(
|
const historicalResult = await yahooFinance.historical(
|
||||||
yahooFinanceSymbol,
|
this.convertToYahooFinanceSymbol(aSymbol),
|
||||||
{
|
{
|
||||||
interval: '1d',
|
interval: '1d',
|
||||||
period1: format(from, DATE_FORMAT),
|
period1: format(from, DATE_FORMAT),
|
||||||
@ -188,27 +239,14 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
// Convert symbol back
|
response[aSymbol] = {};
|
||||||
const symbol = this.convertFromYahooFinanceSymbol(yahooFinanceSymbol);
|
|
||||||
|
|
||||||
response[symbol] = {};
|
|
||||||
|
|
||||||
for (const historicalItem of historicalResult) {
|
for (const historicalItem of historicalResult) {
|
||||||
let marketPrice = historicalItem.close;
|
response[aSymbol][format(historicalItem.date, DATE_FORMAT)] = {
|
||||||
|
marketPrice: this.getConvertedValue({
|
||||||
if (symbol === `${this.baseCurrency}GBp`) {
|
symbol: aSymbol,
|
||||||
// Convert GPB to GBp (pence)
|
value: historicalItem.close
|
||||||
marketPrice = new Big(marketPrice).mul(100).toNumber();
|
}),
|
||||||
} else if (symbol === `${this.baseCurrency}ILA`) {
|
|
||||||
// Convert ILS to ILA
|
|
||||||
marketPrice = new Big(marketPrice).mul(100).toNumber();
|
|
||||||
} else if (symbol === `${this.baseCurrency}ZAc`) {
|
|
||||||
// Convert ZAR to ZAc (cents)
|
|
||||||
marketPrice = new Big(marketPrice).mul(100).toNumber();
|
|
||||||
}
|
|
||||||
|
|
||||||
response[symbol][format(historicalItem.date, DATE_FORMAT)] = {
|
|
||||||
marketPrice,
|
|
||||||
performance: historicalItem.open - historicalItem.close
|
performance: historicalItem.open - historicalItem.close
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -423,6 +461,27 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
return name || shortName || symbol;
|
return name || shortName || symbol;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getConvertedValue({
|
||||||
|
symbol,
|
||||||
|
value
|
||||||
|
}: {
|
||||||
|
symbol: string;
|
||||||
|
value: number;
|
||||||
|
}) {
|
||||||
|
if (symbol === `${this.baseCurrency}GBp`) {
|
||||||
|
// Convert GPB to GBp (pence)
|
||||||
|
return new Big(value).mul(100).toNumber();
|
||||||
|
} else if (symbol === `${this.baseCurrency}ILA`) {
|
||||||
|
// Convert ILS to ILA
|
||||||
|
return new Big(value).mul(100).toNumber();
|
||||||
|
} else if (symbol === `${this.baseCurrency}ZAc`) {
|
||||||
|
// Convert ZAR to ZAc (cents)
|
||||||
|
return new Big(value).mul(100).toNumber();
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
private parseAssetClass(aPrice: Price): {
|
private parseAssetClass(aPrice: Price): {
|
||||||
assetClass: AssetClass;
|
assetClass: AssetClass;
|
||||||
assetSubClass: AssetSubClass;
|
assetSubClass: AssetSubClass;
|
||||||
|
@ -89,6 +89,10 @@
|
|||||||
"baseHref": "/es/",
|
"baseHref": "/es/",
|
||||||
"localize": ["es"]
|
"localize": ["es"]
|
||||||
},
|
},
|
||||||
|
"development-fr": {
|
||||||
|
"baseHref": "/fr/",
|
||||||
|
"localize": ["fr"]
|
||||||
|
},
|
||||||
"development-it": {
|
"development-it": {
|
||||||
"baseHref": "/it/",
|
"baseHref": "/it/",
|
||||||
"localize": ["it"]
|
"localize": ["it"]
|
||||||
@ -97,6 +101,10 @@
|
|||||||
"baseHref": "/nl/",
|
"baseHref": "/nl/",
|
||||||
"localize": ["nl"]
|
"localize": ["nl"]
|
||||||
},
|
},
|
||||||
|
"development-pt": {
|
||||||
|
"baseHref": "/pt/",
|
||||||
|
"localize": ["pt"]
|
||||||
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"fileReplacements": [
|
"fileReplacements": [
|
||||||
{
|
{
|
||||||
@ -144,12 +152,18 @@
|
|||||||
"development-es": {
|
"development-es": {
|
||||||
"browserTarget": "client:build:development-es"
|
"browserTarget": "client:build:development-es"
|
||||||
},
|
},
|
||||||
|
"development-fr": {
|
||||||
|
"browserTarget": "client:build:development-fr"
|
||||||
|
},
|
||||||
"development-it": {
|
"development-it": {
|
||||||
"browserTarget": "client:build:development-it"
|
"browserTarget": "client:build:development-it"
|
||||||
},
|
},
|
||||||
"development-nl": {
|
"development-nl": {
|
||||||
"browserTarget": "client:build:development-nl"
|
"browserTarget": "client:build:development-nl"
|
||||||
},
|
},
|
||||||
|
"development-pt": {
|
||||||
|
"browserTarget": "client:build:development-pt"
|
||||||
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"browserTarget": "client:build:production"
|
"browserTarget": "client:build:production"
|
||||||
}
|
}
|
||||||
@ -164,8 +178,10 @@
|
|||||||
"targetFiles": [
|
"targetFiles": [
|
||||||
"messages.de.xlf",
|
"messages.de.xlf",
|
||||||
"messages.es.xlf",
|
"messages.es.xlf",
|
||||||
|
"messages.fr.xlf",
|
||||||
"messages.it.xlf",
|
"messages.it.xlf",
|
||||||
"messages.nl.xlf"
|
"messages.nl.xlf",
|
||||||
|
"messages.pt.xlf"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -194,6 +210,10 @@
|
|||||||
"baseHref": "/es/",
|
"baseHref": "/es/",
|
||||||
"translation": "apps/client/src/locales/messages.es.xlf"
|
"translation": "apps/client/src/locales/messages.es.xlf"
|
||||||
},
|
},
|
||||||
|
"fr": {
|
||||||
|
"baseHref": "/fr/",
|
||||||
|
"translation": "apps/client/src/locales/messages.fr.xlf"
|
||||||
|
},
|
||||||
"it": {
|
"it": {
|
||||||
"baseHref": "/it/",
|
"baseHref": "/it/",
|
||||||
"translation": "apps/client/src/locales/messages.it.xlf"
|
"translation": "apps/client/src/locales/messages.it.xlf"
|
||||||
@ -201,6 +221,10 @@
|
|||||||
"nl": {
|
"nl": {
|
||||||
"baseHref": "/nl/",
|
"baseHref": "/nl/",
|
||||||
"translation": "apps/client/src/locales/messages.nl.xlf"
|
"translation": "apps/client/src/locales/messages.nl.xlf"
|
||||||
|
},
|
||||||
|
"pt": {
|
||||||
|
"baseHref": "/pt/",
|
||||||
|
"translation": "apps/client/src/locales/messages.pt.xlf"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sourceLocale": "en"
|
"sourceLocale": "en"
|
||||||
|
@ -116,6 +116,13 @@ const routes: Routes = [
|
|||||||
'./pages/blog/2022/12/the-importance-of-tracking-your-personal-finances/the-importance-of-tracking-your-personal-finances-page.module'
|
'./pages/blog/2022/12/the-importance-of-tracking-your-personal-finances/the-importance-of-tracking-your-personal-finances-page.module'
|
||||||
).then((m) => m.TheImportanceOfTrackingYourPersonalFinancesPageModule)
|
).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: 'demo',
|
path: 'demo',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
class="position-fixed w-100"
|
class="position-fixed w-100"
|
||||||
[currentRoute]="currentRoute"
|
[currentRoute]="currentRoute"
|
||||||
[info]="info"
|
[info]="info"
|
||||||
|
[pageTitle]="pageTitle"
|
||||||
[user]="user"
|
[user]="user"
|
||||||
(signOut)="onSignOut()"
|
(signOut)="onSignOut()"
|
||||||
></gf-header>
|
></gf-header>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
@import '~apps/client/src/styles/ghostfolio-style';
|
@import 'apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -5,7 +5,13 @@ import {
|
|||||||
OnDestroy,
|
OnDestroy,
|
||||||
OnInit
|
OnInit
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { NavigationEnd, PRIMARY_OUTLET, Router } from '@angular/router';
|
import { Title } from '@angular/platform-browser';
|
||||||
|
import {
|
||||||
|
ActivatedRoute,
|
||||||
|
NavigationEnd,
|
||||||
|
PRIMARY_OUTLET,
|
||||||
|
Router
|
||||||
|
} from '@angular/router';
|
||||||
import {
|
import {
|
||||||
primaryColorHex,
|
primaryColorHex,
|
||||||
secondaryColorHex,
|
secondaryColorHex,
|
||||||
@ -36,6 +42,7 @@ export class AppComponent implements OnDestroy, OnInit {
|
|||||||
public currentYear = new Date().getFullYear();
|
public currentYear = new Date().getFullYear();
|
||||||
public deviceType: string;
|
public deviceType: string;
|
||||||
public info: InfoItem;
|
public info: InfoItem;
|
||||||
|
public pageTitle: string;
|
||||||
public user: User;
|
public user: User;
|
||||||
public version = environment.version;
|
public version = environment.version;
|
||||||
|
|
||||||
@ -47,6 +54,7 @@ export class AppComponent implements OnDestroy, OnInit {
|
|||||||
private deviceService: DeviceDetectorService,
|
private deviceService: DeviceDetectorService,
|
||||||
private materialCssVarsService: MaterialCssVarsService,
|
private materialCssVarsService: MaterialCssVarsService,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
|
private title: Title,
|
||||||
private tokenStorageService: TokenStorageService,
|
private tokenStorageService: TokenStorageService,
|
||||||
private userService: UserService
|
private userService: UserService
|
||||||
) {
|
) {
|
||||||
@ -66,6 +74,19 @@ export class AppComponent implements OnDestroy, OnInit {
|
|||||||
this.currentRoute = urlSegments[0].path;
|
this.currentRoute = urlSegments[0].path;
|
||||||
|
|
||||||
this.info = this.dataService.fetchInfo();
|
this.info = this.dataService.fetchInfo();
|
||||||
|
|
||||||
|
if (this.deviceType === 'mobile') {
|
||||||
|
setTimeout(() => {
|
||||||
|
const index = this.title.getTitle().indexOf('–');
|
||||||
|
const title =
|
||||||
|
index === -1
|
||||||
|
? ''
|
||||||
|
: this.title.getTitle().substring(0, index).trim();
|
||||||
|
this.pageTitle = title.length <= 15 ? title : 'Ghostfolio';
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.userService.stateChanged
|
this.userService.stateChanged
|
||||||
|
@ -25,6 +25,7 @@ import { DateFormats } from './adapter/date-formats';
|
|||||||
import { AppRoutingModule } from './app-routing.module';
|
import { AppRoutingModule } from './app-routing.module';
|
||||||
import { AppComponent } from './app.component';
|
import { AppComponent } from './app.component';
|
||||||
import { GfHeaderModule } from './components/header/header.module';
|
import { GfHeaderModule } from './components/header/header.module';
|
||||||
|
import { GfSubscriptionInterstitialDialogModule } from './components/subscription-interstitial-dialog/subscription-interstitial-dialog.module';
|
||||||
import { authInterceptorProviders } from './core/auth.interceptor';
|
import { authInterceptorProviders } from './core/auth.interceptor';
|
||||||
import { httpResponseInterceptorProviders } from './core/http-response.interceptor';
|
import { httpResponseInterceptorProviders } from './core/http-response.interceptor';
|
||||||
import { LanguageService } from './core/language.service';
|
import { LanguageService } from './core/language.service';
|
||||||
@ -40,6 +41,7 @@ export function NgxStripeFactory(): string {
|
|||||||
BrowserAnimationsModule,
|
BrowserAnimationsModule,
|
||||||
BrowserModule,
|
BrowserModule,
|
||||||
GfHeaderModule,
|
GfHeaderModule,
|
||||||
|
GfSubscriptionInterstitialDialogModule,
|
||||||
HttpClientModule,
|
HttpClientModule,
|
||||||
MarkdownModule.forRoot(),
|
MarkdownModule.forRoot(),
|
||||||
MatAutocompleteModule,
|
MatAutocompleteModule,
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
@import '~apps/client/src/styles/ghostfolio-style';
|
@import 'apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
@import '~apps/client/src/styles/ghostfolio-style';
|
@import 'apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
@import '~apps/client/src/styles/ghostfolio-style';
|
@import 'apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
@import '~apps/client/src/styles/ghostfolio-style';
|
@import 'apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -20,7 +20,6 @@ import {
|
|||||||
addDays,
|
addDays,
|
||||||
format,
|
format,
|
||||||
isBefore,
|
isBefore,
|
||||||
isDate,
|
|
||||||
isSameDay,
|
isSameDay,
|
||||||
isToday,
|
isToday,
|
||||||
isValid,
|
isValid,
|
||||||
@ -31,6 +30,7 @@ import { last } from 'lodash';
|
|||||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||||
import { Subject, takeUntil } from 'rxjs';
|
import { Subject, takeUntil } from 'rxjs';
|
||||||
|
|
||||||
|
import { MarketDataDetailDialogParams } from './market-data-detail-dialog/interfaces/interfaces';
|
||||||
import { MarketDataDetailDialog } from './market-data-detail-dialog/market-data-detail-dialog.component';
|
import { MarketDataDetailDialog } from './market-data-detail-dialog/market-data-detail-dialog.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@ -40,6 +40,7 @@ import { MarketDataDetailDialog } from './market-data-detail-dialog/market-data-
|
|||||||
templateUrl: './admin-market-data-detail.component.html'
|
templateUrl: './admin-market-data-detail.component.html'
|
||||||
})
|
})
|
||||||
export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
|
export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
|
||||||
|
@Input() currency: string;
|
||||||
@Input() dataSource: DataSource;
|
@Input() dataSource: DataSource;
|
||||||
@Input() dateOfFirstActivity: string;
|
@Input() dateOfFirstActivity: string;
|
||||||
@Input() locale = getLocale();
|
@Input() locale = getLocale();
|
||||||
@ -161,9 +162,10 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dialogRef = this.dialog.open(MarketDataDetailDialog, {
|
const dialogRef = this.dialog.open(MarketDataDetailDialog, {
|
||||||
data: {
|
data: <MarketDataDetailDialogParams>{
|
||||||
date,
|
date,
|
||||||
marketPrice,
|
marketPrice,
|
||||||
|
currency: this.currency,
|
||||||
dataSource: this.dataSource,
|
dataSource: this.dataSource,
|
||||||
symbol: this.symbol,
|
symbol: this.symbol,
|
||||||
user: this.user
|
user: this.user
|
||||||
|
@ -2,6 +2,7 @@ import { User } from '@ghostfolio/common/interfaces';
|
|||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
|
|
||||||
export interface MarketDataDetailDialogParams {
|
export interface MarketDataDetailDialogParams {
|
||||||
|
currency: string;
|
||||||
dataSource: DataSource;
|
dataSource: DataSource;
|
||||||
date: Date;
|
date: Date;
|
||||||
marketPrice: number;
|
marketPrice: number;
|
||||||
|
@ -36,7 +36,7 @@ export class MarketDataDetailDialog implements OnDestroy {
|
|||||||
this.dateAdapter.setLocale(this.locale);
|
this.dateAdapter.setLocale(this.locale);
|
||||||
}
|
}
|
||||||
|
|
||||||
public onCancel(): void {
|
public onCancel() {
|
||||||
this.dialogRef.close({ withRefresh: false });
|
this.dialogRef.close({ withRefresh: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<form class="d-flex flex-column h-100">
|
<form class="d-flex flex-column h-100">
|
||||||
<h1 mat-dialog-title i18n>Details for {{ data.symbol }}</h1>
|
<h1 i18n mat-dialog-title>Details for {{ data.symbol }}</h1>
|
||||||
<div class="flex-grow-1" mat-dialog-content>
|
<div class="flex-grow-1" mat-dialog-content>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<mat-form-field appearance="outline" class="w-100">
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
@ -30,6 +30,7 @@
|
|||||||
type="number"
|
type="number"
|
||||||
[(ngModel)]="data.marketPrice"
|
[(ngModel)]="data.marketPrice"
|
||||||
/>
|
/>
|
||||||
|
<span class="ml-2" matSuffix>{{ data.currency }}</span>
|
||||||
<button
|
<button
|
||||||
mat-icon-button
|
mat-icon-button
|
||||||
matSuffix
|
matSuffix
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
@import '~apps/client/src/styles/ghostfolio-style';
|
@import 'apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -14,6 +14,7 @@ import {
|
|||||||
EnhancedSymbolProfile,
|
EnhancedSymbolProfile,
|
||||||
UniqueAsset
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
|
import { translate } from '@ghostfolio/ui/i18n';
|
||||||
import { MarketData } from '@prisma/client';
|
import { MarketData } from '@prisma/client';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { takeUntil } from 'rxjs/operators';
|
import { takeUntil } from 'rxjs/operators';
|
||||||
@ -28,11 +29,13 @@ import { AssetProfileDialogParams } from './interfaces/interfaces';
|
|||||||
styleUrls: ['./asset-profile-dialog.component.scss']
|
styleUrls: ['./asset-profile-dialog.component.scss']
|
||||||
})
|
})
|
||||||
export class AssetProfileDialog implements OnDestroy, OnInit {
|
export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||||
|
public assetClass: string;
|
||||||
public assetProfile: EnhancedSymbolProfile;
|
public assetProfile: EnhancedSymbolProfile;
|
||||||
public assetProfileForm = this.formBuilder.group({
|
public assetProfileForm = this.formBuilder.group({
|
||||||
comment: '',
|
comment: '',
|
||||||
symbolMapping: ''
|
symbolMapping: ''
|
||||||
});
|
});
|
||||||
|
public assetSubClass: string;
|
||||||
public countries: {
|
public countries: {
|
||||||
[code: string]: { name: string; value: number };
|
[code: string]: { name: string; value: number };
|
||||||
};
|
};
|
||||||
@ -64,6 +67,9 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
|||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(({ assetProfile, marketData }) => {
|
.subscribe(({ assetProfile, marketData }) => {
|
||||||
this.assetProfile = assetProfile;
|
this.assetProfile = assetProfile;
|
||||||
|
|
||||||
|
this.assetClass = translate(this.assetProfile?.assetClass);
|
||||||
|
this.assetSubClass = translate(this.assetProfile?.assetSubClass);
|
||||||
this.countries = {};
|
this.countries = {};
|
||||||
this.marketDataDetails = marketData;
|
this.marketDataDetails = marketData;
|
||||||
this.sectors = {};
|
this.sectors = {};
|
||||||
|
@ -43,6 +43,7 @@
|
|||||||
<div class="flex-grow-1" mat-dialog-content>
|
<div class="flex-grow-1" mat-dialog-content>
|
||||||
<gf-admin-market-data-detail
|
<gf-admin-market-data-detail
|
||||||
class="mb-3"
|
class="mb-3"
|
||||||
|
[currency]="assetProfile?.currency"
|
||||||
[dataSource]="data.dataSource"
|
[dataSource]="data.dataSource"
|
||||||
[dateOfFirstActivity]="assetProfile?.dateOfFirstActivity"
|
[dateOfFirstActivity]="assetProfile?.dateOfFirstActivity"
|
||||||
[locale]="data.locale"
|
[locale]="data.locale"
|
||||||
@ -51,6 +52,16 @@
|
|||||||
(marketDataChanged)="onMarketDataChanged($event)"
|
(marketDataChanged)="onMarketDataChanged($event)"
|
||||||
></gf-admin-market-data-detail>
|
></gf-admin-market-data-detail>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
<div class="col-6 mb-3">
|
||||||
|
<gf-value i18n size="medium" [value]="assetProfile?.symbol"
|
||||||
|
>Symbol</gf-value
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 mb-3">
|
||||||
|
<gf-value i18n size="medium" [value]="assetProfile?.currency"
|
||||||
|
>Currency</gf-value
|
||||||
|
>
|
||||||
|
</div>
|
||||||
<div class="col-6 mb-3">
|
<div class="col-6 mb-3">
|
||||||
<gf-value
|
<gf-value
|
||||||
i18n
|
i18n
|
||||||
@ -71,11 +82,7 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 mb-3">
|
<div class="col-6 mb-3">
|
||||||
<gf-value
|
<gf-value i18n size="medium" [hidden]="!assetClass" [value]="assetClass"
|
||||||
i18n
|
|
||||||
size="medium"
|
|
||||||
[hidden]="!assetProfile?.assetClass"
|
|
||||||
[value]="assetProfile?.assetClass"
|
|
||||||
>Asset Class</gf-value
|
>Asset Class</gf-value
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@ -83,8 +90,8 @@
|
|||||||
<gf-value
|
<gf-value
|
||||||
i18n
|
i18n
|
||||||
size="medium"
|
size="medium"
|
||||||
[hidden]="!assetProfile?.assetSubClass"
|
[hidden]="!assetSubClass"
|
||||||
[value]="assetProfile?.assetSubClass"
|
[value]="assetSubClass"
|
||||||
>Asset Sub Class</gf-value
|
>Asset Sub Class</gf-value
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
@ -8,7 +8,8 @@ import {
|
|||||||
PROPERTY_CURRENCIES,
|
PROPERTY_CURRENCIES,
|
||||||
PROPERTY_IS_READ_ONLY_MODE,
|
PROPERTY_IS_READ_ONLY_MODE,
|
||||||
PROPERTY_IS_USER_SIGNUP_ENABLED,
|
PROPERTY_IS_USER_SIGNUP_ENABLED,
|
||||||
PROPERTY_SYSTEM_MESSAGE
|
PROPERTY_SYSTEM_MESSAGE,
|
||||||
|
ghostfolioPrefix
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { Coupon, InfoItem, User } from '@ghostfolio/common/interfaces';
|
import { Coupon, InfoItem, User } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
@ -97,7 +98,10 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
public onAddCoupon() {
|
public onAddCoupon() {
|
||||||
const coupons = [
|
const coupons = [
|
||||||
...this.coupons,
|
...this.coupons,
|
||||||
{ code: this.generateCouponCode(16), duration: this.couponDuration }
|
{
|
||||||
|
code: `${ghostfolioPrefix}${this.generateCouponCode(14)}`,
|
||||||
|
duration: this.couponDuration
|
||||||
|
}
|
||||||
];
|
];
|
||||||
this.putAdminSetting({ key: PROPERTY_COUPONS, value: coupons });
|
this.putAdminSetting({ key: PROPERTY_COUPONS, value: coupons });
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
@import '~apps/client/src/styles/ghostfolio-style';
|
@import 'apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
@import '~apps/client/src/styles/ghostfolio-style';
|
@import 'apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -5,7 +5,7 @@
|
|||||||
mat-button
|
mat-button
|
||||||
[routerLink]="['/']"
|
[routerLink]="['/']"
|
||||||
>
|
>
|
||||||
<gf-logo></gf-logo>
|
<gf-logo [label]="pageTitle"></gf-logo>
|
||||||
</a>
|
</a>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<a
|
<a
|
||||||
@ -231,7 +231,10 @@
|
|||||||
mat-button
|
mat-button
|
||||||
[routerLink]="['/']"
|
[routerLink]="['/']"
|
||||||
>
|
>
|
||||||
<gf-logo [hideName]="currentRoute === 'register'"></gf-logo>
|
<gf-logo
|
||||||
|
[label]="pageTitle"
|
||||||
|
[showLabel]="currentRoute !== 'register'"
|
||||||
|
></gf-logo>
|
||||||
</a>
|
</a>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<a
|
<a
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
@import '~apps/client/src/styles/ghostfolio-style';
|
@import 'apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -30,6 +30,7 @@ import { catchError, takeUntil } from 'rxjs/operators';
|
|||||||
export class HeaderComponent implements OnChanges {
|
export class HeaderComponent implements OnChanges {
|
||||||
@Input() currentRoute: string;
|
@Input() currentRoute: string;
|
||||||
@Input() info: InfoItem;
|
@Input() info: InfoItem;
|
||||||
|
@Input() pageTitle: string;
|
||||||
@Input() user: User;
|
@Input() user: User;
|
||||||
|
|
||||||
@Output() signOut = new EventEmitter<void>();
|
@Output() signOut = new EventEmitter<void>();
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
@import '~apps/client/src/styles/ghostfolio-style';
|
@import 'apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<h3 class="mb-3 text-center" i18n>Markets</h3>
|
<h3 class="d-none d-sm-block mb-3 text-center" i18n>Markets</h3>
|
||||||
<div class="mb-5 row">
|
<div class="mb-5 row">
|
||||||
<div class="col-xs-12 col-md-8 offset-md-2">
|
<div class="col-xs-12 col-md-8 offset-md-2">
|
||||||
<div class="mb-2 text-center text-muted">
|
<div class="mb-2 text-center text-muted">
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
@import '~apps/client/src/styles/ghostfolio-style';
|
@import 'apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -110,13 +110,12 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
|
|||||||
range: this.user?.settings?.dateRange
|
range: this.user?.settings?.dateRange
|
||||||
})
|
})
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((response) => {
|
.subscribe(({ chart, errors, performance }) => {
|
||||||
this.errors = response.errors;
|
this.errors = errors;
|
||||||
this.hasError = response.hasErrors;
|
this.performance = performance;
|
||||||
this.performance = response.performance;
|
|
||||||
this.isLoadingPerformance = false;
|
this.isLoadingPerformance = false;
|
||||||
|
|
||||||
this.historicalDataItems = response.chart.map(
|
this.historicalDataItems = chart.map(
|
||||||
({ date, netPerformanceInPercentage }) => {
|
({ date, netPerformanceInPercentage }) => {
|
||||||
return {
|
return {
|
||||||
date,
|
date,
|
||||||
|
@ -37,7 +37,6 @@
|
|||||||
[baseCurrency]="user?.settings?.baseCurrency"
|
[baseCurrency]="user?.settings?.baseCurrency"
|
||||||
[deviceType]="deviceType"
|
[deviceType]="deviceType"
|
||||||
[errors]="errors"
|
[errors]="errors"
|
||||||
[hasError]="hasError"
|
|
||||||
[isAllTimeHigh]="isAllTimeHigh"
|
[isAllTimeHigh]="isAllTimeHigh"
|
||||||
[isAllTimeLow]="isAllTimeLow"
|
[isAllTimeLow]="isAllTimeLow"
|
||||||
[isLoading]="isLoadingPerformance"
|
[isLoading]="isLoadingPerformance"
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
@import '~apps/client/src/styles/ghostfolio-style';
|
@import 'apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -1,10 +1,8 @@
|
|||||||
<div class="container pb-3 px-3">
|
<div class="container pb-3 px-3">
|
||||||
|
<h3 class="d-none d-sm-block mb-3 text-center" i18n>Summary</h3>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-xs-12 col-md-8 offset-md-2">
|
<div class="col-xs-12 col-md-8 offset-md-2">
|
||||||
<mat-card class="h-100">
|
<mat-card class="h-100">
|
||||||
<mat-card-header>
|
|
||||||
<mat-card-title i18n>Summary</mat-card-title>
|
|
||||||
</mat-card-header>
|
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<gf-portfolio-summary
|
<gf-portfolio-summary
|
||||||
[baseCurrency]="user?.settings?.baseCurrency"
|
[baseCurrency]="user?.settings?.baseCurrency"
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
@import '~apps/client/src/styles/ghostfolio-style';
|
@import 'apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -11,7 +11,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
getTooltipOptions,
|
getTooltipOptions,
|
||||||
getTooltipPositionerMapTop,
|
getTooltipPositionerMapTop,
|
||||||
getVerticalHoverLinePlugin
|
getVerticalHoverLinePlugin,
|
||||||
|
transformTickToAbbreviation
|
||||||
} from '@ghostfolio/common/chart-helper';
|
} from '@ghostfolio/common/chart-helper';
|
||||||
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config';
|
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
@ -19,8 +20,7 @@ import {
|
|||||||
getBackgroundColor,
|
getBackgroundColor,
|
||||||
getDateFormatString,
|
getDateFormatString,
|
||||||
getTextColor,
|
getTextColor,
|
||||||
parseDate,
|
parseDate
|
||||||
transformTickToAbbreviation
|
|
||||||
} from '@ghostfolio/common/helper';
|
} from '@ghostfolio/common/helper';
|
||||||
import { LineChartItem } from '@ghostfolio/common/interfaces';
|
import { LineChartItem } from '@ghostfolio/common/interfaces';
|
||||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||||
@ -136,10 +136,13 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
|||||||
date,
|
date,
|
||||||
investment: last(this.investments).investment
|
investment: last(this.investments).investment
|
||||||
});
|
});
|
||||||
this.values.push({ date, value: last(this.values).value });
|
this.values.push({
|
||||||
|
date,
|
||||||
|
value: last(this.values).value
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = {
|
const chartData = {
|
||||||
labels: this.historicalDataItems.map(({ date }) => {
|
labels: this.historicalDataItems.map(({ date }) => {
|
||||||
return parseDate(date);
|
return parseDate(date);
|
||||||
}),
|
}),
|
||||||
@ -191,17 +194,29 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
|||||||
|
|
||||||
if (this.chartCanvas) {
|
if (this.chartCanvas) {
|
||||||
if (this.chart) {
|
if (this.chart) {
|
||||||
this.chart.data = data;
|
this.chart.data = chartData;
|
||||||
this.chart.options.plugins.tooltip = <unknown>(
|
this.chart.options.plugins.tooltip = <unknown>(
|
||||||
this.getTooltipPluginConfiguration()
|
this.getTooltipPluginConfiguration()
|
||||||
);
|
);
|
||||||
this.chart.options.scales.x.min = this.daysInMarket
|
this.chart.options.scales.x.min = this.daysInMarket
|
||||||
? subDays(new Date(), this.daysInMarket).toISOString()
|
? subDays(
|
||||||
|
new Date().setHours(0, 0, 0, 0),
|
||||||
|
this.daysInMarket
|
||||||
|
).toISOString()
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.savingsRate &&
|
||||||
|
this.chart.options.plugins.annotation.annotations.savingsRate
|
||||||
|
) {
|
||||||
|
this.chart.options.plugins.annotation.annotations.savingsRate.value =
|
||||||
|
this.savingsRate;
|
||||||
|
}
|
||||||
|
|
||||||
this.chart.update();
|
this.chart.update();
|
||||||
} else {
|
} else {
|
||||||
this.chart = new Chart(this.chartCanvas.nativeElement, {
|
this.chart = new Chart(this.chartCanvas.nativeElement, {
|
||||||
data,
|
data: chartData,
|
||||||
options: {
|
options: {
|
||||||
animation: false,
|
animation: false,
|
||||||
elements: {
|
elements: {
|
||||||
@ -316,6 +331,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
|||||||
...getTooltipOptions({
|
...getTooltipOptions({
|
||||||
colorScheme: this.colorScheme,
|
colorScheme: this.colorScheme,
|
||||||
currency: this.isInPercent ? undefined : this.currency,
|
currency: this.isInPercent ? undefined : this.currency,
|
||||||
|
groupBy: this.groupBy,
|
||||||
locale: this.isInPercent ? undefined : this.locale,
|
locale: this.isInPercent ? undefined : this.locale,
|
||||||
unit: this.isInPercent ? '%' : undefined
|
unit: this.isInPercent ? '%' : undefined
|
||||||
}),
|
}),
|
||||||
|
@ -3,14 +3,14 @@
|
|||||||
<div
|
<div
|
||||||
class="flex-grow-1 status text-muted text-right"
|
class="flex-grow-1 status text-muted text-right"
|
||||||
[title]="
|
[title]="
|
||||||
hasError && !isLoading
|
errors?.length > 0 && !isLoading
|
||||||
? 'Sorry! Our data provider partner is experiencing the hiccups.'
|
? 'Sorry! Our data provider partner is experiencing the hiccups.'
|
||||||
: ''
|
: ''
|
||||||
"
|
"
|
||||||
(click)="errors?.length > 0 && onShowErrors()"
|
(click)="errors?.length > 0 && onShowErrors()"
|
||||||
>
|
>
|
||||||
<ion-icon
|
<ion-icon
|
||||||
*ngIf="hasError && !isLoading"
|
*ngIf="errors?.length > 0 && !isLoading"
|
||||||
name="alert-circle-outline"
|
name="alert-circle-outline"
|
||||||
></ion-icon>
|
></ion-icon>
|
||||||
</div>
|
</div>
|
||||||
|
@ -28,7 +28,6 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
|
|||||||
@Input() baseCurrency: string;
|
@Input() baseCurrency: string;
|
||||||
@Input() deviceType: string;
|
@Input() deviceType: string;
|
||||||
@Input() errors: ResponseError['errors'];
|
@Input() errors: ResponseError['errors'];
|
||||||
@Input() hasError: boolean;
|
|
||||||
@Input() isAllTimeHigh: boolean;
|
@Input() isAllTimeHigh: boolean;
|
||||||
@Input() isAllTimeLow: boolean;
|
@Input() isAllTimeLow: boolean;
|
||||||
@Input() isLoading: boolean;
|
@Input() isLoading: boolean;
|
||||||
|
@ -111,7 +111,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
date: historicalDataItem.date,
|
date: historicalDataItem.date,
|
||||||
value: historicalDataItem.value
|
value: historicalDataItem.marketPrice
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@ -125,7 +125,12 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
|
|||||||
this.quantity = quantity;
|
this.quantity = quantity;
|
||||||
this.sectors = {};
|
this.sectors = {};
|
||||||
this.SymbolProfile = SymbolProfile;
|
this.SymbolProfile = SymbolProfile;
|
||||||
this.tags = tags;
|
this.tags = tags.map(({ id, name }) => {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: translate(name)
|
||||||
|
};
|
||||||
|
});
|
||||||
this.transactionCount = transactionCount;
|
this.transactionCount = transactionCount;
|
||||||
this.value = value;
|
this.value = value;
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
@import '~apps/client/src/styles/ghostfolio-style';
|
@import 'apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -0,0 +1 @@
|
|||||||
|
export interface SubscriptionInterstitialDialogParams {}
|
@ -0,0 +1,22 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
|
||||||
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
|
|
||||||
|
import { SubscriptionInterstitialDialogParams } from './interfaces/interfaces';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
host: { class: 'd-flex flex-column flex-grow-1 h-100' },
|
||||||
|
selector: 'gf-subscription-interstitial-dialog',
|
||||||
|
styleUrls: ['./subscription-interstitial-dialog.scss'],
|
||||||
|
templateUrl: 'subscription-interstitial-dialog.html'
|
||||||
|
})
|
||||||
|
export class SubscriptionInterstitialDialog {
|
||||||
|
public constructor(
|
||||||
|
@Inject(MAT_DIALOG_DATA) public data: SubscriptionInterstitialDialogParams,
|
||||||
|
public dialogRef: MatDialogRef<SubscriptionInterstitialDialog>
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public onCancel() {
|
||||||
|
this.dialogRef.close({});
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,42 @@
|
|||||||
|
<h1 class="align-items-center d-flex" mat-dialog-title>
|
||||||
|
<span>Ghostfolio Premium</span>
|
||||||
|
<gf-premium-indicator class="ml-1"></gf-premium-indicator>
|
||||||
|
</h1>
|
||||||
|
<div class="flex-grow-1" mat-dialog-content>
|
||||||
|
<p class="h5" i18n>
|
||||||
|
Are you an ambitious investor who needs the full picture?
|
||||||
|
</p>
|
||||||
|
<p i18n>
|
||||||
|
By upgrading to Ghostfolio Premium, you will get these additional features:
|
||||||
|
</p>
|
||||||
|
<ul class="list-unstyled mb-3">
|
||||||
|
<li class="align-items-center d-flex mb-1">
|
||||||
|
<ion-icon class="mr-1" name="checkmark-circle-outline"></ion-icon>
|
||||||
|
<span i18n>Portfolio Summary</span>
|
||||||
|
</li>
|
||||||
|
<li class="align-items-center d-flex mb-1">
|
||||||
|
<ion-icon class="mr-1" name="checkmark-circle-outline"></ion-icon>
|
||||||
|
<span i18n>Performance Benchmarks</span>
|
||||||
|
</li>
|
||||||
|
<li class="align-items-center d-flex mb-1">
|
||||||
|
<ion-icon class="mr-1" name="checkmark-circle-outline"></ion-icon>
|
||||||
|
<span i18n>Allocations</span>
|
||||||
|
</li>
|
||||||
|
<li class="align-items-center d-flex mb-1">
|
||||||
|
<ion-icon class="mr-1" name="checkmark-circle-outline"></ion-icon>
|
||||||
|
<span i18n>FIRE Calculator</span>
|
||||||
|
</li>
|
||||||
|
<li class="align-items-center d-flex mb-1">
|
||||||
|
<ion-icon class="mr-1" name="checkmark-circle-outline"></ion-icon>
|
||||||
|
<a i18n [routerLink]="['/features']">and more Features...</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p>Refine your personal investment strategy now.</p>
|
||||||
|
</div>
|
||||||
|
<div class="justify-content-end" mat-dialog-actions>
|
||||||
|
<button i18n mat-button (click)="onCancel()">Skip</button>
|
||||||
|
<a color="primary" mat-flat-button [routerLink]="['/pricing']">
|
||||||
|
<span i18n>Upgrade Plan</span>
|
||||||
|
<ion-icon class="ml-1" name="arrow-forward-outline"></ion-icon>
|
||||||
|
</a>
|
||||||
|
</div>
|
@ -0,0 +1,21 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatDialogModule } from '@angular/material/dialog';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
|
||||||
|
|
||||||
|
import { SubscriptionInterstitialDialog } from './subscription-interstitial-dialog.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [SubscriptionInterstitialDialog],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
GfPremiumIndicatorModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatDialogModule,
|
||||||
|
RouterModule
|
||||||
|
],
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
|
})
|
||||||
|
export class GfSubscriptionInterstitialDialogModule {}
|
@ -0,0 +1,11 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
.mat-dialog-content {
|
||||||
|
max-height: unset;
|
||||||
|
|
||||||
|
ion-icon[name='checkmark-circle-outline'] {
|
||||||
|
color: rgba(var(--palette-accent-500), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -7,6 +7,7 @@ import {
|
|||||||
OnDestroy,
|
OnDestroy,
|
||||||
OnInit
|
OnInit
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { getNumberFormatGroup } from '@ghostfolio/common/helper';
|
||||||
import svgMap from 'svgmap';
|
import svgMap from 'svgmap';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@ -16,9 +17,10 @@ import svgMap from 'svgmap';
|
|||||||
styleUrls: ['./world-map-chart.component.scss']
|
styleUrls: ['./world-map-chart.component.scss']
|
||||||
})
|
})
|
||||||
export class WorldMapChartComponent implements OnChanges, OnDestroy, OnInit {
|
export class WorldMapChartComponent implements OnChanges, OnDestroy, OnInit {
|
||||||
@Input() baseCurrency: string;
|
@Input() countries: { [code: string]: { name?: string; value: number } };
|
||||||
@Input() countries: { [code: string]: { name: string; value: number } };
|
@Input() format: string;
|
||||||
@Input() isInPercent = false;
|
@Input() isInPercent = false;
|
||||||
|
@Input() locale: string;
|
||||||
|
|
||||||
public isLoading = true;
|
public isLoading = true;
|
||||||
public svgMapElement;
|
public svgMapElement;
|
||||||
@ -71,7 +73,8 @@ export class WorldMapChartComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
applyData: 'value',
|
applyData: 'value',
|
||||||
data: {
|
data: {
|
||||||
value: {
|
value: {
|
||||||
format: this.isInPercent ? `{0}%` : `{0} ${this.baseCurrency}`
|
format: this.format,
|
||||||
|
thousandSeparator: getNumberFormatGroup(this.locale)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
values: this.countries
|
values: this.countries
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="mb-5 row">
|
<div class="mb-5 row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h3 class="d-flex justify-content-center mb-3">About Ghostfolio</h3>
|
<h3 class="d-none d-sm-block mb-3 text-center">About Ghostfolio</h3>
|
||||||
<div class="about-container">
|
<div class="about-container">
|
||||||
<p>
|
<p>
|
||||||
Ghostfolio is a lightweight wealth management application for
|
Ghostfolio is a lightweight wealth management application for
|
||||||
@ -42,9 +42,11 @@
|
|||||||
href="https://twitter.com/ghostfolio_"
|
href="https://twitter.com/ghostfolio_"
|
||||||
title="Tweet to Ghostfolio on Twitter"
|
title="Tweet to Ghostfolio on Twitter"
|
||||||
>@ghostfolio_</a
|
>@ghostfolio_</a
|
||||||
|
><ng-container *ngIf="hasPermissionForSubscription"
|
||||||
>, send an e-mail to
|
>, send an e-mail to
|
||||||
<a href="mailto:hi@ghostfol.io" title="Send an e-mail"
|
<a href="mailto:hi@ghostfol.io" title="Send an e-mail"
|
||||||
>hi@ghostfol.io</a
|
>hi@ghostfol.io</a
|
||||||
|
></ng-container
|
||||||
>
|
>
|
||||||
or open an issue at
|
or open an issue at
|
||||||
<a
|
<a
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="mb-5 row">
|
<div class="mb-5 row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h3 class="mb-3 text-center" i18n>Privacy Policy</h3>
|
<h3 class="d-none d-sm-block mb-3 text-center" i18n>Privacy Policy</h3>
|
||||||
<markdown [src]="'../assets/privacy-policy.md'"></markdown>
|
<markdown [src]="'../assets/privacy-policy.md'"></markdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -55,7 +55,17 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
|||||||
public hasPermissionToUpdateViewMode: boolean;
|
public hasPermissionToUpdateViewMode: boolean;
|
||||||
public hasPermissionToUpdateUserSettings: boolean;
|
public hasPermissionToUpdateUserSettings: boolean;
|
||||||
public language = document.documentElement.lang;
|
public language = document.documentElement.lang;
|
||||||
public locales = ['de', 'de-CH', 'en-GB', 'en-US', 'es', 'it', 'nl'];
|
public locales = [
|
||||||
|
'de',
|
||||||
|
'de-CH',
|
||||||
|
'en-GB',
|
||||||
|
'en-US',
|
||||||
|
'es',
|
||||||
|
'fr',
|
||||||
|
'it',
|
||||||
|
'nl',
|
||||||
|
'pt'
|
||||||
|
];
|
||||||
public price: number;
|
public price: number;
|
||||||
public priceId: string;
|
public priceId: string;
|
||||||
public snackBarRef: MatSnackBarRef<TextOnlySnackBar>;
|
public snackBarRef: MatSnackBarRef<TextOnlySnackBar>;
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h3 class="d-flex justify-content-center mb-3" i18n>Account</h3>
|
<h3 class="mb-3 text-center" i18n>Account</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="user?.settings" class="mb-5 row">
|
<div *ngIf="user?.settings" class="mb-5 row">
|
||||||
@ -24,8 +24,8 @@
|
|||||||
></gf-premium-indicator>
|
></gf-premium-indicator>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="user?.subscription?.type === 'Premium'">
|
<div *ngIf="user?.subscription?.type === 'Premium'">
|
||||||
Valid until {{ user?.subscription?.expiresAt | date:
|
<ng-container i18n>Valid until</ng-container> {{
|
||||||
defaultDateFormat }}
|
user?.subscription?.expiresAt | date: defaultDateFormat }}
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="user?.subscription?.type === 'Basic'">
|
<div *ngIf="user?.subscription?.type === 'Basic'">
|
||||||
<ng-container *ngIf="hasPermissionForSubscription">
|
<ng-container *ngIf="hasPermissionForSubscription">
|
||||||
@ -74,8 +74,8 @@
|
|||||||
<div class="pr-1 w-50">
|
<div class="pr-1 w-50">
|
||||||
<div i18n>Presenter View</div>
|
<div i18n>Presenter View</div>
|
||||||
<div class="hint-text text-muted" i18n>
|
<div class="hint-text text-muted" i18n>
|
||||||
Hides sensitive values such as absolute performances and
|
Protection for sensitive information like absolute performances
|
||||||
quantities.
|
and quantity values
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pl-1 w-50">
|
<div class="pl-1 w-50">
|
||||||
@ -135,6 +135,10 @@
|
|||||||
>Español (<ng-container i18n>Community</ng-container
|
>Español (<ng-container i18n>Community</ng-container
|
||||||
>)</mat-option
|
>)</mat-option
|
||||||
>
|
>
|
||||||
|
<mat-option value="fr"
|
||||||
|
>Français (<ng-container i18n>Community</ng-container
|
||||||
|
>)</mat-option
|
||||||
|
>
|
||||||
<mat-option value="it"
|
<mat-option value="it"
|
||||||
>Italiano (<ng-container i18n>Community</ng-container
|
>Italiano (<ng-container i18n>Community</ng-container
|
||||||
>)</mat-option
|
>)</mat-option
|
||||||
@ -143,6 +147,10 @@
|
|||||||
>Nederlands (<ng-container i18n>Community</ng-container
|
>Nederlands (<ng-container i18n>Community</ng-container
|
||||||
>)</mat-option
|
>)</mat-option
|
||||||
>
|
>
|
||||||
|
<!--<mat-option value="pt"
|
||||||
|
>Português (<ng-container i18n>Community</ng-container
|
||||||
|
>)</mat-option
|
||||||
|
>-->
|
||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
@ -202,8 +210,11 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex mt-4 py-1">
|
<div class="d-flex mt-4 py-1">
|
||||||
<div class="align-items-center d-flex pr-1 pt-1 w-50">
|
<div class="pr-1 w-50">
|
||||||
<ng-container i18n>Zen Mode</ng-container>
|
<div i18n>Zen Mode</div>
|
||||||
|
<div class="hint-text text-muted" i18n>
|
||||||
|
Distraction-free experience for turbulent times
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pl-1 w-50">
|
<div class="pl-1 w-50">
|
||||||
<mat-slide-toggle
|
<mat-slide-toggle
|
||||||
@ -231,6 +242,9 @@
|
|||||||
>
|
>
|
||||||
<div class="pr-1 w-50">
|
<div class="pr-1 w-50">
|
||||||
<div i18n>Experimental Features</div>
|
<div i18n>Experimental Features</div>
|
||||||
|
<div class="hint-text text-muted" i18n>
|
||||||
|
Sneak peek at upcoming functionality
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pl-1 w-50">
|
<div class="pl-1 w-50">
|
||||||
<mat-slide-toggle
|
<mat-slide-toggle
|
||||||
|
@ -26,7 +26,7 @@ export class CreateOrUpdateAccessDialog implements OnDestroy {
|
|||||||
|
|
||||||
ngOnInit() {}
|
ngOnInit() {}
|
||||||
|
|
||||||
public onCancel(): void {
|
public onCancel() {
|
||||||
this.dialogRef.close();
|
this.dialogRef.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h3 class="d-flex justify-content-center mb-3" i18n>Accounts</h3>
|
<h3 class="d-none d-sm-block mb-3 text-center" i18n>Accounts</h3>
|
||||||
<div class="accounts">
|
<div class="accounts">
|
||||||
<gf-accounts-table
|
<gf-accounts-table
|
||||||
[accounts]="accounts"
|
[accounts]="accounts"
|
||||||
@ -27,8 +27,8 @@
|
|||||||
class="align-items-center d-flex justify-content-center"
|
class="align-items-center d-flex justify-content-center"
|
||||||
color="primary"
|
color="primary"
|
||||||
mat-fab
|
mat-fab
|
||||||
[routerLink]="[]"
|
|
||||||
[queryParams]="{ createDialog: true }"
|
[queryParams]="{ createDialog: true }"
|
||||||
|
[routerLink]="[]"
|
||||||
>
|
>
|
||||||
<ion-icon name="add-outline" size="large"></ion-icon>
|
<ion-icon name="add-outline" size="large"></ion-icon>
|
||||||
</a>
|
</a>
|
||||||
|
@ -36,7 +36,7 @@ export class CreateOrUpdateAccountDialog implements OnDestroy {
|
|||||||
this.platforms = platforms;
|
this.platforms = platforms;
|
||||||
}
|
}
|
||||||
|
|
||||||
public onCancel(): void {
|
public onCancel() {
|
||||||
this.dialogRef.close();
|
this.dialogRef.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -13,14 +13,21 @@ const routes: Routes = [
|
|||||||
canActivate: [AuthGuard],
|
canActivate: [AuthGuard],
|
||||||
children: [
|
children: [
|
||||||
{ path: '', redirectTo: 'overview', pathMatch: 'full' },
|
{ path: '', redirectTo: 'overview', pathMatch: 'full' },
|
||||||
{ path: 'jobs', component: AdminJobsComponent },
|
{ path: 'jobs', component: AdminJobsComponent, title: $localize`Jobs` },
|
||||||
{ path: 'market-data', component: AdminMarketDataComponent },
|
{
|
||||||
{ path: 'overview', component: AdminOverviewComponent },
|
path: 'market-data',
|
||||||
{ path: 'users', component: AdminUsersComponent }
|
component: AdminMarketDataComponent,
|
||||||
|
title: $localize`Market Data`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'overview',
|
||||||
|
component: AdminOverviewComponent,
|
||||||
|
title: $localize`Admin Control`
|
||||||
|
},
|
||||||
|
{ path: 'users', component: AdminUsersComponent, title: $localize`Users` }
|
||||||
],
|
],
|
||||||
component: AdminPageComponent,
|
component: AdminPageComponent,
|
||||||
path: '',
|
path: ''
|
||||||
title: $localize`Admin Control`
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
@import '~apps/client/src/styles/ghostfolio-style';
|
@import 'apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
color: rgb(var(--dark-primary-text));
|
color: rgb(var(--dark-primary-text));
|
||||||
|
@ -125,7 +125,7 @@
|
|||||||
feedback, bug reports, feature requests and of course contributions!
|
feedback, bug reports, feature requests and of course contributions!
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
You can reach me by email at
|
You can reach me by e-mail at
|
||||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
|
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
|
||||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>.
|
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>.
|
||||||
</p>
|
</p>
|
||||||
|
@ -99,7 +99,7 @@
|
|||||||
>
|
>
|
||||||
of users. In the future, I would like to involve more contributors
|
of users. In the future, I would like to involve more contributors
|
||||||
to further extend the functionality of Ghostfolio (e.g. with new
|
to further extend the functionality of Ghostfolio (e.g. with new
|
||||||
reports). Get in touch with me by email at
|
reports). Get in touch with me by e-mail at
|
||||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
|
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
|
||||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a> if you
|
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a> if you
|
||||||
are interested, I’m happy to discuss ideas.
|
are interested, I’m happy to discuss ideas.
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
<div class="mb-3 text-muted"><small>2022-07-23</small></div>
|
<div class="mb-3 text-muted"><small>2022-07-23</small></div>
|
||||||
<img
|
<img
|
||||||
alt="Ghostfolio meets Internet Identity Teaser"
|
alt="Ghostfolio meets Internet Identity Teaser"
|
||||||
class="rounded w-100"
|
class="border rounded w-100"
|
||||||
src="../assets/images/blog/ghostfolio-meets-internet-identity.png"
|
src="../assets/images/blog/ghostfolio-meets-internet-identity.png"
|
||||||
title="Ghostfolio meets Internet Identity"
|
title="Ghostfolio meets Internet Identity"
|
||||||
/>
|
/>
|
||||||
@ -64,15 +64,15 @@
|
|||||||
<p>
|
<p>
|
||||||
When you authenticate with <i>Internet Identity</i>, the service
|
When you authenticate with <i>Internet Identity</i>, the service
|
||||||
only gets a dedicated pseudonym rather than sensitive user data like
|
only gets a dedicated pseudonym rather than sensitive user data like
|
||||||
the email address or phone number. This preserves your anonymity and
|
the e-mail address or phone number. This preserves your anonymity
|
||||||
prevents you being tracked on the Internet.
|
and prevents you being tracked on the Internet.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
<section class="mb-4">
|
<section class="mb-4">
|
||||||
<h2 class="h4">The key benefits in a nutshell</h2>
|
<h2 class="h4">The key benefits in a nutshell</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
Authenticate yourself securely without the need of an email
|
Authenticate yourself securely without the need of an e-mail
|
||||||
address, username, or a password: all you need is your device to
|
address, username, or a password: all you need is your device to
|
||||||
log in.
|
log in.
|
||||||
</li>
|
</li>
|
||||||
@ -89,7 +89,7 @@
|
|||||||
<section class="mb-4">
|
<section class="mb-4">
|
||||||
<p>
|
<p>
|
||||||
If you would like to provide feedback or get involved in further
|
If you would like to provide feedback or get involved in further
|
||||||
development of Ghostfolio, please get in touch by email via
|
development of Ghostfolio, please get in touch by e-mail via
|
||||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
|
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
|
||||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>.
|
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>.
|
||||||
</p>
|
</p>
|
||||||
|
@ -62,7 +62,7 @@
|
|||||||
new and better Internet based on decentralized blockchains to give
|
new and better Internet based on decentralized blockchains to give
|
||||||
power back to the users. <i>Internet Identity</i> created by the
|
power back to the users. <i>Internet Identity</i> created by the
|
||||||
<a href="https://dfinity.org">Dfinity Foundation</a> enables you to
|
<a href="https://dfinity.org">Dfinity Foundation</a> enables you to
|
||||||
sign in securely and anonymously to Ghostfolio without an email
|
sign in securely and anonymously to Ghostfolio without an e-mail
|
||||||
address, username, or a password. All you need is your device with
|
address, username, or a password. All you need is your device with
|
||||||
built-in biometric authentication.
|
built-in biometric authentication.
|
||||||
</p>
|
</p>
|
||||||
@ -90,7 +90,7 @@
|
|||||||
onboard more contributors who are actively involved in software
|
onboard more contributors who are actively involved in software
|
||||||
engineering to realize the full potential of open source software.
|
engineering to realize the full potential of open source software.
|
||||||
If you are a web developer and interested in personal finance,
|
If you are a web developer and interested in personal finance,
|
||||||
please get in touch by email via
|
please get in touch by e-mail via
|
||||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
|
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
|
||||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>. We are
|
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>. We are
|
||||||
happy to discuss ideas.
|
happy to discuss ideas.
|
||||||
|
@ -83,7 +83,7 @@
|
|||||||
<a href="https://ghostfolio.slack.com">Slack community</a> or get in
|
<a href="https://ghostfolio.slack.com">Slack community</a> or get in
|
||||||
touch on Twitter
|
touch on Twitter
|
||||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a> or by
|
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a> or by
|
||||||
email via <a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a>.
|
e-mail via <a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a>.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
We look forward to hearing from you.<br />
|
We look forward to hearing from you.<br />
|
||||||
|
@ -0,0 +1,20 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { RouterModule, Routes } from '@angular/router';
|
||||||
|
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
|
||||||
|
|
||||||
|
import { GhostfolioAufSackgeldVorgestelltPageComponent } from './ghostfolio-auf-sackgeld-vorgestellt-page.component';
|
||||||
|
|
||||||
|
const routes: Routes = [
|
||||||
|
{
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
component: GhostfolioAufSackgeldVorgestelltPageComponent,
|
||||||
|
path: '',
|
||||||
|
title: 'Ghostfolio auf Sackgeld.com vorgestellt'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [RouterModule.forChild(routes)],
|
||||||
|
exports: [RouterModule]
|
||||||
|
})
|
||||||
|
export class GhostfolioAufSackgeldVorgestelltPageRoutingModule {}
|
@ -0,0 +1,9 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
host: { class: 'page' },
|
||||||
|
selector: 'gf-ghostfolio-auf-sackgeld-vorgestellt-page',
|
||||||
|
styleUrls: ['./ghostfolio-auf-sackgeld-vorgestellt-page.scss'],
|
||||||
|
templateUrl: './ghostfolio-auf-sackgeld-vorgestellt-page.html'
|
||||||
|
})
|
||||||
|
export class GhostfolioAufSackgeldVorgestelltPageComponent {}
|
@ -0,0 +1,178 @@
|
|||||||
|
<div class="blog container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-8 offset-md-2">
|
||||||
|
<article>
|
||||||
|
<div class="mb-4 text-center">
|
||||||
|
<h1 class="mb-1">Ghostfolio auf Sackgeld.com vorgestellt</h1>
|
||||||
|
<div class="mb-3 text-muted"><small>2023-01-21</small></div>
|
||||||
|
<img
|
||||||
|
alt="Ghostfolio auf Sackgeld.com vorgestellt Teaser"
|
||||||
|
class="border rounded w-100"
|
||||||
|
src="../assets/images/blog/ghostfolio-x-sackgeld.png"
|
||||||
|
title="Ghostfolio auf Sackgeld.com vorgestellt"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<section class="mb-4">
|
||||||
|
<p>
|
||||||
|
Wir freuen uns darüber, dass unsere Open Source Portfolio Tracking
|
||||||
|
Software <a href="https://ghostfol.io">Ghostfolio</a> auf dem
|
||||||
|
FinTech Newsportal <i>Sackgeld.com</i> vorgestellt wurde.
|
||||||
|
</p>
|
||||||
|
<div class="container my-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-10 offset-md-1">
|
||||||
|
<blockquote class="blockquote m-0">
|
||||||
|
<p class="mb-0">
|
||||||
|
«Ghostfolio ist ein umfassender Portfolio Performance
|
||||||
|
Tracker der einfach zu bedienen ist, mit einigen sehr
|
||||||
|
innovativen Features aufwartet und echten Mehrwert für den
|
||||||
|
Investor bringt.»
|
||||||
|
</p>
|
||||||
|
</blockquote>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
Im ausführlichen Bericht wird die Funktionsweise von Ghostfolio
|
||||||
|
erläutert, die unterstützten Assets aufgeführt sowie die
|
||||||
|
Preisstruktur im Vergleich zu anderen Anbietern dargelegt.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section class="mb-4">
|
||||||
|
<h2 class="h4">
|
||||||
|
Ghostfolio – Open Source Wealth Management Software
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
Ghostfolio ermöglicht es dir, dein Portfolio einfach zu verfolgen
|
||||||
|
und zu analysieren. Es bietet dir detaillierte Informationen über
|
||||||
|
deine Positionen, historische Entwicklung, Performance und die
|
||||||
|
Zusammenstellung deines Portfolios. Durch die Open Source-Lizenz (<a
|
||||||
|
href="https://github.com/ghostfolio/ghostfolio/blob/main/LICENSE"
|
||||||
|
target="_blank"
|
||||||
|
>GNU Affero General Public License v3.0</a
|
||||||
|
>) wird die Software ständig weiterentwickelt, verbessert und du
|
||||||
|
hast sogar selbst die Möglichkeit, dich daran zu beteiligen. Wir
|
||||||
|
sind davon überzeugt, mit diesem Open-Source-Ansatz von Ghostfolio
|
||||||
|
das Finanzwissen und Investieren für alle zugänglicher zu machen.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section class="mb-4">
|
||||||
|
<h2 class="h4">Sackgeld.com – App für ein höheres Sackgeld</h2>
|
||||||
|
<p>
|
||||||
|
Das Schweizer FinTech Nachrichtenportal
|
||||||
|
<a href="https://www.sackgeld.com" target="_blank">Sackgeld.com</a>
|
||||||
|
informiert über die neuesten Entwicklungen und Innovationen im
|
||||||
|
Bereich FinTech. Dazu gehören News, Artikel und persönliche
|
||||||
|
Erfahrungen aus der Welt der digitalen Finanz Apps, Säule 3a, P2P
|
||||||
|
und Immobilien.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section class="mb-4">
|
||||||
|
<p>
|
||||||
|
Wenn du mehr über Ghostfolio erfahren möchtest, kannst du hier den
|
||||||
|
ganzen Artikel nachlesen:
|
||||||
|
<a
|
||||||
|
href="https://www.sackgeld.com/was-taugt-ghostfolio-als-portfolio-performance-tracking-tool"
|
||||||
|
target="_blank"
|
||||||
|
>Was taugt Ghostfolio als Portfolio Performance Tracking-Tool?</a
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section class="mb-4">
|
||||||
|
<ul class="list-inline">
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">AGPL-3.0</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Aktie</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Altersvorsorge</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Anlage</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">App</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Asset</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Feedback</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Finanzwissen</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Fintech</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Ghostfolio</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Immobilien</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Innovation</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Investieren</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Lizenz</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Media</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Open Source</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">OSS</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">P2P</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Performance</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Portfolio</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Presse</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Sackgeld</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Säule 3a</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Schweiz</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Software</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Taschengeld</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Tool</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Vermögen</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Vorsorge</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Wealth Management</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -0,0 +1,17 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
|
||||||
|
import { GhostfolioAufSackgeldVorgestelltPageRoutingModule } from './ghostfolio-auf-sackgeld-vorgestellt-page-routing.module';
|
||||||
|
import { GhostfolioAufSackgeldVorgestelltPageComponent } from './ghostfolio-auf-sackgeld-vorgestellt-page.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [GhostfolioAufSackgeldVorgestelltPageComponent],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
GhostfolioAufSackgeldVorgestelltPageRoutingModule,
|
||||||
|
RouterModule
|
||||||
|
],
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
|
})
|
||||||
|
export class GhostfolioAufSackgeldVorgestelltPageModule {}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user