Compare commits
79 Commits
Author | SHA1 | Date | |
---|---|---|---|
48f6b8d353 | |||
f369996912 | |||
dc424a86ec | |||
5d8bde5a70 | |||
16360c0c67 | |||
526a6b2030 | |||
5000e9c79b | |||
161cb82820 | |||
fed28f29d1 | |||
8bd9330acc | |||
155c08d665 | |||
b8ad6d6662 | |||
9d6977e3f7 | |||
919b20197f | |||
62885ea890 | |||
035d8ad9eb | |||
9676f96e97 | |||
65e151151b | |||
5d3bbb8f30 | |||
b464fefc57 | |||
bcb7f5f522 | |||
f15b33e950 | |||
ca64492e77 | |||
761376d72d | |||
9c086edffe | |||
585f99e4df | |||
9d907b5eb5 | |||
ba05f5ba30 | |||
3261e3ee59 | |||
5607c6bb52 | |||
1c6050d3e3 | |||
38f2930ec6 | |||
556be61fff | |||
651b4bcff7 | |||
0a8d159f78 | |||
1a4109ebaa | |||
92e502e1c2 | |||
e344c43a5a | |||
d6b78f3457 | |||
9bbb856f66 | |||
d3707bbb87 | |||
7df53896f3 | |||
b2b3fde80e | |||
a83441b3ba | |||
075431d868 | |||
0168c1c4e8 | |||
07de8f87fc | |||
3e16041c16 | |||
5882b7914d | |||
69c9e259b1 | |||
aca37a27f9 | |||
313d2a2f79 | |||
9ac67b0af2 | |||
1e526852a7 | |||
e54638a684 | |||
0179823ad9 | |||
029b7bed9a | |||
635f10e2d0 | |||
cebf879d67 | |||
124bdc028d | |||
d69a69ce18 | |||
15344513ce | |||
b291d9e031 | |||
bee702302f | |||
bb56e09a13 | |||
0873f539c5 | |||
6dcd801d05 | |||
77065dac50 | |||
438484879d | |||
e37a650c70 | |||
6e8c90b3fc | |||
9e1a7fc981 | |||
ff638adf03 | |||
fa44cee781 | |||
db1d474ddf | |||
994275e093 | |||
ee397c8047 | |||
7203939c42 | |||
9725f16c81 |
212
CHANGELOG.md
212
CHANGELOG.md
@ -5,6 +5,216 @@ 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.111.0 - 03.02.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for deleting symbol profile data in the admin control panel
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Used `dataSource` and `symbol` from `SymbolProfile` instead of the `order` object (in `ExportService` and `PortfolioService`)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the symbol selection of the 7d data gathering
|
||||||
|
|
||||||
|
## 1.110.0 - 02.02.2022
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the data source of the _Fear & Greed Index_ (market mood)
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
|
## 1.109.0 - 01.02.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for the (optional) `accountId` in the import functionality for activities
|
||||||
|
- Added support for the (optional) `dataSource` in the import functionality for activities
|
||||||
|
- Added support for the data source transformation
|
||||||
|
- Added support for the cryptocurrency _Mina Protocol_ (`MINA-USD`)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the usability of the form in the create or edit transaction dialog
|
||||||
|
- Improved the consistent use of `symbol` in combination with `dataSource`
|
||||||
|
- Removed the primary data source from the client
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- Removed the unused endpoint `GET api/order/:id`
|
||||||
|
|
||||||
|
## 1.108.0 - 27.01.2022
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the annualized performance in the new calculation engine
|
||||||
|
- Increased the historical data chart of the _Fear & Greed Index_ (market mood) to 90 days
|
||||||
|
|
||||||
|
## 1.107.0 - 24.01.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a new calculation engine (experimental)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the styling in the footer row of the activities table
|
||||||
|
|
||||||
|
## 1.106.0 - 23.01.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the footer row with total fees and total value to the activities table
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Extended the historical data view in the admin control panel
|
||||||
|
- Upgraded _Stripe_ dependencies
|
||||||
|
- Upgraded `prisma` from version `3.7.0` to `3.8.1`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Improved the redirection on logout
|
||||||
|
|
||||||
|
## 1.105.0 - 20.01.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for fetching multiple symbols in the `GOOGLE_SHEETS` data provider
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the data provider with grouping by data source and thereby reducing the number of requests
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the unresolved account names in the _X-ray_ section
|
||||||
|
- Fixed the date conversion in the `GOOGLE_SHEETS` data provider
|
||||||
|
|
||||||
|
## 1.104.0 - 16.01.2022
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the fallback to load currencies directly from the data provider
|
||||||
|
- Fixed the missing symbol profile data connection in the import functionality for activities
|
||||||
|
|
||||||
|
## 1.103.0 - 13.01.2022
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Added links to the statistics section on the about page
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the currency of the value in the position detail dialog
|
||||||
|
|
||||||
|
## 1.102.0 - 11.01.2022
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Start eliminating `dataSource` from activity
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the support for multiple accounts with the same name
|
||||||
|
- Fixed the preselected default account of the create activity dialog
|
||||||
|
|
||||||
|
## 1.101.0 - 08.01.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added `GOOGLE_SHEETS` as a new data source type
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Excluded the url pattern of shared portfolios in the `robots.txt` file
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
|
## 1.100.0 - 05.01.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the _Top 3_ and _Bottom 3_ performers to the analysis page
|
||||||
|
- Added a blog post
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the routing of the create activity dialog
|
||||||
|
- Fixed the link color in the blog posts
|
||||||
|
|
||||||
|
## 1.99.0 - 01.01.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Exposed the profile data gathering by symbol as an endpoint
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the portfolio analysis page: show the y-axis and extend the chart in relation to the days in market
|
||||||
|
- Restructured the about page
|
||||||
|
- Start refactoring _transactions_ to _activities_
|
||||||
|
- Refactored the demo user id
|
||||||
|
- Upgraded `angular` from version `13.0.2` to `13.1.1`
|
||||||
|
- Upgraded `chart.js` from version `3.5.0` to `3.7.0`
|
||||||
|
- Upgraded `Nx` from version `13.3.0` to `13.4.1`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Hid the data provider warning while loading
|
||||||
|
- Fixed an exception with the market state caused by a failed data provider request
|
||||||
|
- Fixed an exception in the portfolio position endpoint
|
||||||
|
- Fixed the reload of the position detail dialog (with query parameters)
|
||||||
|
- Fixed the missing mapping for Russia in the data enhancer for symbol profile data via _Trackinsight_
|
||||||
|
|
||||||
|
## 1.98.0 - 29.12.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the date range component to the holdings tab
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Extended the statistics section on the about page (users in Slack community)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the creation of historical data in the admin control panel (upsert instead of update)
|
||||||
|
- Fixed the scrolling issue in the position detail dialog on mobile
|
||||||
|
|
||||||
|
## 1.97.0 - 28.12.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the transactions to the position detail dialog
|
||||||
|
- Added support for dividend
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
|
## 1.96.0 - 27.12.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Made the data provider warning more discreet
|
||||||
|
- Upgraded `http-status-codes` from version `2.1.4` to `2.2.0`
|
||||||
|
- Upgraded `ngx-device-detector` from version `2.1.1` to `3.0.0`
|
||||||
|
- Upgraded `ngx-markdown` from version `12.0.1` to `13.0.0`
|
||||||
|
- Upgraded `ngx-stripe` from version `12.0.2` to `13.0.0`
|
||||||
|
- Upgraded `prisma` from version `3.6.0` to `3.7.0`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the file type detection in the import functionality for transactions
|
||||||
|
|
||||||
## 1.95.0 - 26.12.2021
|
## 1.95.0 - 26.12.2021
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@ -33,7 +243,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added support for cryptocurrency _Solana_ (`SOL-USD`)
|
- Added support for the cryptocurrency _Solana_ (`SOL-USD`)
|
||||||
- Extended the documentation for self-hosting with the [official Ghostfolio Docker image](https://hub.docker.com/r/ghostfolio/ghostfolio)
|
- Extended the documentation for self-hosting with the [official Ghostfolio Docker image](https://hub.docker.com/r/ghostfolio/ghostfolio)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
@ -12,7 +12,7 @@
|
|||||||
<strong>Open Source Wealth Management Software made for Humans</strong>
|
<strong>Open Source Wealth Management Software made for Humans</strong>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/en/blog/2021/07/hello-ghostfolio"><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>
|
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/blog"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a href="#contributing">
|
<a href="#contributing">
|
||||||
@ -187,6 +187,6 @@ Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Sl
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
© 2021 [Ghostfolio](https://ghostfol.io)
|
© 2022 [Ghostfolio](https://ghostfol.io)
|
||||||
|
|
||||||
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).
|
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
import { PortfolioServiceStrategy } from '@ghostfolio/api/app/portfolio/portfolio-service.strategy';
|
||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
import {
|
import {
|
||||||
nullifyValuesInObject,
|
nullifyValuesInObject,
|
||||||
@ -35,7 +35,7 @@ export class AccountController {
|
|||||||
public constructor(
|
public constructor(
|
||||||
private readonly accountService: AccountService,
|
private readonly accountService: AccountService,
|
||||||
private readonly impersonationService: ImpersonationService,
|
private readonly impersonationService: ImpersonationService,
|
||||||
private readonly portfolioService: PortfolioService,
|
private readonly portfolioServiceStrategy: PortfolioServiceStrategy,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||||
private readonly userService: UserService
|
private readonly userService: UserService
|
||||||
) {}
|
) {}
|
||||||
@ -91,10 +91,9 @@ export class AccountController {
|
|||||||
this.request.user.id
|
this.request.user.id
|
||||||
);
|
);
|
||||||
|
|
||||||
let accountsWithAggregations =
|
let accountsWithAggregations = await this.portfolioServiceStrategy
|
||||||
await this.portfolioService.getAccountsWithAggregations(
|
.get()
|
||||||
impersonationUserId || this.request.user.id
|
.getAccountsWithAggregations(impersonationUserId || this.request.user.id);
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationUserId ||
|
impersonationUserId ||
|
||||||
|
@ -11,6 +11,7 @@ import type { RequestWithUser } from '@ghostfolio/common/types';
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
@ -96,6 +97,29 @@ export class AdminController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('gather/profile-data/:dataSource/:symbol')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async gatherProfileDataForSymbol(
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dataGatheringService.gatherProfileData([{ dataSource, symbol }]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
@Post('gather/:dataSource/:symbol')
|
@Post('gather/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async gatherSymbol(
|
public async gatherSymbol(
|
||||||
@ -172,9 +196,10 @@ export class AdminController {
|
|||||||
return this.adminService.getMarketData();
|
return this.adminService.getMarketData();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('market-data/:symbol')
|
@Get('market-data/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getMarketDataBySymbol(
|
public async getMarketDataBySymbol(
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
): Promise<AdminMarketDataDetails> {
|
): Promise<AdminMarketDataDetails> {
|
||||||
if (
|
if (
|
||||||
@ -189,7 +214,7 @@ export class AdminController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.adminService.getMarketDataBySymbol(symbol);
|
return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('market-data/:dataSource/:symbol/:dateString')
|
@Put('market-data/:dataSource/:symbol/:dateString')
|
||||||
@ -215,7 +240,7 @@ export class AdminController {
|
|||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
|
|
||||||
return this.marketDataService.updateMarketData({
|
return this.marketDataService.updateMarketData({
|
||||||
data,
|
data: { ...data, dataSource },
|
||||||
where: {
|
where: {
|
||||||
date_symbol: {
|
date_symbol: {
|
||||||
date,
|
date,
|
||||||
@ -225,6 +250,27 @@ export class AdminController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Delete('profile-data/:dataSource/:symbol')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async deleteProfileData(
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.adminService.deleteProfileData({ dataSource, symbol });
|
||||||
|
}
|
||||||
|
|
||||||
@Put('settings/:key')
|
@Put('settings/:key')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async updateProperty(
|
public async updateProperty(
|
||||||
|
@ -6,6 +6,7 @@ import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-d
|
|||||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { AdminController } from './admin.controller';
|
import { AdminController } from './admin.controller';
|
||||||
@ -20,7 +21,8 @@ import { AdminService } from './admin.service';
|
|||||||
MarketDataModule,
|
MarketDataModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
PropertyModule,
|
PropertyModule,
|
||||||
SubscriptionModule
|
SubscriptionModule,
|
||||||
|
SymbolProfileModule
|
||||||
],
|
],
|
||||||
controllers: [AdminController],
|
controllers: [AdminController],
|
||||||
providers: [AdminService],
|
providers: [AdminService],
|
||||||
|
@ -5,14 +5,16 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
|
|||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config';
|
import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
AdminData,
|
AdminData,
|
||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
AdminMarketDataDetails
|
AdminMarketDataDetails,
|
||||||
|
AdminMarketDataItem
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Property } from '@prisma/client';
|
import { DataSource, Property } from '@prisma/client';
|
||||||
import { differenceInDays } from 'date-fns';
|
import { differenceInDays } from 'date-fns';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -24,9 +26,21 @@ export class AdminService {
|
|||||||
private readonly marketDataService: MarketDataService,
|
private readonly marketDataService: MarketDataService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly propertyService: PropertyService,
|
private readonly propertyService: PropertyService,
|
||||||
private readonly subscriptionService: SubscriptionService
|
private readonly subscriptionService: SubscriptionService,
|
||||||
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
public async deleteProfileData({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}: {
|
||||||
|
dataSource: DataSource;
|
||||||
|
symbol: string;
|
||||||
|
}) {
|
||||||
|
await this.marketDataService.deleteMany({ dataSource, symbol });
|
||||||
|
await this.symbolProfileService.delete({ dataSource, symbol });
|
||||||
|
}
|
||||||
|
|
||||||
public async get(): Promise<AdminData> {
|
public async get(): Promise<AdminData> {
|
||||||
return {
|
return {
|
||||||
dataGatheringProgress:
|
dataGatheringProgress:
|
||||||
@ -56,25 +70,85 @@ export class AdminService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getMarketData(): Promise<AdminMarketData> {
|
public async getMarketData(): Promise<AdminMarketData> {
|
||||||
return {
|
const marketData = await this.prismaService.marketData.groupBy({
|
||||||
marketData: await (
|
_count: true,
|
||||||
await this.dataGatheringService.getSymbolsMax()
|
by: ['dataSource', 'symbol']
|
||||||
).map((symbol) => {
|
});
|
||||||
return symbol;
|
|
||||||
|
const currencyPairsToGather: AdminMarketDataItem[] =
|
||||||
|
this.exchangeRateDataService
|
||||||
|
.getCurrencyPairs()
|
||||||
|
.map(({ dataSource, symbol }) => {
|
||||||
|
const marketDataItemCount =
|
||||||
|
marketData.find((marketDataItem) => {
|
||||||
|
return (
|
||||||
|
marketDataItem.dataSource === dataSource &&
|
||||||
|
marketDataItem.symbol === symbol
|
||||||
|
);
|
||||||
|
})?._count ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
dataSource,
|
||||||
|
marketDataItemCount,
|
||||||
|
symbol
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const symbolProfilesToGather: AdminMarketDataItem[] = (
|
||||||
|
await this.prismaService.symbolProfile.findMany({
|
||||||
|
orderBy: [{ symbol: 'asc' }],
|
||||||
|
select: {
|
||||||
|
_count: {
|
||||||
|
select: { Order: true }
|
||||||
|
},
|
||||||
|
dataSource: true,
|
||||||
|
Order: {
|
||||||
|
orderBy: [{ date: 'asc' }],
|
||||||
|
select: { date: true },
|
||||||
|
take: 1
|
||||||
|
},
|
||||||
|
scraperConfiguration: true,
|
||||||
|
symbol: true
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
).map((symbolProfile) => {
|
||||||
|
const marketDataItemCount =
|
||||||
|
marketData.find((marketDataItem) => {
|
||||||
|
return (
|
||||||
|
marketDataItem.dataSource === symbolProfile.dataSource &&
|
||||||
|
marketDataItem.symbol === symbolProfile.symbol
|
||||||
|
);
|
||||||
|
})?._count ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
marketDataItemCount,
|
||||||
|
activityCount: symbolProfile._count.Order,
|
||||||
|
dataSource: symbolProfile.dataSource,
|
||||||
|
date: symbolProfile.Order?.[0]?.date,
|
||||||
|
symbol: symbolProfile.symbol
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
marketData: [...currencyPairsToGather, ...symbolProfilesToGather]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getMarketDataBySymbol(
|
public async getMarketDataBySymbol({
|
||||||
aSymbol: string
|
dataSource,
|
||||||
): Promise<AdminMarketDataDetails> {
|
symbol
|
||||||
|
}: {
|
||||||
|
dataSource: DataSource;
|
||||||
|
symbol: string;
|
||||||
|
}): Promise<AdminMarketDataDetails> {
|
||||||
return {
|
return {
|
||||||
marketData: await this.marketDataService.marketDataItems({
|
marketData: await this.marketDataService.marketDataItems({
|
||||||
orderBy: {
|
orderBy: {
|
||||||
date: 'asc'
|
date: 'asc'
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
symbol: aSymbol
|
dataSource,
|
||||||
|
symbol
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
@ -11,12 +11,13 @@ export class ExportService {
|
|||||||
const orders = await this.prismaService.order.findMany({
|
const orders = await this.prismaService.order.findMany({
|
||||||
orderBy: { date: 'desc' },
|
orderBy: { date: 'desc' },
|
||||||
select: {
|
select: {
|
||||||
|
accountId: true,
|
||||||
currency: true,
|
currency: true,
|
||||||
dataSource: true,
|
dataSource: true,
|
||||||
date: true,
|
date: true,
|
||||||
fee: true,
|
fee: true,
|
||||||
quantity: true,
|
quantity: true,
|
||||||
symbol: true,
|
SymbolProfile: true,
|
||||||
type: true,
|
type: true,
|
||||||
unitPrice: true
|
unitPrice: true
|
||||||
},
|
},
|
||||||
@ -25,7 +26,30 @@ export class ExportService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
meta: { date: new Date().toISOString(), version: environment.version },
|
meta: { date: new Date().toISOString(), version: environment.version },
|
||||||
orders
|
orders: orders.map(
|
||||||
|
({
|
||||||
|
accountId,
|
||||||
|
currency,
|
||||||
|
date,
|
||||||
|
fee,
|
||||||
|
quantity,
|
||||||
|
SymbolProfile,
|
||||||
|
type,
|
||||||
|
unitPrice
|
||||||
|
}) => {
|
||||||
|
return {
|
||||||
|
accountId,
|
||||||
|
currency,
|
||||||
|
date,
|
||||||
|
fee,
|
||||||
|
quantity,
|
||||||
|
type,
|
||||||
|
unitPrice,
|
||||||
|
dataSource: SymbolProfile.dataSource,
|
||||||
|
symbol: SymbolProfile.symbol
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
||||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
import { OrderModule } from '@ghostfolio/api/app/order/order.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';
|
||||||
@ -15,10 +15,11 @@ import { ImportService } from './import.service';
|
|||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
|
OrderModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
RedisCacheModule
|
RedisCacheModule
|
||||||
],
|
],
|
||||||
controllers: [ImportController],
|
controllers: [ImportController],
|
||||||
providers: [CacheService, ImportService, OrderService]
|
providers: [CacheService, ImportService]
|
||||||
})
|
})
|
||||||
export class ImportModule {}
|
export class ImportModule {}
|
||||||
|
@ -20,6 +20,11 @@ export class ImportService {
|
|||||||
orders: Partial<Order>[];
|
orders: Partial<Order>[];
|
||||||
userId: string;
|
userId: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
|
for (const order of orders) {
|
||||||
|
order.dataSource =
|
||||||
|
order.dataSource ?? this.dataProviderService.getPrimaryDataSource();
|
||||||
|
}
|
||||||
|
|
||||||
await this.validateOrders({ orders, userId });
|
await this.validateOrders({ orders, userId });
|
||||||
|
|
||||||
for (const {
|
for (const {
|
||||||
@ -34,11 +39,7 @@ export class ImportService {
|
|||||||
unitPrice
|
unitPrice
|
||||||
} of orders) {
|
} of orders) {
|
||||||
await this.orderService.createOrder({
|
await this.orderService.createOrder({
|
||||||
Account: {
|
accountId,
|
||||||
connect: {
|
|
||||||
id_userId: { userId, id: accountId }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
currency,
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
fee,
|
fee,
|
||||||
@ -46,7 +47,22 @@ export class ImportService {
|
|||||||
symbol,
|
symbol,
|
||||||
type,
|
type,
|
||||||
unitPrice,
|
unitPrice,
|
||||||
|
userId,
|
||||||
date: parseISO(<string>(<unknown>date)),
|
date: parseISO(<string>(<unknown>date)),
|
||||||
|
SymbolProfile: {
|
||||||
|
connectOrCreate: {
|
||||||
|
create: {
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
dataSource_symbol: {
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
User: { connect: { id: userId } }
|
User: { connect: { id: userId } }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -1,32 +1,33 @@
|
|||||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.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 { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import {
|
import {
|
||||||
|
DEMO_USER_ID,
|
||||||
PROPERTY_IS_READ_ONLY_MODE,
|
PROPERTY_IS_READ_ONLY_MODE,
|
||||||
|
PROPERTY_SLACK_COMMUNITY_USERS,
|
||||||
PROPERTY_STRIPE_CONFIG,
|
PROPERTY_STRIPE_CONFIG,
|
||||||
PROPERTY_SYSTEM_MESSAGE
|
PROPERTY_SYSTEM_MESSAGE
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
|
import { encodeDataSource } from '@ghostfolio/common/helper';
|
||||||
import { InfoItem } from '@ghostfolio/common/interfaces';
|
import { InfoItem } from '@ghostfolio/common/interfaces';
|
||||||
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
|
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
|
||||||
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
|
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
|
||||||
import { permissions } from '@ghostfolio/common/permissions';
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
import * as bent from 'bent';
|
import * as bent from 'bent';
|
||||||
import { subDays } from 'date-fns';
|
import { subDays } from 'date-fns';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class InfoService {
|
export class InfoService {
|
||||||
private static CACHE_KEY_STATISTICS = 'STATISTICS';
|
private static CACHE_KEY_STATISTICS = 'STATISTICS';
|
||||||
private static DEMO_USER_ID = '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f';
|
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly dataProviderService: DataProviderService,
|
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
@ -50,6 +51,10 @@ export class InfoService {
|
|||||||
globalPermissions.push(permissions.enableBlog);
|
globalPermissions.push(permissions.enableBlog);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
||||||
|
info.fearAndGreedDataSource = encodeDataSource(DataSource.RAKUTEN);
|
||||||
|
}
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
|
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
|
||||||
globalPermissions.push(permissions.enableImport);
|
globalPermissions.push(permissions.enableImport);
|
||||||
}
|
}
|
||||||
@ -91,7 +96,6 @@ export class InfoService {
|
|||||||
currencies: this.exchangeRateDataService.getCurrencies(),
|
currencies: this.exchangeRateDataService.getCurrencies(),
|
||||||
demoAuthToken: this.getDemoAuthToken(),
|
demoAuthToken: this.getDemoAuthToken(),
|
||||||
lastDataGathering: await this.getLastDataGathering(),
|
lastDataGathering: await this.getLastDataGathering(),
|
||||||
primaryDataSource: this.dataProviderService.getPrimaryDataSource(),
|
|
||||||
statistics: await this.getStatistics(),
|
statistics: await this.getStatistics(),
|
||||||
subscriptions: await this.getSubscriptions()
|
subscriptions: await this.getSubscriptions()
|
||||||
};
|
};
|
||||||
@ -187,9 +191,15 @@ export class InfoService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async countSlackCommunityUsers() {
|
||||||
|
return (await this.propertyService.getByKey(
|
||||||
|
PROPERTY_SLACK_COMMUNITY_USERS
|
||||||
|
)) as string;
|
||||||
|
}
|
||||||
|
|
||||||
private getDemoAuthToken() {
|
private getDemoAuthToken() {
|
||||||
return this.jwtService.sign({
|
return this.jwtService.sign({
|
||||||
id: InfoService.DEMO_USER_ID
|
id: DEMO_USER_ID
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -218,19 +228,19 @@ export class InfoService {
|
|||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
const activeUsers1d = await this.countActiveUsers(1);
|
const activeUsers1d = await this.countActiveUsers(1);
|
||||||
const activeUsers7d = await this.countActiveUsers(7);
|
|
||||||
const activeUsers30d = await this.countActiveUsers(30);
|
const activeUsers30d = await this.countActiveUsers(30);
|
||||||
const newUsers30d = await this.countNewUsers(30);
|
const newUsers30d = await this.countNewUsers(30);
|
||||||
const gitHubContributors = await this.countGitHubContributors();
|
const gitHubContributors = await this.countGitHubContributors();
|
||||||
const gitHubStargazers = await this.countGitHubStargazers();
|
const gitHubStargazers = await this.countGitHubStargazers();
|
||||||
|
const slackCommunityUsers = await this.countSlackCommunityUsers();
|
||||||
|
|
||||||
statistics = {
|
statistics = {
|
||||||
activeUsers1d,
|
activeUsers1d,
|
||||||
activeUsers7d,
|
|
||||||
activeUsers30d,
|
activeUsers30d,
|
||||||
gitHubContributors,
|
gitHubContributors,
|
||||||
gitHubStargazers,
|
gitHubStargazers,
|
||||||
newUsers30d
|
newUsers30d,
|
||||||
|
slackCommunityUsers
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.redisCacheService.set(
|
await this.redisCacheService.set(
|
||||||
|
@ -1,14 +1,22 @@
|
|||||||
import { DataSource, Type } from '@prisma/client';
|
import { DataSource, Type } from '@prisma/client';
|
||||||
import { IsEnum, IsISO8601, IsNumber, IsString } from 'class-validator';
|
import {
|
||||||
|
IsEnum,
|
||||||
|
IsISO8601,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
export class CreateOrderDto {
|
export class CreateOrderDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
accountId: string;
|
accountId: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
@IsEnum(DataSource, { each: true })
|
@IsEnum(DataSource, { each: true })
|
||||||
|
@IsOptional()
|
||||||
dataSource: DataSource;
|
dataSource: DataSource;
|
||||||
|
|
||||||
@IsISO8601()
|
@IsISO8601()
|
||||||
|
10
apps/api/src/app/order/interfaces/activities.interface.ts
Normal file
10
apps/api/src/app/order/interfaces/activities.interface.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
|
export interface Activities {
|
||||||
|
activities: Activity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Activity extends OrderWithAccount {
|
||||||
|
feeInBaseCurrency: number;
|
||||||
|
valueInBaseCurrency: number;
|
||||||
|
}
|
@ -1,5 +1,7 @@
|
|||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
|
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
|
||||||
|
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 { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
@ -14,7 +16,8 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
UseGuards
|
UseGuards,
|
||||||
|
UseInterceptors
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
@ -23,6 +26,7 @@ import { parseISO } from 'date-fns';
|
|||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { CreateOrderDto } from './create-order.dto';
|
import { CreateOrderDto } from './create-order.dto';
|
||||||
|
import { Activities } from './interfaces/activities.interface';
|
||||||
import { OrderService } from './order.service';
|
import { OrderService } from './order.service';
|
||||||
import { UpdateOrderDto } from './update-order.dto';
|
import { UpdateOrderDto } from './update-order.dto';
|
||||||
|
|
||||||
@ -57,55 +61,43 @@ export class OrderController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getAllOrders(
|
public async getAllOrders(
|
||||||
@Headers('impersonation-id') impersonationId
|
@Headers('impersonation-id') impersonationId
|
||||||
): Promise<OrderModel[]> {
|
): Promise<Activities> {
|
||||||
const impersonationUserId =
|
const impersonationUserId =
|
||||||
await this.impersonationService.validateImpersonationId(
|
await this.impersonationService.validateImpersonationId(
|
||||||
impersonationId,
|
impersonationId,
|
||||||
this.request.user.id
|
this.request.user.id
|
||||||
);
|
);
|
||||||
|
const userCurrency = this.request.user.Settings.currency;
|
||||||
|
|
||||||
let orders = await this.orderService.orders({
|
let activities = await this.orderService.getOrders({
|
||||||
include: {
|
userCurrency,
|
||||||
Account: {
|
includeDrafts: true,
|
||||||
include: {
|
userId: impersonationUserId || this.request.user.id
|
||||||
Platform: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
SymbolProfile: {
|
|
||||||
select: {
|
|
||||||
name: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
orderBy: { date: 'desc' },
|
|
||||||
where: { userId: impersonationUserId || this.request.user.id }
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationUserId ||
|
impersonationUserId ||
|
||||||
this.userService.isRestrictedView(this.request.user)
|
this.userService.isRestrictedView(this.request.user)
|
||||||
) {
|
) {
|
||||||
orders = nullifyValuesInObjects(orders, ['fee', 'quantity', 'unitPrice']);
|
activities = nullifyValuesInObjects(activities, [
|
||||||
|
'fee',
|
||||||
|
'feeInBaseCurrency',
|
||||||
|
'quantity',
|
||||||
|
'unitPrice',
|
||||||
|
'value',
|
||||||
|
'valueInBaseCurrency'
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return orders;
|
return { activities };
|
||||||
}
|
|
||||||
|
|
||||||
@Get(':id')
|
|
||||||
@UseGuards(AuthGuard('jwt'))
|
|
||||||
public async getOrderById(@Param('id') id: string): Promise<OrderModel> {
|
|
||||||
return this.orderService.order({
|
|
||||||
id_userId: {
|
|
||||||
id,
|
|
||||||
userId: this.request.user.id
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> {
|
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> {
|
||||||
if (
|
if (
|
||||||
!hasPermission(this.request.user.permissions, permissions.createOrder)
|
!hasPermission(this.request.user.permissions, permissions.createOrder)
|
||||||
@ -116,39 +108,31 @@ export class OrderController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const date = parseISO(data.date);
|
|
||||||
|
|
||||||
const accountId = data.accountId;
|
|
||||||
delete data.accountId;
|
|
||||||
|
|
||||||
return this.orderService.createOrder({
|
return this.orderService.createOrder({
|
||||||
...data,
|
...data,
|
||||||
Account: {
|
date: parseISO(data.date),
|
||||||
connect: {
|
|
||||||
id_userId: { id: accountId, userId: this.request.user.id }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
date,
|
|
||||||
SymbolProfile: {
|
SymbolProfile: {
|
||||||
connectOrCreate: {
|
connectOrCreate: {
|
||||||
|
create: {
|
||||||
|
dataSource: data.dataSource,
|
||||||
|
symbol: data.symbol
|
||||||
|
},
|
||||||
where: {
|
where: {
|
||||||
dataSource_symbol: {
|
dataSource_symbol: {
|
||||||
dataSource: data.dataSource,
|
dataSource: data.dataSource,
|
||||||
symbol: data.symbol
|
symbol: data.symbol
|
||||||
}
|
}
|
||||||
},
|
|
||||||
create: {
|
|
||||||
dataSource: data.dataSource,
|
|
||||||
symbol: data.symbol
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
User: { connect: { id: this.request.user.id } }
|
User: { connect: { id: this.request.user.id } },
|
||||||
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) {
|
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) {
|
||||||
if (
|
if (
|
||||||
!hasPermission(this.request.user.permissions, permissions.updateOrder)
|
!hasPermission(this.request.user.permissions, permissions.updateOrder)
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
import { UserModule } from '@ghostfolio/api/app/user/user.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 { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
@ -16,13 +18,14 @@ import { OrderService } from './order.service';
|
|||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
|
ExchangeRateDataModule,
|
||||||
ImpersonationModule,
|
ImpersonationModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
UserModule
|
UserModule
|
||||||
],
|
],
|
||||||
controllers: [OrderController],
|
controllers: [OrderController],
|
||||||
providers: [CacheService, OrderService],
|
providers: [AccountService, CacheService, OrderService],
|
||||||
exports: [OrderService]
|
exports: [OrderService]
|
||||||
})
|
})
|
||||||
export class OrderModule {}
|
export class OrderModule {}
|
||||||
|
@ -1,15 +1,22 @@
|
|||||||
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource, Order, Prisma } from '@prisma/client';
|
import { DataSource, Order, Prisma, Type as TypeOfOrder } from '@prisma/client';
|
||||||
|
import Big from 'big.js';
|
||||||
import { endOfToday, isAfter } from 'date-fns';
|
import { endOfToday, isAfter } from 'date-fns';
|
||||||
|
|
||||||
|
import { Activity } from './interfaces/activities.interface';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OrderService {
|
export class OrderService {
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly accountService: AccountService,
|
||||||
private readonly cacheService: CacheService,
|
private readonly cacheService: CacheService,
|
||||||
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
private readonly prismaService: PrismaService
|
private readonly prismaService: PrismaService
|
||||||
) {}
|
) {}
|
||||||
@ -42,7 +49,24 @@ export class OrderService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createOrder(data: Prisma.OrderCreateInput): Promise<Order> {
|
public async createOrder(
|
||||||
|
data: Prisma.OrderCreateInput & { accountId?: string; userId: string }
|
||||||
|
): Promise<Order> {
|
||||||
|
const defaultAccount = (
|
||||||
|
await this.accountService.getAccounts(data.userId)
|
||||||
|
).find((account) => {
|
||||||
|
return account.isDefault === true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const Account = {
|
||||||
|
connect: {
|
||||||
|
id_userId: {
|
||||||
|
userId: data.userId,
|
||||||
|
id: data.accountId ?? defaultAccount?.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const isDraft = isAfter(data.date as Date, endOfToday());
|
const isDraft = isAfter(data.date as Date, endOfToday());
|
||||||
|
|
||||||
// Convert the symbol to uppercase to avoid case-sensitive duplicates
|
// Convert the symbol to uppercase to avoid case-sensitive duplicates
|
||||||
@ -65,9 +89,15 @@ export class OrderService {
|
|||||||
|
|
||||||
await this.cacheService.flush();
|
await this.cacheService.flush();
|
||||||
|
|
||||||
|
delete data.accountId;
|
||||||
|
delete data.userId;
|
||||||
|
|
||||||
|
const orderData: Prisma.OrderCreateInput = data;
|
||||||
|
|
||||||
return this.prismaService.order.create({
|
return this.prismaService.order.create({
|
||||||
data: {
|
data: {
|
||||||
...data,
|
...orderData,
|
||||||
|
Account,
|
||||||
isDraft,
|
isDraft,
|
||||||
symbol
|
symbol
|
||||||
}
|
}
|
||||||
@ -82,28 +112,65 @@ export class OrderService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public getOrders({
|
public async getOrders({
|
||||||
includeDrafts = false,
|
includeDrafts = false,
|
||||||
|
types,
|
||||||
|
userCurrency,
|
||||||
userId
|
userId
|
||||||
}: {
|
}: {
|
||||||
includeDrafts?: boolean;
|
includeDrafts?: boolean;
|
||||||
|
types?: TypeOfOrder[];
|
||||||
|
userCurrency: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
}) {
|
}): Promise<Activity[]> {
|
||||||
const where: Prisma.OrderWhereInput = { userId };
|
const where: Prisma.OrderWhereInput = { userId };
|
||||||
|
|
||||||
if (includeDrafts === false) {
|
if (includeDrafts === false) {
|
||||||
where.isDraft = false;
|
where.isDraft = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.orders({
|
if (types) {
|
||||||
where,
|
where.OR = types.map((type) => {
|
||||||
include: {
|
return {
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
type: {
|
||||||
Account: true,
|
equals: type
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
}
|
||||||
SymbolProfile: true
|
};
|
||||||
},
|
});
|
||||||
orderBy: { date: 'asc' }
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
await this.orders({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
Account: {
|
||||||
|
include: {
|
||||||
|
Platform: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
SymbolProfile: true
|
||||||
|
},
|
||||||
|
orderBy: { date: 'asc' }
|
||||||
|
})
|
||||||
|
).map((order) => {
|
||||||
|
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...order,
|
||||||
|
value,
|
||||||
|
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
|
order.fee,
|
||||||
|
order.currency,
|
||||||
|
userCurrency
|
||||||
|
),
|
||||||
|
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
|
value,
|
||||||
|
order.currency,
|
||||||
|
userCurrency
|
||||||
|
)
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -85,19 +85,6 @@ describe('CurrentRateService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('getValue', async () => {
|
|
||||||
expect(
|
|
||||||
await currentRateService.getValue({
|
|
||||||
currency: 'USD',
|
|
||||||
date: new Date(Date.UTC(2020, 0, 1, 0, 0, 0)),
|
|
||||||
symbol: 'AMZN',
|
|
||||||
userCurrency: 'CHF'
|
|
||||||
})
|
|
||||||
).toMatchObject({
|
|
||||||
marketPrice: 1847.839966
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('getValues', async () => {
|
it('getValues', async () => {
|
||||||
expect(
|
expect(
|
||||||
await currentRateService.getValues({
|
await currentRateService.getValues({
|
||||||
|
@ -7,7 +7,6 @@ import { isBefore, isToday } from 'date-fns';
|
|||||||
import { flatten } from 'lodash';
|
import { flatten } from 'lodash';
|
||||||
|
|
||||||
import { GetValueObject } from './interfaces/get-value-object.interface';
|
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||||
import { GetValueParams } from './interfaces/get-value-params.interface';
|
|
||||||
import { GetValuesParams } from './interfaces/get-values-params.interface';
|
import { GetValuesParams } from './interfaces/get-values-params.interface';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -18,46 +17,6 @@ export class CurrentRateService {
|
|||||||
private readonly marketDataService: MarketDataService
|
private readonly marketDataService: MarketDataService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async getValue({
|
|
||||||
currency,
|
|
||||||
date,
|
|
||||||
symbol,
|
|
||||||
userCurrency
|
|
||||||
}: GetValueParams): Promise<GetValueObject> {
|
|
||||||
if (isToday(date)) {
|
|
||||||
const dataProviderResult = await this.dataProviderService.get([
|
|
||||||
{
|
|
||||||
symbol,
|
|
||||||
dataSource: this.dataProviderService.getPrimaryDataSource()
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
return {
|
|
||||||
symbol,
|
|
||||||
date: resetHours(date),
|
|
||||||
marketPrice: dataProviderResult?.[symbol]?.marketPrice ?? 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const marketData = await this.marketDataService.get({
|
|
||||||
date,
|
|
||||||
symbol
|
|
||||||
});
|
|
||||||
|
|
||||||
if (marketData) {
|
|
||||||
return {
|
|
||||||
date: marketData.date,
|
|
||||||
marketPrice: this.exchangeRateDataService.toCurrency(
|
|
||||||
marketData.marketPrice,
|
|
||||||
currency,
|
|
||||||
userCurrency
|
|
||||||
),
|
|
||||||
symbol: marketData.symbol
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Value not found for ${symbol} at ${resetHours(date)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getValues({
|
public async getValues({
|
||||||
currencies,
|
currencies,
|
||||||
dataGatheringItems,
|
dataGatheringItems,
|
||||||
|
@ -6,7 +6,7 @@ export interface CurrentPositions {
|
|||||||
positions: TimelinePosition[];
|
positions: TimelinePosition[];
|
||||||
grossPerformance: Big;
|
grossPerformance: Big;
|
||||||
grossPerformancePercentage: Big;
|
grossPerformancePercentage: Big;
|
||||||
netAnnualizedPerformance: Big;
|
netAnnualizedPerformance?: Big;
|
||||||
netPerformance: Big;
|
netPerformance: Big;
|
||||||
netPerformancePercentage: Big;
|
netPerformancePercentage: Big;
|
||||||
currentValue: Big;
|
currentValue: Big;
|
||||||
|
@ -1,6 +0,0 @@
|
|||||||
export interface GetValueParams {
|
|
||||||
currency: string;
|
|
||||||
date: Date;
|
|
||||||
symbol: string;
|
|
||||||
userCurrency: string;
|
|
||||||
}
|
|
@ -0,0 +1,5 @@
|
|||||||
|
import { PortfolioOrder } from './portfolio-order.interface';
|
||||||
|
|
||||||
|
export interface PortfolioOrderItem extends PortfolioOrder {
|
||||||
|
itemType?: '' | 'start' | 'end';
|
||||||
|
}
|
@ -1,3 +1,4 @@
|
|||||||
|
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||||
import { AssetClass, AssetSubClass } from '@prisma/client';
|
import { AssetClass, AssetSubClass } from '@prisma/client';
|
||||||
|
|
||||||
export interface PortfolioPositionDetail {
|
export interface PortfolioPositionDetail {
|
||||||
@ -16,6 +17,7 @@ export interface PortfolioPositionDetail {
|
|||||||
name: string;
|
name: string;
|
||||||
netPerformance: number;
|
netPerformance: number;
|
||||||
netPerformancePercent: number;
|
netPerformancePercent: number;
|
||||||
|
orders: OrderWithAccount[];
|
||||||
quantity: number;
|
quantity: number;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
transactionCount: number;
|
transactionCount: number;
|
||||||
|
73
apps/api/src/app/portfolio/portfolio-calculator-new.spec.ts
Normal file
73
apps/api/src/app/portfolio/portfolio-calculator-new.spec.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import Big from 'big.js';
|
||||||
|
|
||||||
|
import { CurrentRateService } from './current-rate.service';
|
||||||
|
import { PortfolioCalculatorNew } from './portfolio-calculator-new';
|
||||||
|
|
||||||
|
describe('PortfolioCalculatorNew', () => {
|
||||||
|
let currentRateService: CurrentRateService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
currentRateService = new CurrentRateService(null, null, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('annualized performance percentage', () => {
|
||||||
|
const portfolioCalculatorNew = new PortfolioCalculatorNew({
|
||||||
|
currentRateService,
|
||||||
|
currency: 'USD',
|
||||||
|
orders: []
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Get annualized performance', async () => {
|
||||||
|
expect(
|
||||||
|
portfolioCalculatorNew
|
||||||
|
.getAnnualizedPerformancePercent({
|
||||||
|
daysInMarket: NaN, // differenceInDays of date-fns returns NaN for the same day
|
||||||
|
netPerformancePercent: new Big(0)
|
||||||
|
})
|
||||||
|
.toNumber()
|
||||||
|
).toEqual(0);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
portfolioCalculatorNew
|
||||||
|
.getAnnualizedPerformancePercent({
|
||||||
|
daysInMarket: 0,
|
||||||
|
netPerformancePercent: new Big(0)
|
||||||
|
})
|
||||||
|
.toNumber()
|
||||||
|
).toEqual(0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Source: https://www.readyratios.com/reference/analysis/annualized_rate.html
|
||||||
|
*/
|
||||||
|
expect(
|
||||||
|
portfolioCalculatorNew
|
||||||
|
.getAnnualizedPerformancePercent({
|
||||||
|
daysInMarket: 65, // < 1 year
|
||||||
|
netPerformancePercent: new Big(0.1025)
|
||||||
|
})
|
||||||
|
.toNumber()
|
||||||
|
).toBeCloseTo(0.729705);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
portfolioCalculatorNew
|
||||||
|
.getAnnualizedPerformancePercent({
|
||||||
|
daysInMarket: 365, // 1 year
|
||||||
|
netPerformancePercent: new Big(0.05)
|
||||||
|
})
|
||||||
|
.toNumber()
|
||||||
|
).toBeCloseTo(0.05);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Source: https://www.investopedia.com/terms/a/annualized-total-return.asp#annualized-return-formula-and-calculation
|
||||||
|
*/
|
||||||
|
expect(
|
||||||
|
portfolioCalculatorNew
|
||||||
|
.getAnnualizedPerformancePercent({
|
||||||
|
daysInMarket: 575, // > 1 year
|
||||||
|
netPerformancePercent: new Big(0.2374)
|
||||||
|
})
|
||||||
|
.toNumber()
|
||||||
|
).toBeCloseTo(0.145);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
849
apps/api/src/app/portfolio/portfolio-calculator-new.ts
Normal file
849
apps/api/src/app/portfolio/portfolio-calculator-new.ts
Normal file
@ -0,0 +1,849 @@
|
|||||||
|
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
|
||||||
|
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
|
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||||
|
import { TimelinePosition } from '@ghostfolio/common/interfaces';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { Type as TypeOfOrder } from '@prisma/client';
|
||||||
|
import Big from 'big.js';
|
||||||
|
import {
|
||||||
|
addDays,
|
||||||
|
addMilliseconds,
|
||||||
|
addMonths,
|
||||||
|
addYears,
|
||||||
|
endOfDay,
|
||||||
|
format,
|
||||||
|
isAfter,
|
||||||
|
isBefore,
|
||||||
|
max,
|
||||||
|
min
|
||||||
|
} from 'date-fns';
|
||||||
|
import { first, flatten, isNumber, sortBy } from 'lodash';
|
||||||
|
|
||||||
|
import { CurrentRateService } from './current-rate.service';
|
||||||
|
import { CurrentPositions } from './interfaces/current-positions.interface';
|
||||||
|
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||||
|
import { PortfolioOrderItem } from './interfaces/portfolio-calculator.interface';
|
||||||
|
import { PortfolioOrder } from './interfaces/portfolio-order.interface';
|
||||||
|
import { TimelinePeriod } from './interfaces/timeline-period.interface';
|
||||||
|
import {
|
||||||
|
Accuracy,
|
||||||
|
TimelineSpecification
|
||||||
|
} from './interfaces/timeline-specification.interface';
|
||||||
|
import { TransactionPointSymbol } from './interfaces/transaction-point-symbol.interface';
|
||||||
|
import { TransactionPoint } from './interfaces/transaction-point.interface';
|
||||||
|
|
||||||
|
export class PortfolioCalculatorNew {
|
||||||
|
private currency: string;
|
||||||
|
private currentRateService: CurrentRateService;
|
||||||
|
private orders: PortfolioOrder[];
|
||||||
|
private transactionPoints: TransactionPoint[];
|
||||||
|
|
||||||
|
public constructor({
|
||||||
|
currency,
|
||||||
|
currentRateService,
|
||||||
|
orders
|
||||||
|
}: {
|
||||||
|
currency: string;
|
||||||
|
currentRateService: CurrentRateService;
|
||||||
|
orders: PortfolioOrder[];
|
||||||
|
}) {
|
||||||
|
this.currency = currency;
|
||||||
|
this.currentRateService = currentRateService;
|
||||||
|
this.orders = orders;
|
||||||
|
|
||||||
|
this.orders.sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
}
|
||||||
|
|
||||||
|
public computeTransactionPoints() {
|
||||||
|
this.transactionPoints = [];
|
||||||
|
const symbols: { [symbol: string]: TransactionPointSymbol } = {};
|
||||||
|
|
||||||
|
let lastDate: string = null;
|
||||||
|
let lastTransactionPoint: TransactionPoint = null;
|
||||||
|
for (const order of this.orders) {
|
||||||
|
const currentDate = order.date;
|
||||||
|
|
||||||
|
let currentTransactionPointItem: TransactionPointSymbol;
|
||||||
|
const oldAccumulatedSymbol = symbols[order.symbol];
|
||||||
|
|
||||||
|
const factor = this.getFactor(order.type);
|
||||||
|
const unitPrice = new Big(order.unitPrice);
|
||||||
|
if (oldAccumulatedSymbol) {
|
||||||
|
const newQuantity = order.quantity
|
||||||
|
.mul(factor)
|
||||||
|
.plus(oldAccumulatedSymbol.quantity);
|
||||||
|
currentTransactionPointItem = {
|
||||||
|
currency: order.currency,
|
||||||
|
dataSource: order.dataSource,
|
||||||
|
fee: order.fee.plus(oldAccumulatedSymbol.fee),
|
||||||
|
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
|
||||||
|
investment: newQuantity.eq(0)
|
||||||
|
? new Big(0)
|
||||||
|
: unitPrice
|
||||||
|
.mul(order.quantity)
|
||||||
|
.mul(factor)
|
||||||
|
.add(oldAccumulatedSymbol.investment),
|
||||||
|
quantity: newQuantity,
|
||||||
|
symbol: order.symbol,
|
||||||
|
transactionCount: oldAccumulatedSymbol.transactionCount + 1
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
currentTransactionPointItem = {
|
||||||
|
currency: order.currency,
|
||||||
|
dataSource: order.dataSource,
|
||||||
|
fee: order.fee,
|
||||||
|
firstBuyDate: order.date,
|
||||||
|
investment: unitPrice.mul(order.quantity).mul(factor),
|
||||||
|
quantity: order.quantity.mul(factor),
|
||||||
|
symbol: order.symbol,
|
||||||
|
transactionCount: 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
symbols[order.symbol] = currentTransactionPointItem;
|
||||||
|
|
||||||
|
const items = lastTransactionPoint?.items ?? [];
|
||||||
|
const newItems = items.filter(
|
||||||
|
(transactionPointItem) => transactionPointItem.symbol !== order.symbol
|
||||||
|
);
|
||||||
|
newItems.push(currentTransactionPointItem);
|
||||||
|
newItems.sort((a, b) => a.symbol.localeCompare(b.symbol));
|
||||||
|
if (lastDate !== currentDate || lastTransactionPoint === null) {
|
||||||
|
lastTransactionPoint = {
|
||||||
|
date: currentDate,
|
||||||
|
items: newItems
|
||||||
|
};
|
||||||
|
this.transactionPoints.push(lastTransactionPoint);
|
||||||
|
} else {
|
||||||
|
lastTransactionPoint.items = newItems;
|
||||||
|
}
|
||||||
|
lastDate = currentDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getAnnualizedPerformancePercent({
|
||||||
|
daysInMarket,
|
||||||
|
netPerformancePercent
|
||||||
|
}: {
|
||||||
|
daysInMarket: number;
|
||||||
|
netPerformancePercent: Big;
|
||||||
|
}): Big {
|
||||||
|
if (isNumber(daysInMarket) && daysInMarket > 0) {
|
||||||
|
const exponent = new Big(365).div(daysInMarket).toNumber();
|
||||||
|
return new Big(
|
||||||
|
Math.pow(netPerformancePercent.plus(1).toNumber(), exponent)
|
||||||
|
).minus(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Big(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getTransactionPoints(): TransactionPoint[] {
|
||||||
|
return this.transactionPoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setTransactionPoints(transactionPoints: TransactionPoint[]) {
|
||||||
|
this.transactionPoints = transactionPoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getCurrentPositions(start: Date): Promise<CurrentPositions> {
|
||||||
|
if (!this.transactionPoints?.length) {
|
||||||
|
return {
|
||||||
|
currentValue: new Big(0),
|
||||||
|
hasErrors: false,
|
||||||
|
grossPerformance: new Big(0),
|
||||||
|
grossPerformancePercentage: new Big(0),
|
||||||
|
netPerformance: new Big(0),
|
||||||
|
netPerformancePercentage: new Big(0),
|
||||||
|
positions: [],
|
||||||
|
totalInvestment: new Big(0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastTransactionPoint =
|
||||||
|
this.transactionPoints[this.transactionPoints.length - 1];
|
||||||
|
|
||||||
|
// use Date.now() to use the mock for today
|
||||||
|
const today = new Date(Date.now());
|
||||||
|
|
||||||
|
let firstTransactionPoint: TransactionPoint = null;
|
||||||
|
let firstIndex = this.transactionPoints.length;
|
||||||
|
const dates = [];
|
||||||
|
const dataGatheringItems: IDataGatheringItem[] = [];
|
||||||
|
const currencies: { [symbol: string]: string } = {};
|
||||||
|
|
||||||
|
dates.push(resetHours(start));
|
||||||
|
for (const item of this.transactionPoints[firstIndex - 1].items) {
|
||||||
|
dataGatheringItems.push({
|
||||||
|
dataSource: item.dataSource,
|
||||||
|
symbol: item.symbol
|
||||||
|
});
|
||||||
|
currencies[item.symbol] = item.currency;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < this.transactionPoints.length; i++) {
|
||||||
|
if (
|
||||||
|
!isBefore(parseDate(this.transactionPoints[i].date), start) &&
|
||||||
|
firstTransactionPoint === null
|
||||||
|
) {
|
||||||
|
firstTransactionPoint = this.transactionPoints[i];
|
||||||
|
firstIndex = i;
|
||||||
|
}
|
||||||
|
if (firstTransactionPoint !== null) {
|
||||||
|
dates.push(resetHours(parseDate(this.transactionPoints[i].date)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dates.push(resetHours(today));
|
||||||
|
|
||||||
|
const marketSymbols = await this.currentRateService.getValues({
|
||||||
|
currencies,
|
||||||
|
dataGatheringItems,
|
||||||
|
dateQuery: {
|
||||||
|
in: dates
|
||||||
|
},
|
||||||
|
userCurrency: this.currency
|
||||||
|
});
|
||||||
|
|
||||||
|
const marketSymbolMap: {
|
||||||
|
[date: string]: { [symbol: string]: Big };
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
for (const marketSymbol of marketSymbols) {
|
||||||
|
const date = format(marketSymbol.date, DATE_FORMAT);
|
||||||
|
if (!marketSymbolMap[date]) {
|
||||||
|
marketSymbolMap[date] = {};
|
||||||
|
}
|
||||||
|
if (marketSymbol.marketPrice) {
|
||||||
|
marketSymbolMap[date][marketSymbol.symbol] = new Big(
|
||||||
|
marketSymbol.marketPrice
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const todayString = format(today, DATE_FORMAT);
|
||||||
|
|
||||||
|
if (firstIndex > 0) {
|
||||||
|
firstIndex--;
|
||||||
|
}
|
||||||
|
const initialValues: { [symbol: string]: Big } = {};
|
||||||
|
|
||||||
|
const positions: TimelinePosition[] = [];
|
||||||
|
let hasErrorsInSymbolMetrics = false;
|
||||||
|
|
||||||
|
for (const item of lastTransactionPoint.items) {
|
||||||
|
const marketValue = marketSymbolMap[todayString]?.[item.symbol];
|
||||||
|
|
||||||
|
const {
|
||||||
|
grossPerformance,
|
||||||
|
grossPerformancePercentage,
|
||||||
|
hasErrors,
|
||||||
|
initialValue,
|
||||||
|
netPerformance,
|
||||||
|
netPerformancePercentage
|
||||||
|
} = this.getSymbolMetrics({
|
||||||
|
marketSymbolMap,
|
||||||
|
start,
|
||||||
|
symbol: item.symbol
|
||||||
|
});
|
||||||
|
|
||||||
|
hasErrorsInSymbolMetrics = hasErrorsInSymbolMetrics || hasErrors;
|
||||||
|
|
||||||
|
initialValues[item.symbol] = initialValue;
|
||||||
|
|
||||||
|
positions.push({
|
||||||
|
averagePrice: item.quantity.eq(0)
|
||||||
|
? new Big(0)
|
||||||
|
: item.investment.div(item.quantity),
|
||||||
|
currency: item.currency,
|
||||||
|
dataSource: item.dataSource,
|
||||||
|
firstBuyDate: item.firstBuyDate,
|
||||||
|
grossPerformance: !hasErrors ? grossPerformance ?? null : null,
|
||||||
|
grossPerformancePercentage: !hasErrors
|
||||||
|
? grossPerformancePercentage ?? null
|
||||||
|
: null,
|
||||||
|
investment: item.investment,
|
||||||
|
marketPrice: marketValue?.toNumber() ?? null,
|
||||||
|
netPerformance: !hasErrors ? netPerformance ?? null : null,
|
||||||
|
netPerformancePercentage: !hasErrors
|
||||||
|
? netPerformancePercentage ?? null
|
||||||
|
: null,
|
||||||
|
quantity: item.quantity,
|
||||||
|
symbol: item.symbol,
|
||||||
|
transactionCount: item.transactionCount
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const overall = this.calculateOverallPerformance(positions, initialValues);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...overall,
|
||||||
|
positions,
|
||||||
|
hasErrors: hasErrorsInSymbolMetrics || overall.hasErrors
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public getSymbolMetrics({
|
||||||
|
marketSymbolMap,
|
||||||
|
start,
|
||||||
|
symbol
|
||||||
|
}: {
|
||||||
|
marketSymbolMap: {
|
||||||
|
[date: string]: { [symbol: string]: Big };
|
||||||
|
};
|
||||||
|
start: Date;
|
||||||
|
symbol: string;
|
||||||
|
}) {
|
||||||
|
let orders: PortfolioOrderItem[] = this.orders.filter((order) => {
|
||||||
|
return order.symbol === symbol;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (orders.length <= 0) {
|
||||||
|
return {
|
||||||
|
hasErrors: false,
|
||||||
|
initialValue: new Big(0),
|
||||||
|
netPerformance: new Big(0),
|
||||||
|
netPerformancePercentage: new Big(0),
|
||||||
|
grossPerformance: new Big(0),
|
||||||
|
grossPerformancePercentage: new Big(0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateOfFirstTransaction = new Date(first(orders).date);
|
||||||
|
const endDate = new Date(Date.now());
|
||||||
|
|
||||||
|
const unitPriceAtStartDate =
|
||||||
|
marketSymbolMap[format(start, DATE_FORMAT)]?.[symbol];
|
||||||
|
|
||||||
|
const unitPriceAtEndDate =
|
||||||
|
marketSymbolMap[format(endDate, DATE_FORMAT)]?.[symbol];
|
||||||
|
|
||||||
|
if (
|
||||||
|
!unitPriceAtEndDate ||
|
||||||
|
(!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start))
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
hasErrors: true,
|
||||||
|
initialValue: new Big(0),
|
||||||
|
netPerformance: new Big(0),
|
||||||
|
netPerformancePercentage: new Big(0),
|
||||||
|
grossPerformance: new Big(0),
|
||||||
|
grossPerformancePercentage: new Big(0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let feesAtStartDate = new Big(0);
|
||||||
|
let fees = new Big(0);
|
||||||
|
let grossPerformance = new Big(0);
|
||||||
|
let grossPerformanceAtStartDate = new Big(0);
|
||||||
|
let grossPerformanceFromSells = new Big(0);
|
||||||
|
let initialValue: Big;
|
||||||
|
let lastAveragePrice = new Big(0);
|
||||||
|
let lastValueOfInvestment = new Big(0);
|
||||||
|
let lastNetValueOfInvestment = new Big(0);
|
||||||
|
let timeWeightedGrossPerformancePercentage = new Big(1);
|
||||||
|
let timeWeightedNetPerformancePercentage = new Big(1);
|
||||||
|
let totalInvestment = new Big(0);
|
||||||
|
let totalUnits = new Big(0);
|
||||||
|
|
||||||
|
// Add a synthetic order at the start and the end date
|
||||||
|
orders.push({
|
||||||
|
symbol,
|
||||||
|
currency: null,
|
||||||
|
date: format(start, DATE_FORMAT),
|
||||||
|
dataSource: null,
|
||||||
|
fee: new Big(0),
|
||||||
|
itemType: 'start',
|
||||||
|
name: '',
|
||||||
|
quantity: new Big(0),
|
||||||
|
type: TypeOfOrder.BUY,
|
||||||
|
unitPrice: unitPriceAtStartDate ?? new Big(0)
|
||||||
|
});
|
||||||
|
|
||||||
|
orders.push({
|
||||||
|
symbol,
|
||||||
|
currency: null,
|
||||||
|
date: format(endDate, DATE_FORMAT),
|
||||||
|
dataSource: null,
|
||||||
|
fee: new Big(0),
|
||||||
|
itemType: 'end',
|
||||||
|
name: '',
|
||||||
|
quantity: new Big(0),
|
||||||
|
type: TypeOfOrder.BUY,
|
||||||
|
unitPrice: unitPriceAtEndDate ?? new Big(0)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort orders so that the start and end placeholder order are at the right
|
||||||
|
// position
|
||||||
|
orders = sortBy(orders, (order) => {
|
||||||
|
let sortIndex = new Date(order.date);
|
||||||
|
|
||||||
|
if (order.itemType === 'start') {
|
||||||
|
sortIndex = addMilliseconds(sortIndex, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.itemType === 'end') {
|
||||||
|
sortIndex = addMilliseconds(sortIndex, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortIndex.getTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
const indexOfStartOrder = orders.findIndex((order) => {
|
||||||
|
return order.itemType === 'start';
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < orders.length; i += 1) {
|
||||||
|
const order = orders[i];
|
||||||
|
|
||||||
|
const transactionInvestment = order.quantity.mul(order.unitPrice);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!initialValue &&
|
||||||
|
order.itemType !== 'start' &&
|
||||||
|
order.itemType !== 'end'
|
||||||
|
) {
|
||||||
|
initialValue = transactionInvestment;
|
||||||
|
}
|
||||||
|
|
||||||
|
fees = fees.plus(order.fee);
|
||||||
|
|
||||||
|
totalUnits = totalUnits.plus(
|
||||||
|
order.quantity.mul(this.getFactor(order.type))
|
||||||
|
);
|
||||||
|
|
||||||
|
const valueOfInvestment = totalUnits.mul(order.unitPrice);
|
||||||
|
const netValueOfInvestment = totalUnits.mul(order.unitPrice).sub(fees);
|
||||||
|
|
||||||
|
const grossPerformanceFromSell =
|
||||||
|
order.type === TypeOfOrder.SELL
|
||||||
|
? order.unitPrice.minus(lastAveragePrice).mul(order.quantity)
|
||||||
|
: new Big(0);
|
||||||
|
|
||||||
|
grossPerformanceFromSells = grossPerformanceFromSells.plus(
|
||||||
|
grossPerformanceFromSell
|
||||||
|
);
|
||||||
|
|
||||||
|
totalInvestment = totalInvestment
|
||||||
|
.plus(transactionInvestment.mul(this.getFactor(order.type)))
|
||||||
|
.plus(grossPerformanceFromSell);
|
||||||
|
|
||||||
|
lastAveragePrice = totalUnits.eq(0)
|
||||||
|
? new Big(0)
|
||||||
|
: totalInvestment.div(totalUnits);
|
||||||
|
|
||||||
|
const newGrossPerformance = valueOfInvestment
|
||||||
|
.minus(totalInvestment)
|
||||||
|
.plus(grossPerformanceFromSells);
|
||||||
|
|
||||||
|
if (
|
||||||
|
i > indexOfStartOrder &&
|
||||||
|
!lastValueOfInvestment
|
||||||
|
.plus(transactionInvestment.mul(this.getFactor(order.type)))
|
||||||
|
.eq(0)
|
||||||
|
) {
|
||||||
|
timeWeightedGrossPerformancePercentage =
|
||||||
|
timeWeightedGrossPerformancePercentage.mul(
|
||||||
|
new Big(1).plus(
|
||||||
|
valueOfInvestment
|
||||||
|
.minus(
|
||||||
|
lastValueOfInvestment.plus(
|
||||||
|
transactionInvestment.mul(this.getFactor(order.type))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.div(
|
||||||
|
lastValueOfInvestment.plus(
|
||||||
|
transactionInvestment.mul(this.getFactor(order.type))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
timeWeightedNetPerformancePercentage =
|
||||||
|
timeWeightedNetPerformancePercentage.mul(
|
||||||
|
new Big(1).plus(
|
||||||
|
netValueOfInvestment
|
||||||
|
.minus(
|
||||||
|
lastNetValueOfInvestment.plus(
|
||||||
|
transactionInvestment.mul(this.getFactor(order.type))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.div(
|
||||||
|
lastNetValueOfInvestment.plus(
|
||||||
|
transactionInvestment.mul(this.getFactor(order.type))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
grossPerformance = newGrossPerformance;
|
||||||
|
lastNetValueOfInvestment = netValueOfInvestment;
|
||||||
|
lastValueOfInvestment = valueOfInvestment;
|
||||||
|
|
||||||
|
if (order.itemType === 'start') {
|
||||||
|
feesAtStartDate = fees;
|
||||||
|
grossPerformanceAtStartDate = grossPerformance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
timeWeightedGrossPerformancePercentage =
|
||||||
|
timeWeightedGrossPerformancePercentage.sub(1);
|
||||||
|
|
||||||
|
timeWeightedNetPerformancePercentage =
|
||||||
|
timeWeightedNetPerformancePercentage.sub(1);
|
||||||
|
|
||||||
|
const totalGrossPerformance = grossPerformance.minus(
|
||||||
|
grossPerformanceAtStartDate
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalNetPerformance = grossPerformance
|
||||||
|
.minus(grossPerformanceAtStartDate)
|
||||||
|
.minus(fees.minus(feesAtStartDate));
|
||||||
|
|
||||||
|
return {
|
||||||
|
initialValue,
|
||||||
|
hasErrors: !initialValue || !unitPriceAtEndDate,
|
||||||
|
netPerformance: totalNetPerformance,
|
||||||
|
netPerformancePercentage: timeWeightedNetPerformancePercentage,
|
||||||
|
grossPerformance: totalGrossPerformance,
|
||||||
|
grossPerformancePercentage: timeWeightedGrossPerformancePercentage
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public getInvestments(): { date: string; investment: Big }[] {
|
||||||
|
if (this.transactionPoints.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.transactionPoints.map((transactionPoint) => {
|
||||||
|
return {
|
||||||
|
date: transactionPoint.date,
|
||||||
|
investment: transactionPoint.items.reduce(
|
||||||
|
(investment, transactionPointSymbol) =>
|
||||||
|
investment.add(transactionPointSymbol.investment),
|
||||||
|
new Big(0)
|
||||||
|
)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async calculateTimeline(
|
||||||
|
timelineSpecification: TimelineSpecification[],
|
||||||
|
endDate: string
|
||||||
|
): Promise<TimelineInfoInterface> {
|
||||||
|
if (timelineSpecification.length === 0) {
|
||||||
|
return {
|
||||||
|
maxNetPerformance: new Big(0),
|
||||||
|
minNetPerformance: new Big(0),
|
||||||
|
timelinePeriods: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = timelineSpecification[0].start;
|
||||||
|
const start = parseDate(startDate);
|
||||||
|
const end = parseDate(endDate);
|
||||||
|
|
||||||
|
const timelinePeriodPromises: Promise<TimelineInfoInterface>[] = [];
|
||||||
|
let i = 0;
|
||||||
|
let j = -1;
|
||||||
|
for (
|
||||||
|
let currentDate = start;
|
||||||
|
!isAfter(currentDate, end);
|
||||||
|
currentDate = this.addToDate(
|
||||||
|
currentDate,
|
||||||
|
timelineSpecification[i].accuracy
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
if (this.isNextItemActive(timelineSpecification, currentDate, i)) {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
while (
|
||||||
|
j + 1 < this.transactionPoints.length &&
|
||||||
|
!isAfter(parseDate(this.transactionPoints[j + 1].date), currentDate)
|
||||||
|
) {
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
|
||||||
|
let periodEndDate = currentDate;
|
||||||
|
if (timelineSpecification[i].accuracy === 'day') {
|
||||||
|
let nextEndDate = end;
|
||||||
|
if (j + 1 < this.transactionPoints.length) {
|
||||||
|
nextEndDate = parseDate(this.transactionPoints[j + 1].date);
|
||||||
|
}
|
||||||
|
periodEndDate = min([
|
||||||
|
addMonths(currentDate, 3),
|
||||||
|
max([currentDate, nextEndDate])
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
const timePeriodForDates = this.getTimePeriodForDate(
|
||||||
|
j,
|
||||||
|
currentDate,
|
||||||
|
endOfDay(periodEndDate)
|
||||||
|
);
|
||||||
|
currentDate = periodEndDate;
|
||||||
|
if (timePeriodForDates != null) {
|
||||||
|
timelinePeriodPromises.push(timePeriodForDates);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const timelineInfoInterfaces: TimelineInfoInterface[] = await Promise.all(
|
||||||
|
timelinePeriodPromises
|
||||||
|
);
|
||||||
|
const minNetPerformance = timelineInfoInterfaces
|
||||||
|
.map((timelineInfo) => timelineInfo.minNetPerformance)
|
||||||
|
.filter((performance) => performance !== null)
|
||||||
|
.reduce((minPerformance, current) => {
|
||||||
|
if (minPerformance.lt(current)) {
|
||||||
|
return minPerformance;
|
||||||
|
} else {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const maxNetPerformance = timelineInfoInterfaces
|
||||||
|
.map((timelineInfo) => timelineInfo.maxNetPerformance)
|
||||||
|
.filter((performance) => performance !== null)
|
||||||
|
.reduce((maxPerformance, current) => {
|
||||||
|
if (maxPerformance.gt(current)) {
|
||||||
|
return maxPerformance;
|
||||||
|
} else {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const timelinePeriods = timelineInfoInterfaces.map(
|
||||||
|
(timelineInfo) => timelineInfo.timelinePeriods
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
maxNetPerformance,
|
||||||
|
minNetPerformance,
|
||||||
|
timelinePeriods: flatten(timelinePeriods)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateOverallPerformance(
|
||||||
|
positions: TimelinePosition[],
|
||||||
|
initialValues: { [p: string]: Big }
|
||||||
|
) {
|
||||||
|
let hasErrors = false;
|
||||||
|
let currentValue = new Big(0);
|
||||||
|
let totalInvestment = new Big(0);
|
||||||
|
let grossPerformance = new Big(0);
|
||||||
|
let grossPerformancePercentage = new Big(0);
|
||||||
|
let netPerformance = new Big(0);
|
||||||
|
let netPerformancePercentage = new Big(0);
|
||||||
|
let completeInitialValue = new Big(0);
|
||||||
|
|
||||||
|
for (const currentPosition of positions) {
|
||||||
|
if (currentPosition.marketPrice) {
|
||||||
|
currentValue = currentValue.add(
|
||||||
|
new Big(currentPosition.marketPrice).mul(currentPosition.quantity)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
totalInvestment = totalInvestment.add(currentPosition.investment);
|
||||||
|
if (currentPosition.grossPerformance) {
|
||||||
|
grossPerformance = grossPerformance.plus(
|
||||||
|
currentPosition.grossPerformance
|
||||||
|
);
|
||||||
|
netPerformance = netPerformance.plus(currentPosition.netPerformance);
|
||||||
|
} else if (!currentPosition.quantity.eq(0)) {
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
currentPosition.grossPerformancePercentage &&
|
||||||
|
initialValues[currentPosition.symbol]
|
||||||
|
) {
|
||||||
|
const currentInitialValue = initialValues[currentPosition.symbol];
|
||||||
|
completeInitialValue = completeInitialValue.plus(currentInitialValue);
|
||||||
|
grossPerformancePercentage = grossPerformancePercentage.plus(
|
||||||
|
currentPosition.grossPerformancePercentage.mul(currentInitialValue)
|
||||||
|
);
|
||||||
|
netPerformancePercentage = netPerformancePercentage.plus(
|
||||||
|
currentPosition.netPerformancePercentage.mul(currentInitialValue)
|
||||||
|
);
|
||||||
|
} else if (!currentPosition.quantity.eq(0)) {
|
||||||
|
Logger.warn(
|
||||||
|
`Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`
|
||||||
|
);
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!completeInitialValue.eq(0)) {
|
||||||
|
grossPerformancePercentage =
|
||||||
|
grossPerformancePercentage.div(completeInitialValue);
|
||||||
|
netPerformancePercentage =
|
||||||
|
netPerformancePercentage.div(completeInitialValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentValue,
|
||||||
|
grossPerformance,
|
||||||
|
grossPerformancePercentage,
|
||||||
|
hasErrors,
|
||||||
|
netPerformance,
|
||||||
|
netPerformancePercentage,
|
||||||
|
totalInvestment
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getTimePeriodForDate(
|
||||||
|
j: number,
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date
|
||||||
|
): Promise<TimelineInfoInterface> {
|
||||||
|
let investment: Big = new Big(0);
|
||||||
|
let fees: Big = new Big(0);
|
||||||
|
|
||||||
|
const marketSymbolMap: {
|
||||||
|
[date: string]: { [symbol: string]: Big };
|
||||||
|
} = {};
|
||||||
|
if (j >= 0) {
|
||||||
|
const currencies: { [name: string]: string } = {};
|
||||||
|
const dataGatheringItems: IDataGatheringItem[] = [];
|
||||||
|
|
||||||
|
for (const item of this.transactionPoints[j].items) {
|
||||||
|
currencies[item.symbol] = item.currency;
|
||||||
|
dataGatheringItems.push({
|
||||||
|
dataSource: item.dataSource,
|
||||||
|
symbol: item.symbol
|
||||||
|
});
|
||||||
|
investment = investment.add(item.investment);
|
||||||
|
fees = fees.add(item.fee);
|
||||||
|
}
|
||||||
|
|
||||||
|
let marketSymbols: GetValueObject[] = [];
|
||||||
|
if (dataGatheringItems.length > 0) {
|
||||||
|
try {
|
||||||
|
marketSymbols = await this.currentRateService.getValues({
|
||||||
|
currencies,
|
||||||
|
dataGatheringItems,
|
||||||
|
dateQuery: {
|
||||||
|
gte: startDate,
|
||||||
|
lt: endOfDay(endDate)
|
||||||
|
},
|
||||||
|
userCurrency: this.currency
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(
|
||||||
|
`Failed to fetch info for date ${startDate} with exception`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const marketSymbol of marketSymbols) {
|
||||||
|
const date = format(marketSymbol.date, DATE_FORMAT);
|
||||||
|
if (!marketSymbolMap[date]) {
|
||||||
|
marketSymbolMap[date] = {};
|
||||||
|
}
|
||||||
|
if (marketSymbol.marketPrice) {
|
||||||
|
marketSymbolMap[date][marketSymbol.symbol] = new Big(
|
||||||
|
marketSymbol.marketPrice
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: TimelinePeriod[] = [];
|
||||||
|
let maxNetPerformance: Big = null;
|
||||||
|
let minNetPerformance: Big = null;
|
||||||
|
for (
|
||||||
|
let currentDate = startDate;
|
||||||
|
isBefore(currentDate, endDate);
|
||||||
|
currentDate = addDays(currentDate, 1)
|
||||||
|
) {
|
||||||
|
let value = new Big(0);
|
||||||
|
const currentDateAsString = format(currentDate, DATE_FORMAT);
|
||||||
|
let invalid = false;
|
||||||
|
if (j >= 0) {
|
||||||
|
for (const item of this.transactionPoints[j].items) {
|
||||||
|
if (
|
||||||
|
!marketSymbolMap[currentDateAsString]?.hasOwnProperty(item.symbol)
|
||||||
|
) {
|
||||||
|
invalid = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
value = value.add(
|
||||||
|
item.quantity.mul(marketSymbolMap[currentDateAsString][item.symbol])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!invalid) {
|
||||||
|
const grossPerformance = value.minus(investment);
|
||||||
|
const netPerformance = grossPerformance.minus(fees);
|
||||||
|
if (
|
||||||
|
minNetPerformance === null ||
|
||||||
|
minNetPerformance.gt(netPerformance)
|
||||||
|
) {
|
||||||
|
minNetPerformance = netPerformance;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
maxNetPerformance === null ||
|
||||||
|
maxNetPerformance.lt(netPerformance)
|
||||||
|
) {
|
||||||
|
maxNetPerformance = netPerformance;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
grossPerformance,
|
||||||
|
investment,
|
||||||
|
netPerformance,
|
||||||
|
value,
|
||||||
|
date: currentDateAsString
|
||||||
|
};
|
||||||
|
results.push(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
maxNetPerformance,
|
||||||
|
minNetPerformance,
|
||||||
|
timelinePeriods: results
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFactor(type: TypeOfOrder) {
|
||||||
|
let factor: number;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'BUY':
|
||||||
|
factor = 1;
|
||||||
|
break;
|
||||||
|
case 'SELL':
|
||||||
|
factor = -1;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
factor = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return factor;
|
||||||
|
}
|
||||||
|
|
||||||
|
private addToDate(date: Date, accuracy: Accuracy): Date {
|
||||||
|
switch (accuracy) {
|
||||||
|
case 'day':
|
||||||
|
return addDays(date, 1);
|
||||||
|
case 'month':
|
||||||
|
return addMonths(date, 1);
|
||||||
|
case 'year':
|
||||||
|
return addYears(date, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isNextItemActive(
|
||||||
|
timelineSpecification: TimelineSpecification[],
|
||||||
|
currentDate: Date,
|
||||||
|
i: number
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
i + 1 < timelineSpecification.length &&
|
||||||
|
!isBefore(currentDate, parseDate(timelineSpecification[i + 1].start))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -1,16 +1,9 @@
|
|||||||
import { parseDate, resetHours } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
import {
|
import { addDays, endOfDay, format, isBefore, isSameDay } from 'date-fns';
|
||||||
addDays,
|
|
||||||
differenceInCalendarDays,
|
|
||||||
endOfDay,
|
|
||||||
isBefore,
|
|
||||||
isSameDay
|
|
||||||
} from 'date-fns';
|
|
||||||
|
|
||||||
import { CurrentRateService } from './current-rate.service';
|
import { CurrentRateService } from './current-rate.service';
|
||||||
import { GetValueParams } from './interfaces/get-value-params.interface';
|
|
||||||
import { GetValuesParams } from './interfaces/get-values-params.interface';
|
import { GetValuesParams } from './interfaces/get-values-params.interface';
|
||||||
import { PortfolioOrder } from './interfaces/portfolio-order.interface';
|
import { PortfolioOrder } from './interfaces/portfolio-order.interface';
|
||||||
import { TimelinePeriod } from './interfaces/timeline-period.interface';
|
import { TimelinePeriod } from './interfaces/timeline-period.interface';
|
||||||
@ -67,15 +60,202 @@ function mockGetValue(symbol: string, date: Date) {
|
|||||||
|
|
||||||
return { marketPrice: 0 };
|
return { marketPrice: 0 };
|
||||||
case 'VTI':
|
case 'VTI':
|
||||||
return {
|
switch (format(date, DATE_FORMAT)) {
|
||||||
marketPrice: new Big('144.38')
|
case '2019-01-01':
|
||||||
.plus(
|
return { marketPrice: 144.38 };
|
||||||
new Big('0.08').mul(
|
case '2019-02-01':
|
||||||
differenceInCalendarDays(date, parseDate('2019-02-01'))
|
return { marketPrice: 144.38 };
|
||||||
)
|
case '2019-03-01':
|
||||||
)
|
return { marketPrice: 146.62 };
|
||||||
.toNumber()
|
case '2019-04-01':
|
||||||
};
|
return { marketPrice: 149.1 };
|
||||||
|
case '2019-05-01':
|
||||||
|
return { marketPrice: 151.5 };
|
||||||
|
case '2019-06-01':
|
||||||
|
return { marketPrice: 153.98 };
|
||||||
|
case '2019-07-01':
|
||||||
|
return { marketPrice: 156.38 };
|
||||||
|
case '2019-08-01':
|
||||||
|
return { marketPrice: 158.86 };
|
||||||
|
case '2019-08-03':
|
||||||
|
return { marketPrice: 159.02 };
|
||||||
|
case '2019-09-01':
|
||||||
|
return { marketPrice: 161.34 };
|
||||||
|
case '2019-10-01':
|
||||||
|
return { marketPrice: 163.74 };
|
||||||
|
case '2019-11-01':
|
||||||
|
return { marketPrice: 166.22 };
|
||||||
|
case '2019-12-01':
|
||||||
|
return { marketPrice: 168.62 };
|
||||||
|
case '2020-01-01':
|
||||||
|
return { marketPrice: 171.1 };
|
||||||
|
case '2020-02-01':
|
||||||
|
return { marketPrice: 173.58 };
|
||||||
|
case '2020-02-02':
|
||||||
|
return { marketPrice: 173.66 };
|
||||||
|
case '2020-03-01':
|
||||||
|
return { marketPrice: 175.9 };
|
||||||
|
case '2020-04-01':
|
||||||
|
return { marketPrice: 178.38 };
|
||||||
|
case '2020-05-01':
|
||||||
|
return { marketPrice: 180.78 };
|
||||||
|
case '2020-06-01':
|
||||||
|
return { marketPrice: 183.26 };
|
||||||
|
case '2020-07-01':
|
||||||
|
return { marketPrice: 185.66 };
|
||||||
|
case '2020-08-01':
|
||||||
|
return { marketPrice: 188.14 };
|
||||||
|
case '2020-08-02':
|
||||||
|
return { marketPrice: 188.22 };
|
||||||
|
case '2020-08-03':
|
||||||
|
return { marketPrice: 188.3 };
|
||||||
|
case '2020-09-01':
|
||||||
|
return { marketPrice: 190.62 };
|
||||||
|
case '2020-10-01':
|
||||||
|
return { marketPrice: 193.02 };
|
||||||
|
case '2020-11-01':
|
||||||
|
return { marketPrice: 195.5 };
|
||||||
|
case '2020-12-01':
|
||||||
|
return { marketPrice: 197.9 };
|
||||||
|
case '2021-01-01':
|
||||||
|
return { marketPrice: 200.38 };
|
||||||
|
case '2021-02-01':
|
||||||
|
return { marketPrice: 202.86 };
|
||||||
|
case '2021-03-01':
|
||||||
|
return { marketPrice: 205.1 };
|
||||||
|
case '2021-04-01':
|
||||||
|
return { marketPrice: 207.58 };
|
||||||
|
case '2021-05-01':
|
||||||
|
return { marketPrice: 209.98 };
|
||||||
|
case '2021-06-01':
|
||||||
|
return { marketPrice: 212.46 };
|
||||||
|
case '2021-06-02':
|
||||||
|
return { marketPrice: 212.54 };
|
||||||
|
case '2021-06-03':
|
||||||
|
return { marketPrice: 212.62 };
|
||||||
|
case '2021-06-04':
|
||||||
|
return { marketPrice: 212.7 };
|
||||||
|
case '2021-06-05':
|
||||||
|
return { marketPrice: 212.78 };
|
||||||
|
case '2021-06-06':
|
||||||
|
return { marketPrice: 212.86 };
|
||||||
|
case '2021-06-07':
|
||||||
|
return { marketPrice: 212.94 };
|
||||||
|
case '2021-06-08':
|
||||||
|
return { marketPrice: 213.02 };
|
||||||
|
case '2021-06-09':
|
||||||
|
return { marketPrice: 213.1 };
|
||||||
|
case '2021-06-10':
|
||||||
|
return { marketPrice: 213.18 };
|
||||||
|
case '2021-06-11':
|
||||||
|
return { marketPrice: 213.26 };
|
||||||
|
case '2021-06-12':
|
||||||
|
return { marketPrice: 213.34 };
|
||||||
|
case '2021-06-13':
|
||||||
|
return { marketPrice: 213.42 };
|
||||||
|
case '2021-06-14':
|
||||||
|
return { marketPrice: 213.5 };
|
||||||
|
case '2021-06-15':
|
||||||
|
return { marketPrice: 213.58 };
|
||||||
|
case '2021-06-16':
|
||||||
|
return { marketPrice: 213.66 };
|
||||||
|
case '2021-06-17':
|
||||||
|
return { marketPrice: 213.74 };
|
||||||
|
case '2021-06-18':
|
||||||
|
return { marketPrice: 213.82 };
|
||||||
|
case '2021-06-19':
|
||||||
|
return { marketPrice: 213.9 };
|
||||||
|
case '2021-06-20':
|
||||||
|
return { marketPrice: 213.98 };
|
||||||
|
case '2021-06-21':
|
||||||
|
return { marketPrice: 214.06 };
|
||||||
|
case '2021-06-22':
|
||||||
|
return { marketPrice: 214.14 };
|
||||||
|
case '2021-06-23':
|
||||||
|
return { marketPrice: 214.22 };
|
||||||
|
case '2021-06-24':
|
||||||
|
return { marketPrice: 214.3 };
|
||||||
|
case '2021-06-25':
|
||||||
|
return { marketPrice: 214.38 };
|
||||||
|
case '2021-06-26':
|
||||||
|
return { marketPrice: 214.46 };
|
||||||
|
case '2021-06-27':
|
||||||
|
return { marketPrice: 214.54 };
|
||||||
|
case '2021-06-28':
|
||||||
|
return { marketPrice: 214.62 };
|
||||||
|
case '2021-06-29':
|
||||||
|
return { marketPrice: 214.7 };
|
||||||
|
case '2021-06-30':
|
||||||
|
return { marketPrice: 214.78 };
|
||||||
|
case '2021-07-01':
|
||||||
|
return { marketPrice: 214.86 };
|
||||||
|
case '2021-07-02':
|
||||||
|
return { marketPrice: 214.94 };
|
||||||
|
case '2021-07-03':
|
||||||
|
return { marketPrice: 215.02 };
|
||||||
|
case '2021-07-04':
|
||||||
|
return { marketPrice: 215.1 };
|
||||||
|
case '2021-07-05':
|
||||||
|
return { marketPrice: 215.18 };
|
||||||
|
case '2021-07-06':
|
||||||
|
return { marketPrice: 215.26 };
|
||||||
|
case '2021-07-07':
|
||||||
|
return { marketPrice: 215.34 };
|
||||||
|
case '2021-07-08':
|
||||||
|
return { marketPrice: 215.42 };
|
||||||
|
case '2021-07-09':
|
||||||
|
return { marketPrice: 215.5 };
|
||||||
|
case '2021-07-10':
|
||||||
|
return { marketPrice: 215.58 };
|
||||||
|
case '2021-07-11':
|
||||||
|
return { marketPrice: 215.66 };
|
||||||
|
case '2021-07-12':
|
||||||
|
return { marketPrice: 215.74 };
|
||||||
|
case '2021-07-13':
|
||||||
|
return { marketPrice: 215.82 };
|
||||||
|
case '2021-07-14':
|
||||||
|
return { marketPrice: 215.9 };
|
||||||
|
case '2021-07-15':
|
||||||
|
return { marketPrice: 215.98 };
|
||||||
|
case '2021-07-16':
|
||||||
|
return { marketPrice: 216.06 };
|
||||||
|
case '2021-07-17':
|
||||||
|
return { marketPrice: 216.14 };
|
||||||
|
case '2021-07-18':
|
||||||
|
return { marketPrice: 216.22 };
|
||||||
|
case '2021-07-19':
|
||||||
|
return { marketPrice: 216.3 };
|
||||||
|
case '2021-07-20':
|
||||||
|
return { marketPrice: 216.38 };
|
||||||
|
case '2021-07-21':
|
||||||
|
return { marketPrice: 216.46 };
|
||||||
|
case '2021-07-22':
|
||||||
|
return { marketPrice: 216.54 };
|
||||||
|
case '2021-07-23':
|
||||||
|
return { marketPrice: 216.62 };
|
||||||
|
case '2021-07-24':
|
||||||
|
return { marketPrice: 216.7 };
|
||||||
|
case '2021-07-25':
|
||||||
|
return { marketPrice: 216.78 };
|
||||||
|
case '2021-07-26':
|
||||||
|
return { marketPrice: 216.86 };
|
||||||
|
case '2021-07-27':
|
||||||
|
return { marketPrice: 216.94 };
|
||||||
|
case '2021-07-28':
|
||||||
|
return { marketPrice: 217.02 };
|
||||||
|
case '2021-07-29':
|
||||||
|
return { marketPrice: 217.1 };
|
||||||
|
case '2021-07-30':
|
||||||
|
return { marketPrice: 217.18 };
|
||||||
|
case '2021-07-31':
|
||||||
|
return { marketPrice: 217.26 };
|
||||||
|
case '2021-08-01':
|
||||||
|
return { marketPrice: 217.34 };
|
||||||
|
case '2020-10-24':
|
||||||
|
return { marketPrice: 194.86 };
|
||||||
|
default:
|
||||||
|
return { marketPrice: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return { marketPrice: 0 };
|
return { marketPrice: 0 };
|
||||||
@ -87,9 +267,6 @@ jest.mock('./current-rate.service', () => {
|
|||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
CurrentRateService: jest.fn().mockImplementation(() => {
|
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||||
return {
|
return {
|
||||||
getValue: ({ date, symbol }: GetValueParams) => {
|
|
||||||
return Promise.resolve(mockGetValue(symbol, date));
|
|
||||||
},
|
|
||||||
getValues: ({ dataGatheringItems, dateQuery }: GetValuesParams) => {
|
getValues: ({ dataGatheringItems, dateQuery }: GetValuesParams) => {
|
||||||
const result = [];
|
const result = [];
|
||||||
if (dateQuery.lt) {
|
if (dateQuery.lt) {
|
||||||
@ -1645,14 +1822,14 @@ describe('PortfolioCalculator', () => {
|
|||||||
grossPerformance: new Big('498.3'),
|
grossPerformance: new Big('498.3'),
|
||||||
netPerformance: new Big('498.3'),
|
netPerformance: new Big('498.3'),
|
||||||
investment: new Big('2923.7'),
|
investment: new Big('2923.7'),
|
||||||
value: new Big('3422') // 20 * (144.38 + days=335 * 0.08)
|
value: new Big('3422') // 20 * 171.1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-01-01',
|
date: '2021-01-01',
|
||||||
grossPerformance: new Big('349.35'),
|
grossPerformance: new Big('349.35'),
|
||||||
netPerformance: new Big('349.35'),
|
netPerformance: new Big('349.35'),
|
||||||
investment: new Big('652.55'),
|
investment: new Big('652.55'),
|
||||||
value: new Big('1001.9') // 5 * (144.38 + days=700 * 0.08)
|
value: new Big('1001.9') // 5 * 200.38
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@ -1765,14 +1942,14 @@ describe('PortfolioCalculator', () => {
|
|||||||
grossPerformance: new Big('498.3'),
|
grossPerformance: new Big('498.3'),
|
||||||
netPerformance: new Big('398.3'), // 100 fees
|
netPerformance: new Big('398.3'), // 100 fees
|
||||||
investment: new Big('2923.7'),
|
investment: new Big('2923.7'),
|
||||||
value: new Big('3422') // 20 * (144.38 + days=335 * 0.08)
|
value: new Big('3422') // 20 * 171.1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-01-01',
|
date: '2021-01-01',
|
||||||
grossPerformance: new Big('349.35'),
|
grossPerformance: new Big('349.35'),
|
||||||
netPerformance: new Big('199.35'), // 150 fees
|
netPerformance: new Big('199.35'), // 150 fees
|
||||||
investment: new Big('652.55'),
|
investment: new Big('652.55'),
|
||||||
value: new Big('1001.9') // 5 * (144.38 + days=700 * 0.08)
|
value: new Big('1001.9') // 5 * 200.38
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@ -1808,203 +1985,203 @@ describe('PortfolioCalculator', () => {
|
|||||||
grossPerformance: new Big('0'),
|
grossPerformance: new Big('0'),
|
||||||
netPerformance: new Big('0'),
|
netPerformance: new Big('0'),
|
||||||
investment: new Big('1443.8'),
|
investment: new Big('1443.8'),
|
||||||
value: new Big('1443.8') // 10 * (144.38 + days=0 * 0.08)
|
value: new Big('1443.8') // 10 * 144.38
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2019-03-01',
|
date: '2019-03-01',
|
||||||
grossPerformance: new Big('22.4'),
|
grossPerformance: new Big('22.4'),
|
||||||
netPerformance: new Big('22.4'),
|
netPerformance: new Big('22.4'),
|
||||||
investment: new Big('1443.8'),
|
investment: new Big('1443.8'),
|
||||||
value: new Big('1466.2') // 10 * (144.38 + days=28 * 0.08)
|
value: new Big('1466.2') // 10 * 146.62
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2019-04-01',
|
date: '2019-04-01',
|
||||||
grossPerformance: new Big('47.2'),
|
grossPerformance: new Big('47.2'),
|
||||||
netPerformance: new Big('47.2'),
|
netPerformance: new Big('47.2'),
|
||||||
investment: new Big('1443.8'),
|
investment: new Big('1443.8'),
|
||||||
value: new Big('1491') // 10 * (144.38 + days=59 * 0.08)
|
value: new Big('1491') // 10 * 149.1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2019-05-01',
|
date: '2019-05-01',
|
||||||
grossPerformance: new Big('71.2'),
|
grossPerformance: new Big('71.2'),
|
||||||
netPerformance: new Big('71.2'),
|
netPerformance: new Big('71.2'),
|
||||||
investment: new Big('1443.8'),
|
investment: new Big('1443.8'),
|
||||||
value: new Big('1515') // 10 * (144.38 + days=89 * 0.08)
|
value: new Big('1515') // 10 * 151.5
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2019-06-01',
|
date: '2019-06-01',
|
||||||
grossPerformance: new Big('96'),
|
grossPerformance: new Big('96'),
|
||||||
netPerformance: new Big('96'),
|
netPerformance: new Big('96'),
|
||||||
investment: new Big('1443.8'),
|
investment: new Big('1443.8'),
|
||||||
value: new Big('1539.8') // 10 * (144.38 + days=120 * 0.08)
|
value: new Big('1539.8') // 10 * 153.98
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2019-07-01',
|
date: '2019-07-01',
|
||||||
grossPerformance: new Big('120'),
|
grossPerformance: new Big('120'),
|
||||||
netPerformance: new Big('120'),
|
netPerformance: new Big('120'),
|
||||||
investment: new Big('1443.8'),
|
investment: new Big('1443.8'),
|
||||||
value: new Big('1563.8') // 10 * (144.38 + days=150 * 0.08)
|
value: new Big('1563.8') // 10 * 156.38
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2019-08-01',
|
date: '2019-08-01',
|
||||||
grossPerformance: new Big('144.8'),
|
grossPerformance: new Big('144.8'),
|
||||||
netPerformance: new Big('144.8'),
|
netPerformance: new Big('144.8'),
|
||||||
investment: new Big('1443.8'),
|
investment: new Big('1443.8'),
|
||||||
value: new Big('1588.6') // 10 * (144.38 + days=181 * 0.08)
|
value: new Big('1588.6') // 10 * 158.86
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2019-09-01',
|
date: '2019-09-01',
|
||||||
grossPerformance: new Big('303.1'),
|
grossPerformance: new Big('303.1'),
|
||||||
netPerformance: new Big('303.1'),
|
netPerformance: new Big('303.1'),
|
||||||
investment: new Big('2923.7'),
|
investment: new Big('2923.7'),
|
||||||
value: new Big('3226.8') // 20 * (144.38 + days=212 * 0.08)
|
value: new Big('3226.8') // 20 * 161.34
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2019-10-01',
|
date: '2019-10-01',
|
||||||
grossPerformance: new Big('351.1'),
|
grossPerformance: new Big('351.1'),
|
||||||
netPerformance: new Big('351.1'),
|
netPerformance: new Big('351.1'),
|
||||||
investment: new Big('2923.7'),
|
investment: new Big('2923.7'),
|
||||||
value: new Big('3274.8') // 20 * (144.38 + days=242 * 0.08)
|
value: new Big('3274.8') // 20 * 163.74
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2019-11-01',
|
date: '2019-11-01',
|
||||||
grossPerformance: new Big('400.7'),
|
grossPerformance: new Big('400.7'),
|
||||||
netPerformance: new Big('400.7'),
|
netPerformance: new Big('400.7'),
|
||||||
investment: new Big('2923.7'),
|
investment: new Big('2923.7'),
|
||||||
value: new Big('3324.4') // 20 * (144.38 + days=273 * 0.08)
|
value: new Big('3324.4') // 20 * 166.22
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2019-12-01',
|
date: '2019-12-01',
|
||||||
grossPerformance: new Big('448.7'),
|
grossPerformance: new Big('448.7'),
|
||||||
netPerformance: new Big('448.7'),
|
netPerformance: new Big('448.7'),
|
||||||
investment: new Big('2923.7'),
|
investment: new Big('2923.7'),
|
||||||
value: new Big('3372.4') // 20 * (144.38 + days=303 * 0.08)
|
value: new Big('3372.4') // 20 * 168.62
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2020-01-01',
|
date: '2020-01-01',
|
||||||
grossPerformance: new Big('498.3'),
|
grossPerformance: new Big('498.3'),
|
||||||
netPerformance: new Big('498.3'),
|
netPerformance: new Big('498.3'),
|
||||||
investment: new Big('2923.7'),
|
investment: new Big('2923.7'),
|
||||||
value: new Big('3422') // 20 * (144.38 + days=335 * 0.08)
|
value: new Big('3422') // 20 * 171.1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2020-02-01',
|
date: '2020-02-01',
|
||||||
grossPerformance: new Big('547.9'),
|
grossPerformance: new Big('547.9'),
|
||||||
netPerformance: new Big('547.9'),
|
netPerformance: new Big('547.9'),
|
||||||
investment: new Big('2923.7'),
|
investment: new Big('2923.7'),
|
||||||
value: new Big('3471.6') // 20 * (144.38 + days=365 * 0.08)
|
value: new Big('3471.6') // 20 * 173.58
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2020-03-01',
|
date: '2020-03-01',
|
||||||
grossPerformance: new Big('226.95'),
|
grossPerformance: new Big('226.95'),
|
||||||
netPerformance: new Big('226.95'),
|
netPerformance: new Big('226.95'),
|
||||||
investment: new Big('652.55'),
|
investment: new Big('652.55'),
|
||||||
value: new Big('879.5') // 5 * (144.38 + days=394 * 0.08)
|
value: new Big('879.5') // 5 * 175.9
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2020-04-01',
|
date: '2020-04-01',
|
||||||
grossPerformance: new Big('239.35'),
|
grossPerformance: new Big('239.35'),
|
||||||
netPerformance: new Big('239.35'),
|
netPerformance: new Big('239.35'),
|
||||||
investment: new Big('652.55'),
|
investment: new Big('652.55'),
|
||||||
value: new Big('891.9') // 5 * (144.38 + days=425 * 0.08)
|
value: new Big('891.9') // 5 * 178.38
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2020-05-01',
|
date: '2020-05-01',
|
||||||
grossPerformance: new Big('251.35'),
|
grossPerformance: new Big('251.35'),
|
||||||
netPerformance: new Big('251.35'),
|
netPerformance: new Big('251.35'),
|
||||||
investment: new Big('652.55'),
|
investment: new Big('652.55'),
|
||||||
value: new Big('903.9') // 5 * (144.38 + days=455 * 0.08)
|
value: new Big('903.9') // 5 * 180.78
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2020-06-01',
|
date: '2020-06-01',
|
||||||
grossPerformance: new Big('263.75'),
|
grossPerformance: new Big('263.75'),
|
||||||
netPerformance: new Big('263.75'),
|
netPerformance: new Big('263.75'),
|
||||||
investment: new Big('652.55'),
|
investment: new Big('652.55'),
|
||||||
value: new Big('916.3') // 5 * (144.38 + days=486 * 0.08)
|
value: new Big('916.3') // 5 * 183.26
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2020-07-01',
|
date: '2020-07-01',
|
||||||
grossPerformance: new Big('275.75'),
|
grossPerformance: new Big('275.75'),
|
||||||
netPerformance: new Big('275.75'),
|
netPerformance: new Big('275.75'),
|
||||||
investment: new Big('652.55'),
|
investment: new Big('652.55'),
|
||||||
value: new Big('928.3') // 5 * (144.38 + days=516 * 0.08)
|
value: new Big('928.3') // 5 * 185.66
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2020-08-01',
|
date: '2020-08-01',
|
||||||
grossPerformance: new Big('288.15'),
|
grossPerformance: new Big('288.15'),
|
||||||
netPerformance: new Big('288.15'),
|
netPerformance: new Big('288.15'),
|
||||||
investment: new Big('652.55'),
|
investment: new Big('652.55'),
|
||||||
value: new Big('940.7') // 5 * (144.38 + days=547 * 0.08)
|
value: new Big('940.7') // 5 * 188.14
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2020-09-01',
|
date: '2020-09-01',
|
||||||
grossPerformance: new Big('300.55'),
|
grossPerformance: new Big('300.55'),
|
||||||
netPerformance: new Big('300.55'),
|
netPerformance: new Big('300.55'),
|
||||||
investment: new Big('652.55'),
|
investment: new Big('652.55'),
|
||||||
value: new Big('953.1') // 5 * (144.38 + days=578 * 0.08)
|
value: new Big('953.1') // 5 * 190.62
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2020-10-01',
|
date: '2020-10-01',
|
||||||
grossPerformance: new Big('312.55'),
|
grossPerformance: new Big('312.55'),
|
||||||
netPerformance: new Big('312.55'),
|
netPerformance: new Big('312.55'),
|
||||||
investment: new Big('652.55'),
|
investment: new Big('652.55'),
|
||||||
value: new Big('965.1') // 5 * (144.38 + days=608 * 0.08)
|
value: new Big('965.1') // 5 * 193.02
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2020-11-01',
|
date: '2020-11-01',
|
||||||
grossPerformance: new Big('324.95'),
|
grossPerformance: new Big('324.95'),
|
||||||
netPerformance: new Big('324.95'),
|
netPerformance: new Big('324.95'),
|
||||||
investment: new Big('652.55'),
|
investment: new Big('652.55'),
|
||||||
value: new Big('977.5') // 5 * (144.38 + days=639 * 0.08)
|
value: new Big('977.5') // 5 * 195.5
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2020-12-01',
|
date: '2020-12-01',
|
||||||
grossPerformance: new Big('336.95'),
|
grossPerformance: new Big('336.95'),
|
||||||
netPerformance: new Big('336.95'),
|
netPerformance: new Big('336.95'),
|
||||||
investment: new Big('652.55'),
|
investment: new Big('652.55'),
|
||||||
value: new Big('989.5') // 5 * (144.38 + days=669 * 0.08)
|
value: new Big('989.5') // 5 * 197.9
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-01-01',
|
date: '2021-01-01',
|
||||||
grossPerformance: new Big('349.35'),
|
grossPerformance: new Big('349.35'),
|
||||||
netPerformance: new Big('349.35'),
|
netPerformance: new Big('349.35'),
|
||||||
investment: new Big('652.55'),
|
investment: new Big('652.55'),
|
||||||
value: new Big('1001.9') // 5 * (144.38 + days=700 * 0.08)
|
value: new Big('1001.9') // 5 * 200.38
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-02-01',
|
date: '2021-02-01',
|
||||||
grossPerformance: new Big('358.85'),
|
grossPerformance: new Big('358.85'),
|
||||||
netPerformance: new Big('358.85'),
|
netPerformance: new Big('358.85'),
|
||||||
investment: new Big('2684.05'),
|
investment: new Big('2684.05'),
|
||||||
value: new Big('3042.9') // 15 * (144.38 + days=731 * 0.08)
|
value: new Big('3042.9') // 15 * 202.86
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-03-01',
|
date: '2021-03-01',
|
||||||
grossPerformance: new Big('392.45'),
|
grossPerformance: new Big('392.45'),
|
||||||
netPerformance: new Big('392.45'),
|
netPerformance: new Big('392.45'),
|
||||||
investment: new Big('2684.05'),
|
investment: new Big('2684.05'),
|
||||||
value: new Big('3076.5') // 15 * (144.38 + days=759 * 0.08)
|
value: new Big('3076.5') // 15 * 205.1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-04-01',
|
date: '2021-04-01',
|
||||||
grossPerformance: new Big('429.65'),
|
grossPerformance: new Big('429.65'),
|
||||||
netPerformance: new Big('429.65'),
|
netPerformance: new Big('429.65'),
|
||||||
investment: new Big('2684.05'),
|
investment: new Big('2684.05'),
|
||||||
value: new Big('3113.7') // 15 * (144.38 + days=790 * 0.08)
|
value: new Big('3113.7') // 15 * 207.58
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-05-01',
|
date: '2021-05-01',
|
||||||
grossPerformance: new Big('465.65'),
|
grossPerformance: new Big('465.65'),
|
||||||
netPerformance: new Big('465.65'),
|
netPerformance: new Big('465.65'),
|
||||||
investment: new Big('2684.05'),
|
investment: new Big('2684.05'),
|
||||||
value: new Big('3149.7') // 15 * (144.38 + days=820 * 0.08)
|
value: new Big('3149.7') // 15 * 209.98
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-06-01',
|
date: '2021-06-01',
|
||||||
grossPerformance: new Big('502.85'),
|
grossPerformance: new Big('502.85'),
|
||||||
netPerformance: new Big('502.85'),
|
netPerformance: new Big('502.85'),
|
||||||
investment: new Big('2684.05'),
|
investment: new Big('2684.05'),
|
||||||
value: new Big('3186.9') // 15 * (144.38 + days=851 * 0.08)
|
value: new Big('3186.9') // 15 * 212.46
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -2047,49 +2224,49 @@ describe('PortfolioCalculator', () => {
|
|||||||
grossPerformance: new Big('498.3'),
|
grossPerformance: new Big('498.3'),
|
||||||
netPerformance: new Big('498.3'),
|
netPerformance: new Big('498.3'),
|
||||||
investment: new Big('2923.7'),
|
investment: new Big('2923.7'),
|
||||||
value: new Big('3422') // 20 * (144.38 + days=335 * 0.08)
|
value: new Big('3422') // 20 * 171.1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-01-01',
|
date: '2021-01-01',
|
||||||
grossPerformance: new Big('349.35'),
|
grossPerformance: new Big('349.35'),
|
||||||
netPerformance: new Big('349.35'),
|
netPerformance: new Big('349.35'),
|
||||||
investment: new Big('652.55'),
|
investment: new Big('652.55'),
|
||||||
value: new Big('1001.9') // 5 * (144.38 + days=700 * 0.08)
|
value: new Big('1001.9') // 5 * 200.38
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-02-01',
|
date: '2021-02-01',
|
||||||
grossPerformance: new Big('358.85'),
|
grossPerformance: new Big('358.85'),
|
||||||
netPerformance: new Big('358.85'),
|
netPerformance: new Big('358.85'),
|
||||||
investment: new Big('2684.05'),
|
investment: new Big('2684.05'),
|
||||||
value: new Big('3042.9') // 15 * (144.38 + days=731 * 0.08)
|
value: new Big('3042.9') // 15 * 202.86
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-03-01',
|
date: '2021-03-01',
|
||||||
grossPerformance: new Big('392.45'),
|
grossPerformance: new Big('392.45'),
|
||||||
netPerformance: new Big('392.45'),
|
netPerformance: new Big('392.45'),
|
||||||
investment: new Big('2684.05'),
|
investment: new Big('2684.05'),
|
||||||
value: new Big('3076.5') // 15 * (144.38 + days=759 * 0.08)
|
value: new Big('3076.5') // 15 * 205.1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-04-01',
|
date: '2021-04-01',
|
||||||
grossPerformance: new Big('429.65'),
|
grossPerformance: new Big('429.65'),
|
||||||
netPerformance: new Big('429.65'),
|
netPerformance: new Big('429.65'),
|
||||||
investment: new Big('2684.05'),
|
investment: new Big('2684.05'),
|
||||||
value: new Big('3113.7') // 15 * (144.38 + days=790 * 0.08)
|
value: new Big('3113.7') // 15 * 207.58
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-05-01',
|
date: '2021-05-01',
|
||||||
grossPerformance: new Big('465.65'),
|
grossPerformance: new Big('465.65'),
|
||||||
netPerformance: new Big('465.65'),
|
netPerformance: new Big('465.65'),
|
||||||
investment: new Big('2684.05'),
|
investment: new Big('2684.05'),
|
||||||
value: new Big('3149.7') // 15 * (144.38 + days=820 * 0.08)
|
value: new Big('3149.7') // 15 * 209.98
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-06-01',
|
date: '2021-06-01',
|
||||||
grossPerformance: new Big('502.85'),
|
grossPerformance: new Big('502.85'),
|
||||||
netPerformance: new Big('502.85'),
|
netPerformance: new Big('502.85'),
|
||||||
investment: new Big('2684.05'),
|
investment: new Big('2684.05'),
|
||||||
value: new Big('3186.9') // 15 * (144.38 + days=851 * 0.08)
|
value: new Big('3186.9') // 15 * 212.46
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@ -2134,252 +2311,252 @@ describe('PortfolioCalculator', () => {
|
|||||||
grossPerformance: new Big('498.3'),
|
grossPerformance: new Big('498.3'),
|
||||||
netPerformance: new Big('498.3'),
|
netPerformance: new Big('498.3'),
|
||||||
investment: new Big('2923.7'),
|
investment: new Big('2923.7'),
|
||||||
value: new Big('3422') // 20 * (144.38 + days=335 * 0.08)
|
value: new Big('3422') // 20 * 171.1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-01-01',
|
date: '2021-01-01',
|
||||||
grossPerformance: new Big('349.35'),
|
grossPerformance: new Big('349.35'),
|
||||||
netPerformance: new Big('349.35'),
|
netPerformance: new Big('349.35'),
|
||||||
investment: new Big('652.55'),
|
investment: new Big('652.55'),
|
||||||
value: new Big('1001.9') // 5 * (144.38 + days=700 * 0.08)
|
value: new Big('1001.9') // 5 * 200.38
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-02-01',
|
date: '2021-02-01',
|
||||||
grossPerformance: new Big('358.85'),
|
grossPerformance: new Big('358.85'),
|
||||||
netPerformance: new Big('358.85'),
|
netPerformance: new Big('358.85'),
|
||||||
investment: new Big('2684.05'),
|
investment: new Big('2684.05'),
|
||||||
value: new Big('3042.9') // 15 * (144.38 + days=731 * 0.08)
|
value: new Big('3042.9') // 15 * 202.86
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-03-01',
|
date: '2021-03-01',
|
||||||
grossPerformance: new Big('392.45'),
|
grossPerformance: new Big('392.45'),
|
||||||
netPerformance: new Big('392.45'),
|
netPerformance: new Big('392.45'),
|
||||||
investment: new Big('2684.05'),
|
investment: new Big('2684.05'),
|
||||||
value: new Big('3076.5') // 15 * (144.38 + days=759 * 0.08)
|
value: new Big('3076.5') // 15 * 205.1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-04-01',
|
date: '2021-04-01',
|
||||||
grossPerformance: new Big('429.65'),
|
grossPerformance: new Big('429.65'),
|
||||||
netPerformance: new Big('429.65'),
|
netPerformance: new Big('429.65'),
|
||||||
investment: new Big('2684.05'),
|
investment: new Big('2684.05'),
|
||||||
value: new Big('3113.7') // 15 * (144.38 + days=790 * 0.08)
|
value: new Big('3113.7') // 15 * 207.58
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-05-01',
|
date: '2021-05-01',
|
||||||
grossPerformance: new Big('465.65'),
|
grossPerformance: new Big('465.65'),
|
||||||
netPerformance: new Big('465.65'),
|
netPerformance: new Big('465.65'),
|
||||||
investment: new Big('2684.05'),
|
investment: new Big('2684.05'),
|
||||||
value: new Big('3149.7') // 15 * (144.38 + days=820 * 0.08)
|
value: new Big('3149.7') // 15 * 209.98
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-06-01',
|
date: '2021-06-01',
|
||||||
grossPerformance: new Big('502.85'),
|
grossPerformance: new Big('502.85'),
|
||||||
netPerformance: new Big('502.85'),
|
netPerformance: new Big('502.85'),
|
||||||
investment: new Big('2684.05'),
|
investment: new Big('2684.05'),
|
||||||
value: new Big('3186.9') // 15 * (144.38 + days=851 * 0.08)
|
value: new Big('3186.9') // 15 * 212.46
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-06-02',
|
date: '2021-06-02',
|
||||||
grossPerformance: new Big('504.05'),
|
grossPerformance: new Big('504.05'),
|
||||||
netPerformance: new Big('504.05'),
|
netPerformance: new Big('504.05'),
|
||||||
investment: new Big('2684.05'),
|
investment: new Big('2684.05'),
|
||||||
value: new Big('3188.1') // 15 * (144.38 + days=852 * 0.08) / +1.2
|
value: new Big('3188.1') // 15 * 212.54
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-06-03',
|
date: '2021-06-03',
|
||||||
grossPerformance: new Big('505.25'),
|
grossPerformance: new Big('505.25'),
|
||||||
netPerformance: new Big('505.25'),
|
netPerformance: new Big('505.25'),
|
||||||
investment: new Big('2684.05'),
|
investment: new Big('2684.05'),
|
||||||
value: new Big('3189.3') // +1.2
|
value: new Big('3189.3') // 15 * 212.62
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-06-04',
|
date: '2021-06-04',
|
||||||
grossPerformance: new Big('506.45'),
|
grossPerformance: new Big('506.45'),
|
||||||
netPerformance: new Big('506.45'),
|
netPerformance: new Big('506.45'),
|
||||||
investment: new Big('2684.05'),
|
investment: new Big('2684.05'),
|
||||||
value: new Big('3190.5') // +1.2
|
value: new Big('3190.5') // 15 * 212.7
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-06-05',
|
date: '2021-06-05',
|
||||||
grossPerformance: new Big('507.65'),
|
grossPerformance: new Big('507.65'),
|
||||||
netPerformance: new Big('507.65'),
|
netPerformance: new Big('507.65'),
|
||||||
investment: new Big('2684.05'),
|
investment: new Big('2684.05'),
|
||||||
value: new Big('3191.7') // +1.2
|
value: new Big('3191.7') // 15 * 212.78
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-06-06',
|
date: '2021-06-06',
|
||||||
grossPerformance: new Big('508.85'),
|
grossPerformance: new Big('508.85'),
|
||||||
netPerformance: new Big('508.85'),
|
netPerformance: new Big('508.85'),
|
||||||
investment: new Big('2684.05'),
|
investment: new Big('2684.05'),
|
||||||
value: new Big('3192.9') // +1.2
|
value: new Big('3192.9') // 15 * 212.86
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-06-07',
|
date: '2021-06-07',
|
||||||
grossPerformance: new Big('510.05'),
|
grossPerformance: new Big('510.05'),
|
||||||
netPerformance: new Big('510.05'),
|
netPerformance: new Big('510.05'),
|
||||||
investment: new Big('2684.05'),
|
investment: new Big('2684.05'),
|
||||||
value: new Big('3194.1') // +1.2
|
value: new Big('3194.1') // 15 * 212.94
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-06-08',
|
date: '2021-06-08',
|
||||||
grossPerformance: new Big('511.25'),
|
grossPerformance: new Big('511.25'),
|
||||||
netPerformance: new Big('511.25'),
|
netPerformance: new Big('511.25'),
|
||||||
investment: new Big('2684.05'),
|
investment: new Big('2684.05'),
|
||||||
value: new Big('3195.3') // +1.2
|
value: new Big('3195.3') // 15 * 213.02
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-06-09',
|
date: '2021-06-09',
|
||||||
grossPerformance: new Big('512.45'),
|
grossPerformance: new Big('512.45'),
|
||||||
netPerformance: new Big('512.45'),
|
netPerformance: new Big('512.45'),
|
||||||
investment: new Big('2684.05'),
|
investment: new Big('2684.05'),
|
||||||
value: new Big('3196.5') // +1.2
|
value: new Big('3196.5') // 15 * 213.1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-06-10',
|
date: '2021-06-10',
|
||||||
grossPerformance: new Big('513.65'),
|
grossPerformance: new Big('513.65'),
|
||||||
netPerformance: new Big('513.65'),
|
netPerformance: new Big('513.65'),
|
||||||
investment: new Big('2684.05'),
|
investment: new Big('2684.05'),
|
||||||
value: new Big('3197.7') // +1.2
|
value: new Big('3197.7') // 15 * 213.18
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-06-11',
|
date: '2021-06-11',
|
||||||
grossPerformance: new Big('514.85'),
|
grossPerformance: new Big('514.85'),
|
||||||
netPerformance: new Big('514.85'),
|
netPerformance: new Big('514.85'),
|
||||||
investment: new Big('2684.05'),
|
investment: new Big('2684.05'),
|
||||||
value: new Big('3198.9') // +1.2
|
value: new Big('3198.9') // 15 * 213.26
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-06-12',
|
date: '2021-06-12',
|
||||||
grossPerformance: new Big('516.05'),
|
grossPerformance: new Big('516.05'),
|
||||||
netPerformance: new Big('516.05'),
|
netPerformance: new Big('516.05'),
|
||||||
investment: new Big('2684.05'),
|
investment: new Big('2684.05'),
|
||||||
value: new Big('3200.1') // +1.2
|
value: new Big('3200.1') // 15 * 213.34
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-06-13',
|
date: '2021-06-13',
|
||||||
grossPerformance: new Big('517.25'),
|
grossPerformance: new Big('517.25'),
|
||||||
netPerformance: new Big('517.25'),
|
netPerformance: new Big('517.25'),
|
||||||
investment: new Big('2684.05'),
|
investment: new Big('2684.05'),
|
||||||
value: new Big('3201.3') // +1.2
|
value: new Big('3201.3') // 15 * 213.42
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-06-14',
|
date: '2021-06-14',
|
||||||
grossPerformance: new Big('518.45'),
|
grossPerformance: new Big('518.45'),
|
||||||
netPerformance: new Big('518.45'),
|
netPerformance: new Big('518.45'),
|
||||||
investment: new Big('2684.05'),
|
investment: new Big('2684.05'),
|
||||||
value: new Big('3202.5') // +1.2
|
value: new Big('3202.5') // 15 * 213.5
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-06-15',
|
date: '2021-06-15',
|
||||||
grossPerformance: new Big('519.65'),
|
grossPerformance: new Big('519.65'),
|
||||||
netPerformance: new Big('519.65'),
|
netPerformance: new Big('519.65'),
|
||||||
investment: new Big('2684.05'),
|
investment: new Big('2684.05'),
|
||||||
value: new Big('3203.7') // +1.2
|
value: new Big('3203.7') // 15 * 213.58
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-06-16',
|
date: '2021-06-16',
|
||||||
grossPerformance: new Big('520.85'),
|
grossPerformance: new Big('520.85'),
|
||||||
netPerformance: new Big('520.85'),
|
netPerformance: new Big('520.85'),
|
||||||
investment: new Big('2684.05'),
|
investment: new Big('2684.05'),
|
||||||
value: new Big('3204.9') // +1.2
|
value: new Big('3204.9') // 15 * 213.66
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-06-17',
|
date: '2021-06-17',
|
||||||
grossPerformance: new Big('522.05'),
|
grossPerformance: new Big('522.05'),
|
||||||
netPerformance: new Big('522.05'),
|
netPerformance: new Big('522.05'),
|
||||||
investment: new Big('2684.05'),
|
investment: new Big('2684.05'),
|
||||||
value: new Big('3206.1') // +1.2
|
value: new Big('3206.1') // 15 * 213.74
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-06-18',
|
date: '2021-06-18',
|
||||||
grossPerformance: new Big('523.25'),
|
grossPerformance: new Big('523.25'),
|
||||||
netPerformance: new Big('523.25'),
|
netPerformance: new Big('523.25'),
|
||||||
investment: new Big('2684.05'),
|
investment: new Big('2684.05'),
|
||||||
value: new Big('3207.3') // +1.2
|
value: new Big('3207.3') // 15 * 213.82
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-06-19',
|
date: '2021-06-19',
|
||||||
grossPerformance: new Big('524.45'),
|
grossPerformance: new Big('524.45'),
|
||||||
netPerformance: new Big('524.45'),
|
netPerformance: new Big('524.45'),
|
||||||
investment: new Big('2684.05'),
|
investment: new Big('2684.05'),
|
||||||
value: new Big('3208.5') // +1.2
|
value: new Big('3208.5') // 15 * 213.9
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-06-20',
|
date: '2021-06-20',
|
||||||
grossPerformance: new Big('525.65'),
|
grossPerformance: new Big('525.65'),
|
||||||
netPerformance: new Big('525.65'),
|
netPerformance: new Big('525.65'),
|
||||||
investment: new Big('2684.05'),
|
investment: new Big('2684.05'),
|
||||||
value: new Big('3209.7') // +1.2
|
value: new Big('3209.7') // 15 * 213.98
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-06-21',
|
date: '2021-06-21',
|
||||||
grossPerformance: new Big('526.85'),
|
grossPerformance: new Big('526.85'),
|
||||||
netPerformance: new Big('526.85'),
|
netPerformance: new Big('526.85'),
|
||||||
investment: new Big('2684.05'),
|
investment: new Big('2684.05'),
|
||||||
value: new Big('3210.9') // +1.2
|
value: new Big('3210.9') // 15 * 214.06
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-06-22',
|
date: '2021-06-22',
|
||||||
grossPerformance: new Big('528.05'),
|
grossPerformance: new Big('528.05'),
|
||||||
netPerformance: new Big('528.05'),
|
netPerformance: new Big('528.05'),
|
||||||
investment: new Big('2684.05'),
|
investment: new Big('2684.05'),
|
||||||
value: new Big('3212.1') // +1.2
|
value: new Big('3212.1') // 15 * 214.14
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-06-23',
|
date: '2021-06-23',
|
||||||
grossPerformance: new Big('529.25'),
|
grossPerformance: new Big('529.25'),
|
||||||
netPerformance: new Big('529.25'),
|
netPerformance: new Big('529.25'),
|
||||||
investment: new Big('2684.05'),
|
investment: new Big('2684.05'),
|
||||||
value: new Big('3213.3') // +1.2
|
value: new Big('3213.3') // 15 * 214.22
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-06-24',
|
date: '2021-06-24',
|
||||||
grossPerformance: new Big('530.45'),
|
grossPerformance: new Big('530.45'),
|
||||||
netPerformance: new Big('530.45'),
|
netPerformance: new Big('530.45'),
|
||||||
investment: new Big('2684.05'),
|
investment: new Big('2684.05'),
|
||||||
value: new Big('3214.5') // +1.2
|
value: new Big('3214.5') // 15 * 214.3
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-06-25',
|
date: '2021-06-25',
|
||||||
grossPerformance: new Big('531.65'),
|
grossPerformance: new Big('531.65'),
|
||||||
netPerformance: new Big('531.65'),
|
netPerformance: new Big('531.65'),
|
||||||
investment: new Big('2684.05'),
|
investment: new Big('2684.05'),
|
||||||
value: new Big('3215.7') // +1.2
|
value: new Big('3215.7') // 15 * 214.38
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-06-26',
|
date: '2021-06-26',
|
||||||
grossPerformance: new Big('532.85'),
|
grossPerformance: new Big('532.85'),
|
||||||
netPerformance: new Big('532.85'),
|
netPerformance: new Big('532.85'),
|
||||||
investment: new Big('2684.05'),
|
investment: new Big('2684.05'),
|
||||||
value: new Big('3216.9') // +1.2
|
value: new Big('3216.9') // 15 * 214.46
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-06-27',
|
date: '2021-06-27',
|
||||||
grossPerformance: new Big('534.05'),
|
grossPerformance: new Big('534.05'),
|
||||||
netPerformance: new Big('534.05'),
|
netPerformance: new Big('534.05'),
|
||||||
investment: new Big('2684.05'),
|
investment: new Big('2684.05'),
|
||||||
value: new Big('3218.1') // +1.2
|
value: new Big('3218.1') // 15 * 214.54
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-06-28',
|
date: '2021-06-28',
|
||||||
grossPerformance: new Big('535.25'),
|
grossPerformance: new Big('535.25'),
|
||||||
netPerformance: new Big('535.25'),
|
netPerformance: new Big('535.25'),
|
||||||
investment: new Big('2684.05'),
|
investment: new Big('2684.05'),
|
||||||
value: new Big('3219.3') // +1.2
|
value: new Big('3219.3') // 15 * 214.62
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-06-29',
|
date: '2021-06-29',
|
||||||
grossPerformance: new Big('536.45'),
|
grossPerformance: new Big('536.45'),
|
||||||
netPerformance: new Big('536.45'),
|
netPerformance: new Big('536.45'),
|
||||||
investment: new Big('2684.05'),
|
investment: new Big('2684.05'),
|
||||||
value: new Big('3220.5') // +1.2
|
value: new Big('3220.5') // 15 * 214.7
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-06-30',
|
date: '2021-06-30',
|
||||||
grossPerformance: new Big('537.65'),
|
grossPerformance: new Big('537.65'),
|
||||||
netPerformance: new Big('537.65'),
|
netPerformance: new Big('537.65'),
|
||||||
investment: new Big('2684.05'),
|
investment: new Big('2684.05'),
|
||||||
value: new Big('3221.7') // +1.2
|
value: new Big('3221.7') // 15 * 214.78
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
@ -2442,7 +2619,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
grossPerformance: new Big('267.2'),
|
grossPerformance: new Big('267.2'),
|
||||||
netPerformance: new Big('267.2'),
|
netPerformance: new Big('267.2'),
|
||||||
investment: new Big('11553.75'),
|
investment: new Big('11553.75'),
|
||||||
value: new Big('11820.95') // 10 * (144.38 + days=334 * 0.08) + 5 * 2021.99
|
value: new Big('11820.95') // 10 * 171.1 + 5 * 2021.99
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
25
apps/api/src/app/portfolio/portfolio-service.strategy.ts
Normal file
25
apps/api/src/app/portfolio/portfolio-service.strategy.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { REQUEST } from '@nestjs/core';
|
||||||
|
|
||||||
|
import { PortfolioService } from './portfolio.service';
|
||||||
|
import { PortfolioServiceNew } from './portfolio.service-new';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PortfolioServiceStrategy {
|
||||||
|
public constructor(
|
||||||
|
private readonly portfolioService: PortfolioService,
|
||||||
|
private readonly portfolioServiceNew: PortfolioServiceNew,
|
||||||
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public get() {
|
||||||
|
if (
|
||||||
|
this.request.user?.Settings?.settings?.['isNewCalculationEngine'] === true
|
||||||
|
) {
|
||||||
|
return this.portfolioServiceNew;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.portfolioService;
|
||||||
|
}
|
||||||
|
}
|
@ -4,18 +4,21 @@ import {
|
|||||||
hasNotDefinedValuesInObject,
|
hasNotDefinedValuesInObject,
|
||||||
nullifyValuesInObject
|
nullifyValuesInObject
|
||||||
} from '@ghostfolio/api/helper/object.helper';
|
} from '@ghostfolio/api/helper/object.helper';
|
||||||
|
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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { baseCurrency } from '@ghostfolio/common/config';
|
import { baseCurrency } from '@ghostfolio/common/config';
|
||||||
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
PortfolioChart,
|
PortfolioChart,
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
|
PortfolioInvestments,
|
||||||
PortfolioPerformance,
|
PortfolioPerformance,
|
||||||
PortfolioPublicDetails,
|
PortfolioPublicDetails,
|
||||||
PortfolioReport,
|
PortfolioReport,
|
||||||
PortfolioSummary
|
PortfolioSummary
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
@ -25,17 +28,16 @@ import {
|
|||||||
Inject,
|
Inject,
|
||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
Res,
|
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 { Response } from 'express';
|
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
|
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
|
||||||
import { PortfolioPositions } from './interfaces/portfolio-positions.interface';
|
import { PortfolioPositions } from './interfaces/portfolio-positions.interface';
|
||||||
import { PortfolioService } from './portfolio.service';
|
import { PortfolioServiceStrategy } from './portfolio-service.strategy';
|
||||||
|
|
||||||
@Controller('portfolio')
|
@Controller('portfolio')
|
||||||
export class PortfolioController {
|
export class PortfolioController {
|
||||||
@ -43,73 +45,31 @@ export class PortfolioController {
|
|||||||
private readonly accessService: AccessService,
|
private readonly accessService: AccessService,
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly portfolioService: PortfolioService,
|
private readonly portfolioServiceStrategy: PortfolioServiceStrategy,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||||
private readonly userService: UserService
|
private readonly userService: UserService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('investments')
|
|
||||||
@UseGuards(AuthGuard('jwt'))
|
|
||||||
public async findAll(
|
|
||||||
@Headers('impersonation-id') impersonationId,
|
|
||||||
@Res() res: Response
|
|
||||||
): Promise<InvestmentItem[]> {
|
|
||||||
if (
|
|
||||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
|
||||||
this.request.user.subscription.type === 'Basic'
|
|
||||||
) {
|
|
||||||
res.status(StatusCodes.FORBIDDEN);
|
|
||||||
return <any>res.json([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
let investments = await this.portfolioService.getInvestments(
|
|
||||||
impersonationId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
impersonationId ||
|
|
||||||
this.userService.isRestrictedView(this.request.user)
|
|
||||||
) {
|
|
||||||
const maxInvestment = investments.reduce(
|
|
||||||
(investment, item) => Math.max(investment, item.investment),
|
|
||||||
1
|
|
||||||
);
|
|
||||||
|
|
||||||
investments = investments.map((item) => ({
|
|
||||||
date: item.date,
|
|
||||||
investment: item.investment / maxInvestment
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
return <any>res.json(investments);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('chart')
|
@Get('chart')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getChart(
|
public async getChart(
|
||||||
@Headers('impersonation-id') impersonationId,
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
@Query('range') range,
|
@Query('range') range
|
||||||
@Res() res: Response
|
|
||||||
): Promise<PortfolioChart> {
|
): Promise<PortfolioChart> {
|
||||||
const historicalDataContainer = await this.portfolioService.getChart(
|
const historicalDataContainer = await this.portfolioServiceStrategy
|
||||||
impersonationId,
|
.get()
|
||||||
range
|
.getChart(impersonationId, range);
|
||||||
);
|
|
||||||
|
|
||||||
let chartData = historicalDataContainer.items;
|
let chartData = historicalDataContainer.items;
|
||||||
|
|
||||||
let hasNullValue = false;
|
let hasError = false;
|
||||||
|
|
||||||
chartData.forEach((chartDataItem) => {
|
chartData.forEach((chartDataItem) => {
|
||||||
if (hasNotDefinedValuesInObject(chartDataItem)) {
|
if (hasNotDefinedValuesInObject(chartDataItem)) {
|
||||||
hasNullValue = true;
|
hasError = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (hasNullValue) {
|
|
||||||
res.status(StatusCodes.ACCEPTED);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationId ||
|
impersonationId ||
|
||||||
this.userService.isRestrictedView(this.request.user)
|
this.userService.isRestrictedView(this.request.user)
|
||||||
@ -130,37 +90,40 @@ export class PortfolioController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return <any>res.json({
|
return {
|
||||||
|
hasError,
|
||||||
chart: chartData,
|
chart: chartData,
|
||||||
isAllTimeHigh: historicalDataContainer.isAllTimeHigh,
|
isAllTimeHigh: historicalDataContainer.isAllTimeHigh,
|
||||||
isAllTimeLow: historicalDataContainer.isAllTimeLow
|
isAllTimeLow: historicalDataContainer.isAllTimeLow
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('details')
|
@Get('details')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getDetails(
|
public async getDetails(
|
||||||
@Headers('impersonation-id') impersonationId,
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
@Query('range') range,
|
@Query('range') range
|
||||||
@Res() res: Response
|
): Promise<PortfolioDetails & { hasError: boolean }> {
|
||||||
): Promise<PortfolioDetails> {
|
|
||||||
if (
|
if (
|
||||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
this.request.user.subscription.type === 'Basic'
|
this.request.user.subscription.type === 'Basic'
|
||||||
) {
|
) {
|
||||||
res.status(StatusCodes.FORBIDDEN);
|
throw new HttpException(
|
||||||
return <any>res.json({ accounts: {}, holdings: {} });
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let hasError = false;
|
||||||
|
|
||||||
const { accounts, holdings, hasErrors } =
|
const { accounts, holdings, hasErrors } =
|
||||||
await this.portfolioService.getDetails(
|
await this.portfolioServiceStrategy
|
||||||
impersonationId,
|
.get()
|
||||||
this.request.user.id,
|
.getDetails(impersonationId, this.request.user.id, range);
|
||||||
range
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
||||||
res.status(StatusCodes.ACCEPTED);
|
hasError = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -198,54 +161,79 @@ export class PortfolioController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return <any>res.json({ accounts, holdings });
|
return { accounts, hasError, holdings };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('investments')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async getInvestments(
|
||||||
|
@Headers('impersonation-id') impersonationId: string
|
||||||
|
): Promise<PortfolioInvestments> {
|
||||||
|
if (
|
||||||
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
|
this.request.user.subscription.type === 'Basic'
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let investments = await this.portfolioServiceStrategy
|
||||||
|
.get()
|
||||||
|
.getInvestments(impersonationId);
|
||||||
|
|
||||||
|
if (
|
||||||
|
impersonationId ||
|
||||||
|
this.userService.isRestrictedView(this.request.user)
|
||||||
|
) {
|
||||||
|
const maxInvestment = investments.reduce(
|
||||||
|
(investment, item) => Math.max(investment, item.investment),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
investments = investments.map((item) => ({
|
||||||
|
date: item.date,
|
||||||
|
investment: item.investment / maxInvestment
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { firstOrderDate: parseDate(investments[0]?.date), investments };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('performance')
|
@Get('performance')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getPerformance(
|
public async getPerformance(
|
||||||
@Headers('impersonation-id') impersonationId,
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
@Query('range') range,
|
@Query('range') range
|
||||||
@Res() res: Response
|
): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> {
|
||||||
): Promise<PortfolioPerformance> {
|
const performanceInformation = await this.portfolioServiceStrategy
|
||||||
const performanceInformation = await this.portfolioService.getPerformance(
|
.get()
|
||||||
impersonationId,
|
.getPerformance(impersonationId, range);
|
||||||
range
|
|
||||||
);
|
|
||||||
|
|
||||||
if (performanceInformation?.hasErrors) {
|
|
||||||
res.status(StatusCodes.ACCEPTED);
|
|
||||||
}
|
|
||||||
|
|
||||||
let performance = performanceInformation.performance;
|
|
||||||
if (
|
if (
|
||||||
impersonationId ||
|
impersonationId ||
|
||||||
this.userService.isRestrictedView(this.request.user)
|
this.userService.isRestrictedView(this.request.user)
|
||||||
) {
|
) {
|
||||||
performance = nullifyValuesInObject(performance, [
|
performanceInformation.performance = nullifyValuesInObject(
|
||||||
'currentGrossPerformance',
|
performanceInformation.performance,
|
||||||
'currentValue'
|
['currentGrossPerformance', 'currentValue']
|
||||||
]);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <any>res.json(performance);
|
return performanceInformation;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('positions')
|
@Get('positions')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getPositions(
|
public async getPositions(
|
||||||
@Headers('impersonation-id') impersonationId,
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
@Query('range') range,
|
@Query('range') range
|
||||||
@Res() res: Response
|
|
||||||
): Promise<PortfolioPositions> {
|
): Promise<PortfolioPositions> {
|
||||||
const result = await this.portfolioService.getPositions(
|
const result = await this.portfolioServiceStrategy
|
||||||
impersonationId,
|
.get()
|
||||||
range
|
.getPositions(impersonationId, range);
|
||||||
);
|
|
||||||
|
|
||||||
if (result?.hasErrors) {
|
|
||||||
res.status(StatusCodes.ACCEPTED);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationId ||
|
impersonationId ||
|
||||||
@ -261,13 +249,12 @@ export class PortfolioController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return <any>res.json(result);
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('public/:accessId')
|
@Get('public/:accessId')
|
||||||
public async getPublic(
|
public async getPublic(
|
||||||
@Param('accessId') accessId,
|
@Param('accessId') accessId
|
||||||
@Res() res: Response
|
|
||||||
): Promise<PortfolioPublicDetails> {
|
): Promise<PortfolioPublicDetails> {
|
||||||
const access = await this.accessService.access({ id: accessId });
|
const access = await this.accessService.access({ id: accessId });
|
||||||
const user = await this.userService.user({
|
const user = await this.userService.user({
|
||||||
@ -275,8 +262,10 @@ export class PortfolioController {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!access) {
|
if (!access) {
|
||||||
res.status(StatusCodes.NOT_FOUND);
|
throw new HttpException(
|
||||||
return <any>res.json({ accounts: {}, holdings: {} });
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
|
StatusCodes.NOT_FOUND
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let hasDetails = true;
|
let hasDetails = true;
|
||||||
@ -284,10 +273,9 @@ export class PortfolioController {
|
|||||||
hasDetails = user.subscription.type === 'Premium';
|
hasDetails = user.subscription.type === 'Premium';
|
||||||
}
|
}
|
||||||
|
|
||||||
const { holdings } = await this.portfolioService.getDetails(
|
const { holdings } = await this.portfolioServiceStrategy
|
||||||
access.userId,
|
.get()
|
||||||
access.userId
|
.getDetails(access.userId, access.userId);
|
||||||
);
|
|
||||||
|
|
||||||
const portfolioPublicDetails: PortfolioPublicDetails = {
|
const portfolioPublicDetails: PortfolioPublicDetails = {
|
||||||
hasDetails,
|
hasDetails,
|
||||||
@ -320,7 +308,7 @@ export class PortfolioController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return <any>res.json(portfolioPublicDetails);
|
return portfolioPublicDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('summary')
|
@Get('summary')
|
||||||
@ -328,7 +316,9 @@ export class PortfolioController {
|
|||||||
public async getSummary(
|
public async getSummary(
|
||||||
@Headers('impersonation-id') impersonationId
|
@Headers('impersonation-id') impersonationId
|
||||||
): Promise<PortfolioSummary> {
|
): Promise<PortfolioSummary> {
|
||||||
let summary = await this.portfolioService.getSummary(impersonationId);
|
let summary = await this.portfolioServiceStrategy
|
||||||
|
.get()
|
||||||
|
.getSummary(impersonationId);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationId ||
|
impersonationId ||
|
||||||
@ -340,6 +330,7 @@ export class PortfolioController {
|
|||||||
'currentGrossPerformance',
|
'currentGrossPerformance',
|
||||||
'currentNetPerformance',
|
'currentNetPerformance',
|
||||||
'currentValue',
|
'currentValue',
|
||||||
|
'dividend',
|
||||||
'fees',
|
'fees',
|
||||||
'netWorth',
|
'netWorth',
|
||||||
'totalBuy',
|
'totalBuy',
|
||||||
@ -350,16 +341,17 @@ export class PortfolioController {
|
|||||||
return summary;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('position/:symbol')
|
@Get('position/:dataSource/:symbol')
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getPosition(
|
public async getPosition(
|
||||||
@Headers('impersonation-id') impersonationId,
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
|
@Param('dataSource') dataSource,
|
||||||
@Param('symbol') symbol
|
@Param('symbol') symbol
|
||||||
): Promise<PortfolioPositionDetail> {
|
): Promise<PortfolioPositionDetail> {
|
||||||
let position = await this.portfolioService.getPosition(
|
let position = await this.portfolioServiceStrategy
|
||||||
impersonationId,
|
.get()
|
||||||
symbol
|
.getPosition(dataSource, impersonationId, symbol);
|
||||||
);
|
|
||||||
|
|
||||||
if (position) {
|
if (position) {
|
||||||
if (
|
if (
|
||||||
@ -370,6 +362,7 @@ export class PortfolioController {
|
|||||||
'grossPerformance',
|
'grossPerformance',
|
||||||
'investment',
|
'investment',
|
||||||
'netPerformance',
|
'netPerformance',
|
||||||
|
'orders',
|
||||||
'quantity',
|
'quantity',
|
||||||
'value'
|
'value'
|
||||||
]);
|
]);
|
||||||
@ -387,19 +380,18 @@ export class PortfolioController {
|
|||||||
@Get('report')
|
@Get('report')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getReport(
|
public async getReport(
|
||||||
@Headers('impersonation-id') impersonationId,
|
@Headers('impersonation-id') impersonationId: string
|
||||||
@Res() res: Response
|
|
||||||
): Promise<PortfolioReport> {
|
): Promise<PortfolioReport> {
|
||||||
if (
|
if (
|
||||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
this.request.user.subscription.type === 'Basic'
|
this.request.user.subscription.type === 'Basic'
|
||||||
) {
|
) {
|
||||||
res.status(StatusCodes.FORBIDDEN);
|
throw new HttpException(
|
||||||
return <any>res.json({ rules: [] });
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <any>(
|
return await this.portfolioServiceStrategy.get().getReport(impersonationId);
|
||||||
res.json(await this.portfolioService.getReport(impersonationId))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -13,12 +13,14 @@ import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.mod
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { CurrentRateService } from './current-rate.service';
|
import { CurrentRateService } from './current-rate.service';
|
||||||
|
import { PortfolioServiceStrategy } from './portfolio-service.strategy';
|
||||||
import { PortfolioController } from './portfolio.controller';
|
import { PortfolioController } from './portfolio.controller';
|
||||||
import { PortfolioService } from './portfolio.service';
|
import { PortfolioService } from './portfolio.service';
|
||||||
|
import { PortfolioServiceNew } from './portfolio.service-new';
|
||||||
import { RulesService } from './rules.service';
|
import { RulesService } from './rules.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
exports: [PortfolioService],
|
exports: [PortfolioServiceStrategy],
|
||||||
imports: [
|
imports: [
|
||||||
AccessModule,
|
AccessModule,
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
@ -37,6 +39,8 @@ import { RulesService } from './rules.service';
|
|||||||
AccountService,
|
AccountService,
|
||||||
CurrentRateService,
|
CurrentRateService,
|
||||||
PortfolioService,
|
PortfolioService,
|
||||||
|
PortfolioServiceNew,
|
||||||
|
PortfolioServiceStrategy,
|
||||||
RulesService
|
RulesService
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
1213
apps/api/src/app/portfolio/portfolio.service-new.ts
Normal file
1213
apps/api/src/app/portfolio/portfolio.service-new.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -55,7 +55,7 @@ import {
|
|||||||
subDays,
|
subDays,
|
||||||
subYears
|
subYears
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
import { isEmpty } from 'lodash';
|
import { isEmpty, sortBy } from 'lodash';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
HistoricalDataContainer,
|
HistoricalDataContainer,
|
||||||
@ -107,7 +107,7 @@ export class PortfolioService {
|
|||||||
account.currency,
|
account.currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
),
|
),
|
||||||
value: details.accounts[account.name]?.current ?? 0
|
value: details.accounts[account.id]?.current ?? 0
|
||||||
};
|
};
|
||||||
|
|
||||||
delete result.Order;
|
delete result.Order;
|
||||||
@ -150,12 +150,33 @@ export class PortfolioService {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return portfolioCalculator.getInvestments().map((item) => {
|
const investments = portfolioCalculator.getInvestments().map((item) => {
|
||||||
return {
|
return {
|
||||||
date: item.date,
|
date: item.date,
|
||||||
investment: item.investment.toNumber()
|
investment: item.investment.toNumber()
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add investment of today
|
||||||
|
const investmentOfToday = investments.filter((investment) => {
|
||||||
|
return investment.date === format(new Date(), DATE_FORMAT);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (investmentOfToday.length <= 0) {
|
||||||
|
const pastInvestments = investments.filter((investment) => {
|
||||||
|
return isBefore(parseDate(investment.date), new Date());
|
||||||
|
});
|
||||||
|
const lastInvestment = pastInvestments[pastInvestments.length - 1];
|
||||||
|
|
||||||
|
investments.push({
|
||||||
|
date: format(new Date(), DATE_FORMAT),
|
||||||
|
investment: lastInvestment?.investment ?? 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortBy(investments, (investment) => {
|
||||||
|
return investment.date;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getChart(
|
public async getChart(
|
||||||
@ -324,6 +345,7 @@ export class PortfolioService {
|
|||||||
assetSubClass: symbolProfile.assetSubClass,
|
assetSubClass: symbolProfile.assetSubClass,
|
||||||
countries: symbolProfile.countries,
|
countries: symbolProfile.countries,
|
||||||
currency: item.currency,
|
currency: item.currency,
|
||||||
|
dataSource: symbolProfile.dataSource,
|
||||||
exchange: dataProviderResponse.exchange,
|
exchange: dataProviderResponse.exchange,
|
||||||
grossPerformance: item.grossPerformance?.toNumber() ?? 0,
|
grossPerformance: item.grossPerformance?.toNumber() ?? 0,
|
||||||
grossPerformancePercent:
|
grossPerformancePercent:
|
||||||
@ -364,14 +386,21 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getPosition(
|
public async getPosition(
|
||||||
|
aDataSource: DataSource,
|
||||||
aImpersonationId: string,
|
aImpersonationId: string,
|
||||||
aSymbol: string
|
aSymbol: string
|
||||||
): Promise<PortfolioPositionDetail> {
|
): Promise<PortfolioPositionDetail> {
|
||||||
|
const userCurrency = this.request.user.Settings.currency;
|
||||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||||
|
|
||||||
const orders = (await this.orderService.getOrders({ userId })).filter(
|
const orders = (
|
||||||
(order) => order.symbol === aSymbol
|
await this.orderService.getOrders({ userCurrency, userId })
|
||||||
);
|
).filter(({ SymbolProfile }) => {
|
||||||
|
return (
|
||||||
|
SymbolProfile.dataSource === aDataSource &&
|
||||||
|
SymbolProfile.symbol === aSymbol
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
if (orders.length <= 0) {
|
if (orders.length <= 0) {
|
||||||
return {
|
return {
|
||||||
@ -388,6 +417,7 @@ export class PortfolioService {
|
|||||||
name: undefined,
|
name: undefined,
|
||||||
netPerformance: undefined,
|
netPerformance: undefined,
|
||||||
netPerformancePercent: undefined,
|
netPerformancePercent: undefined,
|
||||||
|
orders: [],
|
||||||
quantity: undefined,
|
quantity: undefined,
|
||||||
symbol: aSymbol,
|
symbol: aSymbol,
|
||||||
transactionCount: undefined,
|
transactionCount: undefined,
|
||||||
@ -400,17 +430,21 @@ export class PortfolioService {
|
|||||||
const positionCurrency = orders[0].currency;
|
const positionCurrency = orders[0].currency;
|
||||||
const name = orders[0].SymbolProfile?.name ?? '';
|
const name = orders[0].SymbolProfile?.name ?? '';
|
||||||
|
|
||||||
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
|
const portfolioOrders: PortfolioOrder[] = orders
|
||||||
currency: order.currency,
|
.filter((order) => {
|
||||||
dataSource: order.dataSource,
|
return order.type === 'BUY' || order.type === 'SELL';
|
||||||
date: format(order.date, DATE_FORMAT),
|
})
|
||||||
fee: new Big(order.fee),
|
.map((order) => ({
|
||||||
name: order.SymbolProfile?.name,
|
currency: order.currency,
|
||||||
quantity: new Big(order.quantity),
|
dataSource: order.SymbolProfile?.dataSource ?? order.dataSource,
|
||||||
symbol: order.symbol,
|
date: format(order.date, DATE_FORMAT),
|
||||||
type: order.type,
|
fee: new Big(order.fee),
|
||||||
unitPrice: new Big(order.unitPrice)
|
name: order.SymbolProfile?.name,
|
||||||
}));
|
quantity: new Big(order.quantity),
|
||||||
|
symbol: order.symbol,
|
||||||
|
type: order.type,
|
||||||
|
unitPrice: new Big(order.unitPrice)
|
||||||
|
}));
|
||||||
|
|
||||||
const portfolioCalculator = new PortfolioCalculator(
|
const portfolioCalculator = new PortfolioCalculator(
|
||||||
this.currentRateService,
|
this.currentRateService,
|
||||||
@ -440,19 +474,18 @@ export class PortfolioService {
|
|||||||
} = position;
|
} = position;
|
||||||
|
|
||||||
// Convert investment, gross and net performance to currency of user
|
// Convert investment, gross and net performance to currency of user
|
||||||
const userCurrency = this.request.user.Settings.currency;
|
|
||||||
const investment = this.exchangeRateDataService.toCurrency(
|
const investment = this.exchangeRateDataService.toCurrency(
|
||||||
position.investment.toNumber(),
|
position.investment?.toNumber(),
|
||||||
currency,
|
currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
);
|
);
|
||||||
const grossPerformance = this.exchangeRateDataService.toCurrency(
|
const grossPerformance = this.exchangeRateDataService.toCurrency(
|
||||||
position.grossPerformance.toNumber(),
|
position.grossPerformance?.toNumber(),
|
||||||
currency,
|
currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
);
|
);
|
||||||
const netPerformance = this.exchangeRateDataService.toCurrency(
|
const netPerformance = this.exchangeRateDataService.toCurrency(
|
||||||
position.netPerformance.toNumber(),
|
position.netPerformance?.toNumber(),
|
||||||
currency,
|
currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
);
|
);
|
||||||
@ -521,6 +554,7 @@ export class PortfolioService {
|
|||||||
minPrice,
|
minPrice,
|
||||||
name,
|
name,
|
||||||
netPerformance,
|
netPerformance,
|
||||||
|
orders,
|
||||||
transactionCount,
|
transactionCount,
|
||||||
averagePrice: averagePrice.toNumber(),
|
averagePrice: averagePrice.toNumber(),
|
||||||
grossPerformancePercent: position.grossPerformancePercentage.toNumber(),
|
grossPerformancePercent: position.grossPerformancePercentage.toNumber(),
|
||||||
@ -578,6 +612,7 @@ export class PortfolioService {
|
|||||||
maxPrice,
|
maxPrice,
|
||||||
minPrice,
|
minPrice,
|
||||||
name,
|
name,
|
||||||
|
orders,
|
||||||
averagePrice: 0,
|
averagePrice: 0,
|
||||||
currency: currentData[aSymbol]?.currency,
|
currency: currentData[aSymbol]?.currency,
|
||||||
firstBuyDate: undefined,
|
firstBuyDate: undefined,
|
||||||
@ -655,7 +690,9 @@ export class PortfolioService {
|
|||||||
grossPerformancePercentage:
|
grossPerformancePercentage:
|
||||||
position.grossPerformancePercentage?.toNumber() ?? null,
|
position.grossPerformancePercentage?.toNumber() ?? null,
|
||||||
investment: new Big(position.investment).toNumber(),
|
investment: new Big(position.investment).toNumber(),
|
||||||
marketState: dataProviderResponses[position.symbol].marketState,
|
marketState:
|
||||||
|
dataProviderResponses[position.symbol]?.marketState ??
|
||||||
|
MarketState.delayed,
|
||||||
name: symbolProfileMap[position.symbol].name,
|
name: symbolProfileMap[position.symbol].name,
|
||||||
netPerformance: position.netPerformance?.toNumber() ?? null,
|
netPerformance: position.netPerformance?.toNumber() ?? null,
|
||||||
netPerformancePercentage:
|
netPerformancePercentage:
|
||||||
@ -726,22 +763,6 @@ export class PortfolioService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public getFees(orders: OrderWithAccount[], date = new Date(0)) {
|
|
||||||
return orders
|
|
||||||
.filter((order) => {
|
|
||||||
// Filter out all orders before given date
|
|
||||||
return isBefore(date, new Date(order.date));
|
|
||||||
})
|
|
||||||
.map((order) => {
|
|
||||||
return this.exchangeRateDataService.toCurrency(
|
|
||||||
order.fee,
|
|
||||||
order.currency,
|
|
||||||
this.request.user.Settings.currency
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.reduce((previous, current) => previous + current, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getReport(impersonationId: string): Promise<PortfolioReport> {
|
public async getReport(impersonationId: string): Promise<PortfolioReport> {
|
||||||
const currency = this.request.user.Settings.currency;
|
const currency = this.request.user.Settings.currency;
|
||||||
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||||
@ -822,7 +843,7 @@ export class PortfolioService {
|
|||||||
new FeeRatioInitialInvestment(
|
new FeeRatioInitialInvestment(
|
||||||
this.exchangeRateDataService,
|
this.exchangeRateDataService,
|
||||||
currentPositions.totalInvestment.toNumber(),
|
currentPositions.totalInvestment.toNumber(),
|
||||||
this.getFees(orders)
|
this.getFees(orders).toNumber()
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
{ baseCurrency: currency }
|
{ baseCurrency: currency }
|
||||||
@ -832,21 +853,25 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getSummary(aImpersonationId: string): Promise<PortfolioSummary> {
|
public async getSummary(aImpersonationId: string): Promise<PortfolioSummary> {
|
||||||
const currency = this.request.user.Settings.currency;
|
const userCurrency = this.request.user.Settings.currency;
|
||||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||||
|
|
||||||
const performanceInformation = await this.getPerformance(aImpersonationId);
|
const performanceInformation = await this.getPerformance(aImpersonationId);
|
||||||
|
|
||||||
const { balance } = await this.accountService.getCashDetails(
|
const { balance } = await this.accountService.getCashDetails(
|
||||||
userId,
|
userId,
|
||||||
currency
|
userCurrency
|
||||||
);
|
);
|
||||||
const orders = await this.orderService.getOrders({ userId });
|
const orders = await this.orderService.getOrders({
|
||||||
const fees = this.getFees(orders);
|
userCurrency,
|
||||||
|
userId
|
||||||
|
});
|
||||||
|
const dividend = this.getDividend(orders).toNumber();
|
||||||
|
const fees = this.getFees(orders).toNumber();
|
||||||
const firstOrderDate = orders[0]?.date;
|
const firstOrderDate = orders[0]?.date;
|
||||||
|
|
||||||
const totalBuy = this.getTotalByType(orders, currency, 'BUY');
|
const totalBuy = this.getTotalByType(orders, userCurrency, 'BUY');
|
||||||
const totalSell = this.getTotalByType(orders, currency, 'SELL');
|
const totalSell = this.getTotalByType(orders, userCurrency, 'SELL');
|
||||||
|
|
||||||
const committedFunds = new Big(totalBuy).sub(totalSell);
|
const committedFunds = new Big(totalBuy).sub(totalSell);
|
||||||
|
|
||||||
@ -856,14 +881,19 @@ export class PortfolioService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...performanceInformation.performance,
|
...performanceInformation.performance,
|
||||||
|
dividend,
|
||||||
fees,
|
fees,
|
||||||
firstOrderDate,
|
firstOrderDate,
|
||||||
netWorth,
|
netWorth,
|
||||||
|
totalBuy,
|
||||||
|
totalSell,
|
||||||
|
annualizedPerformancePercent:
|
||||||
|
performanceInformation.performance.annualizedPerformancePercent,
|
||||||
cash: balance,
|
cash: balance,
|
||||||
committedFunds: committedFunds.toNumber(),
|
committedFunds: committedFunds.toNumber(),
|
||||||
ordersCount: orders.length,
|
ordersCount: orders.filter((order) => {
|
||||||
totalBuy: totalBuy,
|
return order.type === 'BUY' || order.type === 'SELL';
|
||||||
totalSell: totalSell
|
}).length
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -875,8 +905,8 @@ export class PortfolioService {
|
|||||||
}: {
|
}: {
|
||||||
cashDetails: CashDetails;
|
cashDetails: CashDetails;
|
||||||
investment: Big;
|
investment: Big;
|
||||||
value: Big;
|
|
||||||
userCurrency: string;
|
userCurrency: string;
|
||||||
|
value: Big;
|
||||||
}) {
|
}) {
|
||||||
const cashPositions = {};
|
const cashPositions = {};
|
||||||
|
|
||||||
@ -936,6 +966,47 @@ export class PortfolioService {
|
|||||||
return cashPositions;
|
return cashPositions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getDividend(orders: OrderWithAccount[], date = new Date(0)) {
|
||||||
|
return orders
|
||||||
|
.filter((order) => {
|
||||||
|
// Filter out all orders before given date and type dividend
|
||||||
|
return (
|
||||||
|
isBefore(date, new Date(order.date)) &&
|
||||||
|
order.type === TypeOfOrder.DIVIDEND
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map((order) => {
|
||||||
|
return this.exchangeRateDataService.toCurrency(
|
||||||
|
new Big(order.quantity).mul(order.unitPrice).toNumber(),
|
||||||
|
order.currency,
|
||||||
|
this.request.user.Settings.currency
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.reduce(
|
||||||
|
(previous, current) => new Big(previous).plus(current),
|
||||||
|
new Big(0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFees(orders: OrderWithAccount[], date = new Date(0)) {
|
||||||
|
return orders
|
||||||
|
.filter((order) => {
|
||||||
|
// Filter out all orders before given date
|
||||||
|
return isBefore(date, new Date(order.date));
|
||||||
|
})
|
||||||
|
.map((order) => {
|
||||||
|
return this.exchangeRateDataService.toCurrency(
|
||||||
|
order.fee,
|
||||||
|
order.currency,
|
||||||
|
this.request.user.Settings.currency
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.reduce(
|
||||||
|
(previous, current) => new Big(previous).plus(current),
|
||||||
|
new Big(0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private getStartDate(aDateRange: DateRange, portfolioStart: Date) {
|
private getStartDate(aDateRange: DateRange, portfolioStart: Date) {
|
||||||
switch (aDateRange) {
|
switch (aDateRange) {
|
||||||
case '1d':
|
case '1d':
|
||||||
@ -964,16 +1035,22 @@ export class PortfolioService {
|
|||||||
transactionPoints: TransactionPoint[];
|
transactionPoints: TransactionPoint[];
|
||||||
orders: OrderWithAccount[];
|
orders: OrderWithAccount[];
|
||||||
}> {
|
}> {
|
||||||
const orders = await this.orderService.getOrders({ includeDrafts, userId });
|
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
|
||||||
|
|
||||||
|
const orders = await this.orderService.getOrders({
|
||||||
|
includeDrafts,
|
||||||
|
userCurrency,
|
||||||
|
userId,
|
||||||
|
types: ['BUY', 'SELL']
|
||||||
|
});
|
||||||
|
|
||||||
if (orders.length <= 0) {
|
if (orders.length <= 0) {
|
||||||
return { transactionPoints: [], orders: [] };
|
return { transactionPoints: [], orders: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
|
|
||||||
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
|
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
|
||||||
currency: order.currency,
|
currency: order.currency,
|
||||||
dataSource: order.dataSource,
|
dataSource: order.SymbolProfile?.dataSource ?? order.dataSource,
|
||||||
date: format(order.date, DATE_FORMAT),
|
date: format(order.date, DATE_FORMAT),
|
||||||
fee: new Big(
|
fee: new Big(
|
||||||
this.exchangeRateDataService.toCurrency(
|
this.exchangeRateDataService.toCurrency(
|
||||||
@ -1026,10 +1103,11 @@ export class PortfolioService {
|
|||||||
account.currency,
|
account.currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
);
|
);
|
||||||
accounts[account.name] = {
|
accounts[account.id] = {
|
||||||
balance: convertedBalance,
|
balance: convertedBalance,
|
||||||
currency: account.currency,
|
currency: account.currency,
|
||||||
current: convertedBalance,
|
current: convertedBalance,
|
||||||
|
name: account.name,
|
||||||
original: convertedBalance
|
original: convertedBalance
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1043,16 +1121,17 @@ export class PortfolioService {
|
|||||||
originalValueOfSymbol *= -1;
|
originalValueOfSymbol *= -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (accounts[order.Account?.name || UNKNOWN_KEY]?.current) {
|
if (accounts[order.Account?.id || UNKNOWN_KEY]?.current) {
|
||||||
accounts[order.Account?.name || UNKNOWN_KEY].current +=
|
accounts[order.Account?.id || UNKNOWN_KEY].current +=
|
||||||
currentValueOfSymbol;
|
currentValueOfSymbol;
|
||||||
accounts[order.Account?.name || UNKNOWN_KEY].original +=
|
accounts[order.Account?.id || UNKNOWN_KEY].original +=
|
||||||
originalValueOfSymbol;
|
originalValueOfSymbol;
|
||||||
} else {
|
} else {
|
||||||
accounts[order.Account?.name || UNKNOWN_KEY] = {
|
accounts[order.Account?.id || UNKNOWN_KEY] = {
|
||||||
balance: 0,
|
balance: 0,
|
||||||
currency: order.Account?.currency,
|
currency: order.Account?.currency,
|
||||||
current: currentValueOfSymbol,
|
current: currentValueOfSymbol,
|
||||||
|
name: account.name,
|
||||||
original: originalValueOfSymbol
|
original: originalValueOfSymbol
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -7,6 +7,7 @@ import {
|
|||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
|
HttpCode,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
Logger,
|
Logger,
|
||||||
@ -17,7 +18,6 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { Response } from 'express';
|
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { SubscriptionService } from './subscription.service';
|
import { SubscriptionService } from './subscription.service';
|
||||||
@ -32,11 +32,9 @@ export class SubscriptionController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('redeem-coupon')
|
@Post('redeem-coupon')
|
||||||
|
@HttpCode(StatusCodes.OK)
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async redeemCoupon(
|
public async redeemCoupon(@Body() { couponCode }: { couponCode: string }) {
|
||||||
@Body() { couponCode }: { couponCode: string },
|
|
||||||
@Res() res: Response
|
|
||||||
) {
|
|
||||||
if (!this.request.user) {
|
if (!this.request.user) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
@ -74,12 +72,10 @@ export class SubscriptionController {
|
|||||||
`Subscription for user '${this.request.user.id}' has been created with coupon`
|
`Subscription for user '${this.request.user.id}' has been created with coupon`
|
||||||
);
|
);
|
||||||
|
|
||||||
res.status(StatusCodes.OK);
|
return {
|
||||||
|
|
||||||
return <any>res.json({
|
|
||||||
message: getReasonPhrase(StatusCodes.OK),
|
message: getReasonPhrase(StatusCodes.OK),
|
||||||
statusCode: StatusCodes.OK
|
statusCode: StatusCodes.OK
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('stripe/callback')
|
@Get('stripe/callback')
|
||||||
|
@ -1,19 +1,17 @@
|
|||||||
|
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 { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
DefaultValuePipe,
|
|
||||||
Get,
|
Get,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
|
||||||
Param,
|
Param,
|
||||||
ParseBoolPipe,
|
|
||||||
Query,
|
Query,
|
||||||
UseGuards
|
UseGuards,
|
||||||
|
UseInterceptors
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { DataSource, MarketData } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
import { isDate, isEmpty } from 'lodash';
|
import { isDate, isEmpty } from 'lodash';
|
||||||
|
|
||||||
@ -23,22 +21,19 @@ import { SymbolService } from './symbol.service';
|
|||||||
|
|
||||||
@Controller('symbol')
|
@Controller('symbol')
|
||||||
export class SymbolController {
|
export class SymbolController {
|
||||||
public constructor(
|
public constructor(private readonly symbolService: SymbolService) {}
|
||||||
private readonly symbolService: SymbolService,
|
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Must be before /:symbol
|
* Must be before /:symbol
|
||||||
*/
|
*/
|
||||||
@Get('lookup')
|
@Get('lookup')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async lookupSymbol(
|
public async lookupSymbol(
|
||||||
@Query() { query = '' }
|
@Query() { query = '' }
|
||||||
): Promise<{ items: LookupItem[] }> {
|
): Promise<{ items: LookupItem[] }> {
|
||||||
try {
|
try {
|
||||||
const encodedQuery = encodeURIComponent(query.toLowerCase());
|
return this.symbolService.lookup(query.toLowerCase());
|
||||||
return this.symbolService.lookup(encodedQuery);
|
|
||||||
} catch {
|
} catch {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||||
@ -52,11 +47,12 @@ export class SymbolController {
|
|||||||
*/
|
*/
|
||||||
@Get(':dataSource/:symbol')
|
@Get(':dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getSymbolData(
|
public async getSymbolData(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('symbol') symbol: string,
|
@Param('symbol') symbol: string,
|
||||||
@Query('includeHistoricalData', new DefaultValuePipe(false), ParseBoolPipe)
|
@Query('includeHistoricalData') includeHistoricalData?: number
|
||||||
includeHistoricalData: boolean
|
|
||||||
): Promise<SymbolItem> {
|
): Promise<SymbolItem> {
|
||||||
if (!DataSource[dataSource]) {
|
if (!DataSource[dataSource]) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
|
@ -5,7 +5,6 @@ import {
|
|||||||
IDataProviderHistoricalResponse
|
IDataProviderHistoricalResponse
|
||||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
|
||||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
@ -18,25 +17,24 @@ import { SymbolItem } from './interfaces/symbol-item.interface';
|
|||||||
export class SymbolService {
|
export class SymbolService {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly marketDataService: MarketDataService,
|
private readonly marketDataService: MarketDataService
|
||||||
private readonly prismaService: PrismaService
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async get({
|
public async get({
|
||||||
dataGatheringItem,
|
dataGatheringItem,
|
||||||
includeHistoricalData = false
|
includeHistoricalData
|
||||||
}: {
|
}: {
|
||||||
dataGatheringItem: IDataGatheringItem;
|
dataGatheringItem: IDataGatheringItem;
|
||||||
includeHistoricalData?: boolean;
|
includeHistoricalData?: number;
|
||||||
}): Promise<SymbolItem> {
|
}): Promise<SymbolItem> {
|
||||||
const response = await this.dataProviderService.get([dataGatheringItem]);
|
const response = await this.dataProviderService.get([dataGatheringItem]);
|
||||||
const { currency, marketPrice } = response[dataGatheringItem.symbol] ?? {};
|
const { currency, marketPrice } = response[dataGatheringItem.symbol] ?? {};
|
||||||
|
|
||||||
if (dataGatheringItem.dataSource && marketPrice) {
|
if (dataGatheringItem.dataSource && marketPrice) {
|
||||||
let historicalData: HistoricalDataItem[];
|
let historicalData: HistoricalDataItem[] = [];
|
||||||
|
|
||||||
if (includeHistoricalData) {
|
if (includeHistoricalData > 0) {
|
||||||
const days = 30;
|
const days = includeHistoricalData;
|
||||||
|
|
||||||
const marketData = await this.marketDataService.getRange({
|
const marketData = await this.marketDataService.getRange({
|
||||||
dateQuery: { gte: subDays(new Date(), days) },
|
dateQuery: { gte: subDays(new Date(), days) },
|
||||||
@ -93,32 +91,6 @@ export class SymbolService {
|
|||||||
try {
|
try {
|
||||||
const { items } = await this.dataProviderService.search(aQuery);
|
const { items } = await this.dataProviderService.search(aQuery);
|
||||||
results.items = items;
|
results.items = items;
|
||||||
|
|
||||||
// Add custom symbols
|
|
||||||
const ghostfolioSymbolProfiles =
|
|
||||||
await this.prismaService.symbolProfile.findMany({
|
|
||||||
select: {
|
|
||||||
currency: true,
|
|
||||||
dataSource: true,
|
|
||||||
name: true,
|
|
||||||
symbol: true
|
|
||||||
},
|
|
||||||
where: {
|
|
||||||
AND: [
|
|
||||||
{
|
|
||||||
dataSource: DataSource.GHOSTFOLIO,
|
|
||||||
name: {
|
|
||||||
startsWith: aQuery
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const ghostfolioSymbolProfile of ghostfolioSymbolProfiles) {
|
|
||||||
results.items.push(ghostfolioSymbolProfile);
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error);
|
Logger.error(error);
|
||||||
|
@ -1,6 +1,11 @@
|
|||||||
import { IsBoolean } from 'class-validator';
|
import { IsBoolean, IsOptional } from 'class-validator';
|
||||||
|
|
||||||
export class UpdateUserSettingDto {
|
export class UpdateUserSettingDto {
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
isNewCalculationEngine?: boolean;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
isRestrictedView?: boolean;
|
isRestrictedView?: boolean;
|
||||||
}
|
}
|
||||||
|
@ -23,7 +23,7 @@ import {
|
|||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { Provider, Role } from '@prisma/client';
|
import { Provider } from '@prisma/client';
|
||||||
import { User as UserModel } from '@prisma/client';
|
import { User as UserModel } from '@prisma/client';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
@ -115,6 +115,12 @@ export class UserController {
|
|||||||
...data
|
...data
|
||||||
};
|
};
|
||||||
|
|
||||||
|
for (const key in userSettings) {
|
||||||
|
if (userSettings[key] === false) {
|
||||||
|
delete userSettings[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return await this.userService.updateUserSetting({
|
return await this.userService.updateUserSetting({
|
||||||
userSettings,
|
userSettings,
|
||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
|
@ -0,0 +1,39 @@
|
|||||||
|
import { decodeDataSource } from '@ghostfolio/common/helper';
|
||||||
|
import {
|
||||||
|
CallHandler,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
NestInterceptor
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
import { ConfigurationService } from '../services/configuration.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TransformDataSourceInRequestInterceptor<T>
|
||||||
|
implements NestInterceptor<T, any>
|
||||||
|
{
|
||||||
|
public constructor(
|
||||||
|
private readonly configurationService: ConfigurationService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public intercept(
|
||||||
|
context: ExecutionContext,
|
||||||
|
next: CallHandler<T>
|
||||||
|
): Observable<any> {
|
||||||
|
const http = context.switchToHttp();
|
||||||
|
const request = http.getRequest();
|
||||||
|
|
||||||
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true) {
|
||||||
|
if (request.body.dataSource) {
|
||||||
|
request.body.dataSource = decodeDataSource(request.body.dataSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.params.dataSource) {
|
||||||
|
request.params.dataSource = decodeDataSource(request.params.dataSource);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,73 @@
|
|||||||
|
import { encodeDataSource } from '@ghostfolio/common/helper';
|
||||||
|
import {
|
||||||
|
CallHandler,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
NestInterceptor
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { map } from 'rxjs/operators';
|
||||||
|
|
||||||
|
import { ConfigurationService } from '../services/configuration.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TransformDataSourceInResponseInterceptor<T>
|
||||||
|
implements NestInterceptor<T, any>
|
||||||
|
{
|
||||||
|
public constructor(
|
||||||
|
private readonly configurationService: ConfigurationService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public intercept(
|
||||||
|
context: ExecutionContext,
|
||||||
|
next: CallHandler<T>
|
||||||
|
): Observable<any> {
|
||||||
|
return next.handle().pipe(
|
||||||
|
map((data: any) => {
|
||||||
|
if (
|
||||||
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true
|
||||||
|
) {
|
||||||
|
if (data.activities) {
|
||||||
|
data.activities.map((activity) => {
|
||||||
|
activity.SymbolProfile.dataSource = encodeDataSource(
|
||||||
|
activity.SymbolProfile.dataSource
|
||||||
|
);
|
||||||
|
activity.dataSource = encodeDataSource(activity.dataSource);
|
||||||
|
return activity;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.dataSource) {
|
||||||
|
data.dataSource = encodeDataSource(data.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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -25,17 +25,17 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
|||||||
};
|
};
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
for (const account of Object.keys(this.accounts)) {
|
for (const [accountId, account] of Object.entries(this.accounts)) {
|
||||||
accounts[account] = {
|
accounts[accountId] = {
|
||||||
name: account,
|
name: account.name,
|
||||||
investment: this.accounts[account].current
|
investment: account.current
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let maxItem;
|
let maxItem;
|
||||||
let totalInvestment = 0;
|
let totalInvestment = 0;
|
||||||
|
|
||||||
Object.values(accounts).forEach((account) => {
|
for (const account of Object.values(accounts)) {
|
||||||
if (!maxItem) {
|
if (!maxItem) {
|
||||||
maxItem = account;
|
maxItem = account;
|
||||||
}
|
}
|
||||||
@ -47,7 +47,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
|||||||
if (account.investment > maxItem?.investment) {
|
if (account.investment > maxItem?.investment) {
|
||||||
maxItem = account;
|
maxItem = account;
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
const maxInvestmentRatio = maxItem.investment / totalInvestment;
|
const maxInvestmentRatio = maxItem.investment / totalInvestment;
|
||||||
|
|
||||||
|
@ -19,35 +19,35 @@ export class AccountClusterRiskInitialInvestment extends Rule<Settings> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public evaluate(ruleSettings?: Settings) {
|
public evaluate(ruleSettings?: Settings) {
|
||||||
const platforms: {
|
const accounts: {
|
||||||
[symbol: string]: Pick<PortfolioPosition, 'name'> & {
|
[symbol: string]: Pick<PortfolioPosition, 'name'> & {
|
||||||
investment: number;
|
investment: number;
|
||||||
};
|
};
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
for (const account of Object.keys(this.accounts)) {
|
for (const [accountId, account] of Object.entries(this.accounts)) {
|
||||||
platforms[account] = {
|
accounts[accountId] = {
|
||||||
name: account,
|
name: account.name,
|
||||||
investment: this.accounts[account].original
|
investment: account.original
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let maxItem;
|
let maxItem;
|
||||||
let totalInvestment = 0;
|
let totalInvestment = 0;
|
||||||
|
|
||||||
Object.values(platforms).forEach((platform) => {
|
for (const account of Object.values(accounts)) {
|
||||||
if (!maxItem) {
|
if (!maxItem) {
|
||||||
maxItem = platform;
|
maxItem = account;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate total investment
|
// Calculate total investment
|
||||||
totalInvestment += platform.investment;
|
totalInvestment += account.investment;
|
||||||
|
|
||||||
// Find maximum
|
// Find maximum
|
||||||
if (platform.investment > maxItem?.investment) {
|
if (account.investment > maxItem?.investment) {
|
||||||
maxItem = platform;
|
maxItem = account;
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
const maxInvestmentRatio = maxItem.investment / totalInvestment;
|
const maxInvestmentRatio = maxItem.investment / totalInvestment;
|
||||||
|
|
||||||
|
@ -13,6 +13,7 @@ export class ConfigurationService {
|
|||||||
ACCESS_TOKEN_SALT: str(),
|
ACCESS_TOKEN_SALT: str(),
|
||||||
ALPHA_VANTAGE_API_KEY: str({ default: '' }),
|
ALPHA_VANTAGE_API_KEY: str({ default: '' }),
|
||||||
CACHE_TTL: num({ default: 1 }),
|
CACHE_TTL: num({ default: 1 }),
|
||||||
|
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
|
||||||
DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }),
|
DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }),
|
||||||
ENABLE_FEATURE_BLOG: bool({ default: false }),
|
ENABLE_FEATURE_BLOG: bool({ default: false }),
|
||||||
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
|
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
|
||||||
@ -25,6 +26,9 @@ export class ConfigurationService {
|
|||||||
ENABLE_FEATURE_SYSTEM_MESSAGE: bool({ default: false }),
|
ENABLE_FEATURE_SYSTEM_MESSAGE: bool({ default: false }),
|
||||||
GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }),
|
GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }),
|
||||||
GOOGLE_SECRET: str({ default: 'dummySecret' }),
|
GOOGLE_SECRET: str({ default: 'dummySecret' }),
|
||||||
|
GOOGLE_SHEETS_ACCOUNT: str({ default: '' }),
|
||||||
|
GOOGLE_SHEETS_ID: str({ default: '' }),
|
||||||
|
GOOGLE_SHEETS_PRIVATE_KEY: str({ default: '' }),
|
||||||
JWT_SECRET_KEY: str({}),
|
JWT_SECRET_KEY: str({}),
|
||||||
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
|
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
|
||||||
MAX_ORDERS_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
|
MAX_ORDERS_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
|
||||||
|
@ -5,6 +5,7 @@
|
|||||||
"AVAX": "Avalanche",
|
"AVAX": "Avalanche",
|
||||||
"DOT": "Polkadot",
|
"DOT": "Polkadot",
|
||||||
"MATIC": "Polygon",
|
"MATIC": "Polygon",
|
||||||
|
"MINA": "Mina Protocol",
|
||||||
"SHIB": "Shiba Inu",
|
"SHIB": "Shiba Inu",
|
||||||
"SOL": "Solana",
|
"SOL": "Solana",
|
||||||
"UNI3": "Uniswap"
|
"UNI3": "Uniswap"
|
||||||
|
@ -473,9 +473,18 @@ export class DataGatheringService {
|
|||||||
private async getSymbols7D(): Promise<IDataGatheringItem[]> {
|
private async getSymbols7D(): Promise<IDataGatheringItem[]> {
|
||||||
const startDate = subDays(resetHours(new Date()), 7);
|
const startDate = subDays(resetHours(new Date()), 7);
|
||||||
|
|
||||||
|
const symbolProfiles = await this.prismaService.symbolProfile.findMany({
|
||||||
|
orderBy: [{ symbol: 'asc' }],
|
||||||
|
select: {
|
||||||
|
dataSource: true,
|
||||||
|
scraperConfiguration: true,
|
||||||
|
symbol: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Only consider symbols with incomplete market data for the last
|
// Only consider symbols with incomplete market data for the last
|
||||||
// 7 days
|
// 7 days
|
||||||
const symbolsToGather = (
|
const symbolsNotToGather = (
|
||||||
await this.prismaService.marketData.groupBy({
|
await this.prismaService.marketData.groupBy({
|
||||||
_count: true,
|
_count: true,
|
||||||
by: ['symbol'],
|
by: ['symbol'],
|
||||||
@ -485,24 +494,15 @@ export class DataGatheringService {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.filter((group) => {
|
.filter((group) => {
|
||||||
return group._count < 6;
|
return group._count >= 6;
|
||||||
})
|
})
|
||||||
.map((group) => {
|
.map((group) => {
|
||||||
return group.symbol;
|
return group.symbol;
|
||||||
});
|
});
|
||||||
|
|
||||||
const symbolProfilesToGather = (
|
const symbolProfilesToGather = symbolProfiles
|
||||||
await this.prismaService.symbolProfile.findMany({
|
|
||||||
orderBy: [{ symbol: 'asc' }],
|
|
||||||
select: {
|
|
||||||
dataSource: true,
|
|
||||||
scraperConfiguration: true,
|
|
||||||
symbol: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.filter(({ symbol }) => {
|
.filter(({ symbol }) => {
|
||||||
return symbolsToGather.includes(symbol);
|
return !symbolsNotToGather.includes(symbol);
|
||||||
})
|
})
|
||||||
.map((symbolProfile) => {
|
.map((symbolProfile) => {
|
||||||
return {
|
return {
|
||||||
@ -514,7 +514,7 @@ export class DataGatheringService {
|
|||||||
const currencyPairsToGather = this.exchangeRateDataService
|
const currencyPairsToGather = this.exchangeRateDataService
|
||||||
.getCurrencyPairs()
|
.getCurrencyPairs()
|
||||||
.filter(({ symbol }) => {
|
.filter(({ symbol }) => {
|
||||||
return symbolsToGather.includes(symbol);
|
return !symbolsNotToGather.includes(symbol);
|
||||||
})
|
})
|
||||||
.map(({ dataSource, symbol }) => {
|
.map(({ dataSource, symbol }) => {
|
||||||
return {
|
return {
|
||||||
|
@ -88,13 +88,13 @@ export class AlphaVantageService implements DataProviderInterface {
|
|||||||
return DataSource.ALPHA_VANTAGE;
|
return DataSource.ALPHA_VANTAGE;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||||
const result = await this.alphaVantage.data.search(aSymbol);
|
const result = await this.alphaVantage.data.search(aQuery);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: result?.bestMatches?.map((bestMatch) => {
|
items: result?.bestMatches?.map((bestMatch) => {
|
||||||
return {
|
return {
|
||||||
dataSource: DataSource.ALPHA_VANTAGE,
|
dataSource: this.getName(),
|
||||||
name: bestMatch['2. name'],
|
name: bestMatch['2. name'],
|
||||||
symbol: bestMatch['1. symbol']
|
symbol: bestMatch['1. symbol']
|
||||||
};
|
};
|
||||||
|
@ -7,6 +7,9 @@ const getJSON = bent('json');
|
|||||||
export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
||||||
private static baseUrl = 'https://data.trackinsight.com/holdings';
|
private static baseUrl = 'https://data.trackinsight.com/holdings';
|
||||||
private static countries = require('countries-list/dist/countries.json');
|
private static countries = require('countries-list/dist/countries.json');
|
||||||
|
private static countriesMapping = {
|
||||||
|
'Russian Federation': 'Russia'
|
||||||
|
};
|
||||||
private static sectorsMapping = {
|
private static sectorsMapping = {
|
||||||
'Consumer Discretionary': 'Consumer Cyclical',
|
'Consumer Discretionary': 'Consumer Cyclical',
|
||||||
'Consumer Defensive': 'Consumer Staples',
|
'Consumer Defensive': 'Consumer Staples',
|
||||||
@ -45,7 +48,11 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
|||||||
for (const [key, country] of Object.entries<any>(
|
for (const [key, country] of Object.entries<any>(
|
||||||
TrackinsightDataEnhancerService.countries
|
TrackinsightDataEnhancerService.countries
|
||||||
)) {
|
)) {
|
||||||
if (country.name === name) {
|
if (
|
||||||
|
country.name === name ||
|
||||||
|
country.name ===
|
||||||
|
TrackinsightDataEnhancerService.countriesMapping[name]
|
||||||
|
) {
|
||||||
countryCode = key;
|
countryCode = key;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
|
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
|
||||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||||
|
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service';
|
||||||
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
||||||
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
@ -21,12 +22,14 @@ import { DataProviderService } from './data-provider.service';
|
|||||||
AlphaVantageService,
|
AlphaVantageService,
|
||||||
DataProviderService,
|
DataProviderService,
|
||||||
GhostfolioScraperApiService,
|
GhostfolioScraperApiService,
|
||||||
|
GoogleSheetsService,
|
||||||
RakutenRapidApiService,
|
RakutenRapidApiService,
|
||||||
YahooFinanceService,
|
YahooFinanceService,
|
||||||
{
|
{
|
||||||
inject: [
|
inject: [
|
||||||
AlphaVantageService,
|
AlphaVantageService,
|
||||||
GhostfolioScraperApiService,
|
GhostfolioScraperApiService,
|
||||||
|
GoogleSheetsService,
|
||||||
RakutenRapidApiService,
|
RakutenRapidApiService,
|
||||||
YahooFinanceService
|
YahooFinanceService
|
||||||
],
|
],
|
||||||
@ -34,11 +37,13 @@ import { DataProviderService } from './data-provider.service';
|
|||||||
useFactory: (
|
useFactory: (
|
||||||
alphaVantageService,
|
alphaVantageService,
|
||||||
ghostfolioScraperApiService,
|
ghostfolioScraperApiService,
|
||||||
|
googleSheetsService,
|
||||||
rakutenRapidApiService,
|
rakutenRapidApiService,
|
||||||
yahooFinanceService
|
yahooFinanceService
|
||||||
) => [
|
) => [
|
||||||
alphaVantageService,
|
alphaVantageService,
|
||||||
ghostfolioScraperApiService,
|
ghostfolioScraperApiService,
|
||||||
|
googleSheetsService,
|
||||||
rakutenRapidApiService,
|
rakutenRapidApiService,
|
||||||
yahooFinanceService
|
yahooFinanceService
|
||||||
]
|
]
|
||||||
|
@ -12,7 +12,7 @@ import { Granularity } from '@ghostfolio/common/types';
|
|||||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource, MarketData } from '@prisma/client';
|
import { DataSource, MarketData } from '@prisma/client';
|
||||||
import { format, isValid } from 'date-fns';
|
import { format, isValid } from 'date-fns';
|
||||||
import { isEmpty } from 'lodash';
|
import { groupBy, isEmpty } from 'lodash';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DataProviderService {
|
export class DataProviderService {
|
||||||
@ -30,18 +30,27 @@ export class DataProviderService {
|
|||||||
[symbol: string]: IDataProviderResponse;
|
[symbol: string]: IDataProviderResponse;
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
for (const item of items) {
|
const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource);
|
||||||
const dataProvider = this.getDataProvider(item.dataSource);
|
|
||||||
response[item.symbol] = (await dataProvider.get([item.symbol]))[
|
|
||||||
item.symbol
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
const promises = [];
|
const promises = [];
|
||||||
for (const symbol of Object.keys(response)) {
|
|
||||||
const promise = Promise.resolve(response[symbol]);
|
for (const [dataSource, dataGatheringItems] of Object.entries(
|
||||||
|
itemsGroupedByDataSource
|
||||||
|
)) {
|
||||||
|
const symbols = dataGatheringItems.map((dataGatheringItem) => {
|
||||||
|
return dataGatheringItem.symbol;
|
||||||
|
});
|
||||||
|
|
||||||
|
const promise = Promise.resolve(
|
||||||
|
this.getDataProvider(DataSource[dataSource]).get(symbols)
|
||||||
|
);
|
||||||
|
|
||||||
promises.push(
|
promises.push(
|
||||||
promise.then((currentResponse) => (response[symbol] = currentResponse))
|
promise.then((result) => {
|
||||||
|
for (const [symbol, dataProviderResponse] of Object.entries(result)) {
|
||||||
|
response[symbol] = dataProviderResponse;
|
||||||
|
}
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -149,13 +158,13 @@ export class DataProviderService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||||
const promises: Promise<{ items: LookupItem[] }>[] = [];
|
const promises: Promise<{ items: LookupItem[] }>[] = [];
|
||||||
let lookupItems: LookupItem[] = [];
|
let lookupItems: LookupItem[] = [];
|
||||||
|
|
||||||
for (const dataSource of this.configurationService.get('DATA_SOURCES')) {
|
for (const dataSource of this.configurationService.get('DATA_SOURCES')) {
|
||||||
promises.push(
|
promises.push(
|
||||||
this.getDataProvider(DataSource[dataSource]).search(aSymbol)
|
this.getDataProvider(DataSource[dataSource]).search(aQuery)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -176,7 +185,7 @@ export class DataProviderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public getPrimaryDataSource(): DataSource {
|
public getPrimaryDataSource(): DataSource {
|
||||||
return DataSource[this.configurationService.get('DATA_SOURCES')[0]];
|
return DataSource[this.configurationService.get('DATA_SOURCE_PRIMARY')];
|
||||||
}
|
}
|
||||||
|
|
||||||
private getDataProvider(providerName: DataSource) {
|
private getDataProvider(providerName: DataSource) {
|
||||||
|
@ -1,4 +1,10 @@
|
|||||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||||
|
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
||||||
|
import {
|
||||||
|
IDataProviderHistoricalResponse,
|
||||||
|
IDataProviderResponse,
|
||||||
|
MarketState
|
||||||
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
import {
|
import {
|
||||||
@ -13,13 +19,6 @@ import * as bent from 'bent';
|
|||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
import {
|
|
||||||
IDataProviderHistoricalResponse,
|
|
||||||
IDataProviderResponse,
|
|
||||||
MarketState
|
|
||||||
} from '../../interfaces/interfaces';
|
|
||||||
import { DataProviderInterface } from '../interfaces/data-provider.interface';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GhostfolioScraperApiService implements DataProviderInterface {
|
export class GhostfolioScraperApiService implements DataProviderInterface {
|
||||||
private static NUMERIC_REGEXP = /[-]{0,1}[\d]*[.,]{0,1}[\d]+/g;
|
private static NUMERIC_REGEXP = /[-]{0,1}[\d]*[.,]{0,1}[\d]+/g;
|
||||||
@ -59,7 +58,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
|||||||
[symbol]: {
|
[symbol]: {
|
||||||
marketPrice,
|
marketPrice,
|
||||||
currency: symbolProfile?.currency,
|
currency: symbolProfile?.currency,
|
||||||
dataSource: DataSource.GHOSTFOLIO,
|
dataSource: this.getName(),
|
||||||
marketState: MarketState.delayed
|
marketState: MarketState.delayed
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -116,8 +115,35 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
|||||||
return DataSource.GHOSTFOLIO;
|
return DataSource.GHOSTFOLIO;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||||
return { items: [] };
|
const items = await this.prismaService.symbolProfile.findMany({
|
||||||
|
select: {
|
||||||
|
currency: true,
|
||||||
|
dataSource: true,
|
||||||
|
name: true,
|
||||||
|
symbol: true
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
dataSource: this.getName(),
|
||||||
|
name: {
|
||||||
|
mode: 'insensitive',
|
||||||
|
startsWith: aQuery
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dataSource: this.getName(),
|
||||||
|
symbol: {
|
||||||
|
mode: 'insensitive',
|
||||||
|
startsWith: aQuery
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { items };
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractNumberFromString(aString: string): number {
|
private extractNumberFromString(aString: string): number {
|
||||||
|
@ -0,0 +1,181 @@
|
|||||||
|
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
|
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
||||||
|
import {
|
||||||
|
IDataProviderHistoricalResponse,
|
||||||
|
IDataProviderResponse,
|
||||||
|
MarketState
|
||||||
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
|
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
||||||
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { GoogleSpreadsheet } from 'google-spreadsheet';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GoogleSheetsService implements DataProviderInterface {
|
||||||
|
public constructor(
|
||||||
|
private readonly configurationService: ConfigurationService,
|
||||||
|
private readonly prismaService: PrismaService,
|
||||||
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public canHandle(symbol: string) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async get(
|
||||||
|
aSymbols: string[]
|
||||||
|
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||||
|
if (aSymbols.length <= 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response: { [symbol: string]: IDataProviderResponse } = {};
|
||||||
|
|
||||||
|
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
|
||||||
|
aSymbols
|
||||||
|
);
|
||||||
|
|
||||||
|
const sheet = await this.getSheet({
|
||||||
|
sheetId: this.configurationService.get('GOOGLE_SHEETS_ID'),
|
||||||
|
symbol: 'Overview'
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = await sheet.getRows();
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const marketPrice = parseFloat(row['marketPrice']);
|
||||||
|
const symbol = row['symbol'];
|
||||||
|
|
||||||
|
if (aSymbols.includes(symbol)) {
|
||||||
|
response[symbol] = {
|
||||||
|
marketPrice,
|
||||||
|
currency: symbolProfiles.find((symbolProfile) => {
|
||||||
|
return symbolProfile.symbol === symbol;
|
||||||
|
})?.currency,
|
||||||
|
dataSource: this.getName(),
|
||||||
|
marketState: MarketState.delayed
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getHistorical(
|
||||||
|
aSymbols: string[],
|
||||||
|
aGranularity: Granularity = 'day',
|
||||||
|
from: Date,
|
||||||
|
to: Date
|
||||||
|
): Promise<{
|
||||||
|
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||||
|
}> {
|
||||||
|
if (aSymbols.length <= 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [symbol] = aSymbols;
|
||||||
|
|
||||||
|
const sheet = await this.getSheet({
|
||||||
|
symbol,
|
||||||
|
sheetId: this.configurationService.get('GOOGLE_SHEETS_ID')
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = await sheet.getRows();
|
||||||
|
|
||||||
|
const historicalData: {
|
||||||
|
[date: string]: IDataProviderHistoricalResponse;
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
rows
|
||||||
|
.filter((row, index) => {
|
||||||
|
return index >= 1;
|
||||||
|
})
|
||||||
|
.forEach((row) => {
|
||||||
|
const date = parseDate(row._rawData[0]);
|
||||||
|
const close = parseFloat(row._rawData[1]);
|
||||||
|
|
||||||
|
historicalData[format(date, DATE_FORMAT)] = { marketPrice: close };
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
[symbol]: historicalData
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
public getName(): DataSource {
|
||||||
|
return DataSource.GOOGLE_SHEETS;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||||
|
const items = await this.prismaService.symbolProfile.findMany({
|
||||||
|
select: {
|
||||||
|
currency: true,
|
||||||
|
dataSource: true,
|
||||||
|
name: true,
|
||||||
|
symbol: true
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
dataSource: this.getName(),
|
||||||
|
name: {
|
||||||
|
mode: 'insensitive',
|
||||||
|
startsWith: aQuery
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dataSource: this.getName(),
|
||||||
|
symbol: {
|
||||||
|
mode: 'insensitive',
|
||||||
|
startsWith: aQuery
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { items };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getSheet({
|
||||||
|
sheetId,
|
||||||
|
symbol
|
||||||
|
}: {
|
||||||
|
sheetId: string;
|
||||||
|
symbol: string;
|
||||||
|
}) {
|
||||||
|
const doc = new GoogleSpreadsheet(sheetId);
|
||||||
|
|
||||||
|
await doc.useServiceAccountAuth({
|
||||||
|
client_email: this.configurationService.get('GOOGLE_SHEETS_ACCOUNT'),
|
||||||
|
private_key: this.configurationService
|
||||||
|
.get('GOOGLE_SHEETS_PRIVATE_KEY')
|
||||||
|
.replace(/\\n/g, '\n')
|
||||||
|
});
|
||||||
|
|
||||||
|
await doc.loadInfo();
|
||||||
|
|
||||||
|
const sheet = doc.sheetsByTitle[symbol];
|
||||||
|
|
||||||
|
await sheet.loadCells();
|
||||||
|
|
||||||
|
return sheet;
|
||||||
|
}
|
||||||
|
}
|
@ -1,11 +1,10 @@
|
|||||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
|
||||||
import { DataSource } from '@prisma/client';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
IDataProviderResponse
|
IDataProviderResponse
|
||||||
} from '../../interfaces/interfaces';
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
|
|
||||||
export interface DataProviderInterface {
|
export interface DataProviderInterface {
|
||||||
canHandle(symbol: string): boolean;
|
canHandle(symbol: string): boolean;
|
||||||
@ -23,5 +22,5 @@ export interface DataProviderInterface {
|
|||||||
|
|
||||||
getName(): DataSource;
|
getName(): DataSource;
|
||||||
|
|
||||||
search(aSymbol: string): Promise<{ items: LookupItem[] }>;
|
search(aQuery: string): Promise<{ items: LookupItem[] }>;
|
||||||
}
|
}
|
||||||
|
@ -45,7 +45,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
|||||||
return {
|
return {
|
||||||
[ghostfolioFearAndGreedIndexSymbol]: {
|
[ghostfolioFearAndGreedIndexSymbol]: {
|
||||||
currency: undefined,
|
currency: undefined,
|
||||||
dataSource: DataSource.RAKUTEN,
|
dataSource: this.getName(),
|
||||||
marketPrice: fgi.now.value,
|
marketPrice: fgi.now.value,
|
||||||
marketState: MarketState.open,
|
marketState: MarketState.open,
|
||||||
name: RakutenRapidApiService.FEAR_AND_GREED_INDEX_NAME
|
name: RakutenRapidApiService.FEAR_AND_GREED_INDEX_NAME
|
||||||
@ -85,7 +85,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
|||||||
await this.prismaService.marketData.create({
|
await this.prismaService.marketData.create({
|
||||||
data: {
|
data: {
|
||||||
symbol,
|
symbol,
|
||||||
dataSource: DataSource.RAKUTEN,
|
dataSource: this.getName(),
|
||||||
date: subWeeks(getToday(), 1),
|
date: subWeeks(getToday(), 1),
|
||||||
marketPrice: fgi.oneWeekAgo.value
|
marketPrice: fgi.oneWeekAgo.value
|
||||||
}
|
}
|
||||||
@ -94,7 +94,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
|||||||
await this.prismaService.marketData.create({
|
await this.prismaService.marketData.create({
|
||||||
data: {
|
data: {
|
||||||
symbol,
|
symbol,
|
||||||
dataSource: DataSource.RAKUTEN,
|
dataSource: this.getName(),
|
||||||
date: subMonths(getToday(), 1),
|
date: subMonths(getToday(), 1),
|
||||||
marketPrice: fgi.oneMonthAgo.value
|
marketPrice: fgi.oneMonthAgo.value
|
||||||
}
|
}
|
||||||
@ -103,7 +103,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
|||||||
await this.prismaService.marketData.create({
|
await this.prismaService.marketData.create({
|
||||||
data: {
|
data: {
|
||||||
symbol,
|
symbol,
|
||||||
dataSource: DataSource.RAKUTEN,
|
dataSource: this.getName(),
|
||||||
date: subYears(getToday(), 1),
|
date: subYears(getToday(), 1),
|
||||||
marketPrice: fgi.oneYearAgo.value
|
marketPrice: fgi.oneYearAgo.value
|
||||||
}
|
}
|
||||||
@ -129,7 +129,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
|||||||
return DataSource.RAKUTEN;
|
return DataSource.RAKUTEN;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||||
return { items: [] };
|
return { items: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -103,7 +103,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
assetClass,
|
assetClass,
|
||||||
assetSubClass,
|
assetSubClass,
|
||||||
currency: value.price?.currency,
|
currency: value.price?.currency,
|
||||||
dataSource: DataSource.YAHOO,
|
dataSource: this.getName(),
|
||||||
exchange: this.parseExchange(value.price?.exchangeName),
|
exchange: this.parseExchange(value.price?.exchangeName),
|
||||||
marketState:
|
marketState:
|
||||||
value.price?.marketState === 'REGULAR' ||
|
value.price?.marketState === 'REGULAR' ||
|
||||||
@ -221,12 +221,14 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
return DataSource.YAHOO;
|
return DataSource.YAHOO;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||||
const items: LookupItem[] = [];
|
const items: LookupItem[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const get = bent(
|
const get = bent(
|
||||||
`${this.yahooFinanceHostname}/v1/finance/search?q=${aSymbol}&lang=en-US®ion=US"esCount=8&newsCount=0&enableFuzzyQuery=false"esQueryId=tss_match_phrase_query&multiQuoteQueryId=multi_quote_single_token_query&newsQueryId=news_cie_vespa&enableCb=true&enableNavLinks=false&enableEnhancedTrivialQuery=true`,
|
`${this.yahooFinanceHostname}/v1/finance/search?q=${encodeURIComponent(
|
||||||
|
aQuery
|
||||||
|
)}&lang=en-US®ion=US"esCount=8&newsCount=0&enableFuzzyQuery=false"esQueryId=tss_match_phrase_query&multiQuoteQueryId=multi_quote_single_token_query&newsQueryId=news_cie_vespa&enableCb=true&enableNavLinks=false&enableEnhancedTrivialQuery=true`,
|
||||||
'GET',
|
'GET',
|
||||||
'json',
|
'json',
|
||||||
200
|
200
|
||||||
@ -268,7 +270,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
items.push({
|
items.push({
|
||||||
symbol,
|
symbol,
|
||||||
currency: value.currency,
|
currency: value.currency,
|
||||||
dataSource: DataSource.YAHOO,
|
dataSource: this.getName(),
|
||||||
name: value.name
|
name: value.name
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -58,9 +58,9 @@ export class ExchangeRateDataService {
|
|||||||
getYesterday()
|
getYesterday()
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isEmpty(result)) {
|
if (Object.keys(result).length !== this.currencyPairs.length) {
|
||||||
// Load currencies directly from data provider as a fallback
|
// Load currencies directly from data provider as a fallback
|
||||||
// if historical data is not yet available
|
// if historical data is not fully available
|
||||||
const historicalData = await this.dataProviderService.get(
|
const historicalData = await this.dataProviderService.get(
|
||||||
this.currencyPairs.map(({ dataSource, symbol }) => {
|
this.currencyPairs.map(({ dataSource, symbol }) => {
|
||||||
return { dataSource, symbol };
|
return { dataSource, symbol };
|
||||||
|
@ -4,6 +4,7 @@ export interface Environment extends CleanedEnvAccessors {
|
|||||||
ACCESS_TOKEN_SALT: string;
|
ACCESS_TOKEN_SALT: string;
|
||||||
ALPHA_VANTAGE_API_KEY: string;
|
ALPHA_VANTAGE_API_KEY: string;
|
||||||
CACHE_TTL: number;
|
CACHE_TTL: number;
|
||||||
|
DATA_SOURCE_PRIMARY: string;
|
||||||
DATA_SOURCES: string | string[]; // string is not correct, error in envalid?
|
DATA_SOURCES: string | string[]; // string is not correct, error in envalid?
|
||||||
ENABLE_FEATURE_BLOG: boolean;
|
ENABLE_FEATURE_BLOG: boolean;
|
||||||
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
|
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
|
||||||
@ -16,6 +17,9 @@ export interface Environment extends CleanedEnvAccessors {
|
|||||||
ENABLE_FEATURE_SYSTEM_MESSAGE: boolean;
|
ENABLE_FEATURE_SYSTEM_MESSAGE: boolean;
|
||||||
GOOGLE_CLIENT_ID: string;
|
GOOGLE_CLIENT_ID: string;
|
||||||
GOOGLE_SECRET: string;
|
GOOGLE_SECRET: string;
|
||||||
|
GOOGLE_SHEETS_ACCOUNT: string;
|
||||||
|
GOOGLE_SHEETS_ID: string;
|
||||||
|
GOOGLE_SHEETS_PRIVATE_KEY: string;
|
||||||
JWT_SECRET_KEY: string;
|
JWT_SECRET_KEY: string;
|
||||||
MAX_ITEM_IN_CACHE: number;
|
MAX_ITEM_IN_CACHE: number;
|
||||||
MAX_ORDERS_TO_IMPORT: number;
|
MAX_ORDERS_TO_IMPORT: number;
|
||||||
|
@ -1,13 +1,29 @@
|
|||||||
|
import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto';
|
||||||
import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.interface';
|
import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.interface';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { resetHours } from '@ghostfolio/common/helper';
|
import { resetHours } from '@ghostfolio/common/helper';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { MarketData, Prisma } from '@prisma/client';
|
import { DataSource, MarketData, Prisma } from '@prisma/client';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MarketDataService {
|
export class MarketDataService {
|
||||||
public constructor(private readonly prismaService: PrismaService) {}
|
public constructor(private readonly prismaService: PrismaService) {}
|
||||||
|
|
||||||
|
public async deleteMany({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}: {
|
||||||
|
dataSource: DataSource;
|
||||||
|
symbol: string;
|
||||||
|
}) {
|
||||||
|
return this.prismaService.marketData.deleteMany({
|
||||||
|
where: {
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public async get({
|
public async get({
|
||||||
date,
|
date,
|
||||||
symbol
|
symbol
|
||||||
@ -67,14 +83,20 @@ export class MarketDataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async updateMarketData(params: {
|
public async updateMarketData(params: {
|
||||||
data: Prisma.MarketDataUpdateInput;
|
data: { dataSource: DataSource } & UpdateMarketDataDto;
|
||||||
where: Prisma.MarketDataWhereUniqueInput;
|
where: Prisma.MarketDataWhereUniqueInput;
|
||||||
}): Promise<MarketData> {
|
}): Promise<MarketData> {
|
||||||
const { data, where } = params;
|
const { data, where } = params;
|
||||||
|
|
||||||
return this.prismaService.marketData.update({
|
return this.prismaService.marketData.upsert({
|
||||||
data,
|
where,
|
||||||
where
|
create: {
|
||||||
|
dataSource: data.dataSource,
|
||||||
|
date: where.date_symbol.date,
|
||||||
|
marketPrice: data.marketPrice,
|
||||||
|
symbol: where.date_symbol.symbol
|
||||||
|
},
|
||||||
|
update: { marketPrice: data.marketPrice }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,14 +4,26 @@ import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
|||||||
import { Country } from '@ghostfolio/common/interfaces/country.interface';
|
import { Country } from '@ghostfolio/common/interfaces/country.interface';
|
||||||
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
|
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Prisma, SymbolProfile } from '@prisma/client';
|
import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
|
||||||
import { continents, countries } from 'countries-list';
|
import { continents, countries } from 'countries-list';
|
||||||
|
|
||||||
import { ScraperConfiguration } from './data-provider/ghostfolio-scraper-api/interfaces/scraper-configuration.interface';
|
import { ScraperConfiguration } from './data-provider/ghostfolio-scraper-api/interfaces/scraper-configuration.interface';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SymbolProfileService {
|
export class SymbolProfileService {
|
||||||
constructor(private readonly prismaService: PrismaService) {}
|
public constructor(private readonly prismaService: PrismaService) {}
|
||||||
|
|
||||||
|
public async delete({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}: {
|
||||||
|
dataSource: DataSource;
|
||||||
|
symbol: string;
|
||||||
|
}) {
|
||||||
|
return this.prismaService.symbolProfile.delete({
|
||||||
|
where: { dataSource_symbol: { dataSource, symbol } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public async getSymbolProfiles(
|
public async getSymbolProfiles(
|
||||||
symbols: string[]
|
symbols: string[]
|
||||||
|
@ -9,6 +9,13 @@ const routes: Routes = [
|
|||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('./pages/about/about-page.module').then((m) => m.AboutPageModule)
|
import('./pages/about/about-page.module').then((m) => m.AboutPageModule)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'about/changelog',
|
||||||
|
loadChildren: () =>
|
||||||
|
import('./pages/about/changelog/changelog-page.module').then(
|
||||||
|
(m) => m.ChangelogPageModule
|
||||||
|
)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'account',
|
path: 'account',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
@ -33,6 +40,11 @@ const routes: Routes = [
|
|||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('./pages/auth/auth-page.module').then((m) => m.AuthPageModule)
|
import('./pages/auth/auth-page.module').then((m) => m.AuthPageModule)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'blog',
|
||||||
|
loadChildren: () =>
|
||||||
|
import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'de/blog/2021/07/hallo-ghostfolio',
|
path: 'de/blog/2021/07/hallo-ghostfolio',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
@ -47,6 +59,13 @@ const routes: Routes = [
|
|||||||
'./pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.module'
|
'./pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.module'
|
||||||
).then((m) => m.HelloGhostfolioPageModule)
|
).then((m) => m.HelloGhostfolioPageModule)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'en/blog/2022/01/ghostfolio-first-months-in-open-source',
|
||||||
|
loadChildren: () =>
|
||||||
|
import(
|
||||||
|
'./pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.module'
|
||||||
|
).then((m) => m.FirstMonthsInOpenSourcePageModule)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'home',
|
path: 'home',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
@ -66,6 +85,13 @@ const routes: Routes = [
|
|||||||
(m) => m.PortfolioPageModule
|
(m) => m.PortfolioPageModule
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'portfolio/activities',
|
||||||
|
loadChildren: () =>
|
||||||
|
import('./pages/portfolio/transactions/transactions-page.module').then(
|
||||||
|
(m) => m.TransactionsPageModule
|
||||||
|
)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'portfolio/allocations',
|
path: 'portfolio/allocations',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
@ -87,13 +113,6 @@ const routes: Routes = [
|
|||||||
(m) => m.ReportPageModule
|
(m) => m.ReportPageModule
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'portfolio/transactions',
|
|
||||||
loadChildren: () =>
|
|
||||||
import('./pages/portfolio/transactions/transactions-page.module').then(
|
|
||||||
(m) => m.TransactionsPageModule
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'pricing',
|
path: 'pricing',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
|
@ -89,7 +89,7 @@ export class AppComponent implements OnDestroy, OnInit {
|
|||||||
this.tokenStorageService.signOut();
|
this.tokenStorageService.signOut();
|
||||||
this.userService.remove();
|
this.userService.remove();
|
||||||
|
|
||||||
this.router.navigate(['/']);
|
document.location.href = '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
public ngOnDestroy() {
|
public ngOnDestroy() {
|
||||||
|
@ -15,7 +15,7 @@
|
|||||||
>(Default)</span
|
>(Default)</span
|
||||||
>
|
>
|
||||||
</td>
|
</td>
|
||||||
<td *matFooterCellDef class="px-1" mat-footer-cell>Total</td>
|
<td *matFooterCellDef class="px-1" mat-footer-cell i18n>Total</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="currency">
|
<ng-container matColumnDef="currency">
|
||||||
|
@ -5,10 +5,10 @@ import { MatInputModule } from '@angular/material/input';
|
|||||||
import { MatMenuModule } from '@angular/material/menu';
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
import { MatTableModule } from '@angular/material/table';
|
import { MatTableModule } from '@angular/material/table';
|
||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module';
|
||||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||||
|
|
||||||
import { GfSymbolIconModule } from '../symbol-icon/symbol-icon.module';
|
|
||||||
import { AccountsTableComponent } from './accounts-table.component';
|
import { AccountsTableComponent } from './accounts-table.component';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
|
@ -20,6 +20,7 @@ import { takeUntil } from 'rxjs/operators';
|
|||||||
templateUrl: './admin-market-data.html'
|
templateUrl: './admin-market-data.html'
|
||||||
})
|
})
|
||||||
export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
||||||
|
public currentDataSource: DataSource;
|
||||||
public currentSymbol: string;
|
public currentSymbol: string;
|
||||||
public defaultDateFormat = DEFAULT_DATE_FORMAT;
|
public defaultDateFormat = DEFAULT_DATE_FORMAT;
|
||||||
public marketData: AdminMarketDataItem[] = [];
|
public marketData: AdminMarketDataItem[] = [];
|
||||||
@ -43,6 +44,32 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
|||||||
this.fetchAdminMarketData();
|
this.fetchAdminMarketData();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onDeleteProfileData({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}: {
|
||||||
|
dataSource: DataSource;
|
||||||
|
symbol: string;
|
||||||
|
}) {
|
||||||
|
this.adminService
|
||||||
|
.deleteProfileData({ dataSource, symbol })
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
public onGatherProfileDataBySymbol({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}: {
|
||||||
|
dataSource: DataSource;
|
||||||
|
symbol: string;
|
||||||
|
}) {
|
||||||
|
this.adminService
|
||||||
|
.gatherProfileDataBySymbol({ dataSource, symbol })
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
public onGatherSymbol({
|
public onGatherSymbol({
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
@ -56,22 +83,33 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
|||||||
.subscribe(() => {});
|
.subscribe(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
public setCurrentSymbol(aSymbol: string) {
|
|
||||||
this.marketDataDetails = [];
|
|
||||||
|
|
||||||
if (this.currentSymbol === aSymbol) {
|
|
||||||
this.currentSymbol = '';
|
|
||||||
} else {
|
|
||||||
this.currentSymbol = aSymbol;
|
|
||||||
|
|
||||||
this.fetchAdminMarketDataBySymbol(this.currentSymbol);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public onMarketDataChanged(withRefresh: boolean = false) {
|
public onMarketDataChanged(withRefresh: boolean = false) {
|
||||||
if (withRefresh) {
|
if (withRefresh) {
|
||||||
this.fetchAdminMarketData();
|
this.fetchAdminMarketData();
|
||||||
this.fetchAdminMarketDataBySymbol(this.currentSymbol);
|
this.fetchAdminMarketDataBySymbol({
|
||||||
|
dataSource: this.currentDataSource,
|
||||||
|
symbol: this.currentSymbol
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public setCurrentProfile({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}: {
|
||||||
|
dataSource: DataSource;
|
||||||
|
symbol: string;
|
||||||
|
}) {
|
||||||
|
this.marketDataDetails = [];
|
||||||
|
|
||||||
|
if (this.currentSymbol === symbol) {
|
||||||
|
this.currentDataSource = undefined;
|
||||||
|
this.currentSymbol = '';
|
||||||
|
} else {
|
||||||
|
this.currentDataSource = dataSource;
|
||||||
|
this.currentSymbol = symbol;
|
||||||
|
|
||||||
|
this.fetchAdminMarketDataBySymbol({ dataSource, symbol });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -91,9 +129,15 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private fetchAdminMarketDataBySymbol(aSymbol: string) {
|
private fetchAdminMarketDataBySymbol({
|
||||||
this.dataService
|
dataSource,
|
||||||
.fetchAdminMarketDataBySymbol(aSymbol)
|
symbol
|
||||||
|
}: {
|
||||||
|
dataSource: DataSource;
|
||||||
|
symbol: string;
|
||||||
|
}) {
|
||||||
|
this.adminService
|
||||||
|
.fetchAdminMarketDataBySymbol({ dataSource, symbol })
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(({ marketData }) => {
|
.subscribe(({ marketData }) => {
|
||||||
this.marketDataDetails = marketData;
|
this.marketDataDetails = marketData;
|
||||||
|
@ -6,7 +6,9 @@
|
|||||||
<tr class="mat-header-row">
|
<tr class="mat-header-row">
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Symbol</th>
|
<th class="mat-header-cell px-1 py-2" i18n>Symbol</th>
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Data Source</th>
|
<th class="mat-header-cell px-1 py-2" i18n>Data Source</th>
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>First Transaction</th>
|
<th class="mat-header-cell px-1 py-2" i18n>First Activity</th>
|
||||||
|
<th class="mat-header-cell px-1 py-2" i18n>Activity Count</th>
|
||||||
|
<th class="mat-header-cell px-1 py-2" i18n>Historical Data</th>
|
||||||
<th class="mat-header-cell px-1 py-2"></th>
|
<th class="mat-header-cell px-1 py-2"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -14,13 +16,15 @@
|
|||||||
<ng-container *ngFor="let item of marketData; let i = index">
|
<ng-container *ngFor="let item of marketData; let i = index">
|
||||||
<tr
|
<tr
|
||||||
class="cursor-pointer mat-row"
|
class="cursor-pointer mat-row"
|
||||||
(click)="setCurrentSymbol(item.symbol)"
|
(click)="setCurrentProfile({ dataSource: item.dataSource, symbol: item.symbol })"
|
||||||
>
|
>
|
||||||
<td class="mat-cell px-1 py-2">{{ item.symbol }}</td>
|
<td class="mat-cell px-1 py-2">{{ item.symbol }}</td>
|
||||||
<td class="mat-cell px-1 py-2">{{ item.dataSource}}</td>
|
<td class="mat-cell px-1 py-2">{{ item.dataSource }}</td>
|
||||||
<td class="mat-cell px-1 py-2">
|
<td class="mat-cell px-1 py-2">
|
||||||
{{ (item.date | date: defaultDateFormat) ?? '' }}
|
{{ (item.date | date: defaultDateFormat) ?? '' }}
|
||||||
</td>
|
</td>
|
||||||
|
<td class="mat-cell px-1 py-2">{{ item.activityCount }}</td>
|
||||||
|
<td class="mat-cell px-1 py-2">{{ item.marketDataItemCount }}</td>
|
||||||
<td class="mat-cell px-1 py-2">
|
<td class="mat-cell px-1 py-2">
|
||||||
<button
|
<button
|
||||||
class="mx-1 no-min-width px-2"
|
class="mx-1 no-min-width px-2"
|
||||||
@ -38,11 +42,26 @@
|
|||||||
>
|
>
|
||||||
Gather Data
|
Gather Data
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
i18n
|
||||||
|
mat-menu-item
|
||||||
|
(click)="onGatherProfileDataBySymbol({dataSource: item.dataSource, symbol: item.symbol})"
|
||||||
|
>
|
||||||
|
Gather Profile Data
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
i18n
|
||||||
|
mat-menu-item
|
||||||
|
[disabled]="item.activityCount !== 0"
|
||||||
|
(click)="onDeleteProfileData({dataSource: item.dataSource, symbol: item.symbol})"
|
||||||
|
>
|
||||||
|
Delete Profile Data
|
||||||
|
</button>
|
||||||
</mat-menu>
|
</mat-menu>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr *ngIf="currentSymbol === item.symbol" class="mat-row">
|
<tr *ngIf="currentSymbol === item.symbol" class="mat-row">
|
||||||
<td class="p-1" colspan="4">
|
<td class="p-1" colspan="6">
|
||||||
<gf-admin-market-data-detail
|
<gf-admin-market-data-detail
|
||||||
[dataSource]="item.dataSource"
|
[dataSource]="item.dataSource"
|
||||||
[marketData]="marketDataDetails"
|
[marketData]="marketDataDetails"
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
<button
|
<button
|
||||||
*ngIf="deviceType === 'mobile'"
|
*ngIf="deviceType === 'mobile'"
|
||||||
|
class="mt-2"
|
||||||
mat-button
|
mat-button
|
||||||
(click)="onClickCloseButton()"
|
(click)="onClickCloseButton()"
|
||||||
>
|
>
|
||||||
|
@ -1,4 +1,7 @@
|
|||||||
:host {
|
:host {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-bottom: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,18 @@
|
|||||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
import {
|
import {
|
||||||
RANGE,
|
RANGE,
|
||||||
SettingsStorageService
|
SettingsStorageService
|
||||||
} from '@ghostfolio/client/services/settings-storage.service';
|
} from '@ghostfolio/client/services/settings-storage.service';
|
||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
|
import { defaultDateRangeOptions } from '@ghostfolio/common/config';
|
||||||
import { Position, User } from '@ghostfolio/common/interfaces';
|
import { Position, User } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import { DateRange } from '@ghostfolio/common/types';
|
import { DateRange } from '@ghostfolio/common/types';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
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 { takeUntil } from 'rxjs/operators';
|
||||||
@ -19,6 +24,7 @@ import { takeUntil } from 'rxjs/operators';
|
|||||||
})
|
})
|
||||||
export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||||
public dateRange: DateRange;
|
public dateRange: DateRange;
|
||||||
|
public dateRangeOptions = defaultDateRangeOptions;
|
||||||
public deviceType: string;
|
public deviceType: string;
|
||||||
public hasPermissionToCreateOrder: boolean;
|
public hasPermissionToCreateOrder: boolean;
|
||||||
public positions: Position[];
|
public positions: Position[];
|
||||||
@ -33,9 +39,27 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
|||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
private dataService: DataService,
|
private dataService: DataService,
|
||||||
private deviceService: DeviceDetectorService,
|
private deviceService: DeviceDetectorService,
|
||||||
|
private dialog: MatDialog,
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private router: Router,
|
||||||
private settingsStorageService: SettingsStorageService,
|
private settingsStorageService: SettingsStorageService,
|
||||||
private userService: UserService
|
private userService: UserService
|
||||||
) {
|
) {
|
||||||
|
route.queryParams
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((params) => {
|
||||||
|
if (
|
||||||
|
params['dataSource'] &&
|
||||||
|
params['positionDetailDialog'] &&
|
||||||
|
params['symbol']
|
||||||
|
) {
|
||||||
|
this.openPositionDialog({
|
||||||
|
dataSource: params['dataSource'],
|
||||||
|
symbol: params['symbol']
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.userService.stateChanged
|
this.userService.stateChanged
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((state) => {
|
.subscribe((state) => {
|
||||||
@ -64,12 +88,55 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
|||||||
this.update();
|
this.update();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onChangeDateRange(aDateRange: DateRange) {
|
||||||
|
this.dateRange = aDateRange;
|
||||||
|
this.settingsStorageService.setSetting(RANGE, this.dateRange);
|
||||||
|
this.update();
|
||||||
|
}
|
||||||
|
|
||||||
public ngOnDestroy() {
|
public ngOnDestroy() {
|
||||||
this.unsubscribeSubject.next();
|
this.unsubscribeSubject.next();
|
||||||
this.unsubscribeSubject.complete();
|
this.unsubscribeSubject.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private openPositionDialog({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}: {
|
||||||
|
dataSource: DataSource;
|
||||||
|
symbol: string;
|
||||||
|
}) {
|
||||||
|
this.userService
|
||||||
|
.get()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((user) => {
|
||||||
|
this.user = user;
|
||||||
|
|
||||||
|
const dialogRef = this.dialog.open(PositionDetailDialog, {
|
||||||
|
autoFocus: false,
|
||||||
|
data: {
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
baseCurrency: this.user?.settings?.baseCurrency,
|
||||||
|
deviceType: this.deviceType,
|
||||||
|
locale: this.user?.settings?.locale
|
||||||
|
},
|
||||||
|
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||||
|
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogRef
|
||||||
|
.afterClosed()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
this.router.navigate(['.'], { relativeTo: this.route });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private update() {
|
private update() {
|
||||||
|
this.positions = undefined;
|
||||||
|
|
||||||
this.dataService
|
this.dataService
|
||||||
.fetchPositions({ range: this.dateRange })
|
.fetchPositions({ range: this.dateRange })
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
@ -1,4 +1,12 @@
|
|||||||
<div class="container justify-content-center pb-3 px-3">
|
<div class="container justify-content-center p-3">
|
||||||
|
<div class="mb-3 text-center">
|
||||||
|
<gf-toggle
|
||||||
|
[defaultValue]="dateRange"
|
||||||
|
[isLoading]="positions === undefined"
|
||||||
|
[options]="dateRangeOptions"
|
||||||
|
(change)="onChangeDateRange($event.value)"
|
||||||
|
></gf-toggle>
|
||||||
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="align-items-center col-xs-12 col-md-8 offset-md-2">
|
<div class="align-items-center col-xs-12 col-md-8 offset-md-2">
|
||||||
<mat-card class="p-0">
|
<mat-card class="p-0">
|
||||||
@ -6,6 +14,7 @@
|
|||||||
<gf-positions
|
<gf-positions
|
||||||
[baseCurrency]="user?.settings?.baseCurrency"
|
[baseCurrency]="user?.settings?.baseCurrency"
|
||||||
[deviceType]="deviceType"
|
[deviceType]="deviceType"
|
||||||
|
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
|
||||||
[locale]="user?.settings?.locale"
|
[locale]="user?.settings?.locale"
|
||||||
[positions]="positions"
|
[positions]="positions"
|
||||||
[range]="dateRange"
|
[range]="dateRange"
|
||||||
@ -17,8 +26,8 @@
|
|||||||
class="mt-3"
|
class="mt-3"
|
||||||
i18n
|
i18n
|
||||||
mat-button
|
mat-button
|
||||||
[routerLink]="['/portfolio', 'transactions']"
|
[routerLink]="['/portfolio', 'activities']"
|
||||||
>Manage Transactions...</a
|
>Manage Activities...</a
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -3,7 +3,9 @@ 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 { GfPositionDetailDialogModule } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.module';
|
||||||
import { GfPositionsModule } from '@ghostfolio/client/components/positions/positions.module';
|
import { GfPositionsModule } from '@ghostfolio/client/components/positions/positions.module';
|
||||||
|
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
|
||||||
|
|
||||||
import { HomeHoldingsComponent } from './home-holdings.component';
|
import { HomeHoldingsComponent } from './home-holdings.component';
|
||||||
|
|
||||||
@ -12,7 +14,9 @@ import { HomeHoldingsComponent } from './home-holdings.component';
|
|||||||
exports: [],
|
exports: [],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
GfPositionDetailDialogModule,
|
||||||
GfPositionsModule,
|
GfPositionsModule,
|
||||||
|
GfToggleModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatCardModule,
|
MatCardModule,
|
||||||
RouterModule
|
RouterModule
|
||||||
|
@ -4,9 +4,8 @@ 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 { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
|
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
|
||||||
import { resetHours } from '@ghostfolio/common/helper';
|
import { resetHours } from '@ghostfolio/common/helper';
|
||||||
import { User } from '@ghostfolio/common/interfaces';
|
import { InfoItem, User } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import { DataSource } from '@prisma/client';
|
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { takeUntil } from 'rxjs/operators';
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
@ -19,7 +18,9 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
|
|||||||
public fearAndGreedIndex: number;
|
public fearAndGreedIndex: number;
|
||||||
public hasPermissionToAccessFearAndGreedIndex: boolean;
|
public hasPermissionToAccessFearAndGreedIndex: boolean;
|
||||||
public historicalData: HistoricalDataItem[];
|
public historicalData: HistoricalDataItem[];
|
||||||
|
public info: InfoItem;
|
||||||
public isLoading = true;
|
public isLoading = true;
|
||||||
|
public readonly numberOfDays = 90;
|
||||||
public user: User;
|
public user: User;
|
||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
@ -32,6 +33,7 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
|
|||||||
private dataService: DataService,
|
private dataService: DataService,
|
||||||
private userService: UserService
|
private userService: UserService
|
||||||
) {
|
) {
|
||||||
|
this.info = this.dataService.fetchInfo();
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
|
|
||||||
this.userService.stateChanged
|
this.userService.stateChanged
|
||||||
@ -48,8 +50,8 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
|
|||||||
if (this.hasPermissionToAccessFearAndGreedIndex) {
|
if (this.hasPermissionToAccessFearAndGreedIndex) {
|
||||||
this.dataService
|
this.dataService
|
||||||
.fetchSymbolItem({
|
.fetchSymbolItem({
|
||||||
dataSource: DataSource.RAKUTEN,
|
dataSource: this.info.fearAndGreedDataSource,
|
||||||
includeHistoricalData: true,
|
includeHistoricalData: this.numberOfDays,
|
||||||
symbol: ghostfolioFearAndGreedIndexSymbol
|
symbol: ghostfolioFearAndGreedIndexSymbol
|
||||||
})
|
})
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
@ -1,18 +1,10 @@
|
|||||||
<div
|
<div
|
||||||
class="
|
class="align-items-center container d-flex flex-grow-1 h-100 justify-content-center w-100"
|
||||||
align-items-center
|
|
||||||
container
|
|
||||||
d-flex
|
|
||||||
flex-grow-1
|
|
||||||
h-100
|
|
||||||
justify-content-center
|
|
||||||
w-100
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<div class="no-gutters row w-100">
|
<div class="no-gutters row w-100">
|
||||||
<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">
|
||||||
<small i18n>Last 30 Days</small>
|
<small i18n>Last {{ numberOfDays }} Days</small>
|
||||||
</div>
|
</div>
|
||||||
<gf-line-chart
|
<gf-line-chart
|
||||||
class="mb-5"
|
class="mb-5"
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { ToggleOption } from '@ghostfolio/client/components/toggle/interfaces/toggle-option.type';
|
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
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 {
|
import {
|
||||||
@ -7,7 +6,9 @@ import {
|
|||||||
SettingsStorageService
|
SettingsStorageService
|
||||||
} from '@ghostfolio/client/services/settings-storage.service';
|
} from '@ghostfolio/client/services/settings-storage.service';
|
||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
|
import { defaultDateRangeOptions } from '@ghostfolio/common/config';
|
||||||
import { PortfolioPerformance, User } from '@ghostfolio/common/interfaces';
|
import { PortfolioPerformance, User } from '@ghostfolio/common/interfaces';
|
||||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import { DateRange } from '@ghostfolio/common/types';
|
import { DateRange } from '@ghostfolio/common/types';
|
||||||
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
|
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
|
||||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||||
@ -21,15 +22,11 @@ import { takeUntil } from 'rxjs/operators';
|
|||||||
})
|
})
|
||||||
export class HomeOverviewComponent implements OnDestroy, OnInit {
|
export class HomeOverviewComponent implements OnDestroy, OnInit {
|
||||||
public dateRange: DateRange;
|
public dateRange: DateRange;
|
||||||
public dateRangeOptions: ToggleOption[] = [
|
public dateRangeOptions = defaultDateRangeOptions;
|
||||||
{ label: 'Today', value: '1d' },
|
|
||||||
{ label: 'YTD', value: 'ytd' },
|
|
||||||
{ label: '1Y', value: '1y' },
|
|
||||||
{ label: '5Y', value: '5y' },
|
|
||||||
{ label: 'Max', value: 'max' }
|
|
||||||
];
|
|
||||||
public deviceType: string;
|
public deviceType: string;
|
||||||
|
public hasError: boolean;
|
||||||
public hasImpersonationId: boolean;
|
public hasImpersonationId: boolean;
|
||||||
|
public hasPermissionToCreateOrder: boolean;
|
||||||
public historicalDataItems: LineChartItem[];
|
public historicalDataItems: LineChartItem[];
|
||||||
public isAllTimeHigh: boolean;
|
public isAllTimeHigh: boolean;
|
||||||
public isAllTimeLow: boolean;
|
public isAllTimeLow: boolean;
|
||||||
@ -56,6 +53,11 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
|
|||||||
if (state?.user) {
|
if (state?.user) {
|
||||||
this.user = state.user;
|
this.user = state.user;
|
||||||
|
|
||||||
|
this.hasPermissionToCreateOrder = hasPermission(
|
||||||
|
this.user.permissions,
|
||||||
|
permissions.createOrder
|
||||||
|
);
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
this.changeDetectorRef.markForCheck();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -116,7 +118,8 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
|
|||||||
.fetchPortfolioPerformance({ range: this.dateRange })
|
.fetchPortfolioPerformance({ range: this.dateRange })
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((response) => {
|
.subscribe((response) => {
|
||||||
this.performance = response;
|
this.hasError = response.hasErrors;
|
||||||
|
this.performance = response.performance;
|
||||||
this.isLoadingPerformance = false;
|
this.isLoadingPerformance = false;
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
this.changeDetectorRef.markForCheck();
|
||||||
|
@ -1,15 +1,5 @@
|
|||||||
<div
|
<div
|
||||||
class="
|
class="align-items-center container d-flex flex-column h-100 justify-content-center overview p-0 position-relative"
|
||||||
align-items-center
|
|
||||||
container
|
|
||||||
d-flex
|
|
||||||
flex-column
|
|
||||||
h-100
|
|
||||||
justify-content-center
|
|
||||||
overview
|
|
||||||
p-0
|
|
||||||
position-relative
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<div class="row w-100">
|
<div class="row w-100">
|
||||||
<div class="chart-container col">
|
<div class="chart-container col">
|
||||||
@ -23,7 +13,7 @@
|
|||||||
[showYAxis]="false"
|
[showYAxis]="false"
|
||||||
></gf-line-chart>
|
></gf-line-chart>
|
||||||
<div
|
<div
|
||||||
*ngIf="historicalDataItems?.length === 0"
|
*ngIf="hasPermissionToCreateOrder && historicalDataItems?.length === 0"
|
||||||
class="align-items-center d-flex h-100 justify-content-center w-100"
|
class="align-items-center d-flex h-100 justify-content-center w-100"
|
||||||
>
|
>
|
||||||
<div class="d-flex justify-content-center">
|
<div class="d-flex justify-content-center">
|
||||||
@ -37,6 +27,8 @@
|
|||||||
<gf-portfolio-performance
|
<gf-portfolio-performance
|
||||||
class="pb-4"
|
class="pb-4"
|
||||||
[baseCurrency]="user?.settings?.baseCurrency"
|
[baseCurrency]="user?.settings?.baseCurrency"
|
||||||
|
[deviceType]="deviceType"
|
||||||
|
[hasError]="hasError"
|
||||||
[isAllTimeHigh]="isAllTimeHigh"
|
[isAllTimeHigh]="isAllTimeHigh"
|
||||||
[isAllTimeLow]="isAllTimeLow"
|
[isAllTimeLow]="isAllTimeLow"
|
||||||
[isLoading]="isLoadingPerformance"
|
[isLoading]="isLoadingPerformance"
|
||||||
|
@ -10,6 +10,7 @@ import {
|
|||||||
ViewChild
|
ViewChild
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { primaryColorRgb } from '@ghostfolio/common/config';
|
import { primaryColorRgb } from '@ghostfolio/common/config';
|
||||||
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||||
import {
|
import {
|
||||||
Chart,
|
Chart,
|
||||||
@ -19,7 +20,7 @@ import {
|
|||||||
PointElement,
|
PointElement,
|
||||||
TimeScale
|
TimeScale
|
||||||
} from 'chart.js';
|
} from 'chart.js';
|
||||||
import { addMonths, isAfter, parseISO, subMonths } from 'date-fns';
|
import { addDays, isAfter, parseISO, subDays } from 'date-fns';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'gf-investment-chart',
|
selector: 'gf-investment-chart',
|
||||||
@ -27,8 +28,10 @@ import { addMonths, isAfter, parseISO, subMonths } from 'date-fns';
|
|||||||
templateUrl: './investment-chart.component.html',
|
templateUrl: './investment-chart.component.html',
|
||||||
styleUrls: ['./investment-chart.component.scss']
|
styleUrls: ['./investment-chart.component.scss']
|
||||||
})
|
})
|
||||||
export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
|
export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||||
|
@Input() daysInMarket: number;
|
||||||
@Input() investments: InvestmentItem[];
|
@Input() investments: InvestmentItem[];
|
||||||
|
@Input() isInPercent = false;
|
||||||
|
|
||||||
@ViewChild('chartCanvas') chartCanvas;
|
@ViewChild('chartCanvas') chartCanvas;
|
||||||
|
|
||||||
@ -45,8 +48,6 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ngOnInit() {}
|
|
||||||
|
|
||||||
public ngOnChanges() {
|
public ngOnChanges() {
|
||||||
if (this.investments) {
|
if (this.investments) {
|
||||||
this.initialize();
|
this.initialize();
|
||||||
@ -61,19 +62,25 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
|
|
||||||
if (this.investments?.length > 0) {
|
if (this.investments?.length > 0) {
|
||||||
// Extend chart by three months (before)
|
// Extend chart by 5% of days in market (before)
|
||||||
const firstItem = this.investments[0];
|
const firstItem = this.investments[0];
|
||||||
this.investments.unshift({
|
this.investments.unshift({
|
||||||
...firstItem,
|
...firstItem,
|
||||||
date: subMonths(parseISO(firstItem.date), 3).toISOString(),
|
date: subDays(
|
||||||
|
parseISO(firstItem.date),
|
||||||
|
this.daysInMarket * 0.05 || 90
|
||||||
|
).toISOString(),
|
||||||
investment: 0
|
investment: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
// Extend chart by three months (after)
|
// Extend chart by 5% of days in market (after)
|
||||||
const lastItem = this.investments[this.investments.length - 1];
|
const lastItem = this.investments[this.investments.length - 1];
|
||||||
this.investments.push({
|
this.investments.push({
|
||||||
...lastItem,
|
...lastItem,
|
||||||
date: addMonths(new Date(), 3).toISOString()
|
date: addDays(
|
||||||
|
parseDate(lastItem.date),
|
||||||
|
this.daysInMarket * 0.05 || 90
|
||||||
|
).toISOString()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -136,12 +143,26 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
y: {
|
y: {
|
||||||
display: false,
|
display: !this.isInPercent,
|
||||||
grid: {
|
grid: {
|
||||||
display: false
|
display: false
|
||||||
},
|
},
|
||||||
ticks: {
|
ticks: {
|
||||||
display: false
|
display: true,
|
||||||
|
callback: (tickValue, index, ticks) => {
|
||||||
|
if (index === 0 || index === ticks.length - 1) {
|
||||||
|
// Only print last and first legend entry
|
||||||
|
if (typeof tickValue === 'number') {
|
||||||
|
return tickValue.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tickValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
mirror: true,
|
||||||
|
z: 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -7,7 +7,6 @@ import { InvestmentChartComponent } from './investment-chart.component';
|
|||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [InvestmentChartComponent],
|
declarations: [InvestmentChartComponent],
|
||||||
exports: [InvestmentChartComponent],
|
exports: [InvestmentChartComponent],
|
||||||
imports: [CommonModule, NgxSkeletonLoaderModule],
|
imports: [CommonModule, NgxSkeletonLoaderModule]
|
||||||
providers: []
|
|
||||||
})
|
})
|
||||||
export class GfInvestmentChartModule {}
|
export class GfInvestmentChartModule {}
|
||||||
|
@ -1,6 +0,0 @@
|
|||||||
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
|
|
||||||
|
|
||||||
export interface PositionDetailDialogParams {
|
|
||||||
deviceType: string;
|
|
||||||
historicalDataItems: LineChartItem[];
|
|
||||||
}
|
|
@ -1,12 +0,0 @@
|
|||||||
:host {
|
|
||||||
display: block;
|
|
||||||
|
|
||||||
.mat-dialog-content {
|
|
||||||
max-height: unset;
|
|
||||||
|
|
||||||
gf-line-chart {
|
|
||||||
aspect-ratio: 16 / 9;
|
|
||||||
margin: 0 -1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,94 +0,0 @@
|
|||||||
import {
|
|
||||||
ChangeDetectionStrategy,
|
|
||||||
ChangeDetectorRef,
|
|
||||||
Component,
|
|
||||||
Inject
|
|
||||||
} from '@angular/core';
|
|
||||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
|
||||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
|
||||||
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
|
|
||||||
import { isToday, parse } from 'date-fns';
|
|
||||||
import { Subject } from 'rxjs';
|
|
||||||
import { takeUntil } from 'rxjs/operators';
|
|
||||||
|
|
||||||
import { PositionDetailDialogParams } from './interfaces/interfaces';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'gf-performance-chart-dialog',
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
||||||
templateUrl: 'performance-chart-dialog.html',
|
|
||||||
styleUrls: ['./performance-chart-dialog.component.scss']
|
|
||||||
})
|
|
||||||
export class PerformanceChartDialog {
|
|
||||||
public benchmarkDataItems: LineChartItem[];
|
|
||||||
public benchmarkSymbol = 'VOO';
|
|
||||||
public currency: string;
|
|
||||||
public firstBuyDate: string;
|
|
||||||
public marketPrice: number;
|
|
||||||
public historicalDataItems: LineChartItem[];
|
|
||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
|
||||||
|
|
||||||
public constructor(
|
|
||||||
private changeDetectorRef: ChangeDetectorRef,
|
|
||||||
private dataService: DataService,
|
|
||||||
public dialogRef: MatDialogRef<PerformanceChartDialog>,
|
|
||||||
@Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams
|
|
||||||
) {
|
|
||||||
this.dataService
|
|
||||||
.fetchPositionDetail(this.benchmarkSymbol)
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe(({ currency, firstBuyDate, historicalData, marketPrice }) => {
|
|
||||||
this.benchmarkDataItems = [];
|
|
||||||
this.currency = currency;
|
|
||||||
this.firstBuyDate = firstBuyDate;
|
|
||||||
this.historicalDataItems = [];
|
|
||||||
this.marketPrice = marketPrice;
|
|
||||||
|
|
||||||
let coefficient = 1;
|
|
||||||
|
|
||||||
this.historicalDataItems = this.data.historicalDataItems;
|
|
||||||
|
|
||||||
this.historicalDataItems?.forEach((historicalDataItem) => {
|
|
||||||
const benchmarkItem = historicalData.find((item) => {
|
|
||||||
return item.date === historicalDataItem.date;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (benchmarkItem) {
|
|
||||||
if (coefficient === 1) {
|
|
||||||
coefficient = historicalDataItem.value / benchmarkItem.value || 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.benchmarkDataItems.push({
|
|
||||||
date: historicalDataItem.date,
|
|
||||||
value: benchmarkItem.value * coefficient
|
|
||||||
});
|
|
||||||
} else if (
|
|
||||||
isToday(parse(historicalDataItem.date, DATE_FORMAT, new Date()))
|
|
||||||
) {
|
|
||||||
this.benchmarkDataItems.push({
|
|
||||||
date: historicalDataItem.date,
|
|
||||||
value: marketPrice * coefficient
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.benchmarkDataItems.push({
|
|
||||||
date: historicalDataItem.date,
|
|
||||||
value: undefined
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public onClose(): void {
|
|
||||||
this.dialogRef.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
public ngOnDestroy() {
|
|
||||||
this.unsubscribeSubject.next();
|
|
||||||
this.unsubscribeSubject.complete();
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,27 +0,0 @@
|
|||||||
<gf-dialog-header
|
|
||||||
mat-dialog-title
|
|
||||||
title="Performance"
|
|
||||||
[deviceType]="data.deviceType"
|
|
||||||
(closeButtonClicked)="onClose()"
|
|
||||||
></gf-dialog-header>
|
|
||||||
|
|
||||||
<div mat-dialog-content>
|
|
||||||
<div class="container p-0">
|
|
||||||
<gf-line-chart
|
|
||||||
class="mb-4"
|
|
||||||
symbol="Performance"
|
|
||||||
[benchmarkDataItems]="benchmarkDataItems"
|
|
||||||
[historicalDataItems]="historicalDataItems"
|
|
||||||
[showGradient]="true"
|
|
||||||
[showLegend]="true"
|
|
||||||
[showXAxis]="true"
|
|
||||||
[showYAxis]="false"
|
|
||||||
></gf-line-chart>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<gf-dialog-footer
|
|
||||||
mat-dialog-actions
|
|
||||||
[deviceType]="data.deviceType"
|
|
||||||
(closeButtonClicked)="onClose()"
|
|
||||||
></gf-dialog-footer>
|
|
@ -1,28 +0,0 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { NgModule } from '@angular/core';
|
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
|
||||||
import { MatDialogModule } from '@angular/material/dialog';
|
|
||||||
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
|
|
||||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
|
||||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
|
||||||
|
|
||||||
import { GfDialogFooterModule } from '../dialog-footer/dialog-footer.module';
|
|
||||||
import { GfDialogHeaderModule } from '../dialog-header/dialog-header.module';
|
|
||||||
import { PerformanceChartDialog } from './performance-chart-dialog.component';
|
|
||||||
|
|
||||||
@NgModule({
|
|
||||||
declarations: [PerformanceChartDialog],
|
|
||||||
exports: [],
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
GfDialogFooterModule,
|
|
||||||
GfDialogHeaderModule,
|
|
||||||
GfLineChartModule,
|
|
||||||
GfValueModule,
|
|
||||||
MatButtonModule,
|
|
||||||
MatDialogModule,
|
|
||||||
NgxSkeletonLoaderModule
|
|
||||||
],
|
|
||||||
providers: []
|
|
||||||
})
|
|
||||||
export class GfPerformanceChartDialogModule {}
|
|
@ -1,12 +1,18 @@
|
|||||||
<div class="container p-0">
|
<div class="container p-0">
|
||||||
<div
|
<div class="no-gutters row">
|
||||||
class="no-gutters row"
|
<div
|
||||||
[ngClass]="{
|
class="flex-grow-1 status text-muted text-right"
|
||||||
'text-danger': isAllTimeLow,
|
[title]="
|
||||||
'text-success': isAllTimeHigh
|
hasError && !isLoading
|
||||||
}"
|
? 'Sorry! Our data provider partner is experiencing the hiccups.'
|
||||||
>
|
: ''
|
||||||
<div class="flex-grow-1"></div>
|
"
|
||||||
|
>
|
||||||
|
<ion-icon
|
||||||
|
*ngIf="hasError && !isLoading"
|
||||||
|
name="alert-circle-outline"
|
||||||
|
></ion-icon>
|
||||||
|
</div>
|
||||||
<div *ngIf="isLoading" class="align-items-center d-flex">
|
<div *ngIf="isLoading" class="align-items-center d-flex">
|
||||||
<ngx-skeleton-loader
|
<ngx-skeleton-loader
|
||||||
animation="pulse"
|
animation="pulse"
|
||||||
@ -20,6 +26,10 @@
|
|||||||
<div
|
<div
|
||||||
class="display-4 font-weight-bold m-0 text-center value-container"
|
class="display-4 font-weight-bold m-0 text-center value-container"
|
||||||
[hidden]="isLoading"
|
[hidden]="isLoading"
|
||||||
|
[ngClass]="{
|
||||||
|
'text-danger': isAllTimeLow,
|
||||||
|
'text-success': isAllTimeHigh
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<span #value id="value"></span>
|
<span #value id="value"></span>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,6 +1,10 @@
|
|||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
|
.status {
|
||||||
|
font-size: 1.33rem;
|
||||||
|
}
|
||||||
|
|
||||||
.value-container {
|
.value-container {
|
||||||
#value {
|
#value {
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
@ -19,6 +19,8 @@ import { isNumber } from 'lodash';
|
|||||||
})
|
})
|
||||||
export class PortfolioPerformanceComponent implements OnChanges, OnInit {
|
export class PortfolioPerformanceComponent implements OnChanges, OnInit {
|
||||||
@Input() baseCurrency: string;
|
@Input() baseCurrency: string;
|
||||||
|
@Input() deviceType: string;
|
||||||
|
@Input() hasError: boolean;
|
||||||
@Input() isAllTimeHigh: boolean;
|
@Input() isAllTimeHigh: boolean;
|
||||||
@Input() isAllTimeLow: boolean;
|
@Input() isAllTimeLow: boolean;
|
||||||
@Input() isLoading: boolean;
|
@Input() isLoading: boolean;
|
||||||
@ -44,7 +46,11 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
|
|||||||
this.unit = this.baseCurrency;
|
this.unit = this.baseCurrency;
|
||||||
|
|
||||||
new CountUp('value', this.performance?.currentValue, {
|
new CountUp('value', this.performance?.currentValue, {
|
||||||
decimalPlaces: 2,
|
decimalPlaces:
|
||||||
|
this.deviceType === 'mobile' &&
|
||||||
|
this.performance?.currentValue >= 100000
|
||||||
|
? 0
|
||||||
|
: 2,
|
||||||
duration: 1,
|
duration: 1,
|
||||||
separator: `'`
|
separator: `'`
|
||||||
}).start();
|
}).start();
|
||||||
|
@ -169,4 +169,18 @@
|
|||||||
></gf-value>
|
></gf-value>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col"><hr /></div>
|
||||||
|
</div>
|
||||||
|
<div class="row px-3 py-1">
|
||||||
|
<div class="d-flex flex-grow-1" i18n>Dividend</div>
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<gf-value
|
||||||
|
class="justify-content-end"
|
||||||
|
[currency]="baseCurrency"
|
||||||
|
[locale]="locale"
|
||||||
|
[value]="isLoading ? undefined : summary?.dividend"
|
||||||
|
></gf-value>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,5 +1,8 @@
|
|||||||
|
import { DataSource } from '@prisma/client';
|
||||||
|
|
||||||
export interface PositionDetailDialogParams {
|
export interface PositionDetailDialogParams {
|
||||||
baseCurrency: string;
|
baseCurrency: string;
|
||||||
|
dataSource: DataSource;
|
||||||
deviceType: string;
|
deviceType: string;
|
||||||
locale: string;
|
locale: string;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
|
@ -3,11 +3,13 @@ import {
|
|||||||
ChangeDetectorRef,
|
ChangeDetectorRef,
|
||||||
Component,
|
Component,
|
||||||
Inject,
|
Inject,
|
||||||
OnDestroy
|
OnDestroy,
|
||||||
|
OnInit
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
|
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||||
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
|
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
|
||||||
import { AssetSubClass } from '@prisma/client';
|
import { AssetSubClass } from '@prisma/client';
|
||||||
import { format, isSameMonth, isToday, parseISO } from 'date-fns';
|
import { format, isSameMonth, isToday, parseISO } from 'date-fns';
|
||||||
@ -23,7 +25,7 @@ import { PositionDetailDialogParams } from './interfaces/interfaces';
|
|||||||
templateUrl: 'position-detail-dialog.html',
|
templateUrl: 'position-detail-dialog.html',
|
||||||
styleUrls: ['./position-detail-dialog.component.scss']
|
styleUrls: ['./position-detail-dialog.component.scss']
|
||||||
})
|
})
|
||||||
export class PositionDetailDialog implements OnDestroy {
|
export class PositionDetailDialog implements OnDestroy, OnInit {
|
||||||
public assetSubClass: AssetSubClass;
|
public assetSubClass: AssetSubClass;
|
||||||
public averagePrice: number;
|
public averagePrice: number;
|
||||||
public benchmarkDataItems: LineChartItem[];
|
public benchmarkDataItems: LineChartItem[];
|
||||||
@ -39,6 +41,7 @@ export class PositionDetailDialog implements OnDestroy {
|
|||||||
public name: string;
|
public name: string;
|
||||||
public netPerformance: number;
|
public netPerformance: number;
|
||||||
public netPerformancePercent: number;
|
public netPerformancePercent: number;
|
||||||
|
public orders: OrderWithAccount[];
|
||||||
public quantity: number;
|
public quantity: number;
|
||||||
public quantityPrecision = 2;
|
public quantityPrecision = 2;
|
||||||
public symbol: string;
|
public symbol: string;
|
||||||
@ -52,9 +55,14 @@ export class PositionDetailDialog implements OnDestroy {
|
|||||||
private dataService: DataService,
|
private dataService: DataService,
|
||||||
public dialogRef: MatDialogRef<PositionDetailDialog>,
|
public dialogRef: MatDialogRef<PositionDetailDialog>,
|
||||||
@Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams
|
@Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams
|
||||||
) {
|
) {}
|
||||||
|
|
||||||
|
public ngOnInit(): void {
|
||||||
this.dataService
|
this.dataService
|
||||||
.fetchPositionDetail(data.symbol)
|
.fetchPositionDetail({
|
||||||
|
dataSource: this.data.dataSource,
|
||||||
|
symbol: this.data.symbol
|
||||||
|
})
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(
|
.subscribe(
|
||||||
({
|
({
|
||||||
@ -72,6 +80,7 @@ export class PositionDetailDialog implements OnDestroy {
|
|||||||
name,
|
name,
|
||||||
netPerformance,
|
netPerformance,
|
||||||
netPerformancePercent,
|
netPerformancePercent,
|
||||||
|
orders,
|
||||||
quantity,
|
quantity,
|
||||||
symbol,
|
symbol,
|
||||||
transactionCount,
|
transactionCount,
|
||||||
@ -104,6 +113,7 @@ export class PositionDetailDialog implements OnDestroy {
|
|||||||
this.name = name;
|
this.name = name;
|
||||||
this.netPerformance = netPerformance;
|
this.netPerformance = netPerformance;
|
||||||
this.netPerformancePercent = netPerformancePercent;
|
this.netPerformancePercent = netPerformancePercent;
|
||||||
|
this.orders = orders;
|
||||||
this.quantity = quantity;
|
this.quantity = quantity;
|
||||||
this.symbol = symbol;
|
this.symbol = symbol;
|
||||||
this.transactionCount = transactionCount;
|
this.transactionCount = transactionCount;
|
||||||
|
@ -124,6 +124,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<gf-activities-table
|
||||||
|
*ngIf="orders?.length > 0"
|
||||||
|
[activities]="orders"
|
||||||
|
[baseCurrency]="data.baseCurrency"
|
||||||
|
[deviceType]="data.deviceType"
|
||||||
|
[hasPermissionToCreateActivity]="false"
|
||||||
|
[hasPermissionToFilter]="false"
|
||||||
|
[hasPermissionToImportActivities]="false"
|
||||||
|
[hasPermissionToOpenDetails]="false"
|
||||||
|
[locale]="data.locale"
|
||||||
|
[showActions]="false"
|
||||||
|
[showSymbolColumn]="false"
|
||||||
|
></gf-activities-table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<gf-dialog-footer
|
<gf-dialog-footer
|
||||||
|
@ -2,12 +2,13 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatDialogModule } from '@angular/material/dialog';
|
import { MatDialogModule } from '@angular/material/dialog';
|
||||||
|
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
|
||||||
|
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
|
||||||
|
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
|
||||||
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
|
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
|
||||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||||
|
|
||||||
import { GfDialogFooterModule } from '../../dialog-footer/dialog-footer.module';
|
|
||||||
import { GfDialogHeaderModule } from '../../dialog-header/dialog-header.module';
|
|
||||||
import { PositionDetailDialog } from './position-detail-dialog.component';
|
import { PositionDetailDialog } from './position-detail-dialog.component';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
@ -15,6 +16,7 @@ import { PositionDetailDialog } from './position-detail-dialog.component';
|
|||||||
exports: [],
|
exports: [],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
GfActivitiesTableModule,
|
||||||
GfDialogFooterModule,
|
GfDialogFooterModule,
|
||||||
GfDialogHeaderModule,
|
GfDialogHeaderModule,
|
||||||
GfLineChartModule,
|
GfLineChartModule,
|
||||||
|
@ -3,7 +3,11 @@
|
|||||||
<a
|
<a
|
||||||
class="d-flex p-3 w-100"
|
class="d-flex p-3 w-100"
|
||||||
[routerLink]="[]"
|
[routerLink]="[]"
|
||||||
[queryParams]="{ positionDetailDialog: true, symbol: position?.symbol }"
|
[queryParams]="{
|
||||||
|
dataSource: position?.dataSource,
|
||||||
|
positionDetailDialog: true,
|
||||||
|
symbol: position?.symbol
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<div class="d-flex mr-2">
|
<div class="d-flex mr-2">
|
||||||
<gf-trend-indicator
|
<gf-trend-indicator
|
||||||
|
@ -5,14 +5,9 @@ import {
|
|||||||
OnDestroy,
|
OnDestroy,
|
||||||
OnInit
|
OnInit
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { MatDialog } from '@angular/material/dialog';
|
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
|
||||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||||
import { Position } from '@ghostfolio/common/interfaces';
|
import { Position } from '@ghostfolio/common/interfaces';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { takeUntil } from 'rxjs/operators';
|
|
||||||
|
|
||||||
import { PositionDetailDialog } from './position-detail-dialog/position-detail-dialog.component';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'gf-position',
|
selector: 'gf-position',
|
||||||
@ -32,23 +27,7 @@ export class PositionComponent implements OnDestroy, OnInit {
|
|||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
public constructor(
|
public constructor() {}
|
||||||
private dialog: MatDialog,
|
|
||||||
private route: ActivatedRoute,
|
|
||||||
private router: Router
|
|
||||||
) {
|
|
||||||
route.queryParams
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe((params) => {
|
|
||||||
if (
|
|
||||||
params['positionDetailDialog'] &&
|
|
||||||
params['symbol'] &&
|
|
||||||
params['symbol'] === this.position?.symbol
|
|
||||||
) {
|
|
||||||
this.openDialog();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public ngOnInit() {}
|
public ngOnInit() {}
|
||||||
|
|
||||||
@ -56,25 +35,4 @@ export class PositionComponent implements OnDestroy, OnInit {
|
|||||||
this.unsubscribeSubject.next();
|
this.unsubscribeSubject.next();
|
||||||
this.unsubscribeSubject.complete();
|
this.unsubscribeSubject.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
private openDialog(): void {
|
|
||||||
const dialogRef = this.dialog.open(PositionDetailDialog, {
|
|
||||||
autoFocus: false,
|
|
||||||
data: {
|
|
||||||
baseCurrency: this.baseCurrency,
|
|
||||||
deviceType: this.deviceType,
|
|
||||||
locale: this.locale,
|
|
||||||
symbol: this.position?.symbol
|
|
||||||
},
|
|
||||||
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
|
||||||
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
|
||||||
});
|
|
||||||
|
|
||||||
dialogRef
|
|
||||||
.afterClosed()
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe(() => {
|
|
||||||
this.router.navigate(['.'], { relativeTo: this.route });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -108,7 +108,7 @@
|
|||||||
}"
|
}"
|
||||||
(click)="
|
(click)="
|
||||||
!ignoreAssetSubClasses.includes(row.assetSubClass) &&
|
!ignoreAssetSubClasses.includes(row.assetSubClass) &&
|
||||||
onOpenPositionDialog({ symbol: row.symbol })
|
onOpenPositionDialog({ dataSource: row.dataSource, symbol: row.symbol })
|
||||||
"
|
"
|
||||||
></tr>
|
></tr>
|
||||||
</table>
|
</table>
|
||||||
@ -123,7 +123,12 @@
|
|||||||
}"
|
}"
|
||||||
></ngx-skeleton-loader>
|
></ngx-skeleton-loader>
|
||||||
|
|
||||||
<div *ngIf="dataSource.data.length === 0 && !isLoading" class="p-3 text-center">
|
<div
|
||||||
|
*ngIf="
|
||||||
|
dataSource.data.length === 0 && hasPermissionToCreateOrder && !isLoading
|
||||||
|
"
|
||||||
|
class="p-3 text-center"
|
||||||
|
>
|
||||||
<gf-no-transactions-info-indicator
|
<gf-no-transactions-info-indicator
|
||||||
[hasBorder]="false"
|
[hasBorder]="false"
|
||||||
></gf-no-transactions-info-indicator>
|
></gf-no-transactions-info-indicator>
|
||||||
|
@ -9,17 +9,13 @@ import {
|
|||||||
Output,
|
Output,
|
||||||
ViewChild
|
ViewChild
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { MatDialog } from '@angular/material/dialog';
|
|
||||||
import { MatPaginator } from '@angular/material/paginator';
|
import { MatPaginator } from '@angular/material/paginator';
|
||||||
import { MatSort } from '@angular/material/sort';
|
import { MatSort } from '@angular/material/sort';
|
||||||
import { MatTableDataSource } from '@angular/material/table';
|
import { MatTableDataSource } from '@angular/material/table';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||||
import { AssetClass, Order as OrderModel } from '@prisma/client';
|
import { AssetClass, DataSource, Order as OrderModel } from '@prisma/client';
|
||||||
import { Subject, Subscription } from 'rxjs';
|
import { Subject, Subscription } from 'rxjs';
|
||||||
import { takeUntil } from 'rxjs/operators';
|
|
||||||
|
|
||||||
import { PositionDetailDialog } from '../position/position-detail-dialog/position-detail-dialog.component';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'gf-positions-table',
|
selector: 'gf-positions-table',
|
||||||
@ -30,6 +26,7 @@ import { PositionDetailDialog } from '../position/position-detail-dialog/positio
|
|||||||
export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
|
export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
|
||||||
@Input() baseCurrency: string;
|
@Input() baseCurrency: string;
|
||||||
@Input() deviceType: string;
|
@Input() deviceType: string;
|
||||||
|
@Input() hasPermissionToCreateOrder: boolean;
|
||||||
@Input() locale: string;
|
@Input() locale: string;
|
||||||
@Input() positions: PortfolioPosition[];
|
@Input() positions: PortfolioPosition[];
|
||||||
|
|
||||||
@ -49,21 +46,7 @@ export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
public constructor(
|
public constructor(private router: Router) {}
|
||||||
private dialog: MatDialog,
|
|
||||||
private route: ActivatedRoute,
|
|
||||||
private router: Router
|
|
||||||
) {
|
|
||||||
this.routeQueryParams = route.queryParams
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe((params) => {
|
|
||||||
if (params['positionDetailDialog'] && params['symbol']) {
|
|
||||||
this.openPositionDialog({
|
|
||||||
symbol: params['symbol']
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public ngOnInit() {}
|
public ngOnInit() {}
|
||||||
|
|
||||||
@ -92,9 +75,15 @@ export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
this.dataSource.filter = filterValue.trim().toLowerCase();
|
this.dataSource.filter = filterValue.trim().toLowerCase();
|
||||||
}*/
|
}*/
|
||||||
|
|
||||||
public onOpenPositionDialog({ symbol }: { symbol: string }): void {
|
public onOpenPositionDialog({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}: {
|
||||||
|
dataSource: DataSource;
|
||||||
|
symbol: string;
|
||||||
|
}): void {
|
||||||
this.router.navigate([], {
|
this.router.navigate([], {
|
||||||
queryParams: { positionDetailDialog: true, symbol }
|
queryParams: { dataSource, symbol, positionDetailDialog: true }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -106,27 +95,6 @@ export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public openPositionDialog({ symbol }: { symbol: string }): void {
|
|
||||||
const dialogRef = this.dialog.open(PositionDetailDialog, {
|
|
||||||
autoFocus: false,
|
|
||||||
data: {
|
|
||||||
symbol,
|
|
||||||
baseCurrency: this.baseCurrency,
|
|
||||||
deviceType: this.deviceType,
|
|
||||||
locale: this.locale
|
|
||||||
},
|
|
||||||
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
|
||||||
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
|
||||||
});
|
|
||||||
|
|
||||||
dialogRef
|
|
||||||
.afterClosed()
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe(() => {
|
|
||||||
this.router.navigate(['.'], { relativeTo: this.route });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public ngOnDestroy() {
|
public ngOnDestroy() {
|
||||||
this.unsubscribeSubject.next();
|
this.unsubscribeSubject.next();
|
||||||
this.unsubscribeSubject.complete();
|
this.unsubscribeSubject.complete();
|
||||||
|
@ -7,12 +7,12 @@ import { MatPaginatorModule } from '@angular/material/paginator';
|
|||||||
import { MatSortModule } from '@angular/material/sort';
|
import { MatSortModule } from '@angular/material/sort';
|
||||||
import { MatTableModule } from '@angular/material/table';
|
import { MatTableModule } from '@angular/material/table';
|
||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { GfPositionDetailDialogModule } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.module';
|
||||||
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
|
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
|
||||||
import { GfNoTransactionsInfoModule } from '@ghostfolio/ui/no-transactions-info';
|
import { GfNoTransactionsInfoModule } from '@ghostfolio/ui/no-transactions-info';
|
||||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||||
|
|
||||||
import { GfPositionDetailDialogModule } from '../position/position-detail-dialog/position-detail-dialog.module';
|
|
||||||
import { GfSymbolIconModule } from '../symbol-icon/symbol-icon.module';
|
import { GfSymbolIconModule } from '../symbol-icon/symbol-icon.module';
|
||||||
import { PositionsTableComponent } from './positions-table.component';
|
import { PositionsTableComponent } from './positions-table.component';
|
||||||
|
|
||||||
|
@ -23,7 +23,10 @@
|
|||||||
[range]="range"
|
[range]="range"
|
||||||
></gf-position>
|
></gf-position>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<div *ngIf="!hasPositions" class="p-3 text-center">
|
<div
|
||||||
|
*ngIf="hasPermissionToCreateOrder && !hasPositions"
|
||||||
|
class="p-3 text-center"
|
||||||
|
>
|
||||||
<gf-no-transactions-info-indicator
|
<gf-no-transactions-info-indicator
|
||||||
[hasBorder]="false"
|
[hasBorder]="false"
|
||||||
></gf-no-transactions-info-indicator>
|
></gf-no-transactions-info-indicator>
|
||||||
|
@ -17,6 +17,7 @@ import { Position } from '@ghostfolio/common/interfaces';
|
|||||||
export class PositionsComponent implements OnChanges, OnInit {
|
export class PositionsComponent implements OnChanges, OnInit {
|
||||||
@Input() baseCurrency: string;
|
@Input() baseCurrency: string;
|
||||||
@Input() deviceType: string;
|
@Input() deviceType: string;
|
||||||
|
@Input() hasPermissionToCreateOrder: boolean;
|
||||||
@Input() locale: string;
|
@Input() locale: string;
|
||||||
@Input() positions: Position[];
|
@Input() positions: Position[];
|
||||||
@Input() range: string;
|
@Input() range: string;
|
||||||
|
@ -1,7 +1,10 @@
|
|||||||
<div class="container p-0">
|
<div class="container p-0">
|
||||||
<div class="row no-gutters">
|
<div class="row no-gutters">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<mat-card *ngIf="rules === null" class="my-2 text-center">
|
<mat-card
|
||||||
|
*ngIf="hasPermissionToCreateOrder && rules === null"
|
||||||
|
class="my-2 text-center"
|
||||||
|
>
|
||||||
<gf-no-transactions-info-indicator
|
<gf-no-transactions-info-indicator
|
||||||
[hasBorder]="false"
|
[hasBorder]="false"
|
||||||
></gf-no-transactions-info-indicator>
|
></gf-no-transactions-info-indicator>
|
||||||
|
@ -8,6 +8,7 @@ import { PortfolioReportRule } from '@ghostfolio/common/interfaces';
|
|||||||
styleUrls: ['./rules.component.scss']
|
styleUrls: ['./rules.component.scss']
|
||||||
})
|
})
|
||||||
export class RulesComponent {
|
export class RulesComponent {
|
||||||
|
@Input() hasPermissionToCreateOrder: boolean;
|
||||||
@Input() rules: PortfolioReportRule;
|
@Input() rules: PortfolioReportRule;
|
||||||
|
|
||||||
public constructor() {}
|
public constructor() {}
|
||||||
|
@ -8,8 +8,7 @@ import {
|
|||||||
Output
|
Output
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { FormControl } from '@angular/forms';
|
import { FormControl } from '@angular/forms';
|
||||||
|
import { ToggleOption } from '@ghostfolio/common/types';
|
||||||
import { ToggleOption } from './interfaces/toggle-option.type';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'gf-toggle',
|
selector: 'gf-toggle',
|
||||||
|
@ -16,6 +16,8 @@ import { UserService } from '../services/user/user.service';
|
|||||||
export class AuthGuard implements CanActivate {
|
export class AuthGuard implements CanActivate {
|
||||||
private static PUBLIC_PAGE_ROUTES = [
|
private static PUBLIC_PAGE_ROUTES = [
|
||||||
'/about',
|
'/about',
|
||||||
|
'/about/changelog',
|
||||||
|
'/blog',
|
||||||
'/de/blog',
|
'/de/blog',
|
||||||
'/en/blog',
|
'/en/blog',
|
||||||
'/p',
|
'/p',
|
||||||
|
@ -4,8 +4,7 @@ import {
|
|||||||
HttpEvent,
|
HttpEvent,
|
||||||
HttpHandler,
|
HttpHandler,
|
||||||
HttpInterceptor,
|
HttpInterceptor,
|
||||||
HttpRequest,
|
HttpRequest
|
||||||
HttpResponse
|
|
||||||
} from '@angular/common/http';
|
} from '@angular/common/http';
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import {
|
import {
|
||||||
@ -43,26 +42,6 @@ export class HttpResponseInterceptor implements HttpInterceptor {
|
|||||||
): Observable<HttpEvent<any>> {
|
): Observable<HttpEvent<any>> {
|
||||||
return next.handle(request).pipe(
|
return next.handle(request).pipe(
|
||||||
tap((event: HttpEvent<any>) => {
|
tap((event: HttpEvent<any>) => {
|
||||||
if (event instanceof HttpResponse) {
|
|
||||||
if (event.status === StatusCodes.ACCEPTED) {
|
|
||||||
if (!this.snackBarRef) {
|
|
||||||
this.snackBarRef = this.snackBar.open(
|
|
||||||
'Sorry! Our data provider partner is experiencing a mild case of the hiccups ;(',
|
|
||||||
'Try again?',
|
|
||||||
{ duration: 6000 }
|
|
||||||
);
|
|
||||||
|
|
||||||
this.snackBarRef.afterDismissed().subscribe(() => {
|
|
||||||
this.snackBarRef = undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.snackBarRef.onAction().subscribe(() => {
|
|
||||||
window.location.reload();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return event;
|
return event;
|
||||||
}),
|
}),
|
||||||
catchError((error: HttpErrorResponse) => {
|
catchError((error: HttpErrorResponse) => {
|
||||||
|
@ -35,8 +35,8 @@
|
|||||||
new feature, please join the Ghostfolio
|
new feature, please join the Ghostfolio
|
||||||
<a
|
<a
|
||||||
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
||||||
title="Join the Ghostfolio Slack channel"
|
title="Join the Ghostfolio Slack community"
|
||||||
>Slack channel</a
|
>Slack community</a
|
||||||
>, tweet to
|
>, tweet to
|
||||||
<a
|
<a
|
||||||
href="https://twitter.com/ghostfolio_"
|
href="https://twitter.com/ghostfolio_"
|
||||||
@ -108,12 +108,7 @@
|
|||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<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">
|
||||||
<h3
|
<h3 class="mb-0">{{ statistics?.activeUsers1d || '-' }}</h3>
|
||||||
class="mb-0"
|
|
||||||
[hidden]="statistics?.activeUsers1d === undefined"
|
|
||||||
>
|
|
||||||
{{ statistics?.activeUsers1d || '-' }}
|
|
||||||
</h3>
|
|
||||||
<div class="h6 mb-0">
|
<div class="h6 mb-0">
|
||||||
<span i18n>Active Users</span> <small class="text-muted"
|
<span i18n>Active Users</span> <small class="text-muted"
|
||||||
>(Last 24 hours)</small
|
>(Last 24 hours)</small
|
||||||
@ -121,35 +116,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-xs-12 col-md-4 my-2">
|
<div class="col-xs-12 col-md-4 my-2">
|
||||||
<h3
|
<h3 class="mb-0">{{ statistics?.newUsers30d ?? '-' }}</h3>
|
||||||
class="mb-0"
|
|
||||||
[hidden]="statistics?.activeUsers7d === undefined"
|
|
||||||
>
|
|
||||||
{{ statistics?.activeUsers7d ?? '-' }}
|
|
||||||
</h3>
|
|
||||||
<div class="h6 mb-0">
|
|
||||||
<span i18n>Active Users</span> <small class="text-muted"
|
|
||||||
>(Last 7 days)</small
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-xs-12 col-md-4 my-2">
|
|
||||||
<h3
|
|
||||||
class="mb-0"
|
|
||||||
[hidden]="statistics?.activeUsers30d === undefined"
|
|
||||||
>
|
|
||||||
{{ statistics?.activeUsers30d ?? '-' }}
|
|
||||||
</h3>
|
|
||||||
<div class="h6 mb-0">
|
|
||||||
<span i18n>Active Users</span> <small class="text-muted"
|
|
||||||
>(Last 30 days)</small
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-xs-12 col-md-4 my-2">
|
|
||||||
<h3 class="mb-0" [hidden]="statistics?.newUsers30d === undefined">
|
|
||||||
{{ statistics?.newUsers30d ?? '-' }}
|
|
||||||
</h3>
|
|
||||||
<div class="h6 mb-0">
|
<div class="h6 mb-0">
|
||||||
<span i18n>New Users</span> <small class="text-muted"
|
<span i18n>New Users</span> <small class="text-muted"
|
||||||
>(Last 30 days)</small
|
>(Last 30 days)</small
|
||||||
@ -157,69 +124,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-xs-12 col-md-4 my-2">
|
<div class="col-xs-12 col-md-4 my-2">
|
||||||
<h3
|
<h3 class="mb-0">{{ statistics?.activeUsers30d ?? '-' }}</h3>
|
||||||
class="mb-0"
|
<div class="h6 mb-0">
|
||||||
[hidden]="statistics?.gitHubContributors === undefined"
|
<span i18n>Active Users</span> <small class="text-muted"
|
||||||
>
|
>(Last 30 days)</small
|
||||||
{{ statistics?.gitHubContributors ?? '-' }}
|
>
|
||||||
</h3>
|
</div>
|
||||||
<div class="h6 mb-0" i18n>Contributors on GitHub</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-xs-12 col-md-4 my-2">
|
<div class="col-xs-12 col-md-4 my-2">
|
||||||
<h3
|
|
||||||
class="mb-0"
|
|
||||||
[hidden]="statistics?.gitHubStargazers === undefined"
|
|
||||||
>
|
|
||||||
{{ statistics?.gitHubStargazers ?? '-' }}
|
|
||||||
</h3>
|
|
||||||
<div class="h6 mb-0" i18n>Stars on GitHub</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</mat-card-content>
|
|
||||||
</mat-card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div *ngIf="hasPermissionForBlog" class="mb-5 row">
|
|
||||||
<div class="col">
|
|
||||||
<h3 class="mb-3 text-center" i18n>Blog</h3>
|
|
||||||
<mat-card class="blog-container">
|
|
||||||
<mat-card-content>
|
|
||||||
<div class="container p-0">
|
|
||||||
<div class="flex-nowrap mb-3 no-gutters row">
|
|
||||||
<a
|
<a
|
||||||
class="d-flex w-100"
|
class="d-block"
|
||||||
[routerLink]="['/en', 'blog', '2021', '07', 'hello-ghostfolio']"
|
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
||||||
>
|
>
|
||||||
<div class="flex-grow-1">
|
<h3 class="mb-0">
|
||||||
<div class="h6 m-0 text-truncate">Hello Ghostfolio</div>
|
{{ statistics?.slackCommunityUsers ?? '-' }}
|
||||||
<div class="d-flex text-muted">31.07.2021</div>
|
</h3>
|
||||||
</div>
|
<div class="h6 mb-0" i18n>Users in Slack community</div>
|
||||||
<div class="align-items-center d-flex">
|
|
||||||
<ion-icon
|
|
||||||
class="chevron text-muted"
|
|
||||||
name="chevron-forward-outline"
|
|
||||||
size="small"
|
|
||||||
></ion-icon>
|
|
||||||
</div>
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-nowrap no-gutters row">
|
<div class="col-xs-12 col-md-4 my-2">
|
||||||
<a
|
<a
|
||||||
class="d-flex w-100"
|
class="d-block"
|
||||||
[routerLink]="['/de', 'blog', '2021', '07', 'hallo-ghostfolio']"
|
href="https://github.com/ghostfolio/ghostfolio/graphs/contributors"
|
||||||
>
|
>
|
||||||
<div class="flex-grow-1">
|
<h3 class="mb-0">
|
||||||
<div class="h6 m-0 text-truncate">Hallo Ghostfolio</div>
|
{{ statistics?.gitHubContributors ?? '-' }}
|
||||||
<div class="d-flex text-muted">31.07.2021</div>
|
</h3>
|
||||||
</div>
|
<div class="h6 mb-0" i18n>Contributors on GitHub</div>
|
||||||
<div class="align-items-center d-flex">
|
</a>
|
||||||
<ion-icon
|
</div>
|
||||||
class="chevron text-muted"
|
<div class="col-xs-12 col-md-4 my-2">
|
||||||
name="chevron-forward-outline"
|
<a
|
||||||
size="small"
|
class="d-block"
|
||||||
></ion-icon>
|
href="https://github.com/ghostfolio/ghostfolio/stargazers"
|
||||||
</div>
|
>
|
||||||
|
<h3 class="mb-0">{{ statistics?.gitHubStargazers ?? '-' }}</h3>
|
||||||
|
<div class="h6 mb-0" i18n>Stars on GitHub</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -228,25 +168,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-5 row">
|
|
||||||
<div class="col">
|
|
||||||
<h3 class="mb-3 text-center" i18n>Changelog</h3>
|
|
||||||
<mat-card class="changelog">
|
|
||||||
<mat-card-content>
|
|
||||||
<markdown [src]="'assets/CHANGELOG.md'"></markdown>
|
|
||||||
</mat-card-content>
|
|
||||||
</mat-card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div
|
||||||
<h3 class="mb-3 text-center" i18n>License</h3>
|
class="col-md-6 col-xs-12 my-2"
|
||||||
<mat-card>
|
[ngClass]="{ 'offset-md-3': !hasPermissionForBlog }"
|
||||||
<mat-card-content>
|
>
|
||||||
<markdown [src]="'assets/LICENSE'"></markdown>
|
<a
|
||||||
</mat-card-content>
|
class="py-2 w-100"
|
||||||
</mat-card>
|
color="primary"
|
||||||
|
i18n
|
||||||
|
mat-stroked-button
|
||||||
|
[routerLink]="['/about', 'changelog']"
|
||||||
|
>Changelog & License</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="hasPermissionForBlog" class="col-md-6 col-xs-12 my-2">
|
||||||
|
<a
|
||||||
|
class="py-2 w-100"
|
||||||
|
color="primary"
|
||||||
|
i18n
|
||||||
|
mat-flat-button
|
||||||
|
[routerLink]="['/blog']"
|
||||||
|
>Blog</a
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -2,7 +2,6 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatCardModule } from '@angular/material/card';
|
import { MatCardModule } from '@angular/material/card';
|
||||||
import { MarkdownModule } from 'ngx-markdown';
|
|
||||||
|
|
||||||
import { AboutPageRoutingModule } from './about-page-routing.module';
|
import { AboutPageRoutingModule } from './about-page-routing.module';
|
||||||
import { AboutPageComponent } from './about-page.component';
|
import { AboutPageComponent } from './about-page.component';
|
||||||
@ -13,7 +12,6 @@ import { AboutPageComponent } from './about-page.component';
|
|||||||
imports: [
|
imports: [
|
||||||
AboutPageRoutingModule,
|
AboutPageRoutingModule,
|
||||||
CommonModule,
|
CommonModule,
|
||||||
MarkdownModule.forChild(),
|
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatCardModule
|
MatCardModule
|
||||||
],
|
],
|
||||||
|
@ -2,13 +2,8 @@
|
|||||||
color: rgb(var(--dark-primary-text));
|
color: rgb(var(--dark-primary-text));
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
a {
|
|
||||||
color: rgb(var(--dark-primary-text));
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-card {
|
.mat-card {
|
||||||
&.about-container,
|
&.about-container {
|
||||||
&.changelog {
|
|
||||||
a {
|
a {
|
||||||
color: rgba(var(--palette-primary-500), 1);
|
color: rgba(var(--palette-primary-500), 1);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@ -19,29 +14,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.changelog {
|
|
||||||
::ng-deep {
|
|
||||||
markdown {
|
|
||||||
h1,
|
|
||||||
p {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: 18px;
|
|
||||||
|
|
||||||
&:not(:first-of-type) {
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-size: 15px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.independent-and-bootstrapped-logo {
|
.independent-and-bootstrapped-logo {
|
||||||
background-image: url('/assets/bootstrapped-dark.svg');
|
background-image: url('/assets/bootstrapped-dark.svg');
|
||||||
background-position: center;
|
background-position: center;
|
||||||
@ -57,10 +29,6 @@
|
|||||||
:host-context(.is-dark-theme) {
|
:host-context(.is-dark-theme) {
|
||||||
color: rgb(var(--light-primary-text));
|
color: rgb(var(--light-primary-text));
|
||||||
|
|
||||||
a {
|
|
||||||
color: rgb(var(--light-primary-text));
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-card {
|
.mat-card {
|
||||||
.independent-and-bootstrapped-logo {
|
.independent-and-bootstrapped-logo {
|
||||||
background-image: url('/assets/bootstrapped-light.svg');
|
background-image: url('/assets/bootstrapped-light.svg');
|
||||||
|
@ -0,0 +1,15 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { RouterModule, Routes } from '@angular/router';
|
||||||
|
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
|
||||||
|
|
||||||
|
import { ChangelogPageComponent } from './changelog-page.component';
|
||||||
|
|
||||||
|
const routes: Routes = [
|
||||||
|
{ path: '', component: ChangelogPageComponent, canActivate: [AuthGuard] }
|
||||||
|
];
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [RouterModule.forChild(routes)],
|
||||||
|
exports: [RouterModule]
|
||||||
|
})
|
||||||
|
export class ChangelogPageRoutingModule {}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user