Compare commits
107 Commits
Author | SHA1 | Date | |
---|---|---|---|
55b03733f4 | |||
0000317041 | |||
e5f2a3865d | |||
c61561664f | |||
a7d8a63ab8 | |||
5c51c1e825 | |||
3a67bf9bb4 | |||
f7597c213d | |||
2e7f46ad78 | |||
cfffb99f52 | |||
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 | |||
d97fe4da9c | |||
b20fa55b79 | |||
dd7a6f1562 | |||
15357bd5b5 | |||
52c7adc266 | |||
1ae8970045 | |||
7c4c047140 | |||
527f7e4faf | |||
50160eb9dc | |||
58dff8a1e0 | |||
2cd41615b2 | |||
66d5793528 | |||
e8d65e1c85 | |||
da827a08f5 | |||
d545e4877c | |||
1918dee9c5 | |||
a08610b603 | |||
c22733db56 | |||
ee4866eb7d | |||
327b1fa0d7 | |||
b155666d21 | |||
c5ee3237ed | |||
16118d635c | |||
49ce4803ce | |||
0b65d05013 | |||
8793284e75 | |||
1c5e4050a8 | |||
4f187e1a9f | |||
b56111ae85 | |||
61dfc1f819 | |||
6137f228a8 | |||
5293de14cd | |||
7340a674b5 | |||
42cb3e2c73 | |||
e8a4a53c9f |
2
.github/workflows/build-code.yml
vendored
2
.github/workflows/build-code.yml
vendored
@ -10,7 +10,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
node_version:
|
||||
- 16
|
||||
- 18
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
200
CHANGELOG.md
200
CHANGELOG.md
@ -5,6 +5,206 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## 1.231.0 - 2023-02-04
|
||||
|
||||
### Added
|
||||
|
||||
- Added the dividend and fees to the position detail dialog
|
||||
- Added support to link a (wealth) item to an account
|
||||
|
||||
### Changed
|
||||
|
||||
- Relaxed the validation rule of the _Redis_ host environment variable (`REDIS_HOST`)
|
||||
- Improved the language localization for German (`de`)
|
||||
- Eliminated `angular-material-css-vars`
|
||||
- Upgraded `angular` from version `14.2.0` to `15.1.2`
|
||||
- Upgraded `Nx` from version `15.0.13` to `15.6.3`
|
||||
|
||||
## 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
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for filtering on the analysis page
|
||||
- Added the price to the `Subscription` database schema
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the execution time of the asset profile data gathering to every Sunday at lunch time
|
||||
- Improved the activities import by providing asset profile details
|
||||
- Upgraded `@codewithdan/observable-store` from version `2.2.11` to `2.2.15`
|
||||
- Upgraded `bull` from version `4.8.5` to `4.10.2`
|
||||
- Upgraded `countup.js` from version `2.0.7` to `2.3.2`
|
||||
- Upgraded the _Internet Identity_ dependencies from version `0.12.1` to `0.15.1`
|
||||
- Upgraded `prisma` from version `4.7.1` to `4.8.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the language localization of the account type
|
||||
|
||||
## 1.221.0 - 2022-12-26
|
||||
|
||||
### Added
|
||||
|
||||
- Added support to manage the tags in the create or edit activity dialog
|
||||
- Added the tags to the admin control panel
|
||||
- Added a blog post: _The importance of tracking your personal finances_
|
||||
- Resolved the title of the blog post
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the activities import by a preview step
|
||||
- Improved the labels based on the type in the create or edit activity dialog
|
||||
- Refreshed the cryptocurrencies list
|
||||
- Removed the data source type `RAKUTEN`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the date conversion for years with only two digits
|
||||
|
||||
## 1.220.0 - 2022-12-23
|
||||
|
||||
### Added
|
||||
|
||||
- Added the position detail dialog to the _Top 3_ and _Bottom 3_ performers of the analysis page
|
||||
- Added the `dryRun` option to the import activities endpoint
|
||||
|
||||
### Changed
|
||||
|
||||
- Increased the historical data chart of the _Fear & Greed Index_ (market mood) to 365 days
|
||||
- Upgraded `color` from version `4.0.1` to `4.2.3`
|
||||
- Upgraded `prettier` from version `2.7.1` to `2.8.1`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the rounding of the y-axis ticks in the benchmark comparator
|
||||
|
||||
## 1.219.0 - 2022-12-17
|
||||
|
||||
### 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
|
||||
WORKDIR /ghostfolio
|
||||
@ -50,7 +50,7 @@ COPY package.json /ghostfolio/dist/apps/api
|
||||
RUN yarn database:generate-typings
|
||||
|
||||
# Image to run, copy everything needed from builder
|
||||
FROM node:16-slim
|
||||
FROM node:18-slim
|
||||
RUN apt update && apt install -y \
|
||||
openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
79
README.md
79
README.md
@ -1,34 +1,26 @@
|
||||
<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>
|
||||
<p>
|
||||
<strong>Open Source Wealth Management Software</strong>
|
||||
</p>
|
||||
<p>
|
||||
<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>
|
||||
<p>
|
||||
<a href="https://www.buymeacoffee.com/ghostfolio">
|
||||
<img src="https://img.shields.io/badge/Buy%20me%20a%20coffee-Support-yellow?logo=buymeacoffee"/></a>
|
||||
<a href="#contributing">
|
||||
<img src="https://img.shields.io/badge/Contributions-Welcome-orange.svg"/></a>
|
||||
<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>
|
||||
[<img src="https://avatars.githubusercontent.com/u/82473144?s=200" width="100" alt="Ghostfolio logo">](https://ghostfol.io)
|
||||
|
||||
# Ghostfolio
|
||||
|
||||
**Open Source Wealth Management Software**
|
||||
|
||||
[**Ghostfol.io**](https://ghostfol.io) | [**Live Demo**](https://ghostfol.io/en/demo) | [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) | [**FAQ**](https://ghostfol.io/en/faq) |
|
||||
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**Twitter**](https://twitter.com/ghostfolio_)
|
||||
|
||||
[](https://www.buymeacoffee.com/ghostfolio)
|
||||
[](#contributing)
|
||||
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||
|
||||
</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.
|
||||
|
||||
<div align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
|
||||
<a href="https://www.youtube.com/watch?v=yY6ObSQVJZk">
|
||||
<img src="./apps/client/src/assets/images/video-preview.jpg" width="600"></a>
|
||||
<div align="center">
|
||||
|
||||
[<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>
|
||||
|
||||
## Ghostfolio Premium
|
||||
@ -48,7 +40,7 @@ Ghostfolio is for you if you are...
|
||||
- 🧘 into minimalism
|
||||
- 🧺 caring about diversifying your financial resources
|
||||
- 🆓 interested in financial independence
|
||||
- 🙅 saying no to spreadsheets in 2022
|
||||
- 🙅 saying no to spreadsheets
|
||||
- 😎 still reading this list
|
||||
|
||||
## Features
|
||||
@ -63,8 +55,10 @@ Ghostfolio is for you if you are...
|
||||
- ✅ Zen Mode
|
||||
- ✅ Progressive Web App (PWA) with a mobile-first design
|
||||
|
||||
<div align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
|
||||
<img src="./apps/client/src/assets/images/screenshot.png" width="300">
|
||||
<div align="center">
|
||||
|
||||
<img src="./apps/client/src/assets/images/screenshot.png" width="300" alt="Image of a phone showing the Ghostfolio app open">
|
||||
|
||||
</div>
|
||||
|
||||
## 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`.
|
||||
|
||||
<div align="center">
|
||||
<a href="https://www.buymeacoffee.com/ghostfolio">
|
||||
<img
|
||||
alt="Buy me a coffee button"
|
||||
src="./apps/client/src/assets/images/button-buy-me-a-coffee.png"
|
||||
width="150"
|
||||
/>
|
||||
</a>
|
||||
|
||||
[<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)
|
||||
|
||||
</div>
|
||||
|
||||
### Supported Environment Variables
|
||||
@ -158,7 +148,7 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
|
||||
### Prerequisites
|
||||
|
||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||
- [Node.js](https://nodejs.org/en/download) (version 16+)
|
||||
- [Node.js](https://nodejs.org/en/download) (version 16)
|
||||
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||
- 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
|
||||
|
||||
<ol type="a">
|
||||
<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>
|
||||
</ol>
|
||||
#### Debug
|
||||
|
||||
Run `yarn watch:server` and click _Launch Program_ in [Visual Studio Code](https://code.visualstudio.com)
|
||||
|
||||
#### Serve
|
||||
|
||||
Run `yarn start:server`
|
||||
|
||||
### 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.
|
||||
|
||||
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
|
||||
|
||||
© 2022 [Ghostfolio](https://ghostfol.io)
|
||||
© 2023 [Ghostfolio](https://ghostfol.io)
|
||||
|
||||
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 { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import {
|
||||
nullifyValuesInObject,
|
||||
nullifyValuesInObjects
|
||||
} from '@ghostfolio/api/helper/object.helper';
|
||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import { Accounts } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
@ -22,7 +19,8 @@ import {
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards
|
||||
UseGuards,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
@ -39,8 +37,7 @@ export class AccountController {
|
||||
private readonly accountService: AccountService,
|
||||
private readonly impersonationService: ImpersonationService,
|
||||
private readonly portfolioService: PortfolioService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
private readonly userService: UserService
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@Delete(':id')
|
||||
@ -85,6 +82,7 @@ export class AccountController {
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
public async getAllAccounts(
|
||||
@Headers('impersonation-id') impersonationId
|
||||
): Promise<Accounts> {
|
||||
@ -94,39 +92,15 @@ export class AccountController {
|
||||
this.request.user.id
|
||||
);
|
||||
|
||||
let accountsWithAggregations =
|
||||
await this.portfolioService.getAccountsWithAggregations({
|
||||
userId: impersonationUserId || this.request.user.id,
|
||||
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;
|
||||
return this.portfolioService.getAccountsWithAggregations({
|
||||
userId: impersonationUserId || this.request.user.id,
|
||||
withExcludedAccounts: true
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
public async getAccountById(
|
||||
@Headers('impersonation-id') impersonationId,
|
||||
@Param('id') id: string
|
||||
@ -137,35 +111,13 @@ export class AccountController {
|
||||
this.request.user.id
|
||||
);
|
||||
|
||||
let accountsWithAggregations =
|
||||
const accountsWithAggregations =
|
||||
await this.portfolioService.getAccountsWithAggregations({
|
||||
filters: [{ id, type: 'ACCOUNT' }],
|
||||
userId: impersonationUserId || this.request.user.id,
|
||||
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];
|
||||
}
|
||||
|
||||
|
@ -1,24 +1,23 @@
|
||||
import { join } from 'path';
|
||||
|
||||
import { AuthDeviceModule } from '@ghostfolio/api/app/auth-device/auth-device.module';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { CronService } from '@ghostfolio/api/services/cron.service';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
|
||||
import { BullModule } from '@nestjs/bull';
|
||||
import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||
|
||||
import { ConfigurationModule } from '../services/configuration.module';
|
||||
import { CronService } from '../services/cron.service';
|
||||
import { DataGatheringModule } from '../services/data-gathering.module';
|
||||
import { DataProviderModule } from '../services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '../services/exchange-rate-data.module';
|
||||
import { PrismaModule } from '../services/prisma.module';
|
||||
import { TwitterBotModule } from '../services/twitter-bot/twitter-bot.module';
|
||||
import { AccessModule } from './access/access.module';
|
||||
import { AccountModule } from './account/account.module';
|
||||
import { AdminModule } from './admin/admin.module';
|
||||
import { AppController } from './app.controller';
|
||||
import { AuthDeviceModule } from './auth-device/auth-device.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { BenchmarkModule } from './benchmark/benchmark.module';
|
||||
import { CacheModule } from './cache/cache.module';
|
||||
@ -30,6 +29,7 @@ import { InfoModule } from './info/info.module';
|
||||
import { LogoModule } from './logo/logo.module';
|
||||
import { OrderModule } from './order/order.module';
|
||||
import { PortfolioModule } from './portfolio/portfolio.module';
|
||||
import { RedisCacheModule } from './redis-cache/redis-cache.module';
|
||||
import { SubscriptionModule } from './subscription/subscription.module';
|
||||
import { SymbolModule } from './symbol/symbol.module';
|
||||
import { UserModule } from './user/user.module';
|
||||
@ -45,7 +45,7 @@ import { UserModule } from './user/user.module';
|
||||
BullModule.forRoot({
|
||||
redis: {
|
||||
host: process.env.REDIS_HOST,
|
||||
port: parseInt(process.env.REDIS_PORT, 10),
|
||||
port: parseInt(process.env.REDIS_PORT ?? '6379', 10),
|
||||
password: process.env.REDIS_PASSWORD
|
||||
}
|
||||
}),
|
||||
|
@ -3,8 +3,10 @@ import * as path from 'path';
|
||||
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { format } from 'date-fns';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
|
||||
@Injectable()
|
||||
@ -12,8 +14,10 @@ export class FrontendMiddleware implements NestMiddleware {
|
||||
public indexHtmlDe = '';
|
||||
public indexHtmlEn = '';
|
||||
public indexHtmlEs = '';
|
||||
public indexHtmlFr = '';
|
||||
public indexHtmlIt = '';
|
||||
public indexHtmlNl = '';
|
||||
public indexHtmlPt = '';
|
||||
public isProduction: boolean;
|
||||
|
||||
public constructor(
|
||||
@ -39,6 +43,10 @@ export class FrontendMiddleware implements NestMiddleware {
|
||||
this.getPathOfIndexHtmlFile('es'),
|
||||
'utf8'
|
||||
);
|
||||
this.indexHtmlFr = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile('fr'),
|
||||
'utf8'
|
||||
);
|
||||
this.indexHtmlIt = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile('it'),
|
||||
'utf8'
|
||||
@ -47,18 +55,41 @@ export class FrontendMiddleware implements NestMiddleware {
|
||||
this.getPathOfIndexHtmlFile('nl'),
|
||||
'utf8'
|
||||
);
|
||||
this.indexHtmlPt = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile('pt'),
|
||||
'utf8'
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
public use(request: Request, response: Response, next: NextFunction) {
|
||||
const currentDate = format(new Date(), DATE_FORMAT);
|
||||
let featureGraphicPath = 'assets/cover.png';
|
||||
let title = 'Ghostfolio – Open Source Wealth Management Software';
|
||||
|
||||
if (request.path.startsWith('/en/blog/2022/08/500-stars-on-github')) {
|
||||
featureGraphicPath = 'assets/images/blog/500-stars-on-github.jpg';
|
||||
title = `500 Stars - ${title}`;
|
||||
} else if (request.path.startsWith('/en/blog/2022/10/hacktoberfest-2022')) {
|
||||
featureGraphicPath = 'assets/images/blog/hacktoberfest-2022.png';
|
||||
title = `Hacktoberfest 2022 - ${title}`;
|
||||
} else if (request.path.startsWith('/en/blog/2022/11/black-friday-2022')) {
|
||||
featureGraphicPath = 'assets/images/blog/black-friday-2022.jpg';
|
||||
title = `Black Friday 2022 - ${title}`;
|
||||
} else if (
|
||||
request.path.startsWith(
|
||||
'/en/blog/2022/12/the-importance-of-tracking-your-personal-finances'
|
||||
)
|
||||
) {
|
||||
featureGraphicPath = 'assets/images/blog/20221226.jpg';
|
||||
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 (
|
||||
@ -71,7 +102,9 @@ export class FrontendMiddleware implements NestMiddleware {
|
||||
} else if (request.path === '/de' || request.path.startsWith('/de/')) {
|
||||
response.send(
|
||||
this.interpolate(this.indexHtmlDe, {
|
||||
currentDate,
|
||||
featureGraphicPath,
|
||||
title,
|
||||
languageCode: 'de',
|
||||
path: request.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
@ -80,16 +113,29 @@ export class FrontendMiddleware implements NestMiddleware {
|
||||
} else if (request.path === '/es' || request.path.startsWith('/es/')) {
|
||||
response.send(
|
||||
this.interpolate(this.indexHtmlEs, {
|
||||
currentDate,
|
||||
featureGraphicPath,
|
||||
title,
|
||||
languageCode: 'es',
|
||||
path: request.path,
|
||||
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/')) {
|
||||
response.send(
|
||||
this.interpolate(this.indexHtmlIt, {
|
||||
currentDate,
|
||||
featureGraphicPath,
|
||||
title,
|
||||
languageCode: 'it',
|
||||
path: request.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
@ -98,16 +144,29 @@ export class FrontendMiddleware implements NestMiddleware {
|
||||
} else if (request.path === '/nl' || request.path.startsWith('/nl/')) {
|
||||
response.send(
|
||||
this.interpolate(this.indexHtmlNl, {
|
||||
currentDate,
|
||||
featureGraphicPath,
|
||||
title,
|
||||
languageCode: 'nl',
|
||||
path: request.path,
|
||||
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 {
|
||||
response.send(
|
||||
this.interpolate(this.indexHtmlEn, {
|
||||
currentDate,
|
||||
featureGraphicPath,
|
||||
title,
|
||||
languageCode: DEFAULT_LANGUAGE_CODE,
|
||||
path: request.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
|
@ -1,16 +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 { ImportResponse } from '@ghostfolio/common/interfaces';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpException,
|
||||
Inject,
|
||||
Logger,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards
|
||||
Query,
|
||||
UseGuards,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { ImportDataDto } from './import-data.dto';
|
||||
@ -26,7 +34,10 @@ export class ImportController {
|
||||
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async import(@Body() importData: ImportDataDto): Promise<void> {
|
||||
public async import(
|
||||
@Body() importData: ImportDataDto,
|
||||
@Query('dryRun') isDryRun?: boolean
|
||||
): Promise<ImportResponse> {
|
||||
if (!this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
@ -45,12 +56,18 @@ export class ImportController {
|
||||
maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||
|
||||
try {
|
||||
return await this.importService.import({
|
||||
const activities = await this.importService.import({
|
||||
maxActivitiesToImport,
|
||||
activities: importData.activities,
|
||||
isDryRun,
|
||||
userCurrency,
|
||||
activitiesDto: importData.activities,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
return { activities };
|
||||
} catch (error) {
|
||||
Logger.error(error, ImportController);
|
||||
|
||||
@ -63,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,11 +1,14 @@
|
||||
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
|
||||
import { CacheModule } from '@ghostfolio/api/app/cache/cache.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 { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ImportController } from './import.controller';
|
||||
@ -19,9 +22,12 @@ import { ImportService } from './import.service';
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
OrderModule,
|
||||
PortfolioModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule
|
||||
RedisCacheModule,
|
||||
SymbolProfileModule
|
||||
],
|
||||
providers: [ImportService]
|
||||
})
|
||||
|
@ -1,30 +1,118 @@
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
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 { isSameDay, parseISO } from 'date-fns';
|
||||
import { SymbolProfile } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import { endOfToday, isAfter, isSameDay, parseISO } from 'date-fns';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
@Injectable()
|
||||
export class ImportService {
|
||||
public constructor(
|
||||
private readonly accountService: AccountService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly orderService: OrderService
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
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({
|
||||
activities,
|
||||
activitiesDto,
|
||||
isDryRun = false,
|
||||
maxActivitiesToImport,
|
||||
userCurrency,
|
||||
userId
|
||||
}: {
|
||||
activities: Partial<CreateOrderDto>[];
|
||||
activitiesDto: Partial<CreateOrderDto>[];
|
||||
isDryRun?: boolean;
|
||||
maxActivitiesToImport: number;
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
}): Promise<void> {
|
||||
for (const activity of activities) {
|
||||
}): Promise<Activity[]> {
|
||||
for (const activity of activitiesDto) {
|
||||
if (!activity.dataSource) {
|
||||
if (activity.type === 'ITEM') {
|
||||
activity.dataSource = 'MANUAL';
|
||||
@ -34,8 +122,8 @@ export class ImportService {
|
||||
}
|
||||
}
|
||||
|
||||
await this.validateActivities({
|
||||
activities,
|
||||
const assetProfiles = await this.validateActivities({
|
||||
activitiesDto,
|
||||
maxActivitiesToImport,
|
||||
userId
|
||||
});
|
||||
@ -46,60 +134,138 @@ export class ImportService {
|
||||
}
|
||||
);
|
||||
|
||||
const activities: Activity[] = [];
|
||||
|
||||
for (const {
|
||||
accountId,
|
||||
comment,
|
||||
currency,
|
||||
dataSource,
|
||||
date,
|
||||
date: dateString,
|
||||
fee,
|
||||
quantity,
|
||||
symbol,
|
||||
type,
|
||||
unitPrice
|
||||
} of activities) {
|
||||
await this.orderService.createOrder({
|
||||
comment,
|
||||
fee,
|
||||
quantity,
|
||||
type,
|
||||
unitPrice,
|
||||
userId,
|
||||
accountId: accountIds.includes(accountId) ? accountId : undefined,
|
||||
date: parseISO(<string>(<unknown>date)),
|
||||
SymbolProfile: {
|
||||
connectOrCreate: {
|
||||
create: {
|
||||
currency,
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
where: {
|
||||
dataSource_symbol: {
|
||||
} of activitiesDto) {
|
||||
const date = parseISO(<string>(<unknown>dateString));
|
||||
const validatedAccountId = accountIds.includes(accountId)
|
||||
? accountId
|
||||
: undefined;
|
||||
|
||||
let order: OrderWithAccount;
|
||||
|
||||
if (isDryRun) {
|
||||
order = {
|
||||
comment,
|
||||
date,
|
||||
fee,
|
||||
quantity,
|
||||
type,
|
||||
unitPrice,
|
||||
userId,
|
||||
accountId: validatedAccountId,
|
||||
accountUserId: undefined,
|
||||
createdAt: new Date(),
|
||||
id: uuidv4(),
|
||||
isDraft: isAfter(date, endOfToday()),
|
||||
SymbolProfile: {
|
||||
currency,
|
||||
dataSource,
|
||||
symbol,
|
||||
assetClass: null,
|
||||
assetSubClass: null,
|
||||
comment: null,
|
||||
countries: null,
|
||||
createdAt: undefined,
|
||||
id: undefined,
|
||||
name: null,
|
||||
scraperConfiguration: null,
|
||||
sectors: null,
|
||||
symbolMapping: null,
|
||||
updatedAt: undefined,
|
||||
url: null,
|
||||
...assetProfiles[symbol]
|
||||
},
|
||||
symbolProfileId: undefined,
|
||||
updatedAt: new Date()
|
||||
};
|
||||
} else {
|
||||
order = await this.orderService.createOrder({
|
||||
comment,
|
||||
date,
|
||||
fee,
|
||||
quantity,
|
||||
type,
|
||||
unitPrice,
|
||||
userId,
|
||||
accountId: validatedAccountId,
|
||||
SymbolProfile: {
|
||||
connectOrCreate: {
|
||||
create: {
|
||||
currency,
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
where: {
|
||||
dataSource_symbol: {
|
||||
dataSource,
|
||||
symbol
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
User: { connect: { id: userId } }
|
||||
},
|
||||
User: { connect: { id: userId } }
|
||||
});
|
||||
}
|
||||
|
||||
const value = new Big(quantity).mul(unitPrice).toNumber();
|
||||
|
||||
activities.push({
|
||||
...order,
|
||||
value,
|
||||
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
fee,
|
||||
currency,
|
||||
userCurrency
|
||||
),
|
||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
value,
|
||||
currency,
|
||||
userCurrency
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
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({
|
||||
activities,
|
||||
activitiesDto,
|
||||
maxActivitiesToImport,
|
||||
userId
|
||||
}: {
|
||||
activities: Partial<CreateOrderDto>[];
|
||||
activitiesDto: Partial<CreateOrderDto>[];
|
||||
maxActivitiesToImport: number;
|
||||
userId: string;
|
||||
}) {
|
||||
if (activities?.length > maxActivitiesToImport) {
|
||||
if (activitiesDto?.length > maxActivitiesToImport) {
|
||||
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
|
||||
}
|
||||
|
||||
const assetProfiles: {
|
||||
[symbol: string]: Partial<SymbolProfile>;
|
||||
} = {};
|
||||
const existingActivities = await this.orderService.orders({
|
||||
include: { SymbolProfile: true },
|
||||
orderBy: { date: 'desc' },
|
||||
@ -109,7 +275,7 @@ export class ImportService {
|
||||
for (const [
|
||||
index,
|
||||
{ currency, dataSource, date, fee, quantity, symbol, type, unitPrice }
|
||||
] of activities.entries()) {
|
||||
] of activitiesDto.entries()) {
|
||||
const duplicateActivity = existingActivities.find((activity) => {
|
||||
return (
|
||||
activity.SymbolProfile.currency === currency &&
|
||||
@ -128,22 +294,28 @@ export class ImportService {
|
||||
}
|
||||
|
||||
if (dataSource !== 'MANUAL') {
|
||||
const quotes = await this.dataProviderService.getQuotes([
|
||||
{ dataSource, symbol }
|
||||
]);
|
||||
const assetProfile = (
|
||||
await this.dataProviderService.getAssetProfiles([
|
||||
{ dataSource, symbol }
|
||||
])
|
||||
)?.[symbol];
|
||||
|
||||
if (quotes[symbol] === undefined) {
|
||||
if (assetProfile === undefined) {
|
||||
throw new Error(
|
||||
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
|
||||
);
|
||||
}
|
||||
|
||||
if (quotes[symbol].currency !== currency) {
|
||||
if (assetProfile.currency !== currency) {
|
||||
throw new Error(
|
||||
`activities.${index}.currency ("${currency}") does not match with "${quotes[symbol].currency}"`
|
||||
`activities.${index}.currency ("${currency}") does not match with "${assetProfile.currency}"`
|
||||
);
|
||||
}
|
||||
|
||||
assetProfiles[symbol] = assetProfile;
|
||||
}
|
||||
}
|
||||
|
||||
return assetProfiles;
|
||||
}
|
||||
}
|
||||
|
@ -7,8 +7,8 @@ import { PropertyService } from '@ghostfolio/api/services/property/property.serv
|
||||
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
||||
import {
|
||||
DEMO_USER_ID,
|
||||
PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
|
||||
PROPERTY_IS_READ_ONLY_MODE,
|
||||
PROPERTY_IS_USER_SIGNUP_ENABLED,
|
||||
PROPERTY_SLACK_COMMUNITY_USERS,
|
||||
PROPERTY_STRIPE_CONFIG,
|
||||
PROPERTY_SYSTEM_MESSAGE,
|
||||
@ -93,6 +93,10 @@ export class InfoService {
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
globalPermissions.push(permissions.enableSubscription);
|
||||
|
||||
info.countriesOfSubscribers =
|
||||
((await this.propertyService.getByKey(
|
||||
PROPERTY_COUNTRIES_OF_SUBSCRIBERS
|
||||
)) as string[]) ?? [];
|
||||
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
|
||||
}
|
||||
|
||||
@ -303,14 +307,14 @@ export class InfoService {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const stripeConfig = await this.prismaService.property.findUnique({
|
||||
let subscriptions: Subscription[] = [];
|
||||
|
||||
const stripeConfig = (await this.prismaService.property.findUnique({
|
||||
where: { key: PROPERTY_STRIPE_CONFIG }
|
||||
});
|
||||
})) ?? { value: '{}' };
|
||||
|
||||
if (stripeConfig) {
|
||||
return [JSON.parse(stripeConfig.value)];
|
||||
}
|
||||
subscriptions = [JSON.parse(stripeConfig.value)];
|
||||
|
||||
return [];
|
||||
return subscriptions;
|
||||
}
|
||||
}
|
||||
|
@ -76,23 +76,21 @@ export class OrderService {
|
||||
userId: string;
|
||||
}
|
||||
): Promise<Order> {
|
||||
const defaultAccount = (
|
||||
await this.accountService.getAccounts(data.userId)
|
||||
).find((account) => {
|
||||
return account.isDefault === true;
|
||||
});
|
||||
let Account;
|
||||
|
||||
if (data.accountId) {
|
||||
Account = {
|
||||
connect: {
|
||||
id_userId: {
|
||||
userId: data.userId,
|
||||
id: data.accountId
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const tags = data.tags ?? [];
|
||||
|
||||
let Account = {
|
||||
connect: {
|
||||
id_userId: {
|
||||
userId: data.userId,
|
||||
id: data.accountId ?? defaultAccount?.id
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (data.type === 'ITEM') {
|
||||
const assetClass = data.assetClass;
|
||||
const assetSubClass = data.assetSubClass;
|
||||
@ -101,7 +99,6 @@ export class OrderService {
|
||||
const id = uuidv4();
|
||||
const name = data.SymbolProfile.connectOrCreate.create.symbol;
|
||||
|
||||
Account = undefined;
|
||||
data.id = id;
|
||||
data.SymbolProfile.connectOrCreate.create.assetClass = assetClass;
|
||||
data.SymbolProfile.connectOrCreate.create.assetSubClass = assetSubClass;
|
||||
@ -362,6 +359,12 @@ export class OrderService {
|
||||
delete data.symbol;
|
||||
delete data.tags;
|
||||
|
||||
// Remove existing tags
|
||||
await this.prismaService.order.update({
|
||||
data: { tags: { set: [] } },
|
||||
where
|
||||
});
|
||||
|
||||
return this.prismaService.order.update({
|
||||
data: {
|
||||
...data,
|
||||
|
@ -7,6 +7,8 @@ import { Tag } from '@prisma/client';
|
||||
|
||||
export interface PortfolioPositionDetail {
|
||||
averagePrice: number;
|
||||
dividendInBaseCurrency: number;
|
||||
feeInBaseCurrency: number;
|
||||
firstBuyDate: string;
|
||||
grossPerformance: number;
|
||||
grossPerformancePercent: number;
|
||||
|
@ -64,7 +64,8 @@ describe('PortfolioCalculator', () => {
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
||||
const investmentsByMonth =
|
||||
portfolioCalculator.getInvestmentsByGroup('month');
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
@ -81,6 +82,7 @@ describe('PortfolioCalculator', () => {
|
||||
averagePrice: new Big('0'),
|
||||
currency: 'CHF',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big('3.2'),
|
||||
firstBuyDate: '2021-11-22',
|
||||
grossPerformance: new Big('-12.6'),
|
||||
grossPerformancePercentage: new Big('-0.0440867739678096571'),
|
||||
|
@ -53,7 +53,8 @@ describe('PortfolioCalculator', () => {
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
||||
const investmentsByMonth =
|
||||
portfolioCalculator.getInvestmentsByGroup('month');
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
@ -70,6 +71,7 @@ describe('PortfolioCalculator', () => {
|
||||
averagePrice: new Big('136.6'),
|
||||
currency: 'CHF',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big('1.55'),
|
||||
firstBuyDate: '2021-11-30',
|
||||
grossPerformance: new Big('24.6'),
|
||||
grossPerformancePercentage: new Big('0.09004392386530014641'),
|
||||
|
@ -64,7 +64,8 @@ describe('PortfolioCalculator', () => {
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
||||
const investmentsByMonth =
|
||||
portfolioCalculator.getInvestmentsByGroup('month');
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
@ -81,6 +82,7 @@ describe('PortfolioCalculator', () => {
|
||||
averagePrice: new Big('320.43'),
|
||||
currency: 'CHF',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big('0'),
|
||||
firstBuyDate: '2015-01-01',
|
||||
grossPerformance: new Big('27172.74'),
|
||||
grossPerformancePercentage: new Big('42.40043067128546016291'),
|
||||
|
@ -41,7 +41,8 @@ describe('PortfolioCalculator', () => {
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
||||
const investmentsByMonth =
|
||||
portfolioCalculator.getInvestmentsByGroup('month');
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
|
@ -64,7 +64,8 @@ describe('PortfolioCalculator', () => {
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
||||
const investmentsByMonth =
|
||||
portfolioCalculator.getInvestmentsByGroup('month');
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
@ -81,6 +82,7 @@ describe('PortfolioCalculator', () => {
|
||||
averagePrice: new Big('75.80'),
|
||||
currency: 'CHF',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big('4.25'),
|
||||
firstBuyDate: '2022-03-07',
|
||||
grossPerformance: new Big('21.93'),
|
||||
grossPerformancePercentage: new Big('0.14465699208443271768'),
|
||||
|
@ -68,7 +68,8 @@ describe('PortfolioCalculator', () => {
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
||||
const investmentsByMonth =
|
||||
portfolioCalculator.getInvestmentsByGroup('month');
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
@ -101,6 +102,7 @@ describe('PortfolioCalculator', () => {
|
||||
averagePrice: new Big('0'),
|
||||
currency: 'CHF',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big('0'),
|
||||
firstBuyDate: '2022-03-07',
|
||||
grossPerformance: new Big('19.86'),
|
||||
grossPerformancePercentage: new Big('0.13100263852242744063'),
|
||||
|
@ -2,6 +2,7 @@ import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/
|
||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||
import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
|
||||
import { GroupBy } from '@ghostfolio/common/types';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Type as TypeOfOrder } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
@ -430,6 +431,7 @@ export class PortfolioCalculator {
|
||||
: item.investment.div(item.quantity),
|
||||
currency: item.currency,
|
||||
dataSource: item.dataSource,
|
||||
fee: item.fee,
|
||||
firstBuyDate: item.firstBuyDate,
|
||||
grossPerformance: !hasErrors ? grossPerformance ?? null : null,
|
||||
grossPerformancePercentage: !hasErrors
|
||||
@ -446,7 +448,7 @@ export class PortfolioCalculator {
|
||||
transactionCount: item.transactionCount
|
||||
});
|
||||
|
||||
if (hasErrors) {
|
||||
if (hasErrors && item.investment.gt(0)) {
|
||||
errors.push({ dataSource: item.dataSource, symbol: item.symbol });
|
||||
}
|
||||
}
|
||||
@ -478,46 +480,60 @@ export class PortfolioCalculator {
|
||||
});
|
||||
}
|
||||
|
||||
public getInvestmentsByMonth(): { date: string; investment: Big }[] {
|
||||
public getInvestmentsByGroup(
|
||||
groupBy: GroupBy
|
||||
): { date: string; investment: Big }[] {
|
||||
if (this.orders.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const investments = [];
|
||||
let currentDate: Date;
|
||||
let investmentByMonth = new Big(0);
|
||||
let investmentByGroup = new Big(0);
|
||||
|
||||
for (const [index, order] of this.orders.entries()) {
|
||||
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))
|
||||
);
|
||||
} else {
|
||||
// New month: Store previous month and reset
|
||||
// New group: Store previous group and reset
|
||||
|
||||
if (currentDate) {
|
||||
investments.push({
|
||||
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
|
||||
investment: investmentByMonth
|
||||
date: format(
|
||||
set(currentDate, {
|
||||
date: 1,
|
||||
month: groupBy === 'year' ? 0 : currentDate.getMonth()
|
||||
}),
|
||||
DATE_FORMAT
|
||||
),
|
||||
investment: investmentByGroup
|
||||
});
|
||||
}
|
||||
|
||||
currentDate = parseDate(order.date);
|
||||
investmentByMonth = order.quantity
|
||||
investmentByGroup = order.quantity
|
||||
.mul(order.unitPrice)
|
||||
.mul(this.getFactor(order.type));
|
||||
}
|
||||
|
||||
if (index === this.orders.length - 1) {
|
||||
// Store current month (latest order)
|
||||
// Store current group (latest order)
|
||||
investments.push({
|
||||
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
|
||||
investment: investmentByMonth
|
||||
date: format(
|
||||
set(currentDate, {
|
||||
date: 1,
|
||||
month: groupBy === 'year' ? 0 : currentDate.getMonth()
|
||||
}),
|
||||
DATE_FORMAT
|
||||
),
|
||||
investment: investmentByGroup
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -18,7 +18,6 @@ import {
|
||||
PortfolioPublicDetails,
|
||||
PortfolioReport
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||
import type {
|
||||
DateRange,
|
||||
GroupBy,
|
||||
@ -132,7 +131,8 @@ export class PortfolioController {
|
||||
portfolioPosition.investment / totalInvestment;
|
||||
portfolioPosition.netPerformance = null;
|
||||
portfolioPosition.quantity = null;
|
||||
portfolioPosition.value = portfolioPosition.value / totalValue;
|
||||
portfolioPosition.valueInPercentage =
|
||||
portfolioPosition.value / totalValue;
|
||||
}
|
||||
|
||||
for (const [name, { current, original }] of Object.entries(accounts)) {
|
||||
@ -190,23 +190,24 @@ export class PortfolioController {
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getDividends(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('groupBy') groupBy?: GroupBy,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('groupBy') groupBy?: GroupBy
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<PortfolioDividends> {
|
||||
let dividends: InvestmentItem[];
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
if (groupBy === 'month') {
|
||||
dividends = await this.portfolioService.getDividends({
|
||||
dateRange,
|
||||
groupBy,
|
||||
impersonationId
|
||||
});
|
||||
} else {
|
||||
dividends = await this.portfolioService.getDividends({
|
||||
dateRange,
|
||||
impersonationId
|
||||
});
|
||||
}
|
||||
let dividends = await this.portfolioService.getDividends({
|
||||
dateRange,
|
||||
filters,
|
||||
groupBy,
|
||||
impersonationId
|
||||
});
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
@ -239,23 +240,24 @@ export class PortfolioController {
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getInvestments(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('groupBy') groupBy?: GroupBy,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('groupBy') groupBy?: GroupBy
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<PortfolioInvestments> {
|
||||
let investments: InvestmentItem[];
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
if (groupBy === 'month') {
|
||||
investments = await this.portfolioService.getInvestments({
|
||||
dateRange,
|
||||
groupBy,
|
||||
impersonationId
|
||||
});
|
||||
} else {
|
||||
investments = await this.portfolioService.getInvestments({
|
||||
dateRange,
|
||||
impersonationId
|
||||
});
|
||||
}
|
||||
let investments = await this.portfolioService.getInvestments({
|
||||
dateRange,
|
||||
filters,
|
||||
groupBy,
|
||||
impersonationId
|
||||
});
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
@ -290,10 +292,20 @@ export class PortfolioController {
|
||||
@Version('2')
|
||||
public async getPerformanceV2(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('range') dateRange: DateRange = 'max'
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<PortfolioPerformanceResponse> {
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
const performanceInformation = await this.portfolioService.getPerformance({
|
||||
dateRange,
|
||||
filters,
|
||||
impersonationId,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
@ -311,7 +323,7 @@ export class PortfolioController {
|
||||
totalInvestment: new Big(totalInvestment)
|
||||
.div(performanceInformation.performance.totalInvestment)
|
||||
.toNumber(),
|
||||
value: new Big(value)
|
||||
valueInPercentage: new Big(value)
|
||||
.div(performanceInformation.performance.currentValue)
|
||||
.toNumber()
|
||||
};
|
||||
@ -345,31 +357,26 @@ export class PortfolioController {
|
||||
|
||||
@Get('positions')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getPositions(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('range') dateRange: DateRange = 'max'
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<PortfolioPositions> {
|
||||
const result = await this.portfolioService.getPositions(
|
||||
impersonationId,
|
||||
dateRange
|
||||
);
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
result.positions = result.positions.map((position) => {
|
||||
return nullifyValuesInObject(position, [
|
||||
'grossPerformance',
|
||||
'investment',
|
||||
'netPerformance',
|
||||
'quantity'
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
return this.portfolioService.getPositions({
|
||||
dateRange,
|
||||
filters,
|
||||
impersonationId
|
||||
});
|
||||
}
|
||||
|
||||
@Get('public/:accessId')
|
||||
@ -420,7 +427,7 @@ export class PortfolioController {
|
||||
|
||||
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||
portfolioPublicDetails.holdings[symbol] = {
|
||||
allocationCurrent: portfolioPosition.value / totalValue,
|
||||
allocationInPercentage: portfolioPosition.value / totalValue,
|
||||
countries: hasDetails ? portfolioPosition.countries : [],
|
||||
currency: hasDetails ? portfolioPosition.currency : undefined,
|
||||
dataSource: portfolioPosition.dataSource,
|
||||
@ -431,7 +438,7 @@ export class PortfolioController {
|
||||
sectors: hasDetails ? portfolioPosition.sectors : [],
|
||||
symbol: portfolioPosition.symbol,
|
||||
url: portfolioPosition.url,
|
||||
value: portfolioPosition.value / totalValue
|
||||
valueInPercentage: portfolioPosition.value / totalValue
|
||||
};
|
||||
}
|
||||
|
||||
@ -439,6 +446,7 @@ export class PortfolioController {
|
||||
}
|
||||
|
||||
@Get('position/:dataSource/:symbol')
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@ -447,27 +455,13 @@ export class PortfolioController {
|
||||
@Param('dataSource') dataSource,
|
||||
@Param('symbol') symbol
|
||||
): Promise<PortfolioPositionDetail> {
|
||||
let position = await this.portfolioService.getPosition(
|
||||
const position = await this.portfolioService.getPosition(
|
||||
dataSource,
|
||||
impersonationId,
|
||||
symbol
|
||||
);
|
||||
|
||||
if (position) {
|
||||
if (
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
position = nullifyValuesInObject(position, [
|
||||
'grossPerformance',
|
||||
'investment',
|
||||
'netPerformance',
|
||||
'orders',
|
||||
'quantity',
|
||||
'value'
|
||||
]);
|
||||
}
|
||||
|
||||
return position;
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
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 { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface';
|
||||
@ -19,11 +20,11 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import {
|
||||
ASSET_SUB_CLASS_EMERGENCY_FUND,
|
||||
EMERGENCY_FUND_TAG_ID,
|
||||
MAX_CHART_ITEMS,
|
||||
UNKNOWN_KEY
|
||||
} from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
||||
import { DATE_FORMAT, getSum, parseDate } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
Accounts,
|
||||
EnhancedSymbolProfile,
|
||||
@ -210,16 +211,19 @@ export class PortfolioService {
|
||||
|
||||
public async getDividends({
|
||||
dateRange,
|
||||
impersonationId,
|
||||
groupBy
|
||||
filters,
|
||||
groupBy,
|
||||
impersonationId
|
||||
}: {
|
||||
dateRange: DateRange;
|
||||
impersonationId: string;
|
||||
filters?: Filter[];
|
||||
groupBy?: GroupBy;
|
||||
impersonationId: string;
|
||||
}): Promise<InvestmentItem[]> {
|
||||
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||
|
||||
const activities = await this.orderService.getOrders({
|
||||
filters,
|
||||
userId,
|
||||
types: ['DIVIDEND'],
|
||||
userCurrency: this.request.user.Settings.settings.baseCurrency
|
||||
@ -232,8 +236,8 @@ export class PortfolioService {
|
||||
};
|
||||
});
|
||||
|
||||
if (groupBy === 'month') {
|
||||
dividends = this.getDividendsByMonth(dividends);
|
||||
if (groupBy) {
|
||||
dividends = this.getDividendsByGroup({ dividends, groupBy });
|
||||
}
|
||||
|
||||
const startDate = this.getStartDate(
|
||||
@ -248,17 +252,20 @@ export class PortfolioService {
|
||||
|
||||
public async getInvestments({
|
||||
dateRange,
|
||||
impersonationId,
|
||||
groupBy
|
||||
filters,
|
||||
groupBy,
|
||||
impersonationId
|
||||
}: {
|
||||
dateRange: DateRange;
|
||||
impersonationId: string;
|
||||
filters?: Filter[];
|
||||
groupBy?: GroupBy;
|
||||
impersonationId: string;
|
||||
}): Promise<InvestmentItem[]> {
|
||||
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||
|
||||
const { portfolioOrders, transactionPoints } =
|
||||
await this.getTransactionPoints({
|
||||
filters,
|
||||
userId,
|
||||
includeDrafts: true
|
||||
});
|
||||
@ -276,26 +283,31 @@ export class PortfolioService {
|
||||
|
||||
let investments: InvestmentItem[];
|
||||
|
||||
if (groupBy === 'month') {
|
||||
investments = portfolioCalculator.getInvestmentsByMonth().map((item) => {
|
||||
return {
|
||||
date: item.date,
|
||||
investment: item.investment.toNumber()
|
||||
};
|
||||
});
|
||||
if (groupBy) {
|
||||
investments = portfolioCalculator
|
||||
.getInvestmentsByGroup(groupBy)
|
||||
.map((item) => {
|
||||
return {
|
||||
date: item.date,
|
||||
investment: item.investment.toNumber()
|
||||
};
|
||||
});
|
||||
|
||||
// Add investment of current month
|
||||
const dateOfCurrentMonth = format(
|
||||
set(new Date(), { date: 1 }),
|
||||
// Add investment of current group
|
||||
const dateOfCurrentGroup = format(
|
||||
set(new Date(), {
|
||||
date: 1,
|
||||
month: groupBy === 'year' ? 0 : new Date().getMonth()
|
||||
}),
|
||||
DATE_FORMAT
|
||||
);
|
||||
const investmentOfCurrentMonth = investments.filter(({ date }) => {
|
||||
return date === dateOfCurrentMonth;
|
||||
const investmentOfCurrentGroup = investments.filter(({ date }) => {
|
||||
return date === dateOfCurrentGroup;
|
||||
});
|
||||
|
||||
if (investmentOfCurrentMonth.length <= 0) {
|
||||
if (investmentOfCurrentGroup.length <= 0) {
|
||||
investments.push({
|
||||
date: dateOfCurrentMonth,
|
||||
date: dateOfCurrentGroup,
|
||||
investment: 0
|
||||
});
|
||||
}
|
||||
@ -343,11 +355,13 @@ export class PortfolioService {
|
||||
|
||||
public async getChart({
|
||||
dateRange = 'max',
|
||||
filters,
|
||||
impersonationId,
|
||||
userCurrency,
|
||||
userId
|
||||
}: {
|
||||
dateRange?: DateRange;
|
||||
filters?: Filter[];
|
||||
impersonationId: string;
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
@ -356,6 +370,7 @@ export class PortfolioService {
|
||||
|
||||
const { portfolioOrders, transactionPoints } =
|
||||
await this.getTransactionPoints({
|
||||
filters,
|
||||
userId
|
||||
});
|
||||
|
||||
@ -397,15 +412,15 @@ export class PortfolioService {
|
||||
}
|
||||
|
||||
public async getDetails({
|
||||
impersonationId,
|
||||
dateRange = 'max',
|
||||
filters,
|
||||
impersonationId,
|
||||
userId,
|
||||
withExcludedAccounts = false
|
||||
}: {
|
||||
impersonationId: string;
|
||||
dateRange?: DateRange;
|
||||
filters?: Filter[];
|
||||
impersonationId: string;
|
||||
userId: string;
|
||||
withExcludedAccounts?: boolean;
|
||||
}): Promise<PortfolioDetails & { hasErrors: boolean }> {
|
||||
@ -522,12 +537,9 @@ export class PortfolioService {
|
||||
|
||||
holdings[item.symbol] = {
|
||||
markets,
|
||||
allocationCurrent: filteredValueInBaseCurrency.eq(0)
|
||||
allocationInPercentage: filteredValueInBaseCurrency.eq(0)
|
||||
? 0
|
||||
: value.div(filteredValueInBaseCurrency).toNumber(),
|
||||
allocationInvestment: item.investment
|
||||
.div(totalInvestmentInBaseCurrency)
|
||||
.toNumber(),
|
||||
assetClass: symbolProfile.assetClass,
|
||||
assetSubClass: symbolProfile.assetSubClass,
|
||||
countries: symbolProfile.countries,
|
||||
@ -560,9 +572,7 @@ export class PortfolioService {
|
||||
) {
|
||||
const cashPositions = await this.getCashPositions({
|
||||
cashDetails,
|
||||
emergencyFund,
|
||||
userCurrency,
|
||||
investment: totalInvestmentInBaseCurrency,
|
||||
value: filteredValueInBaseCurrency
|
||||
});
|
||||
|
||||
@ -580,10 +590,51 @@ export class PortfolioService {
|
||||
withExcludedAccounts
|
||||
});
|
||||
|
||||
if (
|
||||
filters?.length === 1 &&
|
||||
filters[0].id === EMERGENCY_FUND_TAG_ID &&
|
||||
filters[0].type === 'TAG'
|
||||
) {
|
||||
const cashPositions = await this.getCashPositions({
|
||||
cashDetails,
|
||||
userCurrency,
|
||||
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({
|
||||
impersonationId,
|
||||
userCurrency,
|
||||
userId
|
||||
userId,
|
||||
balanceInBaseCurrency: cashDetails.balanceInBaseCurrency,
|
||||
emergencyFundPositionsValueInBaseCurrency:
|
||||
this.getEmergencyFundPositionsValueInBaseCurrency({
|
||||
activities: orders
|
||||
})
|
||||
});
|
||||
|
||||
return {
|
||||
@ -627,6 +678,8 @@ export class PortfolioService {
|
||||
return {
|
||||
tags,
|
||||
averagePrice: undefined,
|
||||
dividendInBaseCurrency: undefined,
|
||||
feeInBaseCurrency: undefined,
|
||||
firstBuyDate: undefined,
|
||||
grossPerformance: undefined,
|
||||
grossPerformancePercent: undefined,
|
||||
@ -646,8 +699,9 @@ export class PortfolioService {
|
||||
}
|
||||
|
||||
const positionCurrency = orders[0].SymbolProfile.currency;
|
||||
const [SymbolProfile] =
|
||||
await this.symbolProfileService.getSymbolProfilesBySymbols([aSymbol]);
|
||||
const [SymbolProfile] = await this.symbolProfileService.getSymbolProfiles([
|
||||
{ dataSource: aDataSource, symbol: aSymbol }
|
||||
]);
|
||||
|
||||
const portfolioOrders: PortfolioOrder[] = orders
|
||||
.filter((order) => {
|
||||
@ -692,12 +746,23 @@ export class PortfolioService {
|
||||
averagePrice,
|
||||
currency,
|
||||
dataSource,
|
||||
fee,
|
||||
firstBuyDate,
|
||||
marketPrice,
|
||||
quantity,
|
||||
transactionCount
|
||||
} = position;
|
||||
|
||||
const dividendInBaseCurrency = getSum(
|
||||
orders
|
||||
.filter(({ type }) => {
|
||||
return type === 'DIVIDEND';
|
||||
})
|
||||
.map(({ valueInBaseCurrency }) => {
|
||||
return new Big(valueInBaseCurrency);
|
||||
})
|
||||
);
|
||||
|
||||
// Convert investment, gross and net performance to currency of user
|
||||
const investment = this.exchangeRateDataService.toCurrency(
|
||||
position.investment?.toNumber(),
|
||||
@ -731,7 +796,8 @@ export class PortfolioService {
|
||||
historicalDataArray.push({
|
||||
averagePrice: orders[0].unitPrice,
|
||||
date: firstBuyDate,
|
||||
value: orders[0].unitPrice
|
||||
marketPrice: orders[0].unitPrice,
|
||||
quantity: orders[0].quantity
|
||||
});
|
||||
}
|
||||
|
||||
@ -747,6 +813,7 @@ export class PortfolioService {
|
||||
j++;
|
||||
}
|
||||
let currentAveragePrice = 0;
|
||||
let currentQuantity = 0;
|
||||
const currentSymbol = transactionPoints[j].items.find(
|
||||
(item) => item.symbol === aSymbol
|
||||
);
|
||||
@ -754,12 +821,14 @@ export class PortfolioService {
|
||||
currentAveragePrice = currentSymbol.quantity.eq(0)
|
||||
? 0
|
||||
: currentSymbol.investment.div(currentSymbol.quantity).toNumber();
|
||||
currentQuantity = currentSymbol.quantity.toNumber();
|
||||
}
|
||||
|
||||
historicalDataArray.push({
|
||||
date,
|
||||
marketPrice,
|
||||
averagePrice: currentAveragePrice,
|
||||
value: marketPrice
|
||||
quantity: currentQuantity
|
||||
});
|
||||
|
||||
maxPrice = Math.max(marketPrice ?? 0, maxPrice);
|
||||
@ -780,6 +849,12 @@ export class PortfolioService {
|
||||
tags,
|
||||
transactionCount,
|
||||
averagePrice: averagePrice.toNumber(),
|
||||
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
|
||||
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
fee.toNumber(),
|
||||
SymbolProfile.currency,
|
||||
userCurrency
|
||||
),
|
||||
grossPerformancePercent:
|
||||
position.grossPerformancePercentage?.toNumber(),
|
||||
historicalData: historicalDataArray,
|
||||
@ -836,6 +911,8 @@ export class PortfolioService {
|
||||
SymbolProfile,
|
||||
tags,
|
||||
averagePrice: 0,
|
||||
dividendInBaseCurrency: 0,
|
||||
feeInBaseCurrency: 0,
|
||||
firstBuyDate: undefined,
|
||||
grossPerformance: undefined,
|
||||
grossPerformancePercent: undefined,
|
||||
@ -850,14 +927,20 @@ export class PortfolioService {
|
||||
}
|
||||
}
|
||||
|
||||
public async getPositions(
|
||||
aImpersonationId: string,
|
||||
aDateRange: DateRange = 'max'
|
||||
): Promise<{ hasErrors: boolean; positions: Position[] }> {
|
||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||
public async getPositions({
|
||||
dateRange = 'max',
|
||||
filters,
|
||||
impersonationId
|
||||
}: {
|
||||
dateRange?: DateRange;
|
||||
filters?: Filter[];
|
||||
impersonationId: string;
|
||||
}): Promise<{ hasErrors: boolean; positions: Position[] }> {
|
||||
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||
|
||||
const { portfolioOrders, transactionPoints } =
|
||||
await this.getTransactionPoints({
|
||||
filters,
|
||||
userId
|
||||
});
|
||||
|
||||
@ -877,7 +960,7 @@ export class PortfolioService {
|
||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||
|
||||
const portfolioStart = parseDate(transactionPoints[0].date);
|
||||
const startDate = this.getStartDate(aDateRange, portfolioStart);
|
||||
const startDate = this.getStartDate(dateRange, portfolioStart);
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
startDate
|
||||
);
|
||||
@ -885,12 +968,14 @@ export class PortfolioService {
|
||||
const positions = currentPositions.positions.filter(
|
||||
(item) => !item.quantity.eq(0)
|
||||
);
|
||||
|
||||
const dataGatheringItem = positions.map((position) => {
|
||||
return {
|
||||
dataSource: position.dataSource,
|
||||
symbol: position.symbol
|
||||
};
|
||||
});
|
||||
|
||||
const symbols = positions.map((position) => position.symbol);
|
||||
|
||||
const [dataProviderResponses, symbolProfiles] = await Promise.all([
|
||||
@ -928,10 +1013,12 @@ export class PortfolioService {
|
||||
|
||||
public async getPerformance({
|
||||
dateRange = 'max',
|
||||
filters,
|
||||
impersonationId,
|
||||
userId
|
||||
}: {
|
||||
dateRange?: DateRange;
|
||||
filters?: Filter[];
|
||||
impersonationId: string;
|
||||
userId: string;
|
||||
}): Promise<PortfolioPerformanceResponse> {
|
||||
@ -941,6 +1028,7 @@ export class PortfolioService {
|
||||
|
||||
const { portfolioOrders, transactionPoints } =
|
||||
await this.getTransactionPoints({
|
||||
filters,
|
||||
userId
|
||||
});
|
||||
|
||||
@ -970,32 +1058,25 @@ export class PortfolioService {
|
||||
|
||||
const portfolioStart = parseDate(transactionPoints[0].date);
|
||||
const startDate = this.getStartDate(dateRange, portfolioStart);
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
startDate
|
||||
);
|
||||
const {
|
||||
currentValue,
|
||||
errors,
|
||||
grossPerformance,
|
||||
grossPerformancePercentage,
|
||||
hasErrors,
|
||||
netPerformance,
|
||||
netPerformancePercentage,
|
||||
totalInvestment
|
||||
} = await portfolioCalculator.getCurrentPositions(startDate);
|
||||
|
||||
const hasErrors = currentPositions.hasErrors;
|
||||
const currentValue = currentPositions.currentValue.toNumber();
|
||||
const currentGrossPerformance = currentPositions.grossPerformance;
|
||||
const currentGrossPerformancePercent =
|
||||
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 currentGrossPerformance = grossPerformance;
|
||||
const currentGrossPerformancePercent = grossPerformancePercentage;
|
||||
let currentNetPerformance = netPerformance;
|
||||
let currentNetPerformancePercent = netPerformancePercentage;
|
||||
|
||||
const historicalDataContainer = await this.getChart({
|
||||
dateRange,
|
||||
filters,
|
||||
impersonationId,
|
||||
userCurrency,
|
||||
userId
|
||||
@ -1013,28 +1094,28 @@ export class PortfolioService {
|
||||
}
|
||||
|
||||
return {
|
||||
errors,
|
||||
hasErrors,
|
||||
chart: historicalDataContainer.items.map(
|
||||
({
|
||||
date,
|
||||
netPerformance,
|
||||
netPerformance: netPerformanceOfItem,
|
||||
netPerformanceInPercentage,
|
||||
totalInvestment,
|
||||
totalInvestment: totalInvestmentOfItem,
|
||||
value
|
||||
}) => {
|
||||
return {
|
||||
date,
|
||||
netPerformance,
|
||||
netPerformanceInPercentage,
|
||||
totalInvestment,
|
||||
value
|
||||
value,
|
||||
netPerformance: netPerformanceOfItem,
|
||||
totalInvestment: totalInvestmentOfItem
|
||||
};
|
||||
}
|
||||
),
|
||||
errors: currentPositions.errors,
|
||||
firstOrderDate: parseDate(historicalDataContainer.items[0]?.date),
|
||||
hasErrors: currentPositions.hasErrors || hasErrors,
|
||||
performance: {
|
||||
currentValue,
|
||||
currentValue: currentValue.toNumber(),
|
||||
currentGrossPerformance: currentGrossPerformance.toNumber(),
|
||||
currentGrossPerformancePercent:
|
||||
currentGrossPerformancePercent.toNumber(),
|
||||
@ -1074,16 +1155,23 @@ export class PortfolioService {
|
||||
portfolioStart
|
||||
);
|
||||
|
||||
const positions = currentPositions.positions.filter(
|
||||
(item) => !item.quantity.eq(0)
|
||||
);
|
||||
|
||||
const portfolioItemsNow: { [symbol: string]: TimelinePosition } = {};
|
||||
for (const position of currentPositions.positions) {
|
||||
|
||||
for (const position of positions) {
|
||||
portfolioItemsNow[position.symbol] = position;
|
||||
}
|
||||
|
||||
const accounts = await this.getValueOfAccounts({
|
||||
orders,
|
||||
portfolioItemsNow,
|
||||
userId,
|
||||
userCurrency
|
||||
userCurrency,
|
||||
userId
|
||||
});
|
||||
|
||||
return {
|
||||
rules: {
|
||||
accountClusterRisk: await this.rulesService.evaluate(
|
||||
@ -1107,19 +1195,19 @@ export class PortfolioService {
|
||||
[
|
||||
new CurrencyClusterRiskBaseCurrencyInitialInvestment(
|
||||
this.exchangeRateDataService,
|
||||
currentPositions
|
||||
positions
|
||||
),
|
||||
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
|
||||
this.exchangeRateDataService,
|
||||
currentPositions
|
||||
positions
|
||||
),
|
||||
new CurrencyClusterRiskInitialInvestment(
|
||||
this.exchangeRateDataService,
|
||||
currentPositions
|
||||
positions
|
||||
),
|
||||
new CurrencyClusterRiskCurrentInvestment(
|
||||
this.exchangeRateDataService,
|
||||
currentPositions
|
||||
positions
|
||||
)
|
||||
],
|
||||
<UserSettings>this.request.user.Settings.settings
|
||||
@ -1129,7 +1217,7 @@ export class PortfolioService {
|
||||
new FeeRatioInitialInvestment(
|
||||
this.exchangeRateDataService,
|
||||
currentPositions.totalInvestment.toNumber(),
|
||||
this.getFees({ orders, userCurrency }).toNumber()
|
||||
this.getFees({ userCurrency, activities: orders }).toNumber()
|
||||
)
|
||||
],
|
||||
<UserSettings>this.request.user.Settings.settings
|
||||
@ -1140,16 +1228,12 @@ export class PortfolioService {
|
||||
|
||||
private async getCashPositions({
|
||||
cashDetails,
|
||||
emergencyFund,
|
||||
investment,
|
||||
userCurrency,
|
||||
value
|
||||
}: {
|
||||
cashDetails: CashDetails;
|
||||
emergencyFund: Big;
|
||||
investment: Big;
|
||||
value: Big;
|
||||
userCurrency: string;
|
||||
value: Big;
|
||||
}) {
|
||||
const cashPositions: PortfolioDetails['holdings'] = {
|
||||
[userCurrency]: this.getInitialCashPosition({
|
||||
@ -1180,62 +1264,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)) {
|
||||
// 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()
|
||||
: 0;
|
||||
cashPositions[symbol].allocationInvestment = investment.gt(0)
|
||||
? new Big(cashPositions[symbol].investment).div(investment).toNumber()
|
||||
: 0;
|
||||
}
|
||||
|
||||
return cashPositions;
|
||||
}
|
||||
|
||||
private getDividend({
|
||||
activities,
|
||||
date = new Date(0),
|
||||
orders,
|
||||
userCurrency
|
||||
}: {
|
||||
activities: OrderWithAccount[];
|
||||
date?: Date;
|
||||
orders: OrderWithAccount[];
|
||||
|
||||
userCurrency: string;
|
||||
}) {
|
||||
return orders
|
||||
.filter((order) => {
|
||||
// Filter out all orders before given date and type dividend
|
||||
return activities
|
||||
.filter((activity) => {
|
||||
// Filter out all activities before given date and type dividend
|
||||
return (
|
||||
isBefore(date, new Date(order.date)) &&
|
||||
order.type === TypeOfOrder.DIVIDEND
|
||||
isBefore(date, new Date(activity.date)) &&
|
||||
activity.type === TypeOfOrder.DIVIDEND
|
||||
);
|
||||
})
|
||||
.map((order) => {
|
||||
.map(({ quantity, SymbolProfile, unitPrice }) => {
|
||||
return this.exchangeRateDataService.toCurrency(
|
||||
new Big(order.quantity).mul(order.unitPrice).toNumber(),
|
||||
order.SymbolProfile.currency,
|
||||
new Big(quantity).mul(unitPrice).toNumber(),
|
||||
SymbolProfile.currency,
|
||||
userCurrency
|
||||
);
|
||||
})
|
||||
@ -1245,67 +1305,118 @@ export class PortfolioService {
|
||||
);
|
||||
}
|
||||
|
||||
private getDividendsByMonth(aDividends: InvestmentItem[]): InvestmentItem[] {
|
||||
if (aDividends.length === 0) {
|
||||
private getDividendsByGroup({
|
||||
dividends,
|
||||
groupBy
|
||||
}: {
|
||||
dividends: InvestmentItem[];
|
||||
groupBy: GroupBy;
|
||||
}): InvestmentItem[] {
|
||||
if (dividends.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const dividends = [];
|
||||
const dividendsByGroup: InvestmentItem[] = [];
|
||||
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 (
|
||||
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 {
|
||||
// New month: Store previous month and reset
|
||||
// New group: Store previous group and reset
|
||||
|
||||
if (currentDate) {
|
||||
dividends.push({
|
||||
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
|
||||
investment: investmentByMonth
|
||||
dividendsByGroup.push({
|
||||
date: format(
|
||||
set(currentDate, {
|
||||
date: 1,
|
||||
month: groupBy === 'year' ? 0 : currentDate.getMonth()
|
||||
}),
|
||||
DATE_FORMAT
|
||||
),
|
||||
investment: investmentByGroup.toNumber()
|
||||
});
|
||||
}
|
||||
|
||||
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)
|
||||
dividends.push({
|
||||
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
|
||||
investment: investmentByMonth
|
||||
dividendsByGroup.push({
|
||||
date: format(
|
||||
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({
|
||||
activities,
|
||||
date = new Date(0),
|
||||
orders,
|
||||
userCurrency
|
||||
}: {
|
||||
activities: OrderWithAccount[];
|
||||
date?: Date;
|
||||
orders: OrderWithAccount[];
|
||||
userCurrency: string;
|
||||
}) {
|
||||
return orders
|
||||
.filter((order) => {
|
||||
// Filter out all orders before given date
|
||||
return isBefore(date, new Date(order.date));
|
||||
return activities
|
||||
.filter((activity) => {
|
||||
// Filter out all activities before given date
|
||||
return isBefore(date, new Date(activity.date));
|
||||
})
|
||||
.map((order) => {
|
||||
.map(({ fee, SymbolProfile }) => {
|
||||
return this.exchangeRateDataService.toCurrency(
|
||||
order.fee,
|
||||
order.SymbolProfile.currency,
|
||||
fee,
|
||||
SymbolProfile.currency,
|
||||
userCurrency
|
||||
);
|
||||
})
|
||||
@ -1324,8 +1435,7 @@ export class PortfolioService {
|
||||
}): PortfolioPosition {
|
||||
return {
|
||||
currency,
|
||||
allocationCurrent: 0,
|
||||
allocationInvestment: 0,
|
||||
allocationInPercentage: 0,
|
||||
assetClass: AssetClass.CASH,
|
||||
assetSubClass: AssetClass.CASH,
|
||||
countries: [],
|
||||
@ -1372,26 +1482,42 @@ export class PortfolioService {
|
||||
private getStartDate(aDateRange: DateRange, portfolioStart: Date) {
|
||||
switch (aDateRange) {
|
||||
case '1d':
|
||||
portfolioStart = max([portfolioStart, subDays(new Date(), 1)]);
|
||||
portfolioStart = max([
|
||||
portfolioStart,
|
||||
subDays(new Date().setHours(0, 0, 0, 0), 1)
|
||||
]);
|
||||
break;
|
||||
case 'ytd':
|
||||
portfolioStart = max([portfolioStart, setDayOfYear(new Date(), 1)]);
|
||||
portfolioStart = max([
|
||||
portfolioStart,
|
||||
setDayOfYear(new Date().setHours(0, 0, 0, 0), 1)
|
||||
]);
|
||||
break;
|
||||
case '1y':
|
||||
portfolioStart = max([portfolioStart, subYears(new Date(), 1)]);
|
||||
portfolioStart = max([
|
||||
portfolioStart,
|
||||
subYears(new Date().setHours(0, 0, 0, 0), 1)
|
||||
]);
|
||||
break;
|
||||
case '5y':
|
||||
portfolioStart = max([portfolioStart, subYears(new Date(), 5)]);
|
||||
portfolioStart = max([
|
||||
portfolioStart,
|
||||
subYears(new Date().setHours(0, 0, 0, 0), 5)
|
||||
]);
|
||||
break;
|
||||
}
|
||||
return portfolioStart;
|
||||
}
|
||||
|
||||
private async getSummary({
|
||||
balanceInBaseCurrency,
|
||||
emergencyFundPositionsValueInBaseCurrency,
|
||||
impersonationId,
|
||||
userCurrency,
|
||||
userId
|
||||
}: {
|
||||
balanceInBaseCurrency: number;
|
||||
emergencyFundPositionsValueInBaseCurrency: number;
|
||||
impersonationId: string;
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
@ -1404,11 +1530,7 @@ export class PortfolioService {
|
||||
userId
|
||||
});
|
||||
|
||||
const { balanceInBaseCurrency } = await this.accountService.getCashDetails({
|
||||
userId,
|
||||
currency: userCurrency
|
||||
});
|
||||
const orders = await this.orderService.getOrders({
|
||||
const activities = await this.orderService.getOrders({
|
||||
userCurrency,
|
||||
userId
|
||||
});
|
||||
@ -1423,18 +1545,24 @@ export class PortfolioService {
|
||||
return account?.isExcluded ?? false;
|
||||
});
|
||||
|
||||
const dividend = this.getDividend({ orders, userCurrency }).toNumber();
|
||||
const dividend = this.getDividend({
|
||||
activities,
|
||||
userCurrency
|
||||
}).toNumber();
|
||||
const emergencyFund = new Big(
|
||||
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
|
||||
);
|
||||
const fees = this.getFees({ orders, userCurrency }).toNumber();
|
||||
const firstOrderDate = orders[0]?.date;
|
||||
const items = this.getItems(orders).toNumber();
|
||||
const fees = this.getFees({ activities, userCurrency }).toNumber();
|
||||
const firstOrderDate = activities[0]?.date;
|
||||
const items = this.getItems(activities).toNumber();
|
||||
|
||||
const totalBuy = this.getTotalByType(orders, userCurrency, 'BUY');
|
||||
const totalSell = this.getTotalByType(orders, userCurrency, 'SELL');
|
||||
const totalBuy = this.getTotalByType(activities, userCurrency, 'BUY');
|
||||
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 totalOfExcludedActivities = new Big(
|
||||
this.getTotalByType(excludedActivities, userCurrency, 'BUY')
|
||||
@ -1490,8 +1618,8 @@ export class PortfolioService {
|
||||
totalSell,
|
||||
committedFunds: committedFunds.toNumber(),
|
||||
emergencyFund: emergencyFund.toNumber(),
|
||||
ordersCount: orders.filter((order) => {
|
||||
return order.type === 'BUY' || order.type === 'SELL';
|
||||
ordersCount: activities.filter(({ type }) => {
|
||||
return type === 'BUY' || type === 'SELL';
|
||||
}).length
|
||||
};
|
||||
}
|
||||
@ -1508,7 +1636,7 @@ export class PortfolioService {
|
||||
withExcludedAccounts?: boolean;
|
||||
}): Promise<{
|
||||
transactionPoints: TransactionPoint[];
|
||||
orders: OrderWithAccount[];
|
||||
orders: Activity[];
|
||||
portfolioOrders: PortfolioOrder[];
|
||||
}> {
|
||||
const userCurrency =
|
||||
@ -1581,6 +1709,14 @@ export class PortfolioService {
|
||||
userId: string;
|
||||
withExcludedAccounts?: boolean;
|
||||
}) {
|
||||
const ordersOfTypeItem = await this.orderService.getOrders({
|
||||
filters,
|
||||
userCurrency,
|
||||
userId,
|
||||
withExcludedAccounts,
|
||||
types: ['ITEM']
|
||||
});
|
||||
|
||||
const accounts: PortfolioDetails['accounts'] = {};
|
||||
|
||||
let currentAccounts: (Account & {
|
||||
@ -1611,10 +1747,18 @@ export class PortfolioService {
|
||||
});
|
||||
|
||||
for (const account of currentAccounts) {
|
||||
const ordersByAccount = orders.filter(({ accountId }) => {
|
||||
let ordersByAccount = orders.filter(({ accountId }) => {
|
||||
return accountId === account.id;
|
||||
});
|
||||
|
||||
const ordersOfTypeItemByAccount = ordersOfTypeItem.filter(
|
||||
({ accountId }) => {
|
||||
return accountId === account.id;
|
||||
}
|
||||
);
|
||||
|
||||
ordersByAccount = ordersByAccount.concat(ordersOfTypeItemByAccount);
|
||||
|
||||
accounts[account.id] = {
|
||||
balance: account.balance,
|
||||
currency: account.currency,
|
||||
@ -1634,7 +1778,9 @@ export class PortfolioService {
|
||||
for (const order of ordersByAccount) {
|
||||
let currentValueOfSymbolInBaseCurrency =
|
||||
order.quantity *
|
||||
portfolioItemsNow[order.SymbolProfile.symbol].marketPrice;
|
||||
(portfolioItemsNow[order.SymbolProfile.symbol]?.marketPrice ??
|
||||
order.unitPrice ??
|
||||
0);
|
||||
let originalValueOfSymbolInBaseCurrency =
|
||||
this.exchangeRateDataService.toCurrency(
|
||||
order.quantity * order.unitPrice,
|
||||
|
@ -63,6 +63,7 @@ export class SubscriptionController {
|
||||
|
||||
await this.subscriptionService.createSubscription({
|
||||
duration: coupon.duration,
|
||||
price: 0,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
|
@ -1,6 +1,10 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||
import {
|
||||
DEFAULT_LANGUAGE_CODE,
|
||||
PROPERTY_STRIPE_CONFIG
|
||||
} from '@ghostfolio/common/config';
|
||||
import { Subscription as SubscriptionInterface } from '@ghostfolio/common/interfaces/subscription.interface';
|
||||
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Subscription } from '@prisma/client';
|
||||
@ -70,13 +74,16 @@ export class SubscriptionService {
|
||||
|
||||
public async createSubscription({
|
||||
duration = '1 year',
|
||||
price,
|
||||
userId
|
||||
}: {
|
||||
duration?: StringValue;
|
||||
price: number;
|
||||
userId: string;
|
||||
}) {
|
||||
await this.prismaService.subscription.create({
|
||||
data: {
|
||||
price,
|
||||
expiresAt: addMilliseconds(new Date(), ms(duration)),
|
||||
User: {
|
||||
connect: {
|
||||
@ -93,7 +100,21 @@ export class SubscriptionService {
|
||||
aCheckoutSessionId
|
||||
);
|
||||
|
||||
await this.createSubscription({ userId: session.client_reference_id });
|
||||
let subscriptions: SubscriptionInterface[] = [];
|
||||
|
||||
const stripeConfig = (await this.prismaService.property.findUnique({
|
||||
where: { key: PROPERTY_STRIPE_CONFIG }
|
||||
})) ?? { value: '{}' };
|
||||
|
||||
subscriptions = [JSON.parse(stripeConfig.value)];
|
||||
|
||||
const coupon = subscriptions[0]?.coupon ?? 0;
|
||||
const price = subscriptions[0]?.price ?? 0;
|
||||
|
||||
await this.createSubscription({
|
||||
price: price - coupon,
|
||||
userId: session.client_reference_id
|
||||
});
|
||||
|
||||
await this.stripe.customers.update(session.customer as string, {
|
||||
description: session.client_reference_id
|
||||
|
@ -1,6 +1,4 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.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 { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
@ -31,7 +29,6 @@ import { UserService } from './user.service';
|
||||
@Controller('user')
|
||||
export class UserController {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly propertyService: PropertyService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
|
@ -97,6 +97,7 @@ export class UserService {
|
||||
const {
|
||||
accessToken,
|
||||
Account,
|
||||
Analytics,
|
||||
authChallenge,
|
||||
createdAt,
|
||||
id,
|
||||
@ -107,7 +108,12 @@ export class UserService {
|
||||
thirdPartyId,
|
||||
updatedAt
|
||||
} = await this.prismaService.user.findUnique({
|
||||
include: { Account: true, Settings: true, Subscription: true },
|
||||
include: {
|
||||
Account: true,
|
||||
Analytics: true,
|
||||
Settings: true,
|
||||
Subscription: true
|
||||
},
|
||||
where: userWhereUniqueInput
|
||||
});
|
||||
|
||||
@ -121,7 +127,8 @@ export class UserService {
|
||||
role,
|
||||
Settings,
|
||||
thirdPartyId,
|
||||
updatedAt
|
||||
updatedAt,
|
||||
activityCount: Analytics?.activityCount
|
||||
};
|
||||
|
||||
if (user?.Settings) {
|
||||
@ -154,15 +161,22 @@ export class UserService {
|
||||
(user.Settings.settings as UserSettings).viewMode = 'DEFAULT';
|
||||
}
|
||||
|
||||
let currentPermissions = getPermissions(user.role);
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
user.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') {
|
||||
currentPermissions.push(permissions.reportDataGlitch);
|
||||
if (user.subscription?.type === 'Premium') {
|
||||
currentPermissions.push(permissions.reportDataGlitch);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,4 @@
|
||||
import { cloneDeep, isObject } from 'lodash';
|
||||
import { cloneDeep, isArray, isObject } from 'lodash';
|
||||
|
||||
export function hasNotDefinedValuesInObject(aObject: Object): boolean {
|
||||
for (const key in aObject) {
|
||||
@ -27,3 +27,48 @@ export function nullifyValuesInObjects<T>(aObjects: T[], keys: string[]): T[] {
|
||||
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 { redactAttributes } from '@ghostfolio/api/helper/object.helper';
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
@ -28,59 +28,36 @@ export class RedactValuesInResponseInterceptor<T>
|
||||
hasImpersonationId ||
|
||||
this.userService.isRestrictedView(request.user)
|
||||
) {
|
||||
if (data.accounts) {
|
||||
for (const accountId of Object.keys(data.accounts)) {
|
||||
if (data.accounts[accountId]?.balance !== undefined) {
|
||||
data.accounts[accountId].balance = 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;
|
||||
}
|
||||
data = redactAttributes({
|
||||
object: data,
|
||||
options: [
|
||||
'balance',
|
||||
'balanceInBaseCurrency',
|
||||
'comment',
|
||||
'convertedBalance',
|
||||
'dividendInBaseCurrency',
|
||||
'fee',
|
||||
'feeInBaseCurrency',
|
||||
'filteredValueInBaseCurrency',
|
||||
'grossPerformance',
|
||||
'investment',
|
||||
'netPerformance',
|
||||
'quantity',
|
||||
'symbolMapping',
|
||||
'totalBalanceInBaseCurrency',
|
||||
'totalValueInBaseCurrency',
|
||||
'unitPrice',
|
||||
'value',
|
||||
'valueInBaseCurrency'
|
||||
].map((attribute) => {
|
||||
return {
|
||||
attribute,
|
||||
valueMap: {
|
||||
'*': null
|
||||
}
|
||||
};
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { redactAttributes } from '@ghostfolio/api/helper/object.helper';
|
||||
import { encodeDataSource } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
CallHandler,
|
||||
@ -5,7 +6,7 @@ import {
|
||||
Injectable,
|
||||
NestInterceptor
|
||||
} from '@nestjs/common';
|
||||
import { isArray } from 'lodash';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
@ -28,63 +29,23 @@ export class TransformDataSourceInResponseInterceptor<T>
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true
|
||||
) {
|
||||
if (data.activities) {
|
||||
data.activities.map((activity) => {
|
||||
activity.SymbolProfile.dataSource = encodeDataSource(
|
||||
activity.SymbolProfile.dataSource
|
||||
);
|
||||
return activity;
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
data = redactAttributes({
|
||||
options: [
|
||||
{
|
||||
attribute: 'dataSource',
|
||||
valueMap: Object.keys(DataSource).reduce(
|
||||
(valueMap, dataSource) => {
|
||||
valueMap[dataSource] = encodeDataSource(
|
||||
DataSource[dataSource]
|
||||
);
|
||||
return valueMap;
|
||||
},
|
||||
{}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
],
|
||||
object: 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 { 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';
|
||||
|
||||
export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Settings> {
|
||||
public constructor(
|
||||
protected exchangeRateDataService: ExchangeRateDataService,
|
||||
private currentPositions: CurrentPositions
|
||||
private positions: TimelinePosition[]
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
name: 'Current Investment: Base Currency'
|
||||
@ -17,7 +16,7 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
|
||||
|
||||
public evaluate(ruleSettings: Settings) {
|
||||
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
|
||||
this.currentPositions.positions,
|
||||
this.positions,
|
||||
'currency',
|
||||
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 { 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';
|
||||
|
||||
export class CurrencyClusterRiskBaseCurrencyInitialInvestment extends Rule<Settings> {
|
||||
public constructor(
|
||||
protected exchangeRateDataService: ExchangeRateDataService,
|
||||
private currentPositions: CurrentPositions
|
||||
private positions: TimelinePosition[]
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
name: 'Initial Investment: Base Currency'
|
||||
@ -17,7 +16,7 @@ export class CurrencyClusterRiskBaseCurrencyInitialInvestment extends Rule<Setti
|
||||
|
||||
public evaluate(ruleSettings: Settings) {
|
||||
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
|
||||
this.currentPositions.positions,
|
||||
this.positions,
|
||||
'currency',
|
||||
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 { 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';
|
||||
|
||||
export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
public constructor(
|
||||
public exchangeRateDataService: ExchangeRateDataService,
|
||||
private currentPositions: CurrentPositions
|
||||
protected exchangeRateDataService: ExchangeRateDataService,
|
||||
private positions: TimelinePosition[]
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
name: 'Current Investment'
|
||||
@ -17,7 +16,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
|
||||
public evaluate(ruleSettings: Settings) {
|
||||
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
|
||||
this.currentPositions.positions,
|
||||
this.positions,
|
||||
'currency',
|
||||
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 { 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';
|
||||
|
||||
export class CurrencyClusterRiskInitialInvestment extends Rule<Settings> {
|
||||
public constructor(
|
||||
protected exchangeRateDataService: ExchangeRateDataService,
|
||||
private currentPositions: CurrentPositions
|
||||
private positions: TimelinePosition[]
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
name: 'Initial Investment'
|
||||
@ -17,7 +16,7 @@ export class CurrencyClusterRiskInitialInvestment extends Rule<Settings> {
|
||||
|
||||
public evaluate(ruleSettings: Settings) {
|
||||
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
|
||||
this.currentPositions.positions,
|
||||
this.positions,
|
||||
'currency',
|
||||
ruleSettings.baseCurrency
|
||||
);
|
||||
|
@ -42,7 +42,7 @@ export class ConfigurationService {
|
||||
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
|
||||
PORT: port({ default: 3333 }),
|
||||
RAPID_API_API_KEY: str({ default: '' }),
|
||||
REDIS_HOST: host({ default: 'localhost' }),
|
||||
REDIS_HOST: str({ default: 'localhost' }),
|
||||
REDIS_PASSWORD: str({ default: '' }),
|
||||
REDIS_PORT: port({ default: 6379 }),
|
||||
ROOT_URL: str({ default: 'http://localhost:4200' }),
|
||||
|
@ -11,14 +11,16 @@ import { TwitterBotService } from './twitter-bot/twitter-bot.service';
|
||||
|
||||
@Injectable()
|
||||
export class CronService {
|
||||
private static readonly EVERY_SUNDAY_AT_LUNCH_TIME = '0 12 * * 0';
|
||||
|
||||
public constructor(
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly twitterBotService: TwitterBotService
|
||||
) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_HOUR)
|
||||
public async runEveryHour() {
|
||||
@Cron(CronExpression.EVERY_4_HOURS)
|
||||
public async runEveryFourHours() {
|
||||
await this.dataGatheringService.gather7Days();
|
||||
}
|
||||
|
||||
@ -28,12 +30,12 @@ export class CronService {
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_5PM)
|
||||
public async runEveryDayAtFivePM() {
|
||||
public async runEveryDayAtFivePm() {
|
||||
this.twitterBotService.tweetFearAndGreedIndex();
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_WEEKEND)
|
||||
public async runEveryWeekend() {
|
||||
@Cron(CronService.EVERY_SUNDAY_AT_LUNCH_TIME)
|
||||
public async runEverySundayAtTwelvePm() {
|
||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||
|
||||
for (const { dataSource, symbol } of uniqueAssets) {
|
||||
|
@ -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(
|
||||
aSymbol: string,
|
||||
aGranularity: Granularity = 'day',
|
||||
|
@ -59,6 +59,10 @@ import { DataProviderService } from './data-provider.service';
|
||||
]
|
||||
}
|
||||
],
|
||||
exports: [DataProviderService, GhostfolioScraperApiService]
|
||||
exports: [
|
||||
DataProviderService,
|
||||
GhostfolioScraperApiService,
|
||||
YahooFinanceService
|
||||
]
|
||||
})
|
||||
export class DataProviderModule {}
|
||||
|
@ -23,6 +23,27 @@ export class DataProviderService {
|
||||
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(
|
||||
aItems: IDataGatheringItem[],
|
||||
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(
|
||||
aSymbol: string,
|
||||
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(
|
||||
aSymbol: string,
|
||||
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(
|
||||
aSymbol: string,
|
||||
aGranularity: Granularity = 'day',
|
||||
|
@ -11,6 +11,18 @@ export interface DataProviderInterface {
|
||||
|
||||
getAssetProfile(aSymbol: string): Promise<Partial<SymbolProfile>>;
|
||||
|
||||
getDividends({
|
||||
from,
|
||||
granularity,
|
||||
symbol,
|
||||
to
|
||||
}: {
|
||||
from: Date;
|
||||
granularity: Granularity;
|
||||
symbol: string;
|
||||
to: Date;
|
||||
}): Promise<{ [date: string]: IDataProviderHistoricalResponse }>;
|
||||
|
||||
getHistorical(
|
||||
aSymbol: string,
|
||||
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(
|
||||
aSymbol: string,
|
||||
aGranularity: Granularity = 'day',
|
||||
|
@ -5,20 +5,18 @@ import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, getToday, getYesterday } from '@ghostfolio/common/helper';
|
||||
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||
import bent from 'bent';
|
||||
import { format, subMonths, subWeeks, subYears } from 'date-fns';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
export class RapidApiService implements DataProviderInterface {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly prismaService: PrismaService
|
||||
private readonly configurationService: ConfigurationService
|
||||
) {}
|
||||
|
||||
public canHandle(symbol: string) {
|
||||
@ -33,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(
|
||||
aSymbol: string,
|
||||
aGranularity: Granularity = 'day',
|
||||
@ -47,41 +59,6 @@ export class RapidApiService implements DataProviderInterface {
|
||||
if (symbol === ghostfolioFearAndGreedIndexSymbol) {
|
||||
const fgi = await this.getFearAndGreedIndex();
|
||||
|
||||
try {
|
||||
// Rebuild historical data
|
||||
// TODO: can be removed after all data from the last year has been gathered
|
||||
// (introduced on 27.03.2021)
|
||||
|
||||
await this.prismaService.marketData.create({
|
||||
data: {
|
||||
symbol,
|
||||
dataSource: this.getName(),
|
||||
date: subWeeks(getToday(), 1),
|
||||
marketPrice: fgi.oneWeekAgo.value
|
||||
}
|
||||
});
|
||||
|
||||
await this.prismaService.marketData.create({
|
||||
data: {
|
||||
symbol,
|
||||
dataSource: this.getName(),
|
||||
date: subMonths(getToday(), 1),
|
||||
marketPrice: fgi.oneMonthAgo.value
|
||||
}
|
||||
});
|
||||
|
||||
await this.prismaService.marketData.create({
|
||||
data: {
|
||||
symbol,
|
||||
dataSource: this.getName(),
|
||||
date: subYears(getToday(), 1),
|
||||
marketPrice: fgi.oneYearAgo.value
|
||||
}
|
||||
});
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
[ghostfolioFearAndGreedIndexSymbol]: {
|
||||
[format(getYesterday(), DATE_FORMAT)]: {
|
||||
|
@ -154,16 +154,65 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
response.url = url;
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Could not get asset profile for ${aSymbol} (${this.getName()}): [${
|
||||
error.name
|
||||
}] ${error.message}`
|
||||
);
|
||||
Logger.error(error, 'YahooFinanceService');
|
||||
}
|
||||
|
||||
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(
|
||||
aSymbol: string,
|
||||
aGranularity: Granularity = 'day',
|
||||
@ -176,11 +225,9 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
to = addDays(to, 1);
|
||||
}
|
||||
|
||||
const yahooFinanceSymbol = this.convertToYahooFinanceSymbol(aSymbol);
|
||||
|
||||
try {
|
||||
const historicalResult = await yahooFinance.historical(
|
||||
yahooFinanceSymbol,
|
||||
this.convertToYahooFinanceSymbol(aSymbol),
|
||||
{
|
||||
interval: '1d',
|
||||
period1: format(from, DATE_FORMAT),
|
||||
@ -192,27 +239,14 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||
} = {};
|
||||
|
||||
// Convert symbol back
|
||||
const symbol = this.convertFromYahooFinanceSymbol(yahooFinanceSymbol);
|
||||
|
||||
response[symbol] = {};
|
||||
response[aSymbol] = {};
|
||||
|
||||
for (const historicalItem of historicalResult) {
|
||||
let marketPrice = historicalItem.close;
|
||||
|
||||
if (symbol === `${this.baseCurrency}GBp`) {
|
||||
// Convert GPB to GBp (pence)
|
||||
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,
|
||||
response[aSymbol][format(historicalItem.date, DATE_FORMAT)] = {
|
||||
marketPrice: this.getConvertedValue({
|
||||
symbol: aSymbol,
|
||||
value: historicalItem.close
|
||||
}),
|
||||
performance: historicalItem.open - historicalItem.close
|
||||
};
|
||||
}
|
||||
@ -427,6 +461,27 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
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): {
|
||||
assetClass: AssetClass;
|
||||
assetSubClass: AssetSubClass;
|
||||
|
@ -1,18 +0,0 @@
|
||||
# This file is used by the build system to adjust CSS and JS output to support the specified browsers below.
|
||||
# For additional information regarding the format and rule options, please see:
|
||||
# https://github.com/browserslist/browserslist#queries
|
||||
|
||||
# For the full list of supported browsers by the Angular framework, please see:
|
||||
# https://angular.io/guide/browser-support
|
||||
|
||||
# You can see what browsers were selected by your queries by running:
|
||||
# npx browserslist
|
||||
|
||||
last 1 Chrome version
|
||||
last 1 Firefox version
|
||||
last 2 Edge major versions
|
||||
last 2 Safari major versions
|
||||
last 2 iOS major versions
|
||||
Firefox ESR
|
||||
not IE 9-10 # Angular support for IE 9-10 has been deprecated and will be removed as of Angular v11. To opt-in, remove the 'not' prefix on this line.
|
||||
not IE 11 # Angular supports IE 11 only as an opt-in. To opt-in, remove the 'not' prefix on this line.
|
@ -65,7 +65,10 @@
|
||||
"output": "./../assets/"
|
||||
}
|
||||
],
|
||||
"styles": ["apps/client/src/styles.scss"],
|
||||
"styles": [
|
||||
"apps/client/src/styles/theme.scss",
|
||||
"apps/client/src/styles.scss"
|
||||
],
|
||||
"scripts": ["node_modules/marked/marked.min.js"],
|
||||
"vendorChunk": true,
|
||||
"extractLicenses": false,
|
||||
@ -89,6 +92,10 @@
|
||||
"baseHref": "/es/",
|
||||
"localize": ["es"]
|
||||
},
|
||||
"development-fr": {
|
||||
"baseHref": "/fr/",
|
||||
"localize": ["fr"]
|
||||
},
|
||||
"development-it": {
|
||||
"baseHref": "/it/",
|
||||
"localize": ["it"]
|
||||
@ -97,6 +104,10 @@
|
||||
"baseHref": "/nl/",
|
||||
"localize": ["nl"]
|
||||
},
|
||||
"development-pt": {
|
||||
"baseHref": "/pt/",
|
||||
"localize": ["pt"]
|
||||
},
|
||||
"production": {
|
||||
"fileReplacements": [
|
||||
{
|
||||
@ -144,12 +155,18 @@
|
||||
"development-es": {
|
||||
"browserTarget": "client:build:development-es"
|
||||
},
|
||||
"development-fr": {
|
||||
"browserTarget": "client:build:development-fr"
|
||||
},
|
||||
"development-it": {
|
||||
"browserTarget": "client:build:development-it"
|
||||
},
|
||||
"development-nl": {
|
||||
"browserTarget": "client:build:development-nl"
|
||||
},
|
||||
"development-pt": {
|
||||
"browserTarget": "client:build:development-pt"
|
||||
},
|
||||
"production": {
|
||||
"browserTarget": "client:build:production"
|
||||
}
|
||||
@ -164,8 +181,10 @@
|
||||
"targetFiles": [
|
||||
"messages.de.xlf",
|
||||
"messages.es.xlf",
|
||||
"messages.fr.xlf",
|
||||
"messages.it.xlf",
|
||||
"messages.nl.xlf"
|
||||
"messages.nl.xlf",
|
||||
"messages.pt.xlf"
|
||||
]
|
||||
}
|
||||
},
|
||||
@ -194,6 +213,10 @@
|
||||
"baseHref": "/es/",
|
||||
"translation": "apps/client/src/locales/messages.es.xlf"
|
||||
},
|
||||
"fr": {
|
||||
"baseHref": "/fr/",
|
||||
"translation": "apps/client/src/locales/messages.fr.xlf"
|
||||
},
|
||||
"it": {
|
||||
"baseHref": "/it/",
|
||||
"translation": "apps/client/src/locales/messages.it.xlf"
|
||||
@ -201,6 +224,10 @@
|
||||
"nl": {
|
||||
"baseHref": "/nl/",
|
||||
"translation": "apps/client/src/locales/messages.nl.xlf"
|
||||
},
|
||||
"pt": {
|
||||
"baseHref": "/pt/",
|
||||
"translation": "apps/client/src/locales/messages.pt.xlf"
|
||||
}
|
||||
},
|
||||
"sourceLocale": "en"
|
||||
|
@ -2,7 +2,7 @@ import { Platform } from '@angular/cdk/platform';
|
||||
import { Inject, forwardRef } from '@angular/core';
|
||||
import { MAT_DATE_LOCALE, NativeDateAdapter } from '@angular/material/core';
|
||||
import { getDateFormatString } from '@ghostfolio/common/helper';
|
||||
import { format, parse } from 'date-fns';
|
||||
import { addYears, format, getYear, parse } from 'date-fns';
|
||||
|
||||
export class CustomDateAdapter extends NativeDateAdapter {
|
||||
public constructor(
|
||||
@ -31,6 +31,16 @@ export class CustomDateAdapter extends NativeDateAdapter {
|
||||
* Parses a date from a provided value
|
||||
*/
|
||||
public parse(aValue: string): Date {
|
||||
return parse(aValue, getDateFormatString(this.locale), new Date());
|
||||
let date = parse(aValue, getDateFormatString(this.locale), new Date());
|
||||
|
||||
if (getYear(date) < 1900) {
|
||||
if (getYear(date) > Number(format(new Date(), 'yy')) + 1) {
|
||||
date = addYears(date, 1900);
|
||||
} else {
|
||||
date = addYears(date, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
return date;
|
||||
}
|
||||
}
|
||||
|
@ -109,6 +109,20 @@ const routes: Routes = [
|
||||
'./pages/blog/2022/11/black-friday-2022/black-friday-2022-page.module'
|
||||
).then((m) => m.BlackFriday2022PageModule)
|
||||
},
|
||||
{
|
||||
path: 'blog/2022/12/the-importance-of-tracking-your-personal-finances',
|
||||
loadChildren: () =>
|
||||
import(
|
||||
'./pages/blog/2022/12/the-importance-of-tracking-your-personal-finances/the-importance-of-tracking-your-personal-finances-page.module'
|
||||
).then((m) => m.TheImportanceOfTrackingYourPersonalFinancesPageModule)
|
||||
},
|
||||
{
|
||||
path: 'blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt',
|
||||
loadChildren: () =>
|
||||
import(
|
||||
'./pages/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt/ghostfolio-auf-sackgeld-vorgestellt-page.module'
|
||||
).then((m) => m.GhostfolioAufSackgeldVorgestelltPageModule)
|
||||
},
|
||||
{
|
||||
path: 'demo',
|
||||
loadChildren: () =>
|
||||
@ -208,9 +222,8 @@ const routes: Routes = [
|
||||
// Preload all lazy loaded modules with the attribute preload === true
|
||||
{
|
||||
anchorScrolling: 'enabled',
|
||||
preloadingStrategy: ModulePreloadService,
|
||||
preloadingStrategy: ModulePreloadService
|
||||
// enableTracing: true // <-- debugging purposes only
|
||||
relativeLinkResolution: 'legacy'
|
||||
}
|
||||
)
|
||||
],
|
||||
|
@ -3,6 +3,7 @@
|
||||
class="position-fixed w-100"
|
||||
[currentRoute]="currentRoute"
|
||||
[info]="info"
|
||||
[pageTitle]="pageTitle"
|
||||
[user]="user"
|
||||
(signOut)="onSignOut()"
|
||||
></gf-header>
|
||||
|
@ -1,4 +1,4 @@
|
||||
@import '~apps/client/src/styles/ghostfolio-style';
|
||||
@import 'apps/client/src/styles/ghostfolio-style';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
|
@ -1,20 +1,17 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
Inject,
|
||||
OnDestroy,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { NavigationEnd, PRIMARY_OUTLET, Router } from '@angular/router';
|
||||
import {
|
||||
primaryColorHex,
|
||||
secondaryColorHex,
|
||||
warnColorHex
|
||||
} from '@ghostfolio/common/config';
|
||||
import { InfoItem, User } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { ColorScheme } from '@ghostfolio/common/types';
|
||||
import { MaterialCssVarsService } from 'angular-material-css-vars';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { Subject } from 'rxjs';
|
||||
import { filter, takeUntil } from 'rxjs/operators';
|
||||
@ -36,6 +33,7 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
public currentYear = new Date().getFullYear();
|
||||
public deviceType: string;
|
||||
public info: InfoItem;
|
||||
public pageTitle: string;
|
||||
public user: User;
|
||||
public version = environment.version;
|
||||
|
||||
@ -45,8 +43,9 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
private deviceService: DeviceDetectorService,
|
||||
private materialCssVarsService: MaterialCssVarsService,
|
||||
@Inject(DOCUMENT) private document: Document,
|
||||
private router: Router,
|
||||
private title: Title,
|
||||
private tokenStorageService: TokenStorageService,
|
||||
private userService: UserService
|
||||
) {
|
||||
@ -66,6 +65,19 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
this.currentRoute = urlSegments[0].path;
|
||||
|
||||
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
|
||||
@ -105,16 +117,20 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
? userPreferredColorScheme === 'DARK'
|
||||
: window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
this.materialCssVarsService.setDarkTheme(isDarkTheme);
|
||||
this.toggleThemeStyleClass(isDarkTheme);
|
||||
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addListener((event) => {
|
||||
if (!this.user?.settings.colorScheme) {
|
||||
this.materialCssVarsService.setDarkTheme(event.matches);
|
||||
this.toggleThemeStyleClass(event.matches);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.materialCssVarsService.setPrimaryColor(primaryColorHex);
|
||||
this.materialCssVarsService.setAccentColor(secondaryColorHex);
|
||||
this.materialCssVarsService.setWarnColor(warnColorHex);
|
||||
private toggleThemeStyleClass(isDarkTheme: boolean) {
|
||||
if (isDarkTheme) {
|
||||
this.document.body.classList.add('is-dark-theme');
|
||||
} else {
|
||||
this.document.body.classList.remove('is-dark-theme');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,20 +1,19 @@
|
||||
import { Platform } from '@angular/cdk/platform';
|
||||
import { HttpClientModule } from '@angular/common/http';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import {
|
||||
DateAdapter,
|
||||
MAT_DATE_FORMATS,
|
||||
MAT_DATE_LOCALE,
|
||||
MatNativeDateModule
|
||||
} from '@angular/material/core';
|
||||
import { MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatLegacyAutocompleteModule as MatAutocompleteModule } from '@angular/material/legacy-autocomplete';
|
||||
import { MatLegacyChipsModule as MatChipsModule } from '@angular/material/legacy-chips';
|
||||
import { MatLegacySnackBarModule as MatSnackBarModule } from '@angular/material/legacy-snack-bar';
|
||||
import { MatLegacyTooltipModule as MatTooltipModule } from '@angular/material/legacy-tooltip';
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { ServiceWorkerModule } from '@angular/service-worker';
|
||||
import { MaterialCssVarsModule } from 'angular-material-css-vars';
|
||||
import { MarkdownModule } from 'ngx-markdown';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
import { NgxStripeModule, STRIPE_PUBLISHABLE_KEY } from 'ngx-stripe';
|
||||
@ -25,6 +24,7 @@ import { DateFormats } from './adapter/date-formats';
|
||||
import { AppRoutingModule } from './app-routing.module';
|
||||
import { AppComponent } from './app.component';
|
||||
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 { httpResponseInterceptorProviders } from './core/http-response.interceptor';
|
||||
import { LanguageService } from './core/language.service';
|
||||
@ -40,15 +40,11 @@ export function NgxStripeFactory(): string {
|
||||
BrowserAnimationsModule,
|
||||
BrowserModule,
|
||||
GfHeaderModule,
|
||||
GfSubscriptionInterstitialDialogModule,
|
||||
HttpClientModule,
|
||||
MarkdownModule.forRoot(),
|
||||
MatAutocompleteModule,
|
||||
MatChipsModule,
|
||||
MaterialCssVarsModule.forRoot({
|
||||
darkThemeClass: 'is-dark-theme',
|
||||
isAutoContrast: true,
|
||||
lightThemeClass: 'is-light-theme'
|
||||
}),
|
||||
MatNativeDateModule,
|
||||
MatSnackBarModule,
|
||||
MatTooltipModule,
|
||||
|
@ -1,4 +1,4 @@
|
||||
@import '~apps/client/src/styles/ghostfolio-style';
|
||||
@import 'apps/client/src/styles/ghostfolio-style';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
|
@ -7,7 +7,7 @@ import {
|
||||
OnInit,
|
||||
Output
|
||||
} from '@angular/core';
|
||||
import { MatTableDataSource } from '@angular/material/table';
|
||||
import { MatLegacyTableDataSource as MatTableDataSource } from '@angular/material/legacy-table';
|
||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||
import { Access } from '@ghostfolio/common/interfaces';
|
||||
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
|
||||
import { MatLegacyMenuModule as MatMenuModule } from '@angular/material/legacy-menu';
|
||||
import { MatLegacyTableModule as MatTableModule } from '@angular/material/legacy-table';
|
||||
|
||||
import { AccessTableComponent } from './access-table.component';
|
||||
|
||||
|
@ -6,13 +6,16 @@ import {
|
||||
OnDestroy,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import {
|
||||
MAT_LEGACY_DIALOG_DATA as MAT_DIALOG_DATA,
|
||||
MatLegacyDialogRef as MatDialogRef
|
||||
} from '@angular/material/legacy-dialog';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { downloadAsFile } from '@ghostfolio/common/helper';
|
||||
import { User } from '@ghostfolio/common/interfaces';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
import { AccountType } from '@prisma/client';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
@ -27,7 +30,7 @@ import { AccountDetailDialogParams } from './interfaces/interfaces';
|
||||
styleUrls: ['./account-detail-dialog.component.scss']
|
||||
})
|
||||
export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||
public accountType: AccountType;
|
||||
public accountType: string;
|
||||
public name: string;
|
||||
public orders: OrderWithAccount[];
|
||||
public platformName: string;
|
||||
@ -59,7 +62,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||
.fetchAccount(this.data.accountId)
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ accountType, name, Platform, valueInBaseCurrency }) => {
|
||||
this.accountType = accountType;
|
||||
this.accountType = translate(accountType);
|
||||
this.name = name;
|
||||
this.platformName = Platform?.name ?? '-';
|
||||
this.valueInBaseCurrency = valueInBaseCurrency;
|
||||
|
@ -1,7 +1,7 @@
|
||||
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 { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
|
||||
import { MatLegacyDialogModule as MatDialogModule } from '@angular/material/legacy-dialog';
|
||||
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
|
||||
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
|
||||
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
|
||||
|
@ -1,4 +1,4 @@
|
||||
@import '~apps/client/src/styles/ghostfolio-style';
|
||||
@import 'apps/client/src/styles/ghostfolio-style';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
|
@ -9,8 +9,8 @@ import {
|
||||
Output,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { MatLegacyTableDataSource as MatTableDataSource } from '@angular/material/legacy-table';
|
||||
import { MatSort } from '@angular/material/sort';
|
||||
import { MatTableDataSource } from '@angular/material/table';
|
||||
import { Router } from '@angular/router';
|
||||
import { Account as AccountModel } from '@prisma/client';
|
||||
import { get } from 'lodash';
|
||||
|
@ -1,10 +1,9 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
|
||||
import { MatLegacyMenuModule as MatMenuModule } from '@angular/material/legacy-menu';
|
||||
import { MatLegacyTableModule as MatTableModule } from '@angular/material/legacy-table';
|
||||
import { MatSortModule } from '@angular/material/sort';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module';
|
||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||
@ -20,7 +19,6 @@ import { AccountsTableComponent } from './accounts-table.component';
|
||||
GfSymbolIconModule,
|
||||
GfValueModule,
|
||||
MatButtonModule,
|
||||
MatInputModule,
|
||||
MatMenuModule,
|
||||
MatSortModule,
|
||||
MatTableModule,
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
|
||||
import { MatLegacyMenuModule as MatMenuModule } from '@angular/material/legacy-menu';
|
||||
import { MatLegacySelectModule as MatSelectModule } from '@angular/material/legacy-select';
|
||||
|
||||
import { AdminJobsComponent } from './admin-jobs.component';
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
@import '~apps/client/src/styles/ghostfolio-style';
|
||||
@import 'apps/client/src/styles/ghostfolio-style';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
|
@ -1,4 +1,4 @@
|
||||
@import '~apps/client/src/styles/ghostfolio-style';
|
||||
@import 'apps/client/src/styles/ghostfolio-style';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
|
@ -7,7 +7,7 @@ import {
|
||||
OnInit,
|
||||
Output
|
||||
} from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatLegacyDialog as MatDialog } from '@angular/material/legacy-dialog';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import {
|
||||
DATE_FORMAT,
|
||||
@ -20,7 +20,6 @@ import {
|
||||
addDays,
|
||||
format,
|
||||
isBefore,
|
||||
isDate,
|
||||
isSameDay,
|
||||
isToday,
|
||||
isValid,
|
||||
@ -31,6 +30,7 @@ import { last } from 'lodash';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
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';
|
||||
|
||||
@Component({
|
||||
@ -40,6 +40,7 @@ import { MarketDataDetailDialog } from './market-data-detail-dialog/market-data-
|
||||
templateUrl: './admin-market-data-detail.component.html'
|
||||
})
|
||||
export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
|
||||
@Input() currency: string;
|
||||
@Input() dataSource: DataSource;
|
||||
@Input() dateOfFirstActivity: string;
|
||||
@Input() locale = getLocale();
|
||||
@ -161,9 +162,10 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
|
||||
}
|
||||
|
||||
const dialogRef = this.dialog.open(MarketDataDetailDialog, {
|
||||
data: {
|
||||
data: <MarketDataDetailDialogParams>{
|
||||
date,
|
||||
marketPrice,
|
||||
currency: this.currency,
|
||||
dataSource: this.dataSource,
|
||||
symbol: this.symbol,
|
||||
user: this.user
|
||||
|
@ -2,6 +2,7 @@ import { User } from '@ghostfolio/common/interfaces';
|
||||
import { DataSource } from '@prisma/client';
|
||||
|
||||
export interface MarketDataDetailDialogParams {
|
||||
currency: string;
|
||||
dataSource: DataSource;
|
||||
date: Date;
|
||||
marketPrice: number;
|
||||
|
@ -6,7 +6,10 @@ import {
|
||||
OnDestroy
|
||||
} from '@angular/core';
|
||||
import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import {
|
||||
MAT_LEGACY_DIALOG_DATA as MAT_DIALOG_DATA,
|
||||
MatLegacyDialogRef as MatDialogRef
|
||||
} from '@angular/material/legacy-dialog';
|
||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||
import { Subject, takeUntil } from 'rxjs';
|
||||
|
||||
@ -36,7 +39,7 @@ export class MarketDataDetailDialog implements OnDestroy {
|
||||
this.dateAdapter.setLocale(this.locale);
|
||||
}
|
||||
|
||||
public onCancel(): void {
|
||||
public onCancel() {
|
||||
this.dialogRef.close({ withRefresh: false });
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
<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="mb-3">
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
@ -30,6 +30,7 @@
|
||||
type="number"
|
||||
[(ngModel)]="data.marketPrice"
|
||||
/>
|
||||
<span class="ml-2" matSuffix>{{ data.currency }}</span>
|
||||
<button
|
||||
mat-icon-button
|
||||
matSuffix
|
||||
|
@ -1,11 +1,11 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
|
||||
import { MatLegacyDialogModule as MatDialogModule } from '@angular/material/legacy-dialog';
|
||||
import { MatLegacyFormFieldModule as MatFormFieldModule } from '@angular/material/legacy-form-field';
|
||||
import { MatLegacyInputModule as MatInputModule } from '@angular/material/legacy-input';
|
||||
|
||||
import { MarketDataDetailDialog } from './market-data-detail-dialog.component';
|
||||
|
||||
|
@ -6,9 +6,9 @@ import {
|
||||
OnInit,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatLegacyDialog as MatDialog } from '@angular/material/legacy-dialog';
|
||||
import { MatLegacyTableDataSource as MatTableDataSource } from '@angular/material/legacy-table';
|
||||
import { MatSort } from '@angular/material/sort';
|
||||
import { MatTableDataSource } from '@angular/material/table';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
|
@ -1,13 +1,13 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
|
||||
import { MatLegacyMenuModule as MatMenuModule } from '@angular/material/legacy-menu';
|
||||
import { MatLegacyTableModule as MatTableModule } from '@angular/material/legacy-table';
|
||||
import { MatSortModule } from '@angular/material/sort';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module';
|
||||
|
||||
import { AdminMarketDataComponent } from './admin-market-data.component';
|
||||
import { GfAssetProfileDialogModule } from './asset-profile-dialog/assset-profile-dialog.module';
|
||||
import { GfAssetProfileDialogModule } from './asset-profile-dialog/asset-profile-dialog.module';
|
||||
|
||||
@NgModule({
|
||||
declarations: [AdminMarketDataComponent],
|
||||
|
@ -1,4 +1,4 @@
|
||||
@import '~apps/client/src/styles/ghostfolio-style';
|
||||
@import 'apps/client/src/styles/ghostfolio-style';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
|
@ -7,13 +7,17 @@ import {
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { FormBuilder } from '@angular/forms';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import {
|
||||
MAT_LEGACY_DIALOG_DATA as MAT_DIALOG_DATA,
|
||||
MatLegacyDialogRef as MatDialogRef
|
||||
} from '@angular/material/legacy-dialog';
|
||||
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
|
||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||
import {
|
||||
EnhancedSymbolProfile,
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
import { MarketData } from '@prisma/client';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
@ -28,11 +32,13 @@ import { AssetProfileDialogParams } from './interfaces/interfaces';
|
||||
styleUrls: ['./asset-profile-dialog.component.scss']
|
||||
})
|
||||
export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
public assetClass: string;
|
||||
public assetProfile: EnhancedSymbolProfile;
|
||||
public assetProfileForm = this.formBuilder.group({
|
||||
comment: '',
|
||||
symbolMapping: ''
|
||||
});
|
||||
public assetSubClass: string;
|
||||
public countries: {
|
||||
[code: string]: { name: string; value: number };
|
||||
};
|
||||
@ -64,6 +70,9 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ assetProfile, marketData }) => {
|
||||
this.assetProfile = assetProfile;
|
||||
|
||||
this.assetClass = translate(this.assetProfile?.assetClass);
|
||||
this.assetSubClass = translate(this.assetProfile?.assetSubClass);
|
||||
this.countries = {};
|
||||
this.marketDataDetails = marketData;
|
||||
this.sectors = {};
|
||||
|
@ -43,6 +43,7 @@
|
||||
<div class="flex-grow-1" mat-dialog-content>
|
||||
<gf-admin-market-data-detail
|
||||
class="mb-3"
|
||||
[currency]="assetProfile?.currency"
|
||||
[dataSource]="data.dataSource"
|
||||
[dateOfFirstActivity]="assetProfile?.dateOfFirstActivity"
|
||||
[locale]="data.locale"
|
||||
@ -51,6 +52,16 @@
|
||||
(marketDataChanged)="onMarketDataChanged($event)"
|
||||
></gf-admin-market-data-detail>
|
||||
<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">
|
||||
<gf-value
|
||||
i18n
|
||||
@ -71,11 +82,7 @@
|
||||
>
|
||||
</div>
|
||||
<div class="col-6 mb-3">
|
||||
<gf-value
|
||||
i18n
|
||||
size="medium"
|
||||
[hidden]="!assetProfile?.assetClass"
|
||||
[value]="assetProfile?.assetClass"
|
||||
<gf-value i18n size="medium" [hidden]="!assetClass" [value]="assetClass"
|
||||
>Asset Class</gf-value
|
||||
>
|
||||
</div>
|
||||
@ -83,8 +90,8 @@
|
||||
<gf-value
|
||||
i18n
|
||||
size="medium"
|
||||
[hidden]="!assetProfile?.assetSubClass"
|
||||
[value]="assetProfile?.assetSubClass"
|
||||
[hidden]="!assetSubClass"
|
||||
[value]="assetSubClass"
|
||||
>Asset Sub Class</gf-value
|
||||
>
|
||||
</div>
|
||||
|
@ -2,10 +2,10 @@ import { TextFieldModule } from '@angular/cdk/text-field';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
|
||||
import { MatLegacyDialogModule as MatDialogModule } from '@angular/material/legacy-dialog';
|
||||
import { MatLegacyInputModule as MatInputModule } from '@angular/material/legacy-input';
|
||||
import { MatLegacyMenuModule as MatMenuModule } from '@angular/material/legacy-menu';
|
||||
import { GfAdminMarketDataDetailModule } from '@ghostfolio/client/components/admin-market-data-detail/admin-market-data-detail.module';
|
||||
import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
|
||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
@ -1,5 +1,5 @@
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { MatSlideToggleChange } from '@angular/material/slide-toggle';
|
||||
import { MatLegacySlideToggleChange as MatSlideToggleChange } from '@angular/material/legacy-slide-toggle';
|
||||
import { CacheService } from '@ghostfolio/client/services/cache.service';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
@ -8,7 +8,8 @@ import {
|
||||
PROPERTY_CURRENCIES,
|
||||
PROPERTY_IS_READ_ONLY_MODE,
|
||||
PROPERTY_IS_USER_SIGNUP_ENABLED,
|
||||
PROPERTY_SYSTEM_MESSAGE
|
||||
PROPERTY_SYSTEM_MESSAGE,
|
||||
ghostfolioPrefix
|
||||
} from '@ghostfolio/common/config';
|
||||
import { Coupon, InfoItem, User } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
@ -97,7 +98,10 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||
public onAddCoupon() {
|
||||
const 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 });
|
||||
}
|
||||
|
@ -72,16 +72,32 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="align-items-start d-flex my-3">
|
||||
<div
|
||||
*ngIf="info?.benchmarks?.length > 0"
|
||||
class="align-items-start d-flex my-3"
|
||||
>
|
||||
<div class="w-50" i18n>Benchmarks</div>
|
||||
<div class="w-50">
|
||||
<table>
|
||||
<tr *ngFor="let benchmark of info?.benchmarks">
|
||||
<tr *ngFor="let benchmark of info.benchmarks">
|
||||
<td class="pl-1">{{ benchmark.symbol }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
*ngIf="info?.tags?.length > 0"
|
||||
class="align-items-start d-flex my-3"
|
||||
>
|
||||
<div class="w-50" i18n>Tags</div>
|
||||
<div class="w-50">
|
||||
<table>
|
||||
<tr *ngFor="let tag of info.tags">
|
||||
<td class="pl-1">{{ tag.name }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex my-3">
|
||||
<div class="w-50" i18n>User Signup</div>
|
||||
<div class="w-50">
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
|
||||
import { MatLegacyCardModule as MatCardModule } from '@angular/material/legacy-card';
|
||||
import { MatLegacySelectModule as MatSelectModule } from '@angular/material/legacy-select';
|
||||
import { MatLegacySlideToggleModule as MatSlideToggleModule } from '@angular/material/legacy-slide-toggle';
|
||||
import { CacheService } from '@ghostfolio/client/services/cache.service';
|
||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
@import '~apps/client/src/styles/ghostfolio-style';
|
||||
@import 'apps/client/src/styles/ghostfolio-style';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
|
||||
import { MatLegacyMenuModule as MatMenuModule } from '@angular/material/legacy-menu';
|
||||
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
|
||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
@import '~apps/client/src/styles/ghostfolio-style';
|
||||
@import 'apps/client/src/styles/ghostfolio-style';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
|
@ -187,7 +187,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
|
||||
position: 'right',
|
||||
ticks: {
|
||||
callback: (value: number) => {
|
||||
return `${value} %`;
|
||||
return `${value.toFixed(2)} %`;
|
||||
},
|
||||
display: true,
|
||||
mirror: true,
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatLegacySelectModule as MatSelectModule } from '@angular/material/legacy-select';
|
||||
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
|
||||
|
||||
import { DialogFooterComponent } from './dialog-footer.component';
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
|
||||
|
||||
import { DialogHeaderComponent } from './dialog-header.component';
|
||||
|
||||
|
@ -5,7 +5,7 @@
|
||||
mat-button
|
||||
[routerLink]="['/']"
|
||||
>
|
||||
<gf-logo></gf-logo>
|
||||
<gf-logo [label]="pageTitle"></gf-logo>
|
||||
</a>
|
||||
<span class="spacer"></span>
|
||||
<a
|
||||
@ -231,7 +231,10 @@
|
||||
mat-button
|
||||
[routerLink]="['/']"
|
||||
>
|
||||
<gf-logo [hideName]="currentRoute === 'register'"></gf-logo>
|
||||
<gf-logo
|
||||
[label]="pageTitle"
|
||||
[showLabel]="currentRoute !== 'register'"
|
||||
></gf-logo>
|
||||
</a>
|
||||
<span class="spacer"></span>
|
||||
<a
|
||||
|
@ -1,4 +1,4 @@
|
||||
@import '~apps/client/src/styles/ghostfolio-style';
|
||||
@import 'apps/client/src/styles/ghostfolio-style';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
|
@ -6,7 +6,7 @@ import {
|
||||
OnChanges,
|
||||
Output
|
||||
} from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatLegacyDialog as MatDialog } from '@angular/material/legacy-dialog';
|
||||
import { Router } from '@angular/router';
|
||||
import { LoginWithAccessTokenDialog } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.component';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
@ -30,6 +30,7 @@ import { catchError, takeUntil } from 'rxjs/operators';
|
||||
export class HeaderComponent implements OnChanges {
|
||||
@Input() currentRoute: string;
|
||||
@Input() info: InfoItem;
|
||||
@Input() pageTitle: string;
|
||||
@Input() user: User;
|
||||
|
||||
@Output() signOut = new EventEmitter<void>();
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
|
||||
import { MatLegacyMenuModule as MatMenuModule } from '@angular/material/legacy-menu';
|
||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { LoginWithAccessTokenDialogModule } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.module';
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatLegacyDialog as MatDialog } from '@angular/material/legacy-dialog';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
|
||||
import { ToggleComponent } from '@ghostfolio/client/components/toggle/toggle.component';
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
|
||||
import { MatLegacyCardModule as MatCardModule } from '@angular/material/legacy-card';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { GfPositionDetailDialogModule } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.module';
|
||||
import { GfPositionsModule } from '@ghostfolio/client/components/positions/positions.module';
|
||||
|
@ -1,4 +1,4 @@
|
||||
@import '~apps/client/src/styles/ghostfolio-style';
|
||||
@import 'apps/client/src/styles/ghostfolio-style';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
|
@ -27,7 +27,7 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
|
||||
public historicalDataItems: HistoricalDataItem[];
|
||||
public info: InfoItem;
|
||||
public isLoading = true;
|
||||
public readonly numberOfDays = 180;
|
||||
public readonly numberOfDays = 365;
|
||||
public user: User;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
@ -1,5 +1,5 @@
|
||||
<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="col-xs-12 col-md-8 offset-md-2">
|
||||
<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 {
|
||||
display: block;
|
||||
|
@ -110,13 +110,12 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
|
||||
range: this.user?.settings?.dateRange
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((response) => {
|
||||
this.errors = response.errors;
|
||||
this.hasError = response.hasErrors;
|
||||
this.performance = response.performance;
|
||||
.subscribe(({ chart, errors, performance }) => {
|
||||
this.errors = errors;
|
||||
this.performance = performance;
|
||||
this.isLoadingPerformance = false;
|
||||
|
||||
this.historicalDataItems = response.chart.map(
|
||||
this.historicalDataItems = chart.map(
|
||||
({ date, netPerformanceInPercentage }) => {
|
||||
return {
|
||||
date,
|
||||
|
@ -37,7 +37,6 @@
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[deviceType]="deviceType"
|
||||
[errors]="errors"
|
||||
[hasError]="hasError"
|
||||
[isAllTimeHigh]="isAllTimeHigh"
|
||||
[isAllTimeLow]="isAllTimeLow"
|
||||
[isLoading]="isLoadingPerformance"
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user