Compare commits
39 Commits
Author | SHA1 | Date | |
---|---|---|---|
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 |
73
CHANGELOG.md
73
CHANGELOG.md
@ -5,6 +5,79 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## 1.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
|
## 1.221.0 - 2022-12-26
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
75
README.md
75
README.md
@ -1,34 +1,26 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<a href="https://ghostfol.io">
|
|
||||||
<img
|
|
||||||
alt="Ghostfolio Logo"
|
|
||||||
src="https://avatars.githubusercontent.com/u/82473144?s=200"
|
|
||||||
width="100"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<h1>Ghostfolio</h1>
|
[<img src="https://avatars.githubusercontent.com/u/82473144?s=200" width="100" alt="Ghostfolio logo">](https://ghostfol.io)
|
||||||
<p>
|
|
||||||
<strong>Open Source Wealth Management Software</strong>
|
# Ghostfolio
|
||||||
</p>
|
|
||||||
<p>
|
**Open Source Wealth Management Software**
|
||||||
<a href="https://ghostfol.io"><strong>Ghostfol.io</strong></a> | <a href="https://ghostfol.io/en/demo"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/en/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/en/faq"><strong>FAQ</strong></a> | <a href="https://ghostfol.io/en/blog"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
|
|
||||||
</p>
|
[**Ghostfol.io**](https://ghostfol.io) | [**Live Demo**](https://ghostfol.io/en/demo) | [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) | [**FAQ**](https://ghostfol.io/en/faq) |
|
||||||
<p>
|
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**Twitter**](https://twitter.com/ghostfolio_)
|
||||||
<a href="https://www.buymeacoffee.com/ghostfolio">
|
|
||||||
<img src="https://img.shields.io/badge/Buy%20me%20a%20coffee-Support-yellow?logo=buymeacoffee"/></a>
|
[](https://www.buymeacoffee.com/ghostfolio)
|
||||||
<a href="#contributing">
|
[](#contributing)
|
||||||
<img src="https://img.shields.io/badge/Contributions-Welcome-orange.svg"/></a>
|
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||||
<a href="https://www.gnu.org/licenses/agpl-3.0" rel="nofollow">
|
|
||||||
<img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3"/></a>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. The software is designed for personal use in continuous operation.
|
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. The software is designed for personal use in continuous operation.
|
||||||
|
|
||||||
<div align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
|
<div align="center">
|
||||||
<a href="https://www.youtube.com/watch?v=yY6ObSQVJZk">
|
|
||||||
<img src="./apps/client/src/assets/images/video-preview.jpg" width="600"></a>
|
[<img src="./apps/client/src/assets/images/video-preview.jpg" width="600" alt="Preview image of the Ghostfolio video trailer">](https://www.youtube.com/watch?v=yY6ObSQVJZk)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## Ghostfolio Premium
|
## Ghostfolio Premium
|
||||||
@ -48,7 +40,7 @@ Ghostfolio is for you if you are...
|
|||||||
- 🧘 into minimalism
|
- 🧘 into minimalism
|
||||||
- 🧺 caring about diversifying your financial resources
|
- 🧺 caring about diversifying your financial resources
|
||||||
- 🆓 interested in financial independence
|
- 🆓 interested in financial independence
|
||||||
- 🙅 saying no to spreadsheets in 2022
|
- 🙅 saying no to spreadsheets in 2023
|
||||||
- 😎 still reading this list
|
- 😎 still reading this list
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
@ -63,8 +55,10 @@ Ghostfolio is for you if you are...
|
|||||||
- ✅ Zen Mode
|
- ✅ Zen Mode
|
||||||
- ✅ Progressive Web App (PWA) with a mobile-first design
|
- ✅ Progressive Web App (PWA) with a mobile-first design
|
||||||
|
|
||||||
<div align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
|
<div align="center">
|
||||||
<img src="./apps/client/src/assets/images/screenshot.png" width="300">
|
|
||||||
|
<img src="./apps/client/src/assets/images/screenshot.png" width="300" alt="Image of a phone showing the Ghostfolio app open">
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## Technology Stack
|
## Technology Stack
|
||||||
@ -84,13 +78,9 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
|
|||||||
We provide official container images hosted on [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio) for `linux/amd64` and `linux/arm64`.
|
We provide official container images hosted on [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio) for `linux/amd64` and `linux/arm64`.
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<a href="https://www.buymeacoffee.com/ghostfolio">
|
|
||||||
<img
|
[<img src="./apps/client/src/assets/images/button-buy-me-a-coffee.png" width="150" alt="Buy me a coffee button"/>](https://www.buymeacoffee.com/ghostfolio)
|
||||||
alt="Buy me a coffee button"
|
|
||||||
src="./apps/client/src/assets/images/button-buy-me-a-coffee.png"
|
|
||||||
width="150"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
### Supported Environment Variables
|
### Supported Environment Variables
|
||||||
@ -175,10 +165,13 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
|
|||||||
|
|
||||||
### Start Server
|
### Start Server
|
||||||
|
|
||||||
<ol type="a">
|
#### Debug
|
||||||
<li>Debug: Run <code>yarn watch:server</code> and click "Launch Program" in <a href="https://code.visualstudio.com">Visual Studio Code</a></li>
|
|
||||||
<li>Serve: Run <code>yarn start:server</code></li>
|
Run `yarn watch:server` and click _Launch Program_ in [Visual Studio Code](https://code.visualstudio.com)
|
||||||
</ol>
|
|
||||||
|
#### Serve
|
||||||
|
|
||||||
|
Run `yarn start:server`
|
||||||
|
|
||||||
### Start Client
|
### Start Client
|
||||||
|
|
||||||
@ -278,10 +271,10 @@ Ghostfolio is **100% free** and **open source**. We encourage and support an act
|
|||||||
|
|
||||||
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), tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you.
|
||||||
|
|
||||||
If you like to support this project, get **[Ghostfolio Premium](https://ghostfol.io/en/pricing)** or **[Buy me a coffee](https://www.buymeacoffee.com/ghostfolio)**.
|
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
© 2022 [Ghostfolio](https://ghostfol.io)
|
© 2023 [Ghostfolio](https://ghostfol.io)
|
||||||
|
|
||||||
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).
|
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).
|
||||||
|
@ -14,8 +14,10 @@ export class FrontendMiddleware implements NestMiddleware {
|
|||||||
public indexHtmlDe = '';
|
public indexHtmlDe = '';
|
||||||
public indexHtmlEn = '';
|
public indexHtmlEn = '';
|
||||||
public indexHtmlEs = '';
|
public indexHtmlEs = '';
|
||||||
|
public indexHtmlFr = '';
|
||||||
public indexHtmlIt = '';
|
public indexHtmlIt = '';
|
||||||
public indexHtmlNl = '';
|
public indexHtmlNl = '';
|
||||||
|
public indexHtmlPt = '';
|
||||||
public isProduction: boolean;
|
public isProduction: boolean;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
@ -41,6 +43,10 @@ export class FrontendMiddleware implements NestMiddleware {
|
|||||||
this.getPathOfIndexHtmlFile('es'),
|
this.getPathOfIndexHtmlFile('es'),
|
||||||
'utf8'
|
'utf8'
|
||||||
);
|
);
|
||||||
|
this.indexHtmlFr = fs.readFileSync(
|
||||||
|
this.getPathOfIndexHtmlFile('fr'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
this.indexHtmlIt = fs.readFileSync(
|
this.indexHtmlIt = fs.readFileSync(
|
||||||
this.getPathOfIndexHtmlFile('it'),
|
this.getPathOfIndexHtmlFile('it'),
|
||||||
'utf8'
|
'utf8'
|
||||||
@ -49,6 +55,10 @@ export class FrontendMiddleware implements NestMiddleware {
|
|||||||
this.getPathOfIndexHtmlFile('nl'),
|
this.getPathOfIndexHtmlFile('nl'),
|
||||||
'utf8'
|
'utf8'
|
||||||
);
|
);
|
||||||
|
this.indexHtmlPt = fs.readFileSync(
|
||||||
|
this.getPathOfIndexHtmlFile('pt'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -104,6 +114,15 @@ export class FrontendMiddleware implements NestMiddleware {
|
|||||||
rootUrl: this.configurationService.get('ROOT_URL')
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
} else if (request.path === '/fr' || request.path.startsWith('/fr/')) {
|
||||||
|
response.send(
|
||||||
|
this.interpolate(this.indexHtmlFr, {
|
||||||
|
featureGraphicPath,
|
||||||
|
languageCode: 'fr',
|
||||||
|
path: request.path,
|
||||||
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
|
})
|
||||||
|
);
|
||||||
} else if (request.path === '/it' || request.path.startsWith('/it/')) {
|
} else if (request.path === '/it' || request.path.startsWith('/it/')) {
|
||||||
response.send(
|
response.send(
|
||||||
this.interpolate(this.indexHtmlIt, {
|
this.interpolate(this.indexHtmlIt, {
|
||||||
@ -126,6 +145,15 @@ export class FrontendMiddleware implements NestMiddleware {
|
|||||||
rootUrl: this.configurationService.get('ROOT_URL')
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
} else if (request.path === '/pt' || request.path.startsWith('/pt/')) {
|
||||||
|
response.send(
|
||||||
|
this.interpolate(this.indexHtmlPt, {
|
||||||
|
featureGraphicPath,
|
||||||
|
languageCode: 'pt',
|
||||||
|
path: request.path,
|
||||||
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
|
})
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
response.send(
|
response.send(
|
||||||
this.interpolate(this.indexHtmlEn, {
|
this.interpolate(this.indexHtmlEn, {
|
||||||
|
@ -1,19 +1,26 @@
|
|||||||
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { ImportResponse } from '@ghostfolio/common/interfaces';
|
import { ImportResponse } from '@ghostfolio/common/interfaces';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
Get,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
Logger,
|
Logger,
|
||||||
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
UseGuards
|
UseGuards,
|
||||||
|
UseInterceptors
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
import { isEmpty } from 'lodash';
|
||||||
|
|
||||||
import { ImportDataDto } from './import-data.dto';
|
import { ImportDataDto } from './import-data.dto';
|
||||||
import { ImportService } from './import.service';
|
import { ImportService } from './import.service';
|
||||||
@ -74,4 +81,23 @@ export class ImportController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('dividends/:dataSource/:symbol')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
|
public async gatherDividends(
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
): Promise<ImportResponse> {
|
||||||
|
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||||
|
|
||||||
|
const activities = await this.importService.getDividends({
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
userCurrency
|
||||||
|
});
|
||||||
|
|
||||||
|
return { activities };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,12 +1,14 @@
|
|||||||
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
|
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
|
||||||
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
||||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||||
|
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { ImportController } from './import.controller';
|
import { ImportController } from './import.controller';
|
||||||
@ -22,8 +24,10 @@ import { ImportService } from './import.service';
|
|||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
OrderModule,
|
OrderModule,
|
||||||
|
PortfolioModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
RedisCacheModule
|
RedisCacheModule,
|
||||||
|
SymbolProfileModule
|
||||||
],
|
],
|
||||||
providers: [ImportService]
|
providers: [ImportService]
|
||||||
})
|
})
|
||||||
|
@ -2,10 +2,18 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
|||||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||||
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
|
import {
|
||||||
|
AccountWithPlatform,
|
||||||
|
OrderWithAccount
|
||||||
|
} from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { SymbolProfile } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
import { endOfToday, isAfter, isSameDay, parseISO } from 'date-fns';
|
import { endOfToday, isAfter, isSameDay, parseISO } from 'date-fns';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
@ -16,9 +24,81 @@ export class ImportService {
|
|||||||
private readonly accountService: AccountService,
|
private readonly accountService: AccountService,
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly orderService: OrderService
|
private readonly orderService: OrderService,
|
||||||
|
private readonly portfolioService: PortfolioService,
|
||||||
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
public async getDividends({
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
userCurrency
|
||||||
|
}: UniqueAsset & { userCurrency: string }): Promise<Activity[]> {
|
||||||
|
try {
|
||||||
|
const { firstBuyDate, historicalData, orders } =
|
||||||
|
await this.portfolioService.getPosition(dataSource, undefined, symbol);
|
||||||
|
|
||||||
|
const [[assetProfile], dividends] = await Promise.all([
|
||||||
|
this.symbolProfileService.getSymbolProfiles([
|
||||||
|
{
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}
|
||||||
|
]),
|
||||||
|
await this.dataProviderService.getDividends({
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
from: parseDate(firstBuyDate),
|
||||||
|
granularity: 'day',
|
||||||
|
to: new Date()
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
const accounts = orders.map((order) => {
|
||||||
|
return order.Account;
|
||||||
|
});
|
||||||
|
|
||||||
|
const Account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;
|
||||||
|
|
||||||
|
return Object.entries(dividends).map(([dateString, { marketPrice }]) => {
|
||||||
|
const quantity =
|
||||||
|
historicalData.find((historicalDataItem) => {
|
||||||
|
return historicalDataItem.date === dateString;
|
||||||
|
})?.quantity ?? 0;
|
||||||
|
|
||||||
|
const value = new Big(quantity).mul(marketPrice).toNumber();
|
||||||
|
|
||||||
|
return {
|
||||||
|
Account,
|
||||||
|
quantity,
|
||||||
|
value,
|
||||||
|
accountId: Account?.id,
|
||||||
|
accountUserId: undefined,
|
||||||
|
comment: undefined,
|
||||||
|
createdAt: undefined,
|
||||||
|
date: parseDate(dateString),
|
||||||
|
fee: 0,
|
||||||
|
feeInBaseCurrency: 0,
|
||||||
|
id: assetProfile.id,
|
||||||
|
isDraft: false,
|
||||||
|
SymbolProfile: <SymbolProfile>(<unknown>assetProfile),
|
||||||
|
symbolProfileId: assetProfile.id,
|
||||||
|
type: 'DIVIDEND',
|
||||||
|
unitPrice: marketPrice,
|
||||||
|
updatedAt: undefined,
|
||||||
|
userId: Account?.userId,
|
||||||
|
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
|
value,
|
||||||
|
assetProfile.currency,
|
||||||
|
userCurrency
|
||||||
|
)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async import({
|
public async import({
|
||||||
activitiesDto,
|
activitiesDto,
|
||||||
isDryRun = false,
|
isDryRun = false,
|
||||||
@ -42,7 +122,7 @@ export class ImportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.validateActivities({
|
const assetProfiles = await this.validateActivities({
|
||||||
activitiesDto,
|
activitiesDto,
|
||||||
maxActivitiesToImport,
|
maxActivitiesToImport,
|
||||||
userId
|
userId
|
||||||
@ -104,7 +184,8 @@ export class ImportService {
|
|||||||
sectors: null,
|
sectors: null,
|
||||||
symbolMapping: null,
|
symbolMapping: null,
|
||||||
updatedAt: undefined,
|
updatedAt: undefined,
|
||||||
url: null
|
url: null,
|
||||||
|
...assetProfiles[symbol]
|
||||||
},
|
},
|
||||||
symbolProfileId: undefined,
|
symbolProfileId: undefined,
|
||||||
updatedAt: new Date()
|
updatedAt: new Date()
|
||||||
@ -159,6 +240,16 @@ export class ImportService {
|
|||||||
return activities;
|
return activities;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isUniqueAccount(accounts: AccountWithPlatform[]) {
|
||||||
|
const uniqueAccountIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const account of accounts) {
|
||||||
|
uniqueAccountIds.add(account.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return uniqueAccountIds.size === 1;
|
||||||
|
}
|
||||||
|
|
||||||
private async validateActivities({
|
private async validateActivities({
|
||||||
activitiesDto,
|
activitiesDto,
|
||||||
maxActivitiesToImport,
|
maxActivitiesToImport,
|
||||||
@ -172,6 +263,9 @@ export class ImportService {
|
|||||||
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
|
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const assetProfiles: {
|
||||||
|
[symbol: string]: Partial<SymbolProfile>;
|
||||||
|
} = {};
|
||||||
const existingActivities = await this.orderService.orders({
|
const existingActivities = await this.orderService.orders({
|
||||||
include: { SymbolProfile: true },
|
include: { SymbolProfile: true },
|
||||||
orderBy: { date: 'desc' },
|
orderBy: { date: 'desc' },
|
||||||
@ -200,22 +294,28 @@ export class ImportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (dataSource !== 'MANUAL') {
|
if (dataSource !== 'MANUAL') {
|
||||||
const quotes = await this.dataProviderService.getQuotes([
|
const assetProfile = (
|
||||||
|
await this.dataProviderService.getAssetProfiles([
|
||||||
{ dataSource, symbol }
|
{ dataSource, symbol }
|
||||||
]);
|
])
|
||||||
|
)?.[symbol];
|
||||||
|
|
||||||
if (quotes[symbol] === undefined) {
|
if (assetProfile === undefined) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
|
`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(
|
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 { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
||||||
import {
|
import {
|
||||||
DEMO_USER_ID,
|
DEMO_USER_ID,
|
||||||
|
PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
|
||||||
PROPERTY_IS_READ_ONLY_MODE,
|
PROPERTY_IS_READ_ONLY_MODE,
|
||||||
PROPERTY_IS_USER_SIGNUP_ENABLED,
|
|
||||||
PROPERTY_SLACK_COMMUNITY_USERS,
|
PROPERTY_SLACK_COMMUNITY_USERS,
|
||||||
PROPERTY_STRIPE_CONFIG,
|
PROPERTY_STRIPE_CONFIG,
|
||||||
PROPERTY_SYSTEM_MESSAGE,
|
PROPERTY_SYSTEM_MESSAGE,
|
||||||
@ -93,6 +93,10 @@ export class InfoService {
|
|||||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
globalPermissions.push(permissions.enableSubscription);
|
globalPermissions.push(permissions.enableSubscription);
|
||||||
|
|
||||||
|
info.countriesOfSubscribers =
|
||||||
|
((await this.propertyService.getByKey(
|
||||||
|
PROPERTY_COUNTRIES_OF_SUBSCRIBERS
|
||||||
|
)) as string[]) ?? [];
|
||||||
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
|
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -303,14 +307,14 @@ export class InfoService {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stripeConfig = await this.prismaService.property.findUnique({
|
let subscriptions: Subscription[] = [];
|
||||||
|
|
||||||
|
const stripeConfig = (await this.prismaService.property.findUnique({
|
||||||
where: { key: PROPERTY_STRIPE_CONFIG }
|
where: { key: PROPERTY_STRIPE_CONFIG }
|
||||||
});
|
})) ?? { value: '{}' };
|
||||||
|
|
||||||
if (stripeConfig) {
|
subscriptions = [JSON.parse(stripeConfig.value)];
|
||||||
return [JSON.parse(stripeConfig.value)];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
return subscriptions;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -64,7 +64,8 @@ describe('PortfolioCalculator', () => {
|
|||||||
|
|
||||||
const investments = portfolioCalculator.getInvestments();
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
const investmentsByMonth =
|
||||||
|
portfolioCalculator.getInvestmentsByGroup('month');
|
||||||
|
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
|
@ -53,7 +53,8 @@ describe('PortfolioCalculator', () => {
|
|||||||
|
|
||||||
const investments = portfolioCalculator.getInvestments();
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
const investmentsByMonth =
|
||||||
|
portfolioCalculator.getInvestmentsByGroup('month');
|
||||||
|
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
|
@ -64,7 +64,8 @@ describe('PortfolioCalculator', () => {
|
|||||||
|
|
||||||
const investments = portfolioCalculator.getInvestments();
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
const investmentsByMonth =
|
||||||
|
portfolioCalculator.getInvestmentsByGroup('month');
|
||||||
|
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
|
@ -41,7 +41,8 @@ describe('PortfolioCalculator', () => {
|
|||||||
|
|
||||||
const investments = portfolioCalculator.getInvestments();
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
const investmentsByMonth =
|
||||||
|
portfolioCalculator.getInvestmentsByGroup('month');
|
||||||
|
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
|
@ -64,7 +64,8 @@ describe('PortfolioCalculator', () => {
|
|||||||
|
|
||||||
const investments = portfolioCalculator.getInvestments();
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
const investmentsByMonth =
|
||||||
|
portfolioCalculator.getInvestmentsByGroup('month');
|
||||||
|
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
|
@ -68,7 +68,8 @@ describe('PortfolioCalculator', () => {
|
|||||||
|
|
||||||
const investments = portfolioCalculator.getInvestments();
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
const investmentsByMonth =
|
||||||
|
portfolioCalculator.getInvestmentsByGroup('month');
|
||||||
|
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
|
@ -2,6 +2,7 @@ import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/
|
|||||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||||
import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
|
import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
|
||||||
|
import { GroupBy } from '@ghostfolio/common/types';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { Type as TypeOfOrder } from '@prisma/client';
|
import { Type as TypeOfOrder } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
@ -478,46 +479,60 @@ export class PortfolioCalculator {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public getInvestmentsByMonth(): { date: string; investment: Big }[] {
|
public getInvestmentsByGroup(
|
||||||
|
groupBy: GroupBy
|
||||||
|
): { date: string; investment: Big }[] {
|
||||||
if (this.orders.length === 0) {
|
if (this.orders.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const investments = [];
|
const investments = [];
|
||||||
let currentDate: Date;
|
let currentDate: Date;
|
||||||
let investmentByMonth = new Big(0);
|
let investmentByGroup = new Big(0);
|
||||||
|
|
||||||
for (const [index, order] of this.orders.entries()) {
|
for (const [index, order] of this.orders.entries()) {
|
||||||
if (
|
if (
|
||||||
isSameMonth(parseDate(order.date), currentDate) &&
|
isSameYear(parseDate(order.date), currentDate) &&
|
||||||
isSameYear(parseDate(order.date), currentDate)
|
(groupBy === 'year' || isSameMonth(parseDate(order.date), currentDate))
|
||||||
) {
|
) {
|
||||||
// Same month: Add up investments
|
// Same group: Add up investments
|
||||||
|
|
||||||
investmentByMonth = investmentByMonth.plus(
|
investmentByGroup = investmentByGroup.plus(
|
||||||
order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
|
order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// New month: Store previous month and reset
|
// New group: Store previous group and reset
|
||||||
|
|
||||||
if (currentDate) {
|
if (currentDate) {
|
||||||
investments.push({
|
investments.push({
|
||||||
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
|
date: format(
|
||||||
investment: investmentByMonth
|
set(currentDate, {
|
||||||
|
date: 1,
|
||||||
|
month: groupBy === 'year' ? 0 : currentDate.getMonth()
|
||||||
|
}),
|
||||||
|
DATE_FORMAT
|
||||||
|
),
|
||||||
|
investment: investmentByGroup
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
currentDate = parseDate(order.date);
|
currentDate = parseDate(order.date);
|
||||||
investmentByMonth = order.quantity
|
investmentByGroup = order.quantity
|
||||||
.mul(order.unitPrice)
|
.mul(order.unitPrice)
|
||||||
.mul(this.getFactor(order.type));
|
.mul(this.getFactor(order.type));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (index === this.orders.length - 1) {
|
if (index === this.orders.length - 1) {
|
||||||
// Store current month (latest order)
|
// Store current group (latest order)
|
||||||
investments.push({
|
investments.push({
|
||||||
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
|
date: format(
|
||||||
investment: investmentByMonth
|
set(currentDate, {
|
||||||
|
date: 1,
|
||||||
|
month: groupBy === 'year' ? 0 : currentDate.getMonth()
|
||||||
|
}),
|
||||||
|
DATE_FORMAT
|
||||||
|
),
|
||||||
|
investment: investmentByGroup
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -18,7 +18,6 @@ import {
|
|||||||
PortfolioPublicDetails,
|
PortfolioPublicDetails,
|
||||||
PortfolioReport
|
PortfolioReport
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
|
||||||
import type {
|
import type {
|
||||||
DateRange,
|
DateRange,
|
||||||
GroupBy,
|
GroupBy,
|
||||||
@ -190,23 +189,24 @@ export class PortfolioController {
|
|||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getDividends(
|
public async getDividends(
|
||||||
@Headers('impersonation-id') impersonationId: string,
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
|
@Query('accounts') filterByAccounts?: string,
|
||||||
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
|
@Query('groupBy') groupBy?: GroupBy,
|
||||||
@Query('range') dateRange: DateRange = 'max',
|
@Query('range') dateRange: DateRange = 'max',
|
||||||
@Query('groupBy') groupBy?: GroupBy
|
@Query('tags') filterByTags?: string
|
||||||
): Promise<PortfolioDividends> {
|
): Promise<PortfolioDividends> {
|
||||||
let dividends: InvestmentItem[];
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
|
filterByAccounts,
|
||||||
|
filterByAssetClasses,
|
||||||
|
filterByTags
|
||||||
|
});
|
||||||
|
|
||||||
if (groupBy === 'month') {
|
let dividends = await this.portfolioService.getDividends({
|
||||||
dividends = await this.portfolioService.getDividends({
|
|
||||||
dateRange,
|
dateRange,
|
||||||
|
filters,
|
||||||
groupBy,
|
groupBy,
|
||||||
impersonationId
|
impersonationId
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
dividends = await this.portfolioService.getDividends({
|
|
||||||
dateRange,
|
|
||||||
impersonationId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationId ||
|
impersonationId ||
|
||||||
@ -239,23 +239,24 @@ export class PortfolioController {
|
|||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getInvestments(
|
public async getInvestments(
|
||||||
@Headers('impersonation-id') impersonationId: string,
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
|
@Query('accounts') filterByAccounts?: string,
|
||||||
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
|
@Query('groupBy') groupBy?: GroupBy,
|
||||||
@Query('range') dateRange: DateRange = 'max',
|
@Query('range') dateRange: DateRange = 'max',
|
||||||
@Query('groupBy') groupBy?: GroupBy
|
@Query('tags') filterByTags?: string
|
||||||
): Promise<PortfolioInvestments> {
|
): Promise<PortfolioInvestments> {
|
||||||
let investments: InvestmentItem[];
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
|
filterByAccounts,
|
||||||
|
filterByAssetClasses,
|
||||||
|
filterByTags
|
||||||
|
});
|
||||||
|
|
||||||
if (groupBy === 'month') {
|
let investments = await this.portfolioService.getInvestments({
|
||||||
investments = await this.portfolioService.getInvestments({
|
|
||||||
dateRange,
|
dateRange,
|
||||||
|
filters,
|
||||||
groupBy,
|
groupBy,
|
||||||
impersonationId
|
impersonationId
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
investments = await this.portfolioService.getInvestments({
|
|
||||||
dateRange,
|
|
||||||
impersonationId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationId ||
|
impersonationId ||
|
||||||
@ -290,10 +291,20 @@ export class PortfolioController {
|
|||||||
@Version('2')
|
@Version('2')
|
||||||
public async getPerformanceV2(
|
public async getPerformanceV2(
|
||||||
@Headers('impersonation-id') impersonationId: string,
|
@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> {
|
): Promise<PortfolioPerformanceResponse> {
|
||||||
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
|
filterByAccounts,
|
||||||
|
filterByAssetClasses,
|
||||||
|
filterByTags
|
||||||
|
});
|
||||||
|
|
||||||
const performanceInformation = await this.portfolioService.getPerformance({
|
const performanceInformation = await this.portfolioService.getPerformance({
|
||||||
dateRange,
|
dateRange,
|
||||||
|
filters,
|
||||||
impersonationId,
|
impersonationId,
|
||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
@ -348,12 +359,22 @@ export class PortfolioController {
|
|||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getPositions(
|
public async getPositions(
|
||||||
@Headers('impersonation-id') impersonationId: string,
|
@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> {
|
): Promise<PortfolioPositions> {
|
||||||
const result = await this.portfolioService.getPositions(
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
impersonationId,
|
filterByAccounts,
|
||||||
dateRange
|
filterByAssetClasses,
|
||||||
);
|
filterByTags
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await this.portfolioService.getPositions({
|
||||||
|
dateRange,
|
||||||
|
filters,
|
||||||
|
impersonationId
|
||||||
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationId ||
|
impersonationId ||
|
||||||
|
@ -210,16 +210,19 @@ export class PortfolioService {
|
|||||||
|
|
||||||
public async getDividends({
|
public async getDividends({
|
||||||
dateRange,
|
dateRange,
|
||||||
impersonationId,
|
filters,
|
||||||
groupBy
|
groupBy,
|
||||||
|
impersonationId
|
||||||
}: {
|
}: {
|
||||||
dateRange: DateRange;
|
dateRange: DateRange;
|
||||||
impersonationId: string;
|
filters?: Filter[];
|
||||||
groupBy?: GroupBy;
|
groupBy?: GroupBy;
|
||||||
|
impersonationId: string;
|
||||||
}): Promise<InvestmentItem[]> {
|
}): Promise<InvestmentItem[]> {
|
||||||
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||||
|
|
||||||
const activities = await this.orderService.getOrders({
|
const activities = await this.orderService.getOrders({
|
||||||
|
filters,
|
||||||
userId,
|
userId,
|
||||||
types: ['DIVIDEND'],
|
types: ['DIVIDEND'],
|
||||||
userCurrency: this.request.user.Settings.settings.baseCurrency
|
userCurrency: this.request.user.Settings.settings.baseCurrency
|
||||||
@ -232,8 +235,8 @@ export class PortfolioService {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
if (groupBy === 'month') {
|
if (groupBy) {
|
||||||
dividends = this.getDividendsByMonth(dividends);
|
dividends = this.getDividendsByGroup({ dividends, groupBy });
|
||||||
}
|
}
|
||||||
|
|
||||||
const startDate = this.getStartDate(
|
const startDate = this.getStartDate(
|
||||||
@ -248,17 +251,20 @@ export class PortfolioService {
|
|||||||
|
|
||||||
public async getInvestments({
|
public async getInvestments({
|
||||||
dateRange,
|
dateRange,
|
||||||
impersonationId,
|
filters,
|
||||||
groupBy
|
groupBy,
|
||||||
|
impersonationId
|
||||||
}: {
|
}: {
|
||||||
dateRange: DateRange;
|
dateRange: DateRange;
|
||||||
impersonationId: string;
|
filters?: Filter[];
|
||||||
groupBy?: GroupBy;
|
groupBy?: GroupBy;
|
||||||
|
impersonationId: string;
|
||||||
}): Promise<InvestmentItem[]> {
|
}): Promise<InvestmentItem[]> {
|
||||||
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||||
|
|
||||||
const { portfolioOrders, transactionPoints } =
|
const { portfolioOrders, transactionPoints } =
|
||||||
await this.getTransactionPoints({
|
await this.getTransactionPoints({
|
||||||
|
filters,
|
||||||
userId,
|
userId,
|
||||||
includeDrafts: true
|
includeDrafts: true
|
||||||
});
|
});
|
||||||
@ -276,26 +282,31 @@ export class PortfolioService {
|
|||||||
|
|
||||||
let investments: InvestmentItem[];
|
let investments: InvestmentItem[];
|
||||||
|
|
||||||
if (groupBy === 'month') {
|
if (groupBy) {
|
||||||
investments = portfolioCalculator.getInvestmentsByMonth().map((item) => {
|
investments = portfolioCalculator
|
||||||
|
.getInvestmentsByGroup(groupBy)
|
||||||
|
.map((item) => {
|
||||||
return {
|
return {
|
||||||
date: item.date,
|
date: item.date,
|
||||||
investment: item.investment.toNumber()
|
investment: item.investment.toNumber()
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add investment of current month
|
// Add investment of current group
|
||||||
const dateOfCurrentMonth = format(
|
const dateOfCurrentGroup = format(
|
||||||
set(new Date(), { date: 1 }),
|
set(new Date(), {
|
||||||
|
date: 1,
|
||||||
|
month: groupBy === 'year' ? 0 : new Date().getMonth()
|
||||||
|
}),
|
||||||
DATE_FORMAT
|
DATE_FORMAT
|
||||||
);
|
);
|
||||||
const investmentOfCurrentMonth = investments.filter(({ date }) => {
|
const investmentOfCurrentGroup = investments.filter(({ date }) => {
|
||||||
return date === dateOfCurrentMonth;
|
return date === dateOfCurrentGroup;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (investmentOfCurrentMonth.length <= 0) {
|
if (investmentOfCurrentGroup.length <= 0) {
|
||||||
investments.push({
|
investments.push({
|
||||||
date: dateOfCurrentMonth,
|
date: dateOfCurrentGroup,
|
||||||
investment: 0
|
investment: 0
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -343,11 +354,13 @@ export class PortfolioService {
|
|||||||
|
|
||||||
public async getChart({
|
public async getChart({
|
||||||
dateRange = 'max',
|
dateRange = 'max',
|
||||||
|
filters,
|
||||||
impersonationId,
|
impersonationId,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
userId
|
userId
|
||||||
}: {
|
}: {
|
||||||
dateRange?: DateRange;
|
dateRange?: DateRange;
|
||||||
|
filters?: Filter[];
|
||||||
impersonationId: string;
|
impersonationId: string;
|
||||||
userCurrency: string;
|
userCurrency: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
@ -356,6 +369,7 @@ export class PortfolioService {
|
|||||||
|
|
||||||
const { portfolioOrders, transactionPoints } =
|
const { portfolioOrders, transactionPoints } =
|
||||||
await this.getTransactionPoints({
|
await this.getTransactionPoints({
|
||||||
|
filters,
|
||||||
userId
|
userId
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -397,15 +411,15 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getDetails({
|
public async getDetails({
|
||||||
impersonationId,
|
|
||||||
dateRange = 'max',
|
dateRange = 'max',
|
||||||
filters,
|
filters,
|
||||||
|
impersonationId,
|
||||||
userId,
|
userId,
|
||||||
withExcludedAccounts = false
|
withExcludedAccounts = false
|
||||||
}: {
|
}: {
|
||||||
impersonationId: string;
|
|
||||||
dateRange?: DateRange;
|
dateRange?: DateRange;
|
||||||
filters?: Filter[];
|
filters?: Filter[];
|
||||||
|
impersonationId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
withExcludedAccounts?: boolean;
|
withExcludedAccounts?: boolean;
|
||||||
}): Promise<PortfolioDetails & { hasErrors: boolean }> {
|
}): Promise<PortfolioDetails & { hasErrors: boolean }> {
|
||||||
@ -646,8 +660,9 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const positionCurrency = orders[0].SymbolProfile.currency;
|
const positionCurrency = orders[0].SymbolProfile.currency;
|
||||||
const [SymbolProfile] =
|
const [SymbolProfile] = await this.symbolProfileService.getSymbolProfiles([
|
||||||
await this.symbolProfileService.getSymbolProfilesBySymbols([aSymbol]);
|
{ dataSource: aDataSource, symbol: aSymbol }
|
||||||
|
]);
|
||||||
|
|
||||||
const portfolioOrders: PortfolioOrder[] = orders
|
const portfolioOrders: PortfolioOrder[] = orders
|
||||||
.filter((order) => {
|
.filter((order) => {
|
||||||
@ -731,6 +746,7 @@ export class PortfolioService {
|
|||||||
historicalDataArray.push({
|
historicalDataArray.push({
|
||||||
averagePrice: orders[0].unitPrice,
|
averagePrice: orders[0].unitPrice,
|
||||||
date: firstBuyDate,
|
date: firstBuyDate,
|
||||||
|
quantity: orders[0].quantity,
|
||||||
value: orders[0].unitPrice
|
value: orders[0].unitPrice
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -747,6 +763,7 @@ export class PortfolioService {
|
|||||||
j++;
|
j++;
|
||||||
}
|
}
|
||||||
let currentAveragePrice = 0;
|
let currentAveragePrice = 0;
|
||||||
|
let currentQuantity = 0;
|
||||||
const currentSymbol = transactionPoints[j].items.find(
|
const currentSymbol = transactionPoints[j].items.find(
|
||||||
(item) => item.symbol === aSymbol
|
(item) => item.symbol === aSymbol
|
||||||
);
|
);
|
||||||
@ -754,11 +771,13 @@ export class PortfolioService {
|
|||||||
currentAveragePrice = currentSymbol.quantity.eq(0)
|
currentAveragePrice = currentSymbol.quantity.eq(0)
|
||||||
? 0
|
? 0
|
||||||
: currentSymbol.investment.div(currentSymbol.quantity).toNumber();
|
: currentSymbol.investment.div(currentSymbol.quantity).toNumber();
|
||||||
|
currentQuantity = currentSymbol.quantity.toNumber();
|
||||||
}
|
}
|
||||||
|
|
||||||
historicalDataArray.push({
|
historicalDataArray.push({
|
||||||
date,
|
date,
|
||||||
averagePrice: currentAveragePrice,
|
averagePrice: currentAveragePrice,
|
||||||
|
quantity: currentQuantity,
|
||||||
value: marketPrice
|
value: marketPrice
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -850,14 +869,20 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getPositions(
|
public async getPositions({
|
||||||
aImpersonationId: string,
|
dateRange = 'max',
|
||||||
aDateRange: DateRange = 'max'
|
filters,
|
||||||
): Promise<{ hasErrors: boolean; positions: Position[] }> {
|
impersonationId
|
||||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
}: {
|
||||||
|
dateRange?: DateRange;
|
||||||
|
filters?: Filter[];
|
||||||
|
impersonationId: string;
|
||||||
|
}): Promise<{ hasErrors: boolean; positions: Position[] }> {
|
||||||
|
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||||
|
|
||||||
const { portfolioOrders, transactionPoints } =
|
const { portfolioOrders, transactionPoints } =
|
||||||
await this.getTransactionPoints({
|
await this.getTransactionPoints({
|
||||||
|
filters,
|
||||||
userId
|
userId
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -877,7 +902,7 @@ export class PortfolioService {
|
|||||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||||
|
|
||||||
const portfolioStart = parseDate(transactionPoints[0].date);
|
const portfolioStart = parseDate(transactionPoints[0].date);
|
||||||
const startDate = this.getStartDate(aDateRange, portfolioStart);
|
const startDate = this.getStartDate(dateRange, portfolioStart);
|
||||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||||
startDate
|
startDate
|
||||||
);
|
);
|
||||||
@ -885,12 +910,14 @@ export class PortfolioService {
|
|||||||
const positions = currentPositions.positions.filter(
|
const positions = currentPositions.positions.filter(
|
||||||
(item) => !item.quantity.eq(0)
|
(item) => !item.quantity.eq(0)
|
||||||
);
|
);
|
||||||
|
|
||||||
const dataGatheringItem = positions.map((position) => {
|
const dataGatheringItem = positions.map((position) => {
|
||||||
return {
|
return {
|
||||||
dataSource: position.dataSource,
|
dataSource: position.dataSource,
|
||||||
symbol: position.symbol
|
symbol: position.symbol
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const symbols = positions.map((position) => position.symbol);
|
const symbols = positions.map((position) => position.symbol);
|
||||||
|
|
||||||
const [dataProviderResponses, symbolProfiles] = await Promise.all([
|
const [dataProviderResponses, symbolProfiles] = await Promise.all([
|
||||||
@ -928,10 +955,12 @@ export class PortfolioService {
|
|||||||
|
|
||||||
public async getPerformance({
|
public async getPerformance({
|
||||||
dateRange = 'max',
|
dateRange = 'max',
|
||||||
|
filters,
|
||||||
impersonationId,
|
impersonationId,
|
||||||
userId
|
userId
|
||||||
}: {
|
}: {
|
||||||
dateRange?: DateRange;
|
dateRange?: DateRange;
|
||||||
|
filters?: Filter[];
|
||||||
impersonationId: string;
|
impersonationId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
}): Promise<PortfolioPerformanceResponse> {
|
}): Promise<PortfolioPerformanceResponse> {
|
||||||
@ -941,6 +970,7 @@ export class PortfolioService {
|
|||||||
|
|
||||||
const { portfolioOrders, transactionPoints } =
|
const { portfolioOrders, transactionPoints } =
|
||||||
await this.getTransactionPoints({
|
await this.getTransactionPoints({
|
||||||
|
filters,
|
||||||
userId
|
userId
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -996,6 +1026,7 @@ export class PortfolioService {
|
|||||||
|
|
||||||
const historicalDataContainer = await this.getChart({
|
const historicalDataContainer = await this.getChart({
|
||||||
dateRange,
|
dateRange,
|
||||||
|
filters,
|
||||||
impersonationId,
|
impersonationId,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
userId
|
userId
|
||||||
@ -1074,16 +1105,23 @@ export class PortfolioService {
|
|||||||
portfolioStart
|
portfolioStart
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const positions = currentPositions.positions.filter(
|
||||||
|
(item) => !item.quantity.eq(0)
|
||||||
|
);
|
||||||
|
|
||||||
const portfolioItemsNow: { [symbol: string]: TimelinePosition } = {};
|
const portfolioItemsNow: { [symbol: string]: TimelinePosition } = {};
|
||||||
for (const position of currentPositions.positions) {
|
|
||||||
|
for (const position of positions) {
|
||||||
portfolioItemsNow[position.symbol] = position;
|
portfolioItemsNow[position.symbol] = position;
|
||||||
}
|
}
|
||||||
|
|
||||||
const accounts = await this.getValueOfAccounts({
|
const accounts = await this.getValueOfAccounts({
|
||||||
orders,
|
orders,
|
||||||
portfolioItemsNow,
|
portfolioItemsNow,
|
||||||
userId,
|
userCurrency,
|
||||||
userCurrency
|
userId
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rules: {
|
rules: {
|
||||||
accountClusterRisk: await this.rulesService.evaluate(
|
accountClusterRisk: await this.rulesService.evaluate(
|
||||||
@ -1107,19 +1145,19 @@ export class PortfolioService {
|
|||||||
[
|
[
|
||||||
new CurrencyClusterRiskBaseCurrencyInitialInvestment(
|
new CurrencyClusterRiskBaseCurrencyInitialInvestment(
|
||||||
this.exchangeRateDataService,
|
this.exchangeRateDataService,
|
||||||
currentPositions
|
positions
|
||||||
),
|
),
|
||||||
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
|
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
|
||||||
this.exchangeRateDataService,
|
this.exchangeRateDataService,
|
||||||
currentPositions
|
positions
|
||||||
),
|
),
|
||||||
new CurrencyClusterRiskInitialInvestment(
|
new CurrencyClusterRiskInitialInvestment(
|
||||||
this.exchangeRateDataService,
|
this.exchangeRateDataService,
|
||||||
currentPositions
|
positions
|
||||||
),
|
),
|
||||||
new CurrencyClusterRiskCurrentInvestment(
|
new CurrencyClusterRiskCurrentInvestment(
|
||||||
this.exchangeRateDataService,
|
this.exchangeRateDataService,
|
||||||
currentPositions
|
positions
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
<UserSettings>this.request.user.Settings.settings
|
<UserSettings>this.request.user.Settings.settings
|
||||||
@ -1245,47 +1283,66 @@ export class PortfolioService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getDividendsByMonth(aDividends: InvestmentItem[]): InvestmentItem[] {
|
private getDividendsByGroup({
|
||||||
if (aDividends.length === 0) {
|
dividends,
|
||||||
|
groupBy
|
||||||
|
}: {
|
||||||
|
dividends: InvestmentItem[];
|
||||||
|
groupBy: GroupBy;
|
||||||
|
}): InvestmentItem[] {
|
||||||
|
if (dividends.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const dividends = [];
|
const dividendsByGroup: InvestmentItem[] = [];
|
||||||
let currentDate: Date;
|
let currentDate: Date;
|
||||||
let investmentByMonth = new Big(0);
|
let investmentByGroup = new Big(0);
|
||||||
|
|
||||||
for (const [index, dividend] of aDividends.entries()) {
|
for (const [index, dividend] of dividends.entries()) {
|
||||||
if (
|
if (
|
||||||
isSameMonth(parseDate(dividend.date), currentDate) &&
|
isSameYear(parseDate(dividend.date), currentDate) &&
|
||||||
isSameYear(parseDate(dividend.date), currentDate)
|
(groupBy === 'year' ||
|
||||||
|
isSameMonth(parseDate(dividend.date), currentDate))
|
||||||
) {
|
) {
|
||||||
// Same month: Add up divididends
|
// Same group: Add up dividends
|
||||||
|
|
||||||
investmentByMonth = investmentByMonth.plus(dividend.investment);
|
investmentByGroup = investmentByGroup.plus(dividend.investment);
|
||||||
} else {
|
} else {
|
||||||
// New month: Store previous month and reset
|
// New group: Store previous group and reset
|
||||||
|
|
||||||
if (currentDate) {
|
if (currentDate) {
|
||||||
dividends.push({
|
dividendsByGroup.push({
|
||||||
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
|
date: format(
|
||||||
investment: investmentByMonth
|
set(currentDate, {
|
||||||
|
date: 1,
|
||||||
|
month: groupBy === 'year' ? 0 : currentDate.getMonth()
|
||||||
|
}),
|
||||||
|
DATE_FORMAT
|
||||||
|
),
|
||||||
|
investment: investmentByGroup.toNumber()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
currentDate = parseDate(dividend.date);
|
currentDate = parseDate(dividend.date);
|
||||||
investmentByMonth = new Big(dividend.investment);
|
investmentByGroup = new Big(dividend.investment);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (index === aDividends.length - 1) {
|
if (index === dividends.length - 1) {
|
||||||
// Store current month (latest order)
|
// Store current month (latest order)
|
||||||
dividends.push({
|
dividendsByGroup.push({
|
||||||
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
|
date: format(
|
||||||
investment: investmentByMonth
|
set(currentDate, {
|
||||||
|
date: 1,
|
||||||
|
month: groupBy === 'year' ? 0 : currentDate.getMonth()
|
||||||
|
}),
|
||||||
|
DATE_FORMAT
|
||||||
|
),
|
||||||
|
investment: investmentByGroup.toNumber()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return dividends;
|
return dividendsByGroup;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getFees({
|
private getFees({
|
||||||
@ -1634,7 +1691,7 @@ export class PortfolioService {
|
|||||||
for (const order of ordersByAccount) {
|
for (const order of ordersByAccount) {
|
||||||
let currentValueOfSymbolInBaseCurrency =
|
let currentValueOfSymbolInBaseCurrency =
|
||||||
order.quantity *
|
order.quantity *
|
||||||
portfolioItemsNow[order.SymbolProfile.symbol].marketPrice;
|
portfolioItemsNow[order.SymbolProfile.symbol]?.marketPrice ?? 0;
|
||||||
let originalValueOfSymbolInBaseCurrency =
|
let originalValueOfSymbolInBaseCurrency =
|
||||||
this.exchangeRateDataService.toCurrency(
|
this.exchangeRateDataService.toCurrency(
|
||||||
order.quantity * order.unitPrice,
|
order.quantity * order.unitPrice,
|
||||||
|
@ -63,6 +63,7 @@ export class SubscriptionController {
|
|||||||
|
|
||||||
await this.subscriptionService.createSubscription({
|
await this.subscriptionService.createSubscription({
|
||||||
duration: coupon.duration,
|
duration: coupon.duration,
|
||||||
|
price: 0,
|
||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1,6 +1,10 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.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 { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Subscription } from '@prisma/client';
|
import { Subscription } from '@prisma/client';
|
||||||
@ -70,13 +74,16 @@ export class SubscriptionService {
|
|||||||
|
|
||||||
public async createSubscription({
|
public async createSubscription({
|
||||||
duration = '1 year',
|
duration = '1 year',
|
||||||
|
price,
|
||||||
userId
|
userId
|
||||||
}: {
|
}: {
|
||||||
duration?: StringValue;
|
duration?: StringValue;
|
||||||
|
price: number;
|
||||||
userId: string;
|
userId: string;
|
||||||
}) {
|
}) {
|
||||||
await this.prismaService.subscription.create({
|
await this.prismaService.subscription.create({
|
||||||
data: {
|
data: {
|
||||||
|
price,
|
||||||
expiresAt: addMilliseconds(new Date(), ms(duration)),
|
expiresAt: addMilliseconds(new Date(), ms(duration)),
|
||||||
User: {
|
User: {
|
||||||
connect: {
|
connect: {
|
||||||
@ -93,7 +100,21 @@ export class SubscriptionService {
|
|||||||
aCheckoutSessionId
|
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, {
|
await this.stripe.customers.update(session.customer as string, {
|
||||||
description: session.client_reference_id
|
description: session.client_reference_id
|
||||||
|
@ -27,3 +27,40 @@ export function nullifyValuesInObjects<T>(aObjects: T[], keys: string[]): T[] {
|
|||||||
return nullifyValuesInObject(object, keys);
|
return nullifyValuesInObject(object, keys);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function redactAttributes({
|
||||||
|
object,
|
||||||
|
options
|
||||||
|
}: {
|
||||||
|
object: any;
|
||||||
|
options: { attribute: string; valueMap: { [key: string]: any } }[];
|
||||||
|
}): any {
|
||||||
|
if (!object || !options || !options.length) {
|
||||||
|
return object;
|
||||||
|
}
|
||||||
|
|
||||||
|
const redactedObject = cloneDeep(object);
|
||||||
|
|
||||||
|
for (const option of options) {
|
||||||
|
if (redactedObject.hasOwnProperty(option.attribute)) {
|
||||||
|
redactedObject[option.attribute] =
|
||||||
|
option.valueMap[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 (typeof redactedObject[property] === 'object') {
|
||||||
|
// Recursively call the function on the nested object
|
||||||
|
redactedObject[property] = redactAttributes({
|
||||||
|
options,
|
||||||
|
object: redactedObject[property]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return redactedObject;
|
||||||
|
}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { redactAttributes } from '@ghostfolio/api/helper/object.helper';
|
||||||
import { encodeDataSource } from '@ghostfolio/common/helper';
|
import { encodeDataSource } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
CallHandler,
|
CallHandler,
|
||||||
@ -5,7 +6,7 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NestInterceptor
|
NestInterceptor
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { isArray } from 'lodash';
|
import { DataSource } from '@prisma/client';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { map } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
|
|
||||||
@ -28,65 +29,25 @@ export class TransformDataSourceInResponseInterceptor<T>
|
|||||||
if (
|
if (
|
||||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true
|
||||||
) {
|
) {
|
||||||
if (data.activities) {
|
data = redactAttributes({
|
||||||
data.activities.map((activity) => {
|
options: [
|
||||||
activity.SymbolProfile.dataSource = encodeDataSource(
|
{
|
||||||
activity.SymbolProfile.dataSource
|
attribute: 'dataSource',
|
||||||
|
valueMap: Object.keys(DataSource).reduce(
|
||||||
|
(valueMap, dataSource) => {
|
||||||
|
valueMap[dataSource] = encodeDataSource(
|
||||||
|
DataSource[dataSource]
|
||||||
);
|
);
|
||||||
return activity;
|
return valueMap;
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
object: data
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isArray(data.benchmarks)) {
|
|
||||||
data.benchmarks.map((benchmark) => {
|
|
||||||
benchmark.dataSource = encodeDataSource(benchmark.dataSource);
|
|
||||||
return benchmark;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.dataSource) {
|
|
||||||
data.dataSource = encodeDataSource(data.dataSource);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.errors) {
|
|
||||||
for (const error of data.errors) {
|
|
||||||
if (error.dataSource) {
|
|
||||||
error.dataSource = encodeDataSource(error.dataSource);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.holdings) {
|
|
||||||
for (const symbol of Object.keys(data.holdings)) {
|
|
||||||
if (data.holdings[symbol].dataSource) {
|
|
||||||
data.holdings[symbol].dataSource = encodeDataSource(
|
|
||||||
data.holdings[symbol].dataSource
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.items) {
|
|
||||||
data.items.map((item) => {
|
|
||||||
item.dataSource = encodeDataSource(item.dataSource);
|
|
||||||
return item;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.positions) {
|
|
||||||
data.positions.map((position) => {
|
|
||||||
position.dataSource = encodeDataSource(position.dataSource);
|
|
||||||
return position;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.SymbolProfile) {
|
|
||||||
data.SymbolProfile.dataSource = encodeDataSource(
|
|
||||||
data.SymbolProfile.dataSource
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -1,14 +1,13 @@
|
|||||||
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
|
|
||||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { UserSettings } from '@ghostfolio/common/interfaces';
|
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
import { Rule } from '../../rule';
|
||||||
|
|
||||||
export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Settings> {
|
export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Settings> {
|
||||||
public constructor(
|
public constructor(
|
||||||
protected exchangeRateDataService: ExchangeRateDataService,
|
protected exchangeRateDataService: ExchangeRateDataService,
|
||||||
private currentPositions: CurrentPositions
|
private positions: TimelinePosition[]
|
||||||
) {
|
) {
|
||||||
super(exchangeRateDataService, {
|
super(exchangeRateDataService, {
|
||||||
name: 'Current Investment: Base Currency'
|
name: 'Current Investment: Base Currency'
|
||||||
@ -17,7 +16,7 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
|
|||||||
|
|
||||||
public evaluate(ruleSettings: Settings) {
|
public evaluate(ruleSettings: Settings) {
|
||||||
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
|
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
|
||||||
this.currentPositions.positions,
|
this.positions,
|
||||||
'currency',
|
'currency',
|
||||||
ruleSettings.baseCurrency
|
ruleSettings.baseCurrency
|
||||||
);
|
);
|
||||||
|
@ -1,14 +1,13 @@
|
|||||||
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
|
|
||||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { UserSettings } from '@ghostfolio/common/interfaces';
|
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
import { Rule } from '../../rule';
|
||||||
|
|
||||||
export class CurrencyClusterRiskBaseCurrencyInitialInvestment extends Rule<Settings> {
|
export class CurrencyClusterRiskBaseCurrencyInitialInvestment extends Rule<Settings> {
|
||||||
public constructor(
|
public constructor(
|
||||||
protected exchangeRateDataService: ExchangeRateDataService,
|
protected exchangeRateDataService: ExchangeRateDataService,
|
||||||
private currentPositions: CurrentPositions
|
private positions: TimelinePosition[]
|
||||||
) {
|
) {
|
||||||
super(exchangeRateDataService, {
|
super(exchangeRateDataService, {
|
||||||
name: 'Initial Investment: Base Currency'
|
name: 'Initial Investment: Base Currency'
|
||||||
@ -17,7 +16,7 @@ export class CurrencyClusterRiskBaseCurrencyInitialInvestment extends Rule<Setti
|
|||||||
|
|
||||||
public evaluate(ruleSettings: Settings) {
|
public evaluate(ruleSettings: Settings) {
|
||||||
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
|
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
|
||||||
this.currentPositions.positions,
|
this.positions,
|
||||||
'currency',
|
'currency',
|
||||||
ruleSettings.baseCurrency
|
ruleSettings.baseCurrency
|
||||||
);
|
);
|
||||||
|
@ -1,14 +1,13 @@
|
|||||||
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
|
|
||||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { UserSettings } from '@ghostfolio/common/interfaces';
|
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
import { Rule } from '../../rule';
|
||||||
|
|
||||||
export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||||
public constructor(
|
public constructor(
|
||||||
public exchangeRateDataService: ExchangeRateDataService,
|
protected exchangeRateDataService: ExchangeRateDataService,
|
||||||
private currentPositions: CurrentPositions
|
private positions: TimelinePosition[]
|
||||||
) {
|
) {
|
||||||
super(exchangeRateDataService, {
|
super(exchangeRateDataService, {
|
||||||
name: 'Current Investment'
|
name: 'Current Investment'
|
||||||
@ -17,7 +16,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
|||||||
|
|
||||||
public evaluate(ruleSettings: Settings) {
|
public evaluate(ruleSettings: Settings) {
|
||||||
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
|
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
|
||||||
this.currentPositions.positions,
|
this.positions,
|
||||||
'currency',
|
'currency',
|
||||||
ruleSettings.baseCurrency
|
ruleSettings.baseCurrency
|
||||||
);
|
);
|
||||||
|
@ -1,14 +1,13 @@
|
|||||||
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
|
|
||||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { UserSettings } from '@ghostfolio/common/interfaces';
|
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
import { Rule } from '../../rule';
|
||||||
|
|
||||||
export class CurrencyClusterRiskInitialInvestment extends Rule<Settings> {
|
export class CurrencyClusterRiskInitialInvestment extends Rule<Settings> {
|
||||||
public constructor(
|
public constructor(
|
||||||
protected exchangeRateDataService: ExchangeRateDataService,
|
protected exchangeRateDataService: ExchangeRateDataService,
|
||||||
private currentPositions: CurrentPositions
|
private positions: TimelinePosition[]
|
||||||
) {
|
) {
|
||||||
super(exchangeRateDataService, {
|
super(exchangeRateDataService, {
|
||||||
name: 'Initial Investment'
|
name: 'Initial Investment'
|
||||||
@ -17,7 +16,7 @@ export class CurrencyClusterRiskInitialInvestment extends Rule<Settings> {
|
|||||||
|
|
||||||
public evaluate(ruleSettings: Settings) {
|
public evaluate(ruleSettings: Settings) {
|
||||||
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
|
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
|
||||||
this.currentPositions.positions,
|
this.positions,
|
||||||
'currency',
|
'currency',
|
||||||
ruleSettings.baseCurrency
|
ruleSettings.baseCurrency
|
||||||
);
|
);
|
||||||
|
@ -11,6 +11,8 @@ import { TwitterBotService } from './twitter-bot/twitter-bot.service';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CronService {
|
export class CronService {
|
||||||
|
private static readonly EVERY_SUNDAY_AT_LUNCH_TIME = '0 12 * * 0';
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
@ -28,12 +30,12 @@ export class CronService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Cron(CronExpression.EVERY_DAY_AT_5PM)
|
@Cron(CronExpression.EVERY_DAY_AT_5PM)
|
||||||
public async runEveryDayAtFivePM() {
|
public async runEveryDayAtFivePm() {
|
||||||
this.twitterBotService.tweetFearAndGreedIndex();
|
this.twitterBotService.tweetFearAndGreedIndex();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Cron(CronExpression.EVERY_WEEKEND)
|
@Cron(CronService.EVERY_SUNDAY_AT_LUNCH_TIME)
|
||||||
public async runEveryWeekend() {
|
public async runEverySundayAtTwelvePm() {
|
||||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||||
|
|
||||||
for (const { dataSource, symbol } of uniqueAssets) {
|
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(
|
public async getHistorical(
|
||||||
aSymbol: string,
|
aSymbol: string,
|
||||||
aGranularity: Granularity = 'day',
|
aGranularity: Granularity = 'day',
|
||||||
|
@ -59,6 +59,10 @@ import { DataProviderService } from './data-provider.service';
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
exports: [DataProviderService, GhostfolioScraperApiService]
|
exports: [
|
||||||
|
DataProviderService,
|
||||||
|
GhostfolioScraperApiService,
|
||||||
|
YahooFinanceService
|
||||||
|
]
|
||||||
})
|
})
|
||||||
export class DataProviderModule {}
|
export class DataProviderModule {}
|
||||||
|
@ -23,6 +23,27 @@ export class DataProviderService {
|
|||||||
private readonly prismaService: PrismaService
|
private readonly prismaService: PrismaService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
public async getDividends({
|
||||||
|
dataSource,
|
||||||
|
from,
|
||||||
|
granularity = 'day',
|
||||||
|
symbol,
|
||||||
|
to
|
||||||
|
}: {
|
||||||
|
dataSource: DataSource;
|
||||||
|
from: Date;
|
||||||
|
granularity: Granularity;
|
||||||
|
symbol: string;
|
||||||
|
to: Date;
|
||||||
|
}) {
|
||||||
|
return this.getDataProvider(DataSource[dataSource]).getDividends({
|
||||||
|
from,
|
||||||
|
granularity,
|
||||||
|
symbol,
|
||||||
|
to
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public async getHistorical(
|
public async getHistorical(
|
||||||
aItems: IDataGatheringItem[],
|
aItems: IDataGatheringItem[],
|
||||||
aGranularity: Granularity = 'month',
|
aGranularity: Granularity = 'month',
|
||||||
|
@ -37,6 +37,20 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getDividends({
|
||||||
|
from,
|
||||||
|
granularity = 'day',
|
||||||
|
symbol,
|
||||||
|
to
|
||||||
|
}: {
|
||||||
|
from: Date;
|
||||||
|
granularity: Granularity;
|
||||||
|
symbol: string;
|
||||||
|
to: Date;
|
||||||
|
}) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
public async getHistorical(
|
public async getHistorical(
|
||||||
aSymbol: string,
|
aSymbol: string,
|
||||||
aGranularity: Granularity = 'day',
|
aGranularity: Granularity = 'day',
|
||||||
|
@ -37,6 +37,20 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getDividends({
|
||||||
|
from,
|
||||||
|
granularity = 'day',
|
||||||
|
symbol,
|
||||||
|
to
|
||||||
|
}: {
|
||||||
|
from: Date;
|
||||||
|
granularity: Granularity;
|
||||||
|
symbol: string;
|
||||||
|
to: Date;
|
||||||
|
}) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
public async getHistorical(
|
public async getHistorical(
|
||||||
aSymbol: string,
|
aSymbol: string,
|
||||||
aGranularity: Granularity = 'day',
|
aGranularity: Granularity = 'day',
|
||||||
|
@ -34,6 +34,20 @@ export class GoogleSheetsService implements DataProviderInterface {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getDividends({
|
||||||
|
from,
|
||||||
|
granularity = 'day',
|
||||||
|
symbol,
|
||||||
|
to
|
||||||
|
}: {
|
||||||
|
from: Date;
|
||||||
|
granularity: Granularity;
|
||||||
|
symbol: string;
|
||||||
|
to: Date;
|
||||||
|
}) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
public async getHistorical(
|
public async getHistorical(
|
||||||
aSymbol: string,
|
aSymbol: string,
|
||||||
aGranularity: Granularity = 'day',
|
aGranularity: Granularity = 'day',
|
||||||
|
@ -11,6 +11,18 @@ export interface DataProviderInterface {
|
|||||||
|
|
||||||
getAssetProfile(aSymbol: string): Promise<Partial<SymbolProfile>>;
|
getAssetProfile(aSymbol: string): Promise<Partial<SymbolProfile>>;
|
||||||
|
|
||||||
|
getDividends({
|
||||||
|
from,
|
||||||
|
granularity,
|
||||||
|
symbol,
|
||||||
|
to
|
||||||
|
}: {
|
||||||
|
from: Date;
|
||||||
|
granularity: Granularity;
|
||||||
|
symbol: string;
|
||||||
|
to: Date;
|
||||||
|
}): Promise<{ [date: string]: IDataProviderHistoricalResponse }>;
|
||||||
|
|
||||||
getHistorical(
|
getHistorical(
|
||||||
aSymbol: string,
|
aSymbol: string,
|
||||||
aGranularity: Granularity,
|
aGranularity: Granularity,
|
||||||
|
@ -29,6 +29,20 @@ export class ManualService implements DataProviderInterface {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getDividends({
|
||||||
|
from,
|
||||||
|
granularity = 'day',
|
||||||
|
symbol,
|
||||||
|
to
|
||||||
|
}: {
|
||||||
|
from: Date;
|
||||||
|
granularity: Granularity;
|
||||||
|
symbol: string;
|
||||||
|
to: Date;
|
||||||
|
}) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
public async getHistorical(
|
public async getHistorical(
|
||||||
aSymbol: string,
|
aSymbol: string,
|
||||||
aGranularity: Granularity = 'day',
|
aGranularity: Granularity = 'day',
|
||||||
|
@ -31,6 +31,20 @@ export class RapidApiService implements DataProviderInterface {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getDividends({
|
||||||
|
from,
|
||||||
|
granularity = 'day',
|
||||||
|
symbol,
|
||||||
|
to
|
||||||
|
}: {
|
||||||
|
from: Date;
|
||||||
|
granularity: Granularity;
|
||||||
|
symbol: string;
|
||||||
|
to: Date;
|
||||||
|
}) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
public async getHistorical(
|
public async getHistorical(
|
||||||
aSymbol: string,
|
aSymbol: string,
|
||||||
aGranularity: Granularity = 'day',
|
aGranularity: Granularity = 'day',
|
||||||
|
@ -154,16 +154,65 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
response.url = url;
|
response.url = url;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
Logger.error(error, 'YahooFinanceService');
|
||||||
`Could not get asset profile for ${aSymbol} (${this.getName()}): [${
|
|
||||||
error.name
|
|
||||||
}] ${error.message}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getDividends({
|
||||||
|
from,
|
||||||
|
granularity = 'day',
|
||||||
|
symbol,
|
||||||
|
to
|
||||||
|
}: {
|
||||||
|
from: Date;
|
||||||
|
granularity: Granularity;
|
||||||
|
symbol: string;
|
||||||
|
to: Date;
|
||||||
|
}) {
|
||||||
|
if (isSameDay(from, to)) {
|
||||||
|
to = addDays(to, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const historicalResult = await yahooFinance.historical(
|
||||||
|
this.convertToYahooFinanceSymbol(symbol),
|
||||||
|
{
|
||||||
|
events: 'dividends',
|
||||||
|
interval: granularity === 'month' ? '1mo' : '1d',
|
||||||
|
period1: format(from, DATE_FORMAT),
|
||||||
|
period2: format(to, DATE_FORMAT)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const response: {
|
||||||
|
[date: string]: IDataProviderHistoricalResponse;
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
for (const historicalItem of historicalResult) {
|
||||||
|
response[format(historicalItem.date, DATE_FORMAT)] = {
|
||||||
|
marketPrice: this.getConvertedValue({
|
||||||
|
symbol,
|
||||||
|
value: historicalItem.dividends
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(
|
||||||
|
`Could not get dividends for ${symbol} (${this.getName()}) from ${format(
|
||||||
|
from,
|
||||||
|
DATE_FORMAT
|
||||||
|
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`,
|
||||||
|
'YahooFinanceService'
|
||||||
|
);
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async getHistorical(
|
public async getHistorical(
|
||||||
aSymbol: string,
|
aSymbol: string,
|
||||||
aGranularity: Granularity = 'day',
|
aGranularity: Granularity = 'day',
|
||||||
@ -176,11 +225,9 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
to = addDays(to, 1);
|
to = addDays(to, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const yahooFinanceSymbol = this.convertToYahooFinanceSymbol(aSymbol);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const historicalResult = await yahooFinance.historical(
|
const historicalResult = await yahooFinance.historical(
|
||||||
yahooFinanceSymbol,
|
this.convertToYahooFinanceSymbol(aSymbol),
|
||||||
{
|
{
|
||||||
interval: '1d',
|
interval: '1d',
|
||||||
period1: format(from, DATE_FORMAT),
|
period1: format(from, DATE_FORMAT),
|
||||||
@ -192,27 +239,14 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
// Convert symbol back
|
response[aSymbol] = {};
|
||||||
const symbol = this.convertFromYahooFinanceSymbol(yahooFinanceSymbol);
|
|
||||||
|
|
||||||
response[symbol] = {};
|
|
||||||
|
|
||||||
for (const historicalItem of historicalResult) {
|
for (const historicalItem of historicalResult) {
|
||||||
let marketPrice = historicalItem.close;
|
response[aSymbol][format(historicalItem.date, DATE_FORMAT)] = {
|
||||||
|
marketPrice: this.getConvertedValue({
|
||||||
if (symbol === `${this.baseCurrency}GBp`) {
|
symbol: aSymbol,
|
||||||
// Convert GPB to GBp (pence)
|
value: historicalItem.close
|
||||||
marketPrice = new Big(marketPrice).mul(100).toNumber();
|
}),
|
||||||
} else if (symbol === `${this.baseCurrency}ILA`) {
|
|
||||||
// Convert ILS to ILA
|
|
||||||
marketPrice = new Big(marketPrice).mul(100).toNumber();
|
|
||||||
} else if (symbol === `${this.baseCurrency}ZAc`) {
|
|
||||||
// Convert ZAR to ZAc (cents)
|
|
||||||
marketPrice = new Big(marketPrice).mul(100).toNumber();
|
|
||||||
}
|
|
||||||
|
|
||||||
response[symbol][format(historicalItem.date, DATE_FORMAT)] = {
|
|
||||||
marketPrice,
|
|
||||||
performance: historicalItem.open - historicalItem.close
|
performance: historicalItem.open - historicalItem.close
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -427,6 +461,27 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
return name || shortName || symbol;
|
return name || shortName || symbol;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getConvertedValue({
|
||||||
|
symbol,
|
||||||
|
value
|
||||||
|
}: {
|
||||||
|
symbol: string;
|
||||||
|
value: number;
|
||||||
|
}) {
|
||||||
|
if (symbol === `${this.baseCurrency}GBp`) {
|
||||||
|
// Convert GPB to GBp (pence)
|
||||||
|
return new Big(value).mul(100).toNumber();
|
||||||
|
} else if (symbol === `${this.baseCurrency}ILA`) {
|
||||||
|
// Convert ILS to ILA
|
||||||
|
return new Big(value).mul(100).toNumber();
|
||||||
|
} else if (symbol === `${this.baseCurrency}ZAc`) {
|
||||||
|
// Convert ZAR to ZAc (cents)
|
||||||
|
return new Big(value).mul(100).toNumber();
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
private parseAssetClass(aPrice: Price): {
|
private parseAssetClass(aPrice: Price): {
|
||||||
assetClass: AssetClass;
|
assetClass: AssetClass;
|
||||||
assetSubClass: AssetSubClass;
|
assetSubClass: AssetSubClass;
|
||||||
|
@ -89,6 +89,10 @@
|
|||||||
"baseHref": "/es/",
|
"baseHref": "/es/",
|
||||||
"localize": ["es"]
|
"localize": ["es"]
|
||||||
},
|
},
|
||||||
|
"development-fr": {
|
||||||
|
"baseHref": "/fr/",
|
||||||
|
"localize": ["fr"]
|
||||||
|
},
|
||||||
"development-it": {
|
"development-it": {
|
||||||
"baseHref": "/it/",
|
"baseHref": "/it/",
|
||||||
"localize": ["it"]
|
"localize": ["it"]
|
||||||
@ -97,6 +101,10 @@
|
|||||||
"baseHref": "/nl/",
|
"baseHref": "/nl/",
|
||||||
"localize": ["nl"]
|
"localize": ["nl"]
|
||||||
},
|
},
|
||||||
|
"development-pt": {
|
||||||
|
"baseHref": "/pt/",
|
||||||
|
"localize": ["pt"]
|
||||||
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"fileReplacements": [
|
"fileReplacements": [
|
||||||
{
|
{
|
||||||
@ -144,12 +152,18 @@
|
|||||||
"development-es": {
|
"development-es": {
|
||||||
"browserTarget": "client:build:development-es"
|
"browserTarget": "client:build:development-es"
|
||||||
},
|
},
|
||||||
|
"development-fr": {
|
||||||
|
"browserTarget": "client:build:development-fr"
|
||||||
|
},
|
||||||
"development-it": {
|
"development-it": {
|
||||||
"browserTarget": "client:build:development-it"
|
"browserTarget": "client:build:development-it"
|
||||||
},
|
},
|
||||||
"development-nl": {
|
"development-nl": {
|
||||||
"browserTarget": "client:build:development-nl"
|
"browserTarget": "client:build:development-nl"
|
||||||
},
|
},
|
||||||
|
"development-pt": {
|
||||||
|
"browserTarget": "client:build:development-pt"
|
||||||
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"browserTarget": "client:build:production"
|
"browserTarget": "client:build:production"
|
||||||
}
|
}
|
||||||
@ -164,8 +178,10 @@
|
|||||||
"targetFiles": [
|
"targetFiles": [
|
||||||
"messages.de.xlf",
|
"messages.de.xlf",
|
||||||
"messages.es.xlf",
|
"messages.es.xlf",
|
||||||
|
"messages.fr.xlf",
|
||||||
"messages.it.xlf",
|
"messages.it.xlf",
|
||||||
"messages.nl.xlf"
|
"messages.nl.xlf",
|
||||||
|
"messages.pt.xlf"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -194,6 +210,10 @@
|
|||||||
"baseHref": "/es/",
|
"baseHref": "/es/",
|
||||||
"translation": "apps/client/src/locales/messages.es.xlf"
|
"translation": "apps/client/src/locales/messages.es.xlf"
|
||||||
},
|
},
|
||||||
|
"fr": {
|
||||||
|
"baseHref": "/fr/",
|
||||||
|
"translation": "apps/client/src/locales/messages.fr.xlf"
|
||||||
|
},
|
||||||
"it": {
|
"it": {
|
||||||
"baseHref": "/it/",
|
"baseHref": "/it/",
|
||||||
"translation": "apps/client/src/locales/messages.it.xlf"
|
"translation": "apps/client/src/locales/messages.it.xlf"
|
||||||
@ -201,6 +221,10 @@
|
|||||||
"nl": {
|
"nl": {
|
||||||
"baseHref": "/nl/",
|
"baseHref": "/nl/",
|
||||||
"translation": "apps/client/src/locales/messages.nl.xlf"
|
"translation": "apps/client/src/locales/messages.nl.xlf"
|
||||||
|
},
|
||||||
|
"pt": {
|
||||||
|
"baseHref": "/pt/",
|
||||||
|
"translation": "apps/client/src/locales/messages.pt.xlf"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sourceLocale": "en"
|
"sourceLocale": "en"
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
class="position-fixed w-100"
|
class="position-fixed w-100"
|
||||||
[currentRoute]="currentRoute"
|
[currentRoute]="currentRoute"
|
||||||
[info]="info"
|
[info]="info"
|
||||||
|
[pageTitle]="pageTitle"
|
||||||
[user]="user"
|
[user]="user"
|
||||||
(signOut)="onSignOut()"
|
(signOut)="onSignOut()"
|
||||||
></gf-header>
|
></gf-header>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
@import '~apps/client/src/styles/ghostfolio-style';
|
@import 'apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -5,7 +5,13 @@ import {
|
|||||||
OnDestroy,
|
OnDestroy,
|
||||||
OnInit
|
OnInit
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { NavigationEnd, PRIMARY_OUTLET, Router } from '@angular/router';
|
import { Title } from '@angular/platform-browser';
|
||||||
|
import {
|
||||||
|
ActivatedRoute,
|
||||||
|
NavigationEnd,
|
||||||
|
PRIMARY_OUTLET,
|
||||||
|
Router
|
||||||
|
} from '@angular/router';
|
||||||
import {
|
import {
|
||||||
primaryColorHex,
|
primaryColorHex,
|
||||||
secondaryColorHex,
|
secondaryColorHex,
|
||||||
@ -36,6 +42,7 @@ export class AppComponent implements OnDestroy, OnInit {
|
|||||||
public currentYear = new Date().getFullYear();
|
public currentYear = new Date().getFullYear();
|
||||||
public deviceType: string;
|
public deviceType: string;
|
||||||
public info: InfoItem;
|
public info: InfoItem;
|
||||||
|
public pageTitle: string;
|
||||||
public user: User;
|
public user: User;
|
||||||
public version = environment.version;
|
public version = environment.version;
|
||||||
|
|
||||||
@ -47,6 +54,7 @@ export class AppComponent implements OnDestroy, OnInit {
|
|||||||
private deviceService: DeviceDetectorService,
|
private deviceService: DeviceDetectorService,
|
||||||
private materialCssVarsService: MaterialCssVarsService,
|
private materialCssVarsService: MaterialCssVarsService,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
|
private title: Title,
|
||||||
private tokenStorageService: TokenStorageService,
|
private tokenStorageService: TokenStorageService,
|
||||||
private userService: UserService
|
private userService: UserService
|
||||||
) {
|
) {
|
||||||
@ -66,6 +74,19 @@ export class AppComponent implements OnDestroy, OnInit {
|
|||||||
this.currentRoute = urlSegments[0].path;
|
this.currentRoute = urlSegments[0].path;
|
||||||
|
|
||||||
this.info = this.dataService.fetchInfo();
|
this.info = this.dataService.fetchInfo();
|
||||||
|
|
||||||
|
if (this.deviceType === 'mobile') {
|
||||||
|
setTimeout(() => {
|
||||||
|
const index = this.title.getTitle().indexOf('–');
|
||||||
|
const title =
|
||||||
|
index === -1
|
||||||
|
? ''
|
||||||
|
: this.title.getTitle().substring(0, index).trim();
|
||||||
|
this.pageTitle = title.length <= 15 ? title : 'Ghostfolio';
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.userService.stateChanged
|
this.userService.stateChanged
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
@import '~apps/client/src/styles/ghostfolio-style';
|
@import 'apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -12,7 +12,7 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
|
|||||||
import { downloadAsFile } from '@ghostfolio/common/helper';
|
import { downloadAsFile } from '@ghostfolio/common/helper';
|
||||||
import { User } from '@ghostfolio/common/interfaces';
|
import { User } from '@ghostfolio/common/interfaces';
|
||||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||||
import { AccountType } from '@prisma/client';
|
import { translate } from '@ghostfolio/ui/i18n';
|
||||||
import { format, parseISO } from 'date-fns';
|
import { format, parseISO } from 'date-fns';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { takeUntil } from 'rxjs/operators';
|
import { takeUntil } from 'rxjs/operators';
|
||||||
@ -27,7 +27,7 @@ import { AccountDetailDialogParams } from './interfaces/interfaces';
|
|||||||
styleUrls: ['./account-detail-dialog.component.scss']
|
styleUrls: ['./account-detail-dialog.component.scss']
|
||||||
})
|
})
|
||||||
export class AccountDetailDialog implements OnDestroy, OnInit {
|
export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||||
public accountType: AccountType;
|
public accountType: string;
|
||||||
public name: string;
|
public name: string;
|
||||||
public orders: OrderWithAccount[];
|
public orders: OrderWithAccount[];
|
||||||
public platformName: string;
|
public platformName: string;
|
||||||
@ -59,7 +59,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
|||||||
.fetchAccount(this.data.accountId)
|
.fetchAccount(this.data.accountId)
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(({ accountType, name, Platform, valueInBaseCurrency }) => {
|
.subscribe(({ accountType, name, Platform, valueInBaseCurrency }) => {
|
||||||
this.accountType = accountType;
|
this.accountType = translate(accountType);
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.platformName = Platform?.name ?? '-';
|
this.platformName = Platform?.name ?? '-';
|
||||||
this.valueInBaseCurrency = valueInBaseCurrency;
|
this.valueInBaseCurrency = valueInBaseCurrency;
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
@import '~apps/client/src/styles/ghostfolio-style';
|
@import 'apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
@import '~apps/client/src/styles/ghostfolio-style';
|
@import 'apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
@import '~apps/client/src/styles/ghostfolio-style';
|
@import 'apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -20,7 +20,6 @@ import {
|
|||||||
addDays,
|
addDays,
|
||||||
format,
|
format,
|
||||||
isBefore,
|
isBefore,
|
||||||
isDate,
|
|
||||||
isSameDay,
|
isSameDay,
|
||||||
isToday,
|
isToday,
|
||||||
isValid,
|
isValid,
|
||||||
@ -31,6 +30,7 @@ import { last } from 'lodash';
|
|||||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||||
import { Subject, takeUntil } from 'rxjs';
|
import { Subject, takeUntil } from 'rxjs';
|
||||||
|
|
||||||
|
import { MarketDataDetailDialogParams } from './market-data-detail-dialog/interfaces/interfaces';
|
||||||
import { MarketDataDetailDialog } from './market-data-detail-dialog/market-data-detail-dialog.component';
|
import { MarketDataDetailDialog } from './market-data-detail-dialog/market-data-detail-dialog.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@ -40,6 +40,7 @@ import { MarketDataDetailDialog } from './market-data-detail-dialog/market-data-
|
|||||||
templateUrl: './admin-market-data-detail.component.html'
|
templateUrl: './admin-market-data-detail.component.html'
|
||||||
})
|
})
|
||||||
export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
|
export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
|
||||||
|
@Input() currency: string;
|
||||||
@Input() dataSource: DataSource;
|
@Input() dataSource: DataSource;
|
||||||
@Input() dateOfFirstActivity: string;
|
@Input() dateOfFirstActivity: string;
|
||||||
@Input() locale = getLocale();
|
@Input() locale = getLocale();
|
||||||
@ -161,9 +162,10 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dialogRef = this.dialog.open(MarketDataDetailDialog, {
|
const dialogRef = this.dialog.open(MarketDataDetailDialog, {
|
||||||
data: {
|
data: <MarketDataDetailDialogParams>{
|
||||||
date,
|
date,
|
||||||
marketPrice,
|
marketPrice,
|
||||||
|
currency: this.currency,
|
||||||
dataSource: this.dataSource,
|
dataSource: this.dataSource,
|
||||||
symbol: this.symbol,
|
symbol: this.symbol,
|
||||||
user: this.user
|
user: this.user
|
||||||
|
@ -2,6 +2,7 @@ import { User } from '@ghostfolio/common/interfaces';
|
|||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
|
|
||||||
export interface MarketDataDetailDialogParams {
|
export interface MarketDataDetailDialogParams {
|
||||||
|
currency: string;
|
||||||
dataSource: DataSource;
|
dataSource: DataSource;
|
||||||
date: Date;
|
date: Date;
|
||||||
marketPrice: number;
|
marketPrice: number;
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<form class="d-flex flex-column h-100">
|
<form class="d-flex flex-column h-100">
|
||||||
<h1 mat-dialog-title i18n>Details for {{ data.symbol }}</h1>
|
<h1 i18n mat-dialog-title>Details for {{ data.symbol }}</h1>
|
||||||
<div class="flex-grow-1" mat-dialog-content>
|
<div class="flex-grow-1" mat-dialog-content>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<mat-form-field appearance="outline" class="w-100">
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
@ -30,6 +30,7 @@
|
|||||||
type="number"
|
type="number"
|
||||||
[(ngModel)]="data.marketPrice"
|
[(ngModel)]="data.marketPrice"
|
||||||
/>
|
/>
|
||||||
|
<span class="ml-2" matSuffix>{{ data.currency }}</span>
|
||||||
<button
|
<button
|
||||||
mat-icon-button
|
mat-icon-button
|
||||||
matSuffix
|
matSuffix
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
@import '~apps/client/src/styles/ghostfolio-style';
|
@import 'apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -14,6 +14,7 @@ import {
|
|||||||
EnhancedSymbolProfile,
|
EnhancedSymbolProfile,
|
||||||
UniqueAsset
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
|
import { translate } from '@ghostfolio/ui/i18n';
|
||||||
import { MarketData } from '@prisma/client';
|
import { MarketData } from '@prisma/client';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { takeUntil } from 'rxjs/operators';
|
import { takeUntil } from 'rxjs/operators';
|
||||||
@ -28,11 +29,13 @@ import { AssetProfileDialogParams } from './interfaces/interfaces';
|
|||||||
styleUrls: ['./asset-profile-dialog.component.scss']
|
styleUrls: ['./asset-profile-dialog.component.scss']
|
||||||
})
|
})
|
||||||
export class AssetProfileDialog implements OnDestroy, OnInit {
|
export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||||
|
public assetClass: string;
|
||||||
public assetProfile: EnhancedSymbolProfile;
|
public assetProfile: EnhancedSymbolProfile;
|
||||||
public assetProfileForm = this.formBuilder.group({
|
public assetProfileForm = this.formBuilder.group({
|
||||||
comment: '',
|
comment: '',
|
||||||
symbolMapping: ''
|
symbolMapping: ''
|
||||||
});
|
});
|
||||||
|
public assetSubClass: string;
|
||||||
public countries: {
|
public countries: {
|
||||||
[code: string]: { name: string; value: number };
|
[code: string]: { name: string; value: number };
|
||||||
};
|
};
|
||||||
@ -64,6 +67,9 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
|||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(({ assetProfile, marketData }) => {
|
.subscribe(({ assetProfile, marketData }) => {
|
||||||
this.assetProfile = assetProfile;
|
this.assetProfile = assetProfile;
|
||||||
|
|
||||||
|
this.assetClass = translate(this.assetProfile?.assetClass);
|
||||||
|
this.assetSubClass = translate(this.assetProfile?.assetSubClass);
|
||||||
this.countries = {};
|
this.countries = {};
|
||||||
this.marketDataDetails = marketData;
|
this.marketDataDetails = marketData;
|
||||||
this.sectors = {};
|
this.sectors = {};
|
||||||
|
@ -43,6 +43,7 @@
|
|||||||
<div class="flex-grow-1" mat-dialog-content>
|
<div class="flex-grow-1" mat-dialog-content>
|
||||||
<gf-admin-market-data-detail
|
<gf-admin-market-data-detail
|
||||||
class="mb-3"
|
class="mb-3"
|
||||||
|
[currency]="assetProfile?.currency"
|
||||||
[dataSource]="data.dataSource"
|
[dataSource]="data.dataSource"
|
||||||
[dateOfFirstActivity]="assetProfile?.dateOfFirstActivity"
|
[dateOfFirstActivity]="assetProfile?.dateOfFirstActivity"
|
||||||
[locale]="data.locale"
|
[locale]="data.locale"
|
||||||
@ -51,6 +52,16 @@
|
|||||||
(marketDataChanged)="onMarketDataChanged($event)"
|
(marketDataChanged)="onMarketDataChanged($event)"
|
||||||
></gf-admin-market-data-detail>
|
></gf-admin-market-data-detail>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
<div class="col-6 mb-3">
|
||||||
|
<gf-value i18n size="medium" [value]="assetProfile?.symbol"
|
||||||
|
>Symbol</gf-value
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 mb-3">
|
||||||
|
<gf-value i18n size="medium" [value]="assetProfile?.currency"
|
||||||
|
>Currency</gf-value
|
||||||
|
>
|
||||||
|
</div>
|
||||||
<div class="col-6 mb-3">
|
<div class="col-6 mb-3">
|
||||||
<gf-value
|
<gf-value
|
||||||
i18n
|
i18n
|
||||||
@ -71,11 +82,7 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 mb-3">
|
<div class="col-6 mb-3">
|
||||||
<gf-value
|
<gf-value i18n size="medium" [hidden]="!assetClass" [value]="assetClass"
|
||||||
i18n
|
|
||||||
size="medium"
|
|
||||||
[hidden]="!assetProfile?.assetClass"
|
|
||||||
[value]="assetProfile?.assetClass"
|
|
||||||
>Asset Class</gf-value
|
>Asset Class</gf-value
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@ -83,8 +90,8 @@
|
|||||||
<gf-value
|
<gf-value
|
||||||
i18n
|
i18n
|
||||||
size="medium"
|
size="medium"
|
||||||
[hidden]="!assetProfile?.assetSubClass"
|
[hidden]="!assetSubClass"
|
||||||
[value]="assetProfile?.assetSubClass"
|
[value]="assetSubClass"
|
||||||
>Asset Sub Class</gf-value
|
>Asset Sub Class</gf-value
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
@ -4,6 +4,7 @@ import { CacheService } from '@ghostfolio/client/services/cache.service';
|
|||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
import {
|
import {
|
||||||
|
ghostfolioPrefix,
|
||||||
PROPERTY_COUPONS,
|
PROPERTY_COUPONS,
|
||||||
PROPERTY_CURRENCIES,
|
PROPERTY_CURRENCIES,
|
||||||
PROPERTY_IS_READ_ONLY_MODE,
|
PROPERTY_IS_READ_ONLY_MODE,
|
||||||
@ -97,7 +98,10 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
public onAddCoupon() {
|
public onAddCoupon() {
|
||||||
const coupons = [
|
const coupons = [
|
||||||
...this.coupons,
|
...this.coupons,
|
||||||
{ code: this.generateCouponCode(16), duration: this.couponDuration }
|
{
|
||||||
|
code: `${ghostfolioPrefix}${this.generateCouponCode(14)}`,
|
||||||
|
duration: this.couponDuration
|
||||||
|
}
|
||||||
];
|
];
|
||||||
this.putAdminSetting({ key: PROPERTY_COUPONS, value: coupons });
|
this.putAdminSetting({ key: PROPERTY_COUPONS, value: coupons });
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
@import '~apps/client/src/styles/ghostfolio-style';
|
@import 'apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
@import '~apps/client/src/styles/ghostfolio-style';
|
@import 'apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -5,7 +5,7 @@
|
|||||||
mat-button
|
mat-button
|
||||||
[routerLink]="['/']"
|
[routerLink]="['/']"
|
||||||
>
|
>
|
||||||
<gf-logo></gf-logo>
|
<gf-logo [label]="pageTitle"></gf-logo>
|
||||||
</a>
|
</a>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<a
|
<a
|
||||||
@ -231,7 +231,10 @@
|
|||||||
mat-button
|
mat-button
|
||||||
[routerLink]="['/']"
|
[routerLink]="['/']"
|
||||||
>
|
>
|
||||||
<gf-logo [hideName]="currentRoute === 'register'"></gf-logo>
|
<gf-logo
|
||||||
|
[label]="pageTitle"
|
||||||
|
[showLabel]="currentRoute !== 'register'"
|
||||||
|
></gf-logo>
|
||||||
</a>
|
</a>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<a
|
<a
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
@import '~apps/client/src/styles/ghostfolio-style';
|
@import 'apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -30,6 +30,7 @@ import { catchError, takeUntil } from 'rxjs/operators';
|
|||||||
export class HeaderComponent implements OnChanges {
|
export class HeaderComponent implements OnChanges {
|
||||||
@Input() currentRoute: string;
|
@Input() currentRoute: string;
|
||||||
@Input() info: InfoItem;
|
@Input() info: InfoItem;
|
||||||
|
@Input() pageTitle: string;
|
||||||
@Input() user: User;
|
@Input() user: User;
|
||||||
|
|
||||||
@Output() signOut = new EventEmitter<void>();
|
@Output() signOut = new EventEmitter<void>();
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
@import '~apps/client/src/styles/ghostfolio-style';
|
@import 'apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<h3 class="mb-3 text-center" i18n>Markets</h3>
|
<h3 class="d-none d-sm-block mb-3 text-center" i18n>Markets</h3>
|
||||||
<div class="mb-5 row">
|
<div class="mb-5 row">
|
||||||
<div class="col-xs-12 col-md-8 offset-md-2">
|
<div class="col-xs-12 col-md-8 offset-md-2">
|
||||||
<div class="mb-2 text-center text-muted">
|
<div class="mb-2 text-center text-muted">
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
@import '~apps/client/src/styles/ghostfolio-style';
|
@import 'apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
@import '~apps/client/src/styles/ghostfolio-style';
|
@import 'apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -1,10 +1,8 @@
|
|||||||
<div class="container pb-3 px-3">
|
<div class="container pb-3 px-3">
|
||||||
|
<h3 class="d-none d-sm-block mb-3 text-center" i18n>Summary</h3>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-xs-12 col-md-8 offset-md-2">
|
<div class="col-xs-12 col-md-8 offset-md-2">
|
||||||
<mat-card class="h-100">
|
<mat-card class="h-100">
|
||||||
<mat-card-header>
|
|
||||||
<mat-card-title i18n>Summary</mat-card-title>
|
|
||||||
</mat-card-header>
|
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<gf-portfolio-summary
|
<gf-portfolio-summary
|
||||||
[baseCurrency]="user?.settings?.baseCurrency"
|
[baseCurrency]="user?.settings?.baseCurrency"
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
@import '~apps/client/src/styles/ghostfolio-style';
|
@import 'apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -198,6 +198,15 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
|||||||
this.chart.options.scales.x.min = this.daysInMarket
|
this.chart.options.scales.x.min = this.daysInMarket
|
||||||
? subDays(new Date(), this.daysInMarket).toISOString()
|
? subDays(new Date(), this.daysInMarket).toISOString()
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.savingsRate &&
|
||||||
|
this.chart.options.plugins.annotation.annotations.savingsRate
|
||||||
|
) {
|
||||||
|
this.chart.options.plugins.annotation.annotations.savingsRate.value =
|
||||||
|
this.savingsRate;
|
||||||
|
}
|
||||||
|
|
||||||
this.chart.update();
|
this.chart.update();
|
||||||
} else {
|
} else {
|
||||||
this.chart = new Chart(this.chartCanvas.nativeElement, {
|
this.chart = new Chart(this.chartCanvas.nativeElement, {
|
||||||
|
@ -19,7 +19,7 @@
|
|||||||
<div class="my-3 text-center text-muted" i18n>or</div>
|
<div class="my-3 text-center text-muted" i18n>or</div>
|
||||||
<div class="d-flex flex-column">
|
<div class="d-flex flex-column">
|
||||||
<button
|
<button
|
||||||
class="mb-2"
|
class="mb-2 px-4 rounded-pill"
|
||||||
mat-stroked-button
|
mat-stroked-button
|
||||||
(click)="onLoginWithInternetIdentity()"
|
(click)="onLoginWithInternetIdentity()"
|
||||||
>
|
>
|
||||||
@ -29,7 +29,10 @@
|
|||||||
style="height: 0.75rem"
|
style="height: 0.75rem"
|
||||||
/><span i18n>Sign in with Internet Identity</span>
|
/><span i18n>Sign in with Internet Identity</span>
|
||||||
</button>
|
</button>
|
||||||
<a href="../api/v1/auth/google" mat-stroked-button
|
<a
|
||||||
|
class="px-4 rounded-pill"
|
||||||
|
href="../api/v1/auth/google"
|
||||||
|
mat-stroked-button
|
||||||
><img
|
><img
|
||||||
class="mr-2"
|
class="mr-2"
|
||||||
src="../assets/icons/google.svg"
|
src="../assets/icons/google.svg"
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
@import '~apps/client/src/styles/ghostfolio-style';
|
@import 'apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -7,6 +7,7 @@ import {
|
|||||||
OnDestroy,
|
OnDestroy,
|
||||||
OnInit
|
OnInit
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { getNumberFormatGroup } from '@ghostfolio/common/helper';
|
||||||
import svgMap from 'svgmap';
|
import svgMap from 'svgmap';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@ -16,9 +17,10 @@ import svgMap from 'svgmap';
|
|||||||
styleUrls: ['./world-map-chart.component.scss']
|
styleUrls: ['./world-map-chart.component.scss']
|
||||||
})
|
})
|
||||||
export class WorldMapChartComponent implements OnChanges, OnDestroy, OnInit {
|
export class WorldMapChartComponent implements OnChanges, OnDestroy, OnInit {
|
||||||
@Input() baseCurrency: string;
|
@Input() countries: { [code: string]: { name?: string; value: number } };
|
||||||
@Input() countries: { [code: string]: { name: string; value: number } };
|
@Input() format: string;
|
||||||
@Input() isInPercent = false;
|
@Input() isInPercent = false;
|
||||||
|
@Input() locale: string;
|
||||||
|
|
||||||
public isLoading = true;
|
public isLoading = true;
|
||||||
public svgMapElement;
|
public svgMapElement;
|
||||||
@ -71,7 +73,8 @@ export class WorldMapChartComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
applyData: 'value',
|
applyData: 'value',
|
||||||
data: {
|
data: {
|
||||||
value: {
|
value: {
|
||||||
format: this.isInPercent ? `{0}%` : `{0} ${this.baseCurrency}`
|
format: this.format,
|
||||||
|
thousandSeparator: getNumberFormatGroup(this.locale)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
values: this.countries
|
values: this.countries
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="mb-5 row">
|
<div class="mb-5 row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h3 class="d-flex justify-content-center mb-3">About Ghostfolio</h3>
|
<h3 class="d-none d-sm-block mb-3 text-center">About Ghostfolio</h3>
|
||||||
<div class="about-container">
|
<div class="about-container">
|
||||||
<p>
|
<p>
|
||||||
Ghostfolio is a lightweight wealth management application for
|
Ghostfolio is a lightweight wealth management application for
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="mb-5 row">
|
<div class="mb-5 row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h3 class="mb-3 text-center" i18n>Privacy Policy</h3>
|
<h3 class="d-none d-sm-block mb-3 text-center" i18n>Privacy Policy</h3>
|
||||||
<markdown [src]="'../assets/privacy-policy.md'"></markdown>
|
<markdown [src]="'../assets/privacy-policy.md'"></markdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -55,7 +55,17 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
|||||||
public hasPermissionToUpdateViewMode: boolean;
|
public hasPermissionToUpdateViewMode: boolean;
|
||||||
public hasPermissionToUpdateUserSettings: boolean;
|
public hasPermissionToUpdateUserSettings: boolean;
|
||||||
public language = document.documentElement.lang;
|
public language = document.documentElement.lang;
|
||||||
public locales = ['de', 'de-CH', 'en-GB', 'en-US', 'es', 'it', 'nl'];
|
public locales = [
|
||||||
|
'de',
|
||||||
|
'de-CH',
|
||||||
|
'en-GB',
|
||||||
|
'en-US',
|
||||||
|
'es',
|
||||||
|
'fr',
|
||||||
|
'it',
|
||||||
|
'nl',
|
||||||
|
'pt'
|
||||||
|
];
|
||||||
public price: number;
|
public price: number;
|
||||||
public priceId: string;
|
public priceId: string;
|
||||||
public snackBarRef: MatSnackBarRef<TextOnlySnackBar>;
|
public snackBarRef: MatSnackBarRef<TextOnlySnackBar>;
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h3 class="d-flex justify-content-center mb-3" i18n>Account</h3>
|
<h3 class="mb-3 text-center" i18n>Account</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="user?.settings" class="mb-5 row">
|
<div *ngIf="user?.settings" class="mb-5 row">
|
||||||
@ -135,6 +135,10 @@
|
|||||||
>Español (<ng-container i18n>Community</ng-container
|
>Español (<ng-container i18n>Community</ng-container
|
||||||
>)</mat-option
|
>)</mat-option
|
||||||
>
|
>
|
||||||
|
<mat-option value="fr"
|
||||||
|
>Français (<ng-container i18n>Community</ng-container
|
||||||
|
>)</mat-option
|
||||||
|
>
|
||||||
<mat-option value="it"
|
<mat-option value="it"
|
||||||
>Italiano (<ng-container i18n>Community</ng-container
|
>Italiano (<ng-container i18n>Community</ng-container
|
||||||
>)</mat-option
|
>)</mat-option
|
||||||
@ -143,6 +147,10 @@
|
|||||||
>Nederlands (<ng-container i18n>Community</ng-container
|
>Nederlands (<ng-container i18n>Community</ng-container
|
||||||
>)</mat-option
|
>)</mat-option
|
||||||
>
|
>
|
||||||
|
<!--<mat-option value="pt"
|
||||||
|
>Português (<ng-container i18n>Community</ng-container
|
||||||
|
>)</mat-option
|
||||||
|
>-->
|
||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h3 class="d-flex justify-content-center mb-3" i18n>Accounts</h3>
|
<h3 class="d-none d-sm-block mb-3 text-center" i18n>Accounts</h3>
|
||||||
<div class="accounts">
|
<div class="accounts">
|
||||||
<gf-accounts-table
|
<gf-accounts-table
|
||||||
[accounts]="accounts"
|
[accounts]="accounts"
|
||||||
@ -27,8 +27,8 @@
|
|||||||
class="align-items-center d-flex justify-content-center"
|
class="align-items-center d-flex justify-content-center"
|
||||||
color="primary"
|
color="primary"
|
||||||
mat-fab
|
mat-fab
|
||||||
[routerLink]="[]"
|
|
||||||
[queryParams]="{ createDialog: true }"
|
[queryParams]="{ createDialog: true }"
|
||||||
|
[routerLink]="[]"
|
||||||
>
|
>
|
||||||
<ion-icon name="add-outline" size="large"></ion-icon>
|
<ion-icon name="add-outline" size="large"></ion-icon>
|
||||||
</a>
|
</a>
|
||||||
|
@ -13,14 +13,21 @@ const routes: Routes = [
|
|||||||
canActivate: [AuthGuard],
|
canActivate: [AuthGuard],
|
||||||
children: [
|
children: [
|
||||||
{ path: '', redirectTo: 'overview', pathMatch: 'full' },
|
{ path: '', redirectTo: 'overview', pathMatch: 'full' },
|
||||||
{ path: 'jobs', component: AdminJobsComponent },
|
{ path: 'jobs', component: AdminJobsComponent, title: $localize`Jobs` },
|
||||||
{ path: 'market-data', component: AdminMarketDataComponent },
|
{
|
||||||
{ path: 'overview', component: AdminOverviewComponent },
|
path: 'market-data',
|
||||||
{ path: 'users', component: AdminUsersComponent }
|
component: AdminMarketDataComponent,
|
||||||
|
title: $localize`Market Data`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'overview',
|
||||||
|
component: AdminOverviewComponent,
|
||||||
|
title: $localize`Admin Control`
|
||||||
|
},
|
||||||
|
{ path: 'users', component: AdminUsersComponent, title: $localize`Users` }
|
||||||
],
|
],
|
||||||
component: AdminPageComponent,
|
component: AdminPageComponent,
|
||||||
path: '',
|
path: ''
|
||||||
title: $localize`Admin Control`
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
@import '~apps/client/src/styles/ghostfolio-style';
|
@import 'apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
color: rgb(var(--dark-primary-text));
|
color: rgb(var(--dark-primary-text));
|
||||||
|
@ -125,7 +125,7 @@
|
|||||||
feedback, bug reports, feature requests and of course contributions!
|
feedback, bug reports, feature requests and of course contributions!
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
You can reach me by email at
|
You can reach me by e-mail at
|
||||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
|
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
|
||||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>.
|
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>.
|
||||||
</p>
|
</p>
|
||||||
|
@ -99,7 +99,7 @@
|
|||||||
>
|
>
|
||||||
of users. In the future, I would like to involve more contributors
|
of users. In the future, I would like to involve more contributors
|
||||||
to further extend the functionality of Ghostfolio (e.g. with new
|
to further extend the functionality of Ghostfolio (e.g. with new
|
||||||
reports). Get in touch with me by email at
|
reports). Get in touch with me by e-mail at
|
||||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
|
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
|
||||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a> if you
|
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a> if you
|
||||||
are interested, I’m happy to discuss ideas.
|
are interested, I’m happy to discuss ideas.
|
||||||
|
@ -64,15 +64,15 @@
|
|||||||
<p>
|
<p>
|
||||||
When you authenticate with <i>Internet Identity</i>, the service
|
When you authenticate with <i>Internet Identity</i>, the service
|
||||||
only gets a dedicated pseudonym rather than sensitive user data like
|
only gets a dedicated pseudonym rather than sensitive user data like
|
||||||
the email address or phone number. This preserves your anonymity and
|
the e-mail address or phone number. This preserves your anonymity
|
||||||
prevents you being tracked on the Internet.
|
and prevents you being tracked on the Internet.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
<section class="mb-4">
|
<section class="mb-4">
|
||||||
<h2 class="h4">The key benefits in a nutshell</h2>
|
<h2 class="h4">The key benefits in a nutshell</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
Authenticate yourself securely without the need of an email
|
Authenticate yourself securely without the need of an e-mail
|
||||||
address, username, or a password: all you need is your device to
|
address, username, or a password: all you need is your device to
|
||||||
log in.
|
log in.
|
||||||
</li>
|
</li>
|
||||||
@ -89,7 +89,7 @@
|
|||||||
<section class="mb-4">
|
<section class="mb-4">
|
||||||
<p>
|
<p>
|
||||||
If you would like to provide feedback or get involved in further
|
If you would like to provide feedback or get involved in further
|
||||||
development of Ghostfolio, please get in touch by email via
|
development of Ghostfolio, please get in touch by e-mail via
|
||||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
|
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
|
||||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>.
|
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>.
|
||||||
</p>
|
</p>
|
||||||
|
@ -62,7 +62,7 @@
|
|||||||
new and better Internet based on decentralized blockchains to give
|
new and better Internet based on decentralized blockchains to give
|
||||||
power back to the users. <i>Internet Identity</i> created by the
|
power back to the users. <i>Internet Identity</i> created by the
|
||||||
<a href="https://dfinity.org">Dfinity Foundation</a> enables you to
|
<a href="https://dfinity.org">Dfinity Foundation</a> enables you to
|
||||||
sign in securely and anonymously to Ghostfolio without an email
|
sign in securely and anonymously to Ghostfolio without an e-mail
|
||||||
address, username, or a password. All you need is your device with
|
address, username, or a password. All you need is your device with
|
||||||
built-in biometric authentication.
|
built-in biometric authentication.
|
||||||
</p>
|
</p>
|
||||||
@ -90,7 +90,7 @@
|
|||||||
onboard more contributors who are actively involved in software
|
onboard more contributors who are actively involved in software
|
||||||
engineering to realize the full potential of open source software.
|
engineering to realize the full potential of open source software.
|
||||||
If you are a web developer and interested in personal finance,
|
If you are a web developer and interested in personal finance,
|
||||||
please get in touch by email via
|
please get in touch by e-mail via
|
||||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
|
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
|
||||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>. We are
|
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>. We are
|
||||||
happy to discuss ideas.
|
happy to discuss ideas.
|
||||||
|
@ -83,7 +83,7 @@
|
|||||||
<a href="https://ghostfolio.slack.com">Slack community</a> or get in
|
<a href="https://ghostfolio.slack.com">Slack community</a> or get in
|
||||||
touch on Twitter
|
touch on Twitter
|
||||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a> or by
|
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a> or by
|
||||||
email via <a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a>.
|
e-mail via <a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a>.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
We look forward to hearing from you.<br />
|
We look forward to hearing from you.<br />
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="mb-5 row">
|
<div class="mb-5 row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h3 class="mb-3 text-center" i18n>Blog</h3>
|
<h3 class="d-none d-sm-block mb-3 text-center" i18n>Blog</h3>
|
||||||
<mat-card class="mb-3">
|
<mat-card class="mb-3">
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<div class="container p-0">
|
<div class="container p-0">
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="mb-5 row">
|
<div class="mb-5 row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h3 class="mb-3 text-center">Frequently Asked Questions (FAQ)</h3>
|
<h3 class="d-none d-sm-block mb-3 text-center">
|
||||||
|
Frequently Asked Questions (FAQ)
|
||||||
|
</h3>
|
||||||
<mat-card class="mb-3">
|
<mat-card class="mb-3">
|
||||||
<mat-card-title>What is Ghostfolio?</mat-card-title>
|
<mat-card-title>What is Ghostfolio?</mat-card-title>
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
@ -46,7 +48,7 @@
|
|||||||
<a href="../en/blog/2022/07/ghostfolio-meets-internet-identity"
|
<a href="../en/blog/2022/07/ghostfolio-meets-internet-identity"
|
||||||
>Internet Identity</a
|
>Internet Identity</a
|
||||||
>) enable you to sign in securely and anonymously to Ghostfolio. There
|
>) enable you to sign in securely and anonymously to Ghostfolio. There
|
||||||
is no need for an email address, phone number, or a username.
|
is no need for an e-mail address, phone number, or a username.
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
<mat-card class="mb-3">
|
<mat-card class="mb-3">
|
||||||
@ -55,10 +57,11 @@
|
|||||||
>This project is driven by the efforts of contributors from around the
|
>This project is driven by the efforts of contributors from around the
|
||||||
world. The
|
world. The
|
||||||
<a href="https://github.com/ghostfolio/ghostfolio">source code</a> is
|
<a href="https://github.com/ghostfolio/ghostfolio">source code</a> is
|
||||||
fully available as open source software (OSS). Our
|
fully available as open source software (OSS). Thanks to our generous
|
||||||
<a [routerLink]="['/pricing']">Ghostfolio Premium</a> users and
|
<a [routerLink]="['/pricing']">Ghostfolio Premium</a> users and
|
||||||
<a href="https://www.buymeacoffee.com/ghostfolio">sponsors</a> allow
|
<a href="https://www.buymeacoffee.com/ghostfolio">sponsors</a> we have
|
||||||
us to run a free, limited plan for new investors.</mat-card-content
|
the ability to run a free, limited plan for new
|
||||||
|
investors.</mat-card-content
|
||||||
>
|
>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
<mat-card class="mb-3">
|
<mat-card class="mb-3">
|
||||||
@ -75,7 +78,8 @@
|
|||||||
><a [routerLink]="['/pricing']">Ghostfolio Premium</a> is a fully
|
><a [routerLink]="['/pricing']">Ghostfolio Premium</a> is a fully
|
||||||
managed Ghostfolio cloud offering for ambitious investors. The revenue
|
managed Ghostfolio cloud offering for ambitious investors. The revenue
|
||||||
is used to cover the hosting infrastructure. It is the Open Source
|
is used to cover the hosting infrastructure. It is the Open Source
|
||||||
code base with some extras like the market overview.</mat-card-content
|
code base with some extras like the
|
||||||
|
<a [routerLink]="['/markets']">markets overview</a>.</mat-card-content
|
||||||
>
|
>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
<mat-card class="mb-3">
|
<mat-card class="mb-3">
|
||||||
@ -83,24 +87,45 @@
|
|||||||
<mat-card-content
|
<mat-card-content
|
||||||
>Yes, you can try
|
>Yes, you can try
|
||||||
<a [routerLink]="['/pricing']">Ghostfolio Premium</a> by signing up
|
<a [routerLink]="['/pricing']">Ghostfolio Premium</a> by signing up
|
||||||
for Ghostfolio and applying for a trial (see “My Ghostfolio”). It’s
|
for Ghostfolio and applying for a trial (see “My Ghostfolio”). It is
|
||||||
easy, free and there is no commitment. You can stop using it at any
|
easy, free and there is no commitment. You can stop using it at any
|
||||||
time.</mat-card-content
|
time.</mat-card-content
|
||||||
>
|
>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
|
<mat-card class="mb-3">
|
||||||
|
<mat-card-title
|
||||||
|
>How can I get a student discount for Ghostfolio
|
||||||
|
Premium?</mat-card-title
|
||||||
|
>
|
||||||
|
<mat-card-content
|
||||||
|
>Request your student discount
|
||||||
|
<a href="mailto:hi@ghostfol.io?Subject=Student Discount">here</a> with
|
||||||
|
your university e-mail address.</mat-card-content
|
||||||
|
>
|
||||||
|
</mat-card>
|
||||||
<mat-card class="mb-3">
|
<mat-card class="mb-3">
|
||||||
<mat-card-title>Which devices are supported?</mat-card-title>
|
<mat-card-title>Which devices are supported?</mat-card-title>
|
||||||
<mat-card-content
|
<mat-card-content
|
||||||
>Ghostfolio works in every modern web browser on smartphones, tablets
|
>Ghostfolio works in every modern web browser on smartphones, tablets
|
||||||
and desktop computers (where you have even more analysis options and
|
and desktop computers. For <i>Android</i> users, there is a dedicated
|
||||||
statistics). For Android users, there is a dedicated Ghostfolio app
|
Ghostfolio app available in the
|
||||||
available in the
|
|
||||||
<a
|
<a
|
||||||
href="https://play.google.com/store/apps/details?id=ch.dotsilver.ghostfolio.twa"
|
href="https://play.google.com/store/apps/details?id=ch.dotsilver.ghostfolio.twa"
|
||||||
>Google Play Store</a
|
>Google Play Store</a
|
||||||
>.</mat-card-content
|
>.</mat-card-content
|
||||||
>
|
>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
|
<mat-card class="mb-3">
|
||||||
|
<mat-card-title
|
||||||
|
>I cannot find my broker in the list of platforms. What can I
|
||||||
|
do?</mat-card-title
|
||||||
|
>
|
||||||
|
<mat-card-content>
|
||||||
|
Please send an e-mail with the web address of your broker to
|
||||||
|
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> and we are happy to
|
||||||
|
add it.
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
<mat-card class="mb-3">
|
<mat-card class="mb-3">
|
||||||
<mat-card-title
|
<mat-card-title
|
||||||
>Ghostfolio sounds cool, how can I get involved?</mat-card-title
|
>Ghostfolio sounds cool, how can I get involved?</mat-card-title
|
||||||
@ -115,7 +140,7 @@
|
|||||||
>, a star on
|
>, a star on
|
||||||
<a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>,
|
<a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>,
|
||||||
feedback, bug reports, feature requests and of course contributions!
|
feedback, bug reports, feature requests and of course contributions!
|
||||||
You can reach us by email at
|
You can reach us by e-mail at
|
||||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
|
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
|
||||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a
|
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a
|
||||||
>.</mat-card-content
|
>.</mat-card-content
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h3 class="d-flex justify-content-center mb-3 text-center">Features</h3>
|
<h3 class="d-none d-sm-block mb-3 text-center">Features</h3>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<p>
|
<p>
|
||||||
Check out the numerous features of <strong>Ghostfolio</strong> to
|
Check out the numerous features of <strong>Ghostfolio</strong> to
|
||||||
@ -197,8 +197,11 @@
|
|||||||
<div class="flex-grow-1">
|
<div class="flex-grow-1">
|
||||||
<h4>Multi-Language</h4>
|
<h4>Multi-Language</h4>
|
||||||
<p class="m-0">
|
<p class="m-0">
|
||||||
Use Ghostfolio in multiple languages: English, Dutch, German,
|
Use Ghostfolio in multiple languages: English, Dutch, Français,
|
||||||
Italian and Spanish are currently supported.
|
German, Italian<ng-container *ngIf="false"
|
||||||
|
>, Portuguese</ng-container
|
||||||
|
>
|
||||||
|
and Spanish are currently supported.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
|
@ -13,14 +13,28 @@ const routes: Routes = [
|
|||||||
canActivate: [AuthGuard],
|
canActivate: [AuthGuard],
|
||||||
children: [
|
children: [
|
||||||
{ path: '', redirectTo: 'overview', pathMatch: 'full' },
|
{ path: '', redirectTo: 'overview', pathMatch: 'full' },
|
||||||
{ path: 'overview', component: HomeOverviewComponent },
|
{
|
||||||
{ path: 'holdings', component: HomeHoldingsComponent },
|
path: 'overview',
|
||||||
{ path: 'summary', component: HomeSummaryComponent },
|
component: HomeOverviewComponent
|
||||||
{ path: 'market', component: HomeMarketComponent }
|
},
|
||||||
|
{
|
||||||
|
path: 'holdings',
|
||||||
|
component: HomeHoldingsComponent,
|
||||||
|
title: $localize`Holdings`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'summary',
|
||||||
|
component: HomeSummaryComponent,
|
||||||
|
title: $localize`Summary`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'market',
|
||||||
|
component: HomeMarketComponent,
|
||||||
|
title: $localize`Markets`
|
||||||
|
}
|
||||||
],
|
],
|
||||||
component: HomePageComponent,
|
component: HomePageComponent,
|
||||||
path: '',
|
path: ''
|
||||||
title: $localize`Overview`
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
@import '~apps/client/src/styles/ghostfolio-style';
|
@import 'apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
color: rgb(var(--dark-primary-text));
|
color: rgb(var(--dark-primary-text));
|
||||||
|
@ -13,10 +13,14 @@ import { Subject } from 'rxjs';
|
|||||||
templateUrl: './landing-page.html'
|
templateUrl: './landing-page.html'
|
||||||
})
|
})
|
||||||
export class LandingPageComponent implements OnDestroy, OnInit {
|
export class LandingPageComponent implements OnDestroy, OnInit {
|
||||||
|
public countriesOfSubscribersMap: {
|
||||||
|
[code: string]: { value: number };
|
||||||
|
} = {};
|
||||||
public currentYear = format(new Date(), 'yyyy');
|
public currentYear = format(new Date(), 'yyyy');
|
||||||
public demoAuthToken: string;
|
public demoAuthToken: string;
|
||||||
public deviceType: string;
|
public deviceType: string;
|
||||||
public hasPermissionForStatistics: boolean;
|
public hasPermissionForStatistics: boolean;
|
||||||
|
public hasPermissionForSubscription: boolean;
|
||||||
public hasPermissionToCreateUser: boolean;
|
public hasPermissionToCreateUser: boolean;
|
||||||
public statistics: Statistics;
|
public statistics: Statistics;
|
||||||
public testimonials = [
|
public testimonials = [
|
||||||
@ -48,13 +52,25 @@ export class LandingPageComponent implements OnDestroy, OnInit {
|
|||||||
private dataService: DataService,
|
private dataService: DataService,
|
||||||
private deviceService: DeviceDetectorService
|
private deviceService: DeviceDetectorService
|
||||||
) {
|
) {
|
||||||
const { globalPermissions, statistics } = this.dataService.fetchInfo();
|
const { countriesOfSubscribers, globalPermissions, statistics } =
|
||||||
|
this.dataService.fetchInfo();
|
||||||
|
|
||||||
|
for (const country of countriesOfSubscribers) {
|
||||||
|
this.countriesOfSubscribersMap[country] = {
|
||||||
|
value: 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
this.hasPermissionForStatistics = hasPermission(
|
this.hasPermissionForStatistics = hasPermission(
|
||||||
globalPermissions,
|
globalPermissions,
|
||||||
permissions.enableStatistics
|
permissions.enableStatistics
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.hasPermissionForSubscription = hasPermission(
|
||||||
|
globalPermissions,
|
||||||
|
permissions.enableSubscription
|
||||||
|
);
|
||||||
|
|
||||||
this.hasPermissionToCreateUser = hasPermission(
|
this.hasPermissionToCreateUser = hasPermission(
|
||||||
globalPermissions,
|
globalPermissions,
|
||||||
permissions.createUserAccount
|
permissions.createUserAccount
|
||||||
|
@ -256,7 +256,7 @@
|
|||||||
<gf-logo
|
<gf-logo
|
||||||
class="mr-3 mt-2 pt-1"
|
class="mr-3 mt-2 pt-1"
|
||||||
size="medium"
|
size="medium"
|
||||||
[hideName]="true"
|
[showLabel]="false"
|
||||||
></gf-logo>
|
></gf-logo>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -269,6 +269,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="hasPermissionForSubscription" class="row my-5">
|
||||||
|
<div class="col-12">
|
||||||
|
<h2 class="h4 text-center">
|
||||||
|
Members from around the globe are using
|
||||||
|
<a href="pricing"><strong>Ghostfolio Premium</strong></a>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8 customer-map-container offset-md-2">
|
||||||
|
<gf-world-map-chart
|
||||||
|
format="👻"
|
||||||
|
[countries]="countriesOfSubscribersMap"
|
||||||
|
></gf-world-map-chart>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row my-3">
|
<div class="row my-3">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<h2 class="h4 mb-1 text-center">
|
<h2 class="h4 mb-1 text-center">
|
||||||
|
@ -3,6 +3,7 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
|||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatCardModule } from '@angular/material/card';
|
import { MatCardModule } from '@angular/material/card';
|
||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { GfWorldMapChartModule } from '@ghostfolio/client/components/world-map-chart/world-map-chart.module';
|
||||||
import { GfLogoModule } from '@ghostfolio/ui/logo';
|
import { GfLogoModule } from '@ghostfolio/ui/logo';
|
||||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||||
|
|
||||||
@ -15,6 +16,7 @@ import { LandingPageComponent } from './landing-page.component';
|
|||||||
CommonModule,
|
CommonModule,
|
||||||
GfLogoModule,
|
GfLogoModule,
|
||||||
GfValueModule,
|
GfValueModule,
|
||||||
|
GfWorldMapChartModule,
|
||||||
LandingPageRoutingModule,
|
LandingPageRoutingModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatCardModule,
|
MatCardModule,
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
@import '~apps/client/src/styles/ghostfolio-style';
|
@import 'apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
@ -9,6 +9,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.customer-map-container {
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
}
|
||||||
|
|
||||||
.downloads {
|
.downloads {
|
||||||
img {
|
img {
|
||||||
height: 2.5rem;
|
height: 2.5rem;
|
||||||
|
@ -11,7 +11,7 @@ import { IcsService } from '@ghostfolio/client/services/ics/ics.service';
|
|||||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
import { downloadAsFile } from '@ghostfolio/common/helper';
|
import { downloadAsFile } from '@ghostfolio/common/helper';
|
||||||
import { User } from '@ghostfolio/common/interfaces';
|
import { UniqueAsset, User } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import { DataSource, Order as OrderModel } from '@prisma/client';
|
import { DataSource, Order as OrderModel } from '@prisma/client';
|
||||||
import { format, parseISO } from 'date-fns';
|
import { format, parseISO } from 'date-fns';
|
||||||
@ -198,6 +198,24 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onImportDividends() {
|
||||||
|
const dialogRef = this.dialog.open(ImportActivitiesDialog, {
|
||||||
|
data: <ImportActivitiesDialogParams>{
|
||||||
|
activityTypes: ['DIVIDEND'],
|
||||||
|
deviceType: this.deviceType,
|
||||||
|
user: this.user
|
||||||
|
},
|
||||||
|
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogRef
|
||||||
|
.afterClosed()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
this.fetchActivities();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public onUpdateActivity(aActivity: OrderModel) {
|
public onUpdateActivity(aActivity: OrderModel) {
|
||||||
this.router.navigate([], {
|
this.router.navigate([], {
|
||||||
queryParams: { activityId: aActivity.id, editDialog: true }
|
queryParams: { activityId: aActivity.id, editDialog: true }
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h3 class="d-flex justify-content-center mb-3" i18n>Activities</h3>
|
<h3 class="d-none d-sm-block mb-3 text-center" i18n>Activities</h3>
|
||||||
<gf-activities-table
|
<gf-activities-table
|
||||||
[activities]="activities"
|
[activities]="activities"
|
||||||
[baseCurrency]="user?.settings?.baseCurrency"
|
[baseCurrency]="user?.settings?.baseCurrency"
|
||||||
@ -17,6 +17,7 @@
|
|||||||
(export)="onExport($event)"
|
(export)="onExport($event)"
|
||||||
(exportDrafts)="onExportDrafts($event)"
|
(exportDrafts)="onExportDrafts($event)"
|
||||||
(import)="onImport()"
|
(import)="onImport()"
|
||||||
|
(importDividends)="onImportDividends()"
|
||||||
></gf-activities-table>
|
></gf-activities-table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -5,12 +5,16 @@ import {
|
|||||||
Inject,
|
Inject,
|
||||||
OnDestroy
|
OnDestroy
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
import { ImportActivitiesService } from '@ghostfolio/client/services/import-activities.service';
|
import { ImportActivitiesService } from '@ghostfolio/client/services/import-activities.service';
|
||||||
import { isArray } from 'lodash';
|
import { Position } from '@ghostfolio/common/interfaces';
|
||||||
import { Subject } from 'rxjs';
|
import { AssetClass } from '@prisma/client';
|
||||||
|
import { isArray, sortBy } from 'lodash';
|
||||||
|
import { Subject, takeUntil } from 'rxjs';
|
||||||
|
|
||||||
import { ImportActivitiesDialogParams } from './interfaces/interfaces';
|
import { ImportActivitiesDialogParams } from './interfaces/interfaces';
|
||||||
|
|
||||||
@ -24,20 +28,57 @@ export class ImportActivitiesDialog implements OnDestroy {
|
|||||||
public activities: Activity[] = [];
|
public activities: Activity[] = [];
|
||||||
public details: any[] = [];
|
public details: any[] = [];
|
||||||
public errorMessages: string[] = [];
|
public errorMessages: string[] = [];
|
||||||
|
public holdings: Position[] = [];
|
||||||
public isFileSelected = false;
|
public isFileSelected = false;
|
||||||
|
public mode: 'DIVIDEND';
|
||||||
public selectedActivities: Activity[] = [];
|
public selectedActivities: Activity[] = [];
|
||||||
|
public uniqueAssetForm: FormGroup;
|
||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
@Inject(MAT_DIALOG_DATA) public data: ImportActivitiesDialogParams,
|
@Inject(MAT_DIALOG_DATA) public data: ImportActivitiesDialogParams,
|
||||||
|
private dataService: DataService,
|
||||||
|
private formBuilder: FormBuilder,
|
||||||
public dialogRef: MatDialogRef<ImportActivitiesDialog>,
|
public dialogRef: MatDialogRef<ImportActivitiesDialog>,
|
||||||
private importActivitiesService: ImportActivitiesService,
|
private importActivitiesService: ImportActivitiesService,
|
||||||
private snackBar: MatSnackBar
|
private snackBar: MatSnackBar
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public ngOnInit() {}
|
public ngOnInit() {
|
||||||
|
this.uniqueAssetForm = this.formBuilder.group({
|
||||||
|
uniqueAsset: [undefined, Validators.required]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.data?.activityTypes?.length === 1 &&
|
||||||
|
this.data?.activityTypes?.[0] === 'DIVIDEND'
|
||||||
|
) {
|
||||||
|
this.mode = 'DIVIDEND';
|
||||||
|
this.uniqueAssetForm.controls['uniqueAsset'].disable();
|
||||||
|
|
||||||
|
this.dataService
|
||||||
|
.fetchPositions({
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
id: AssetClass.EQUITY,
|
||||||
|
type: 'ASSET_CLASS'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
range: 'max'
|
||||||
|
})
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(({ positions }) => {
|
||||||
|
this.holdings = sortBy(positions, ({ name }) => {
|
||||||
|
return name.toLowerCase();
|
||||||
|
});
|
||||||
|
this.uniqueAssetForm.controls['uniqueAsset'].enable();
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public onCancel(): void {
|
public onCancel(): void {
|
||||||
this.dialogRef.close();
|
this.dialogRef.close();
|
||||||
@ -71,6 +112,26 @@ export class ImportActivitiesDialog implements OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onLoadDividends() {
|
||||||
|
this.uniqueAssetForm.controls['uniqueAsset'].disable();
|
||||||
|
|
||||||
|
const { dataSource, symbol } =
|
||||||
|
this.uniqueAssetForm.controls['uniqueAsset'].value;
|
||||||
|
|
||||||
|
this.dataService
|
||||||
|
.fetchDividendsImport({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
})
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(({ activities }) => {
|
||||||
|
this.activities = activities;
|
||||||
|
this.isFileSelected = true;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public onReset() {
|
public onReset() {
|
||||||
this.details = [];
|
this.details = [];
|
||||||
this.errorMessages = [];
|
this.errorMessages = [];
|
||||||
@ -95,8 +156,6 @@ export class ImportActivitiesDialog implements OnDestroy {
|
|||||||
reader.onload = async (readerEvent) => {
|
reader.onload = async (readerEvent) => {
|
||||||
const fileContent = readerEvent.target.result as string;
|
const fileContent = readerEvent.target.result as string;
|
||||||
|
|
||||||
console.log(fileContent);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (file.name.endsWith('.json')) {
|
if (file.name.endsWith('.json')) {
|
||||||
const content = JSON.parse(fileContent);
|
const content = JSON.parse(fileContent);
|
||||||
|
@ -7,6 +7,31 @@
|
|||||||
|
|
||||||
<div class="flex-grow-1" mat-dialog-content>
|
<div class="flex-grow-1" mat-dialog-content>
|
||||||
<ng-container *ngIf="!isFileSelected">
|
<ng-container *ngIf="!isFileSelected">
|
||||||
|
<ng-container *ngIf="mode === 'DIVIDEND'; else selectFile">
|
||||||
|
<form [formGroup]="uniqueAssetForm" (ngSubmit)="onLoadDividends()">
|
||||||
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
|
<mat-label i18n>Holding</mat-label>
|
||||||
|
<mat-select formControlName="uniqueAsset">
|
||||||
|
<mat-option
|
||||||
|
*ngFor="let holding of holdings"
|
||||||
|
[value]="{dataSource: holding.dataSource, symbol: holding.symbol}"
|
||||||
|
>{{ holding.name }}</mat-option
|
||||||
|
>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<div class="d-flex justify-content-center flex-column">
|
||||||
|
<button
|
||||||
|
color="primary"
|
||||||
|
mat-flat-button
|
||||||
|
type="submit"
|
||||||
|
[disabled]="!uniqueAssetForm.valid"
|
||||||
|
>
|
||||||
|
<span i18n>Load Dividends</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</ng-container>
|
||||||
|
<ng-template #selectFile>
|
||||||
<div class="d-flex justify-content-center flex-column">
|
<div class="d-flex justify-content-center flex-column">
|
||||||
<button
|
<button
|
||||||
class="py-3"
|
class="py-3"
|
||||||
@ -18,7 +43,9 @@
|
|||||||
<span i18n>Choose File</span>
|
<span i18n>Choose File</span>
|
||||||
</button>
|
</button>
|
||||||
<p class="mb-0 mt-4 text-center">
|
<p class="mb-0 mt-4 text-center">
|
||||||
<span class="mr-1" i18n>The following file formats are supported:</span>
|
<span class="mr-1" i18n
|
||||||
|
>The following file formats are supported:</span
|
||||||
|
>
|
||||||
<a
|
<a
|
||||||
href="https://github.com/ghostfolio/ghostfolio/blob/main/test/import/ok.csv"
|
href="https://github.com/ghostfolio/ghostfolio/blob/main/test/import/ok.csv"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@ -32,6 +59,7 @@
|
|||||||
>
|
>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</ng-template>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container *ngIf="isFileSelected">
|
<ng-container *ngIf="isFileSelected">
|
||||||
<ng-container *ngIf="errorMessages.length === 0; else errorMessage">
|
<ng-container *ngIf="errorMessages.length === 0; else errorMessage">
|
||||||
@ -47,6 +75,7 @@
|
|||||||
[locale]="data?.user?.settings?.locale"
|
[locale]="data?.user?.settings?.locale"
|
||||||
[showActions]="false"
|
[showActions]="false"
|
||||||
[showCheckbox]="true"
|
[showCheckbox]="true"
|
||||||
|
[showFooter]="false"
|
||||||
[showSymbolColumn]="false"
|
[showSymbolColumn]="false"
|
||||||
(selectedActivities)="updateSelection($event)"
|
(selectedActivities)="updateSelection($event)"
|
||||||
></gf-activities-table>
|
></gf-activities-table>
|
||||||
|
@ -1,8 +1,11 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatDialogModule } from '@angular/material/dialog';
|
import { MatDialogModule } from '@angular/material/dialog';
|
||||||
import { MatExpansionModule } from '@angular/material/expansion';
|
import { MatExpansionModule } from '@angular/material/expansion';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
|
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
|
||||||
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
|
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
|
||||||
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
|
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
|
||||||
@ -13,12 +16,16 @@ import { ImportActivitiesDialog } from './import-activities-dialog.component';
|
|||||||
declarations: [ImportActivitiesDialog],
|
declarations: [ImportActivitiesDialog],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
GfActivitiesTableModule,
|
GfActivitiesTableModule,
|
||||||
GfDialogFooterModule,
|
GfDialogFooterModule,
|
||||||
GfDialogHeaderModule,
|
GfDialogHeaderModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatDialogModule,
|
MatDialogModule,
|
||||||
MatExpansionModule
|
MatExpansionModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatSelectModule,
|
||||||
|
ReactiveFormsModule
|
||||||
],
|
],
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import { User } from '@ghostfolio/common/interfaces';
|
import { User } from '@ghostfolio/common/interfaces';
|
||||||
|
import { Type } from '@prisma/client';
|
||||||
|
|
||||||
export interface ImportActivitiesDialogParams {
|
export interface ImportActivitiesDialogParams {
|
||||||
|
activityTypes: Type[];
|
||||||
deviceType: string;
|
deviceType: string;
|
||||||
user: User;
|
user: User;
|
||||||
}
|
}
|
||||||
|
@ -84,6 +84,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
|||||||
};
|
};
|
||||||
|
|
||||||
public user: User;
|
public user: User;
|
||||||
|
public worldMapChartFormat: string;
|
||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
@ -193,6 +194,11 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
|||||||
...tagFilters
|
...tagFilters
|
||||||
];
|
];
|
||||||
|
|
||||||
|
this.worldMapChartFormat =
|
||||||
|
this.hasImpersonationId || this.user.settings.isRestrictedView
|
||||||
|
? `{0}%`
|
||||||
|
: `{0} ${this.user?.settings?.baseCurrency}`;
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
this.changeDetectorRef.markForCheck();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h3 class="d-flex justify-content-center mb-3" i18n>Allocations</h3>
|
<h3 class="d-none d-sm-block mb-3 text-center" i18n>Allocations</h3>
|
||||||
<gf-activities-filter
|
<gf-activities-filter
|
||||||
[allFilters]="allFilters"
|
[allFilters]="allFilters"
|
||||||
[isLoading]="isLoading"
|
[isLoading]="isLoading"
|
||||||
@ -257,9 +257,10 @@
|
|||||||
</mat-card-header>
|
</mat-card-header>
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<gf-world-map-chart
|
<gf-world-map-chart
|
||||||
[baseCurrency]="user?.settings?.baseCurrency"
|
|
||||||
[countries]="countries"
|
[countries]="countries"
|
||||||
|
[format]="worldMapChartFormat"
|
||||||
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
|
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
|
||||||
|
[locale]="user?.settings?.locale"
|
||||||
></gf-world-map-chart>
|
></gf-world-map-chart>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-xs-12 col-md-4 my-2">
|
<div class="col-xs-12 col-md-4 my-2">
|
||||||
|
@ -8,6 +8,7 @@ import { DataService } from '@ghostfolio/client/services/data.service';
|
|||||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
import {
|
import {
|
||||||
|
Filter,
|
||||||
HistoricalDataItem,
|
HistoricalDataItem,
|
||||||
Position,
|
Position,
|
||||||
User
|
User
|
||||||
@ -15,12 +16,13 @@ import {
|
|||||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import { DateRange, GroupBy, ToggleOption } from '@ghostfolio/common/types';
|
import { DateRange, GroupBy, ToggleOption } from '@ghostfolio/common/types';
|
||||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
import { translate } from '@ghostfolio/ui/i18n';
|
||||||
|
import { AssetClass, DataSource, SymbolProfile } from '@prisma/client';
|
||||||
import { differenceInDays } from 'date-fns';
|
import { differenceInDays } from 'date-fns';
|
||||||
import { sortBy } from 'lodash';
|
import { sortBy } from 'lodash';
|
||||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { takeUntil } from 'rxjs/operators';
|
import { distinctUntilChanged, map, takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
host: { class: 'page' },
|
host: { class: 'page' },
|
||||||
@ -29,27 +31,32 @@ import { takeUntil } from 'rxjs/operators';
|
|||||||
templateUrl: './analysis-page.html'
|
templateUrl: './analysis-page.html'
|
||||||
})
|
})
|
||||||
export class AnalysisPageComponent implements OnDestroy, OnInit {
|
export class AnalysisPageComponent implements OnDestroy, OnInit {
|
||||||
|
public activeFilters: Filter[] = [];
|
||||||
|
public allFilters: Filter[];
|
||||||
public benchmarkDataItems: HistoricalDataItem[] = [];
|
public benchmarkDataItems: HistoricalDataItem[] = [];
|
||||||
public benchmarks: Partial<SymbolProfile>[];
|
public benchmarks: Partial<SymbolProfile>[];
|
||||||
public bottom3: Position[];
|
public bottom3: Position[];
|
||||||
public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS;
|
public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS;
|
||||||
public daysInMarket: number;
|
public daysInMarket: number;
|
||||||
public deviceType: string;
|
public deviceType: string;
|
||||||
public dividendsByMonth: InvestmentItem[];
|
public dividendsByGroup: InvestmentItem[];
|
||||||
public dividendTimelineDataLabel = $localize`Dividend`;
|
public dividendTimelineDataLabel = $localize`Dividend`;
|
||||||
|
public filters$ = new Subject<Filter[]>();
|
||||||
public firstOrderDate: Date;
|
public firstOrderDate: Date;
|
||||||
public hasImpersonationId: boolean;
|
public hasImpersonationId: boolean;
|
||||||
public investments: InvestmentItem[];
|
public investments: InvestmentItem[];
|
||||||
public investmentTimelineDataLabel = $localize`Deposit`;
|
public investmentTimelineDataLabel = $localize`Deposit`;
|
||||||
public investmentsByMonth: InvestmentItem[];
|
public investmentsByGroup: InvestmentItem[];
|
||||||
public isLoadingBenchmarkComparator: boolean;
|
public isLoadingBenchmarkComparator: boolean;
|
||||||
public isLoadingInvestmentChart: boolean;
|
public isLoadingInvestmentChart: boolean;
|
||||||
public mode: GroupBy = 'month';
|
public mode: GroupBy = 'month';
|
||||||
public modeOptions: ToggleOption[] = [
|
public modeOptions: ToggleOption[] = [
|
||||||
{ label: $localize`Monthly`, value: 'month' }
|
{ label: $localize`Monthly`, value: 'month' },
|
||||||
|
{ label: $localize`Yearly`, value: 'year' }
|
||||||
];
|
];
|
||||||
public performanceDataItems: HistoricalDataItem[];
|
public performanceDataItems: HistoricalDataItem[];
|
||||||
public performanceDataItemsInPercentage: HistoricalDataItem[];
|
public performanceDataItemsInPercentage: HistoricalDataItem[];
|
||||||
|
public placeholder = '';
|
||||||
public portfolioEvolutionDataLabel = $localize`Deposit`;
|
public portfolioEvolutionDataLabel = $localize`Deposit`;
|
||||||
public top3: Position[];
|
public top3: Position[];
|
||||||
public user: User;
|
public user: User;
|
||||||
@ -85,6 +92,17 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get savingsRate() {
|
||||||
|
const savingsRatePerMonth =
|
||||||
|
this.hasImpersonationId || this.user.settings.isRestrictedView
|
||||||
|
? undefined
|
||||||
|
: this.user?.settings?.savingsRate;
|
||||||
|
|
||||||
|
return this.mode === 'year'
|
||||||
|
? savingsRatePerMonth * 12
|
||||||
|
: savingsRatePerMonth;
|
||||||
|
}
|
||||||
|
|
||||||
public ngOnInit() {
|
public ngOnInit() {
|
||||||
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||||
|
|
||||||
@ -95,12 +113,63 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
|||||||
this.hasImpersonationId = !!aId;
|
this.hasImpersonationId = !!aId;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.filters$
|
||||||
|
.pipe(
|
||||||
|
distinctUntilChanged(),
|
||||||
|
map((filters) => {
|
||||||
|
this.activeFilters = filters;
|
||||||
|
this.placeholder =
|
||||||
|
this.activeFilters.length <= 0
|
||||||
|
? $localize`Filter by account or tag...`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
this.update();
|
||||||
|
}),
|
||||||
|
takeUntil(this.unsubscribeSubject)
|
||||||
|
)
|
||||||
|
.subscribe(() => {});
|
||||||
|
|
||||||
this.userService.stateChanged
|
this.userService.stateChanged
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((state) => {
|
.subscribe((state) => {
|
||||||
if (state?.user) {
|
if (state?.user) {
|
||||||
this.user = state.user;
|
this.user = state.user;
|
||||||
|
|
||||||
|
const accountFilters: Filter[] = this.user.accounts
|
||||||
|
.filter(({ accountType }) => {
|
||||||
|
return accountType === 'SECURITIES';
|
||||||
|
})
|
||||||
|
.map(({ id, name }) => {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label: name,
|
||||||
|
type: 'ACCOUNT'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const assetClassFilters: Filter[] = [];
|
||||||
|
for (const assetClass of Object.keys(AssetClass)) {
|
||||||
|
assetClassFilters.push({
|
||||||
|
id: assetClass,
|
||||||
|
label: translate(assetClass),
|
||||||
|
type: 'ASSET_CLASS'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagFilters: Filter[] = this.user.tags.map(({ id, name }) => {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label: name,
|
||||||
|
type: 'TAG'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
this.allFilters = [
|
||||||
|
...accountFilters,
|
||||||
|
...assetClassFilters,
|
||||||
|
...tagFilters
|
||||||
|
];
|
||||||
|
|
||||||
this.update();
|
this.update();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -144,6 +213,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
|||||||
|
|
||||||
public onChangeGroupBy(aMode: GroupBy) {
|
public onChangeGroupBy(aMode: GroupBy) {
|
||||||
this.mode = aMode;
|
this.mode = aMode;
|
||||||
|
this.fetchDividendsAndInvestments();
|
||||||
}
|
}
|
||||||
|
|
||||||
public ngOnDestroy() {
|
public ngOnDestroy() {
|
||||||
@ -151,6 +221,34 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
|||||||
this.unsubscribeSubject.complete();
|
this.unsubscribeSubject.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fetchDividendsAndInvestments() {
|
||||||
|
this.dataService
|
||||||
|
.fetchDividends({
|
||||||
|
filters: this.activeFilters,
|
||||||
|
groupBy: this.mode,
|
||||||
|
range: this.user?.settings?.dateRange
|
||||||
|
})
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(({ dividends }) => {
|
||||||
|
this.dividendsByGroup = dividends;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.dataService
|
||||||
|
.fetchInvestments({
|
||||||
|
filters: this.activeFilters,
|
||||||
|
groupBy: this.mode,
|
||||||
|
range: this.user?.settings?.dateRange
|
||||||
|
})
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(({ investments }) => {
|
||||||
|
this.investmentsByGroup = investments;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private openPositionDialog({
|
private openPositionDialog({
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
@ -198,6 +296,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
|||||||
|
|
||||||
this.dataService
|
this.dataService
|
||||||
.fetchPortfolioPerformance({
|
.fetchPortfolioPerformance({
|
||||||
|
filters: this.activeFilters,
|
||||||
range: this.user?.settings?.dateRange
|
range: this.user?.settings?.dateRange
|
||||||
})
|
})
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
@ -234,32 +333,11 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.dataService
|
this.dataService
|
||||||
.fetchDividends({
|
.fetchPositions({
|
||||||
groupBy: 'month',
|
filters: this.activeFilters,
|
||||||
range: this.user?.settings?.dateRange
|
range: this.user?.settings?.dateRange
|
||||||
})
|
})
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(({ dividends }) => {
|
|
||||||
this.dividendsByMonth = dividends;
|
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.dataService
|
|
||||||
.fetchInvestments({
|
|
||||||
groupBy: 'month',
|
|
||||||
range: this.user?.settings?.dateRange
|
|
||||||
})
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe(({ investments }) => {
|
|
||||||
this.investmentsByMonth = investments;
|
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.dataService
|
|
||||||
.fetchPositions({ range: this.user?.settings?.dateRange })
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe(({ positions }) => {
|
.subscribe(({ positions }) => {
|
||||||
const positionsSorted = sortBy(
|
const positionsSorted = sortBy(
|
||||||
positions,
|
positions,
|
||||||
@ -277,6 +355,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
|||||||
this.changeDetectorRef.markForCheck();
|
this.changeDetectorRef.markForCheck();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.fetchDividendsAndInvestments();
|
||||||
this.changeDetectorRef.markForCheck();
|
this.changeDetectorRef.markForCheck();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<h3 class="d-flex justify-content-center" i18n>Analysis</h3>
|
<h3 class="d-none d-sm-block mb-3 text-center" i18n>Analysis</h3>
|
||||||
<div *ngIf="user?.settings?.viewMode !== 'ZEN'" class="my-4 text-center">
|
<div *ngIf="user?.settings?.viewMode !== 'ZEN'" class="my-4 text-center">
|
||||||
<gf-toggle
|
<gf-toggle
|
||||||
[defaultValue]="user?.settings?.dateRange"
|
[defaultValue]="user?.settings?.dateRange"
|
||||||
@ -8,6 +8,12 @@
|
|||||||
(change)="onChangeDateRange($event.value)"
|
(change)="onChangeDateRange($event.value)"
|
||||||
></gf-toggle>
|
></gf-toggle>
|
||||||
</div>
|
</div>
|
||||||
|
<gf-activities-filter
|
||||||
|
[allFilters]="allFilters"
|
||||||
|
[isLoading]="isLoadingBenchmarkComparator || isLoadingInvestmentChart"
|
||||||
|
[placeholder]="placeholder"
|
||||||
|
(valueChanged)="filters$.next($event)"
|
||||||
|
></gf-activities-filter>
|
||||||
<div class="mb-5 row">
|
<div class="mb-5 row">
|
||||||
<div class="col-lg">
|
<div class="col-lg">
|
||||||
<gf-benchmark-comparator
|
<gf-benchmark-comparator
|
||||||
@ -174,15 +180,15 @@
|
|||||||
<div class="chart-container">
|
<div class="chart-container">
|
||||||
<gf-investment-chart
|
<gf-investment-chart
|
||||||
class="h-100"
|
class="h-100"
|
||||||
groupBy="month"
|
[benchmarkDataItems]="investmentsByGroup"
|
||||||
[benchmarkDataItems]="investmentsByMonth"
|
|
||||||
[benchmarkDataLabel]="investmentTimelineDataLabel"
|
[benchmarkDataLabel]="investmentTimelineDataLabel"
|
||||||
[currency]="user?.settings?.baseCurrency"
|
[currency]="user?.settings?.baseCurrency"
|
||||||
[daysInMarket]="daysInMarket"
|
[daysInMarket]="daysInMarket"
|
||||||
|
[groupBy]="mode"
|
||||||
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
|
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
|
||||||
[locale]="user?.settings?.locale"
|
[locale]="user?.settings?.locale"
|
||||||
[range]="user?.settings?.dateRange"
|
[range]="user?.settings?.dateRange"
|
||||||
[savingsRate]="(hasImpersonationId || user.settings.isRestrictedView) ? undefined : user?.settings?.savingsRate"
|
[savingsRate]="savingsRate"
|
||||||
></gf-investment-chart>
|
></gf-investment-chart>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -211,11 +217,11 @@
|
|||||||
<div class="chart-container">
|
<div class="chart-container">
|
||||||
<gf-investment-chart
|
<gf-investment-chart
|
||||||
class="h-100"
|
class="h-100"
|
||||||
groupBy="month"
|
[benchmarkDataItems]="dividendsByGroup"
|
||||||
[benchmarkDataItems]="dividendsByMonth"
|
|
||||||
[benchmarkDataLabel]="dividendTimelineDataLabel"
|
[benchmarkDataLabel]="dividendTimelineDataLabel"
|
||||||
[currency]="user?.settings?.baseCurrency"
|
[currency]="user?.settings?.baseCurrency"
|
||||||
[daysInMarket]="daysInMarket"
|
[daysInMarket]="daysInMarket"
|
||||||
|
[groupBy]="mode"
|
||||||
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
|
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
|
||||||
[locale]="user?.settings?.locale"
|
[locale]="user?.settings?.locale"
|
||||||
[range]="user?.settings?.dateRange"
|
[range]="user?.settings?.dateRange"
|
||||||
|
@ -4,6 +4,7 @@ import { MatCardModule } from '@angular/material/card';
|
|||||||
import { GfBenchmarkComparatorModule } from '@ghostfolio/client/components/benchmark-comparator/benchmark-comparator.module';
|
import { GfBenchmarkComparatorModule } from '@ghostfolio/client/components/benchmark-comparator/benchmark-comparator.module';
|
||||||
import { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.module';
|
import { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.module';
|
||||||
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
|
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
|
||||||
|
import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module';
|
||||||
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
|
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
|
||||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||||
@ -16,6 +17,7 @@ import { AnalysisPageComponent } from './analysis-page.component';
|
|||||||
imports: [
|
imports: [
|
||||||
AnalysisPageRoutingModule,
|
AnalysisPageRoutingModule,
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
GfActivitiesFilterModule,
|
||||||
GfBenchmarkComparatorModule,
|
GfBenchmarkComparatorModule,
|
||||||
GfInvestmentChartModule,
|
GfInvestmentChartModule,
|
||||||
GfPremiumIndicatorModule,
|
GfPremiumIndicatorModule,
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="row mb-5">
|
<div class="row mb-5">
|
||||||
<div class="col-lg">
|
<div class="col-lg">
|
||||||
<h3 class="d-flex justify-content-center mb-3" i18n>FIRE</h3>
|
<h3 class="d-none d-sm-block mb-3 text-center" i18n>FIRE</h3>
|
||||||
<div>
|
<div>
|
||||||
<h4 class="align-items-center d-flex mb-3">
|
<h4 class="align-items-center d-flex mb-3">
|
||||||
<span i18n>Calculator</span
|
<span i18n>Calculator</span
|
||||||
@ -83,9 +83,7 @@
|
|||||||
<div class="container mt-5">
|
<div class="container mt-5">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h3 class="align-items-center d-flex justify-content-center mb-3">
|
<h3 class="mb-3 text-center">X-ray</h3>
|
||||||
X-ray
|
|
||||||
</h3>
|
|
||||||
<p class="mb-4">
|
<p class="mb-4">
|
||||||
Ghostfolio X-ray uses static analysis to identify potential issues and
|
Ghostfolio X-ray uses static analysis to identify potential issues and
|
||||||
risks in your portfolio.
|
risks in your portfolio.
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user