Compare commits
142 Commits
Author | SHA1 | Date | |
---|---|---|---|
56a5664c87 | |||
823501f43e | |||
331ac7ded2 | |||
92b44ba658 | |||
982ba7377a | |||
4bbd17a37a | |||
6a7def6c48 | |||
ea219f0b88 | |||
23b2e03923 | |||
04e6518226 | |||
72dbe00091 | |||
9834c52739 | |||
c47578bd3e | |||
9e4a49d811 | |||
a3a98c68a5 | |||
21570cca19 | |||
71a3115fc6 | |||
de83dc7b84 | |||
4a0695613e | |||
328d814922 | |||
d23addb673 | |||
fb15cebb64 | |||
9c51a257ae | |||
f9b9dc32cb | |||
e7194ef3ce | |||
ec5523b459 | |||
c8c21a016a | |||
9821b7f8f0 | |||
ed731afc66 | |||
ff15d5cbc4 | |||
3c4949de35 | |||
bd0e53525b | |||
cbdb68e2f8 | |||
8571709014 | |||
e7ef1d426e | |||
39cba0a8eb | |||
a90c314e30 | |||
47d71405e1 | |||
5e9cecc6c1 | |||
fb9e66318f | |||
b8194eb64f | |||
cbb81916ee | |||
9b1e9397a8 | |||
b779964adb | |||
409afac2a9 | |||
e0a4e16ea1 | |||
dc84abdc0a | |||
b031b028f1 | |||
3b7e0a0106 | |||
ea66081073 | |||
602a770a09 | |||
e522722aa6 | |||
03ca5d7663 | |||
136563c949 | |||
948c45c602 | |||
e0be792e46 | |||
c3d010135f | |||
d6a16a6093 | |||
34c13c80ec | |||
f65a108436 | |||
993f066e08 | |||
852902d1ab | |||
ee89822bfe | |||
e0435e5cad | |||
e2c23703dc | |||
1226c26a9d | |||
fdc89f7182 | |||
1e368d6e2d | |||
04e03bd080 | |||
66e7ad3fd2 | |||
b4dc21dd61 | |||
8a482e63b9 | |||
aabfb39e8f | |||
cdc8faff7f | |||
7b696e39de | |||
c88ad2c225 | |||
fbc9269abf | |||
cbe079ae66 | |||
8e4ee7feea | |||
f1b3c61675 | |||
24dc312367 | |||
7ac7442f73 | |||
099571437e | |||
7dac059a55 | |||
48fbeda72d | |||
19007cdc34 | |||
5037393866 | |||
ddf24163b4 | |||
b26521c4bd | |||
cfee6c1ddd | |||
19bcd601d1 | |||
836df69e68 | |||
dd86adcea1 | |||
4f7628921d | |||
88f0cb095d | |||
7538133d09 | |||
50b280c5a6 | |||
67606e4026 | |||
9de56c32ac | |||
5c0f710563 | |||
1c65599a16 | |||
61e667213e | |||
ba47212057 | |||
f0c6517019 | |||
80ba112bc0 | |||
40696b425e | |||
6dbdf23a68 | |||
cdcbe3ab71 | |||
6996e5a140 | |||
be8d60968d | |||
d53e5c4da5 | |||
a3a9957196 | |||
9072cbdba1 | |||
120b691336 | |||
bd4ad76953 | |||
94d56f553f | |||
ecdd325228 | |||
51fbc538ca | |||
39a76f7f40 | |||
e4d325daab | |||
b765df65d6 | |||
c7b7efae3b | |||
be5b58f49a | |||
91c748c7ad | |||
ecfe694f0b | |||
1491bf7f76 | |||
b3b9a051c3 | |||
bf1146bfd6 | |||
0774ca91a1 | |||
f403807f2d | |||
f22991b090 | |||
1135a5b335 | |||
d9ea255c17 | |||
2c19d8c8e7 | |||
db090229ce | |||
fbe590ddb9 | |||
0d65136a9e | |||
dea87cc3cf | |||
a062a3cee4 | |||
5b1b207a6f | |||
63cc7b2871 | |||
3986e8f879 |
@ -44,7 +44,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"@typescript-eslint/member-ordering": "error",
|
"@typescript-eslint/member-ordering": "error",
|
||||||
"@typescript-eslint/naming-convention": "error",
|
"@typescript-eslint/naming-convention": "off",
|
||||||
"@typescript-eslint/no-empty-function": "off",
|
"@typescript-eslint/no-empty-function": "off",
|
||||||
"@typescript-eslint/no-empty-interface": "error",
|
"@typescript-eslint/no-empty-interface": "error",
|
||||||
"@typescript-eslint/no-inferrable-types": [
|
"@typescript-eslint/no-inferrable-types": [
|
||||||
|
102
CHANGELOG.md
102
CHANGELOG.md
@ -5,6 +5,108 @@ 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.31.0 - 01.08.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added more data points to the chart
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Refactored the core engine for the calculations
|
||||||
|
|
||||||
|
## 1.30.0 - 31.07.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the date range component to the positions tab
|
||||||
|
- Added a blog
|
||||||
|
|
||||||
|
## 1.29.0 - 26.07.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Introduced tabs on the home page
|
||||||
|
- Changed the menu icon if the menu is open on mobile
|
||||||
|
|
||||||
|
## 1.28.0 - 24.07.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the data management by symbol profile data
|
||||||
|
- Added a currency attribute to the symbol profile model
|
||||||
|
- Added a positions button on the home page which scrolls into the view
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the style of the active page in the navigation on desktop
|
||||||
|
- Removed the footer for users
|
||||||
|
- Extended the _Zen Mode_ by positions
|
||||||
|
- Improved the _Create Account_ message in the _Live Demo_
|
||||||
|
|
||||||
|
## 1.27.0 - 18.07.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the onboarding
|
||||||
|
- Flow of creating a new account
|
||||||
|
- Info message to add the first transaction
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the chart on the landing page
|
||||||
|
- Fixed the url to the _Fear & Greed Index_ on the resources page
|
||||||
|
|
||||||
|
## 1.26.0 - 17.07.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the import functionality for transactions
|
||||||
|
- Added the `robots.txt` file
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the styling of the current pricing plan
|
||||||
|
- Improved the styling of the transaction type badge
|
||||||
|
- Set the public _Stripe_ key dynamically
|
||||||
|
- Upgraded `angular-material-css-vars` from version `2.0.0` to `2.1.0`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the warn color (button) of the theme
|
||||||
|
|
||||||
|
## 1.25.0 - 11.07.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the export functionality for transactions
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Respected the cash balance on the analysis page
|
||||||
|
- Improved the settings selectors on the account page
|
||||||
|
- Harmonized the slogan to "Open Source Wealth Management Software"
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed rendering of currency and platform in dialogs (account and transaction)
|
||||||
|
- Fixed an issue in the calculation of the average buy prices in the position detail chart
|
||||||
|
|
||||||
|
## 1.24.0 - 07.07.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the total value in the create or edit transaction dialog
|
||||||
|
- Added a balance attribute to the account model
|
||||||
|
- Calculated the total balance (cash)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Upgraded `@angular/cdk` and `@angular/material` from version `11.0.4` to `12.0.6`
|
||||||
|
- Upgraded `@nestjs` dependencies
|
||||||
|
- Upgraded `angular-material-css-vars` from version `1.2.0` to `2.0.0`
|
||||||
|
- Upgraded `Nx` from version `12.3.6` to `12.5.4`
|
||||||
|
|
||||||
## 1.23.1 - 03.07.2021
|
## 1.23.1 - 03.07.2021
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
25
README.md
25
README.md
@ -1,13 +1,22 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
<a href="https://ghostfol.io">
|
||||||
|
<img
|
||||||
|
alt="Ghostfolio Logo"
|
||||||
|
src="https://avatars.githubusercontent.com/u/82473144?s=200"
|
||||||
|
width="100"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
|
||||||
<h1>Ghostfolio</h1>
|
<h1>Ghostfolio</h1>
|
||||||
<p>
|
<p>
|
||||||
<strong>Open Source Portfolio Tracker</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"><strong>Live Demo</strong></a>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<img src="https://img.shields.io/badge/contributions-welcome-orange.svg"/>
|
<a href="#contributing">
|
||||||
|
<img src="https://img.shields.io/badge/contributions-welcome-orange.svg"/></a>
|
||||||
<a href="https://travis-ci.com/github/ghostfolio/ghostfolio" rel="nofollow">
|
<a href="https://travis-ci.com/github/ghostfolio/ghostfolio" rel="nofollow">
|
||||||
<img src="https://travis-ci.com/ghostfolio/ghostfolio.svg?branch=main" alt="Build Status"/></a>
|
<img src="https://travis-ci.com/ghostfolio/ghostfolio.svg?branch=main" alt="Build Status"/></a>
|
||||||
<a href="https://www.gnu.org/licenses/agpl-3.0" rel="nofollow">
|
<a href="https://www.gnu.org/licenses/agpl-3.0" rel="nofollow">
|
||||||
@ -15,7 +24,13 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
**Ghostfolio** is an open source portfolio tracker based on web technology. The software empowers busy folks to have a sharp look of their financial assets and to make solid, data-driven investment decisions by evaluating automated static portfolio analysis rules.
|
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of their wealth like stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions.
|
||||||
|
|
||||||
|
## Ghostfolio Premium
|
||||||
|
|
||||||
|
Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
|
||||||
|
|
||||||
|
If you prefer to run Ghostfolio on your own infrastructure, please find the source code and further instructions here on _GitHub_.
|
||||||
|
|
||||||
## Why Ghostfolio?
|
## Why Ghostfolio?
|
||||||
|
|
||||||
@ -79,8 +94,8 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
|
|||||||
1. Run `yarn setup:database` to initialize the database schema and populate your database with (example) data
|
1. Run `yarn setup:database` to initialize the database schema and populate your database with (example) data
|
||||||
1. Start server and client (see [_Development_](#Development))
|
1. Start server and client (see [_Development_](#Development))
|
||||||
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
|
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
|
||||||
1. Go to the _Admin Control Panel_ and press _Gather All Data_ to fetch historical data
|
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
||||||
1. Press _Sign out_ and check out the _Live Demo_
|
1. Click _Sign out_ and check out the _Live Demo_
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
@ -103,6 +103,11 @@
|
|||||||
"input": "",
|
"input": "",
|
||||||
"output": "./"
|
"output": "./"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"glob": "robots.txt",
|
||||||
|
"input": "apps/client/src/assets",
|
||||||
|
"output": "./"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"glob": "sitemap.xml",
|
"glob": "sitemap.xml",
|
||||||
"input": "apps/client/src/assets",
|
"input": "apps/client/src/assets",
|
||||||
|
@ -11,5 +11,6 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||||
coverageDirectory: '../../coverage/apps/api',
|
coverageDirectory: '../../coverage/apps/api',
|
||||||
testTimeout: 10000
|
testTimeout: 10000,
|
||||||
|
testEnvironment: 'node'
|
||||||
};
|
};
|
||||||
|
@ -4,6 +4,7 @@ import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alph
|
|||||||
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 { 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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
@ -20,6 +21,7 @@ import { AccountService } from './account.service';
|
|||||||
AlphaVantageService,
|
AlphaVantageService,
|
||||||
ConfigurationService,
|
ConfigurationService,
|
||||||
DataProviderService,
|
DataProviderService,
|
||||||
|
ExchangeRateDataService,
|
||||||
GhostfolioScraperApiService,
|
GhostfolioScraperApiService,
|
||||||
ImpersonationService,
|
ImpersonationService,
|
||||||
PrismaService,
|
PrismaService,
|
||||||
|
@ -1,12 +1,15 @@
|
|||||||
|
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 { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Account, Order, Prisma } from '@prisma/client';
|
import { Account, Currency, Order, Prisma } from '@prisma/client';
|
||||||
|
|
||||||
import { RedisCacheService } from '../redis-cache/redis-cache.service';
|
import { RedisCacheService } from '../redis-cache/redis-cache.service';
|
||||||
|
import { CashDetails } from './interfaces/cash-details.interface';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AccountService {
|
export class AccountService {
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly redisCacheService: RedisCacheService,
|
private readonly redisCacheService: RedisCacheService,
|
||||||
private prisma: PrismaService
|
private prisma: PrismaService
|
||||||
) {}
|
) {}
|
||||||
@ -73,6 +76,27 @@ export class AccountService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getCashDetails(
|
||||||
|
aUserId: string,
|
||||||
|
aCurrency: Currency
|
||||||
|
): Promise<CashDetails> {
|
||||||
|
let totalCashBalance = 0;
|
||||||
|
|
||||||
|
const accounts = await this.accounts({
|
||||||
|
where: { userId: aUserId }
|
||||||
|
});
|
||||||
|
|
||||||
|
accounts.forEach((account) => {
|
||||||
|
totalCashBalance += this.exchangeRateDataService.toCurrency(
|
||||||
|
account.balance,
|
||||||
|
account.currency,
|
||||||
|
aCurrency
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { accounts, balance: totalCashBalance };
|
||||||
|
}
|
||||||
|
|
||||||
public async updateAccount(
|
public async updateAccount(
|
||||||
params: {
|
params: {
|
||||||
where: Prisma.AccountWhereUniqueInput;
|
where: Prisma.AccountWhereUniqueInput;
|
||||||
|
@ -1,10 +1,16 @@
|
|||||||
import { AccountType } from '@prisma/client';
|
import { AccountType, Currency } from '@prisma/client';
|
||||||
import { IsString, ValidateIf } from 'class-validator';
|
import { IsNumber, IsString, ValidateIf } from 'class-validator';
|
||||||
|
|
||||||
export class CreateAccountDto {
|
export class CreateAccountDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
accountType: AccountType;
|
accountType: AccountType;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
balance: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
currency: Currency;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
|
@ -0,0 +1,6 @@
|
|||||||
|
import { Account } from '@prisma/client';
|
||||||
|
|
||||||
|
export interface CashDetails {
|
||||||
|
accounts: Account[];
|
||||||
|
balance: number;
|
||||||
|
}
|
@ -1,10 +1,16 @@
|
|||||||
import { AccountType } from '@prisma/client';
|
import { AccountType, Currency } from '@prisma/client';
|
||||||
import { IsString, ValidateIf } from 'class-validator';
|
import { IsNumber, IsString, ValidateIf } from 'class-validator';
|
||||||
|
|
||||||
export class UpdateAccountDto {
|
export class UpdateAccountDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
accountType: AccountType;
|
accountType: AccountType;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
balance: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
currency: Currency;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
|
@ -61,8 +61,29 @@ export class AdminController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.dataGatheringService.gatherProfileData();
|
||||||
this.dataGatheringService.gatherMax();
|
this.dataGatheringService.gatherMax();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('gather/profile-data')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async gatherProfileData(): Promise<void> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
getPermissions(this.request.user.role),
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dataGatheringService.gatherProfileData();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -22,7 +22,10 @@ import { AdminModule } from './admin/admin.module';
|
|||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { CacheModule } from './cache/cache.module';
|
import { CacheModule } from './cache/cache.module';
|
||||||
|
import { CoreModule } from './core/core.module';
|
||||||
import { ExperimentalModule } from './experimental/experimental.module';
|
import { ExperimentalModule } from './experimental/experimental.module';
|
||||||
|
import { ExportModule } from './export/export.module';
|
||||||
|
import { ImportModule } from './import/import.module';
|
||||||
import { InfoModule } from './info/info.module';
|
import { InfoModule } from './info/info.module';
|
||||||
import { OrderModule } from './order/order.module';
|
import { OrderModule } from './order/order.module';
|
||||||
import { PortfolioModule } from './portfolio/portfolio.module';
|
import { PortfolioModule } from './portfolio/portfolio.module';
|
||||||
@ -40,7 +43,10 @@ import { UserModule } from './user/user.module';
|
|||||||
AuthModule,
|
AuthModule,
|
||||||
CacheModule,
|
CacheModule,
|
||||||
ConfigModule.forRoot(),
|
ConfigModule.forRoot(),
|
||||||
|
CoreModule,
|
||||||
ExperimentalModule,
|
ExperimentalModule,
|
||||||
|
ExportModule,
|
||||||
|
ImportModule,
|
||||||
InfoModule,
|
InfoModule,
|
||||||
OrderModule,
|
OrderModule,
|
||||||
PortfolioModule,
|
PortfolioModule,
|
||||||
|
30
apps/api/src/app/core/core.module.ts
Normal file
30
apps/api/src/app/core/core.module.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
||||||
|
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
||||||
|
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { CurrentRateService } from './current-rate.service';
|
||||||
|
import { MarketDataService } from './market-data.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [],
|
||||||
|
controllers: [],
|
||||||
|
providers: [
|
||||||
|
AlphaVantageService,
|
||||||
|
ConfigurationService,
|
||||||
|
CurrentRateService,
|
||||||
|
DataProviderService,
|
||||||
|
ExchangeRateDataService,
|
||||||
|
GhostfolioScraperApiService,
|
||||||
|
MarketDataService,
|
||||||
|
PrismaService,
|
||||||
|
RakutenRapidApiService,
|
||||||
|
YahooFinanceService
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class CoreModule {}
|
129
apps/api/src/app/core/current-rate.service.spec.ts
Normal file
129
apps/api/src/app/core/current-rate.service.spec.ts
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import { CurrentRateService } from '@ghostfolio/api/app/core/current-rate.service';
|
||||||
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
|
import { Currency, MarketData } from '@prisma/client';
|
||||||
|
|
||||||
|
import { MarketDataService } from './market-data.service';
|
||||||
|
|
||||||
|
jest.mock('./market-data.service', () => {
|
||||||
|
return {
|
||||||
|
MarketDataService: jest.fn().mockImplementation(() => {
|
||||||
|
return {
|
||||||
|
get: (date: Date, symbol: string) => {
|
||||||
|
return Promise.resolve<MarketData>({
|
||||||
|
date,
|
||||||
|
symbol,
|
||||||
|
createdAt: date,
|
||||||
|
id: 'aefcbe3a-ee10-4c4f-9f2d-8ffad7b05584',
|
||||||
|
marketPrice: 1847.839966
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getRange: ({
|
||||||
|
dateRangeEnd,
|
||||||
|
dateRangeStart,
|
||||||
|
symbols
|
||||||
|
}: {
|
||||||
|
dateRangeEnd: Date;
|
||||||
|
dateRangeStart: Date;
|
||||||
|
symbols: string[];
|
||||||
|
}) => {
|
||||||
|
return Promise.resolve<MarketData[]>([
|
||||||
|
{
|
||||||
|
createdAt: dateRangeStart,
|
||||||
|
date: dateRangeStart,
|
||||||
|
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
|
||||||
|
marketPrice: 1841.823902,
|
||||||
|
symbol: symbols[0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
createdAt: dateRangeEnd,
|
||||||
|
date: dateRangeEnd,
|
||||||
|
id: '082d6893-df27-4c91-8a5d-092e84315b56',
|
||||||
|
marketPrice: 1847.839966,
|
||||||
|
symbol: symbols[0]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock('@ghostfolio/api/services/exchange-rate-data.service', () => {
|
||||||
|
return {
|
||||||
|
ExchangeRateDataService: jest.fn().mockImplementation(() => {
|
||||||
|
return {
|
||||||
|
initialize: () => Promise.resolve(),
|
||||||
|
toCurrency: (value: number) => {
|
||||||
|
return 1 * value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CurrentRateService', () => {
|
||||||
|
let currentRateService: CurrentRateService;
|
||||||
|
let dataProviderService: DataProviderService;
|
||||||
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
let marketDataService: MarketDataService;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
dataProviderService = new DataProviderService(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
exchangeRateDataService = new ExchangeRateDataService(null);
|
||||||
|
marketDataService = new MarketDataService(null);
|
||||||
|
|
||||||
|
await exchangeRateDataService.initialize();
|
||||||
|
|
||||||
|
currentRateService = new CurrentRateService(
|
||||||
|
dataProviderService,
|
||||||
|
exchangeRateDataService,
|
||||||
|
marketDataService
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getValue', async () => {
|
||||||
|
expect(
|
||||||
|
await currentRateService.getValue({
|
||||||
|
currency: Currency.USD,
|
||||||
|
date: new Date(Date.UTC(2020, 0, 1, 0, 0, 0)),
|
||||||
|
symbol: 'AMZN',
|
||||||
|
userCurrency: Currency.CHF
|
||||||
|
})
|
||||||
|
).toMatchObject({
|
||||||
|
marketPrice: 1847.839966
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getValues', async () => {
|
||||||
|
expect(
|
||||||
|
await currentRateService.getValues({
|
||||||
|
currencies: { AMZN: Currency.USD },
|
||||||
|
dateQuery: {
|
||||||
|
lt: new Date(Date.UTC(2020, 0, 2, 0, 0, 0)),
|
||||||
|
gte: new Date(Date.UTC(2020, 0, 1, 0, 0, 0))
|
||||||
|
},
|
||||||
|
symbols: ['AMZN'],
|
||||||
|
userCurrency: Currency.CHF
|
||||||
|
})
|
||||||
|
).toMatchObject([
|
||||||
|
{
|
||||||
|
date: undefined,
|
||||||
|
marketPrice: 1841.823902,
|
||||||
|
symbol: 'AMZN'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: undefined,
|
||||||
|
marketPrice: 1847.839966,
|
||||||
|
symbol: 'AMZN'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
128
apps/api/src/app/core/current-rate.service.ts
Normal file
128
apps/api/src/app/core/current-rate.service.ts
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import { GetValueObject } from '@ghostfolio/api/app/core/interfaces/get-value-object.interface';
|
||||||
|
import { GetValueParams } from '@ghostfolio/api/app/core/interfaces/get-value-params.interface';
|
||||||
|
import { GetValuesParams } from '@ghostfolio/api/app/core/interfaces/get-values-params.interface';
|
||||||
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
|
import { resetHours } from '@ghostfolio/common/helper';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { isBefore, isToday } from 'date-fns';
|
||||||
|
import { flatten } from 'lodash';
|
||||||
|
|
||||||
|
import { MarketDataService } from './market-data.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CurrentRateService {
|
||||||
|
public constructor(
|
||||||
|
private readonly dataProviderService: DataProviderService,
|
||||||
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
|
private readonly marketDataService: MarketDataService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async getValue({
|
||||||
|
currency,
|
||||||
|
date,
|
||||||
|
symbol,
|
||||||
|
userCurrency
|
||||||
|
}: GetValueParams): Promise<GetValueObject> {
|
||||||
|
if (isToday(date)) {
|
||||||
|
const dataProviderResult = await this.dataProviderService.get([symbol]);
|
||||||
|
return {
|
||||||
|
date: resetHours(date),
|
||||||
|
marketPrice: dataProviderResult?.[symbol]?.marketPrice ?? 0,
|
||||||
|
symbol: symbol
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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({
|
||||||
|
currencies,
|
||||||
|
dateQuery,
|
||||||
|
symbols,
|
||||||
|
userCurrency
|
||||||
|
}: GetValuesParams): Promise<GetValueObject[]> {
|
||||||
|
const includeToday =
|
||||||
|
(!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) &&
|
||||||
|
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
|
||||||
|
(!dateQuery.in || this.containsToday(dateQuery.in));
|
||||||
|
|
||||||
|
const promises: Promise<
|
||||||
|
{
|
||||||
|
date: Date;
|
||||||
|
marketPrice: number;
|
||||||
|
symbol: string;
|
||||||
|
}[]
|
||||||
|
>[] = [];
|
||||||
|
|
||||||
|
if (includeToday) {
|
||||||
|
const today = resetHours(new Date());
|
||||||
|
promises.push(
|
||||||
|
this.dataProviderService.get(symbols).then((dataResultProvider) => {
|
||||||
|
const result = [];
|
||||||
|
for (const symbol of symbols) {
|
||||||
|
result.push({
|
||||||
|
symbol,
|
||||||
|
date: today,
|
||||||
|
marketPrice: this.exchangeRateDataService.toCurrency(
|
||||||
|
dataResultProvider?.[symbol]?.marketPrice ?? 0,
|
||||||
|
dataResultProvider?.[symbol]?.currency,
|
||||||
|
userCurrency
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
promises.push(
|
||||||
|
this.marketDataService
|
||||||
|
.getRange({
|
||||||
|
dateQuery,
|
||||||
|
symbols
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
return data.map((marketDataItem) => {
|
||||||
|
return {
|
||||||
|
date: marketDataItem.date,
|
||||||
|
marketPrice: this.exchangeRateDataService.toCurrency(
|
||||||
|
marketDataItem.marketPrice,
|
||||||
|
currencies[marketDataItem.symbol],
|
||||||
|
userCurrency
|
||||||
|
),
|
||||||
|
symbol: marketDataItem.symbol
|
||||||
|
};
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return flatten(await Promise.all(promises));
|
||||||
|
}
|
||||||
|
|
||||||
|
private containsToday(dates: Date[]): boolean {
|
||||||
|
for (const date of dates) {
|
||||||
|
if (isToday(date)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,11 @@
|
|||||||
|
import { TimelinePosition } from '@ghostfolio/common/interfaces';
|
||||||
|
import Big from 'big.js';
|
||||||
|
|
||||||
|
export interface CurrentPositions {
|
||||||
|
hasErrors: boolean;
|
||||||
|
positions: TimelinePosition[];
|
||||||
|
grossPerformance: Big;
|
||||||
|
grossPerformancePercentage: Big;
|
||||||
|
currentValue: Big;
|
||||||
|
totalInvestment: Big;
|
||||||
|
}
|
5
apps/api/src/app/core/interfaces/date-query.interface.ts
Normal file
5
apps/api/src/app/core/interfaces/date-query.interface.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export interface DateQuery {
|
||||||
|
gte?: Date;
|
||||||
|
in?: Date[];
|
||||||
|
lt?: Date;
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
export interface GetValueObject {
|
||||||
|
date: Date;
|
||||||
|
marketPrice: number;
|
||||||
|
symbol: string;
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
import { Currency } from '@prisma/client';
|
||||||
|
|
||||||
|
export interface GetValueParams {
|
||||||
|
currency: Currency;
|
||||||
|
date: Date;
|
||||||
|
symbol: string;
|
||||||
|
userCurrency: Currency;
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
import { DateQuery } from '@ghostfolio/api/app/core/interfaces/date-query.interface';
|
||||||
|
import { Currency } from '@prisma/client';
|
||||||
|
|
||||||
|
export interface GetValuesParams {
|
||||||
|
currencies: { [symbol: string]: Currency };
|
||||||
|
dateQuery: DateQuery;
|
||||||
|
symbols: string[];
|
||||||
|
userCurrency: Currency;
|
||||||
|
}
|
@ -0,0 +1,13 @@
|
|||||||
|
import { OrderType } from '@ghostfolio/api/models/order-type';
|
||||||
|
import { Currency } from '@prisma/client';
|
||||||
|
import Big from 'big.js';
|
||||||
|
|
||||||
|
export interface PortfolioOrder {
|
||||||
|
currency: Currency;
|
||||||
|
date: string;
|
||||||
|
name: string;
|
||||||
|
quantity: Big;
|
||||||
|
symbol: string;
|
||||||
|
type: OrderType;
|
||||||
|
unitPrice: Big;
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
import Big from 'big.js';
|
||||||
|
|
||||||
|
export interface TimelinePeriod {
|
||||||
|
date: string;
|
||||||
|
grossPerformance: Big;
|
||||||
|
investment: Big;
|
||||||
|
value: Big;
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
export type Accuracy = 'day' | 'month' | 'year';
|
||||||
|
|
||||||
|
export interface TimelineSpecification {
|
||||||
|
accuracy: Accuracy;
|
||||||
|
start: string;
|
||||||
|
}
|
@ -0,0 +1,12 @@
|
|||||||
|
import { Currency } from '@prisma/client';
|
||||||
|
import Big from 'big.js';
|
||||||
|
|
||||||
|
export interface TransactionPointSymbol {
|
||||||
|
currency: Currency;
|
||||||
|
firstBuyDate: string;
|
||||||
|
investment: Big;
|
||||||
|
name: string;
|
||||||
|
quantity: Big;
|
||||||
|
symbol: string;
|
||||||
|
transactionCount: number;
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
import { TransactionPointSymbol } from '@ghostfolio/api/app/core/interfaces/transaction-point-symbol.interface';
|
||||||
|
|
||||||
|
export interface TransactionPoint {
|
||||||
|
date: string;
|
||||||
|
items: TransactionPointSymbol[];
|
||||||
|
}
|
51
apps/api/src/app/core/market-data.service.ts
Normal file
51
apps/api/src/app/core/market-data.service.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { resetHours } from '@ghostfolio/common/helper';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { MarketData } from '@prisma/client';
|
||||||
|
|
||||||
|
import { DateQuery } from './interfaces/date-query.interface';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MarketDataService {
|
||||||
|
public constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
public async get({
|
||||||
|
date,
|
||||||
|
symbol
|
||||||
|
}: {
|
||||||
|
date: Date;
|
||||||
|
symbol: string;
|
||||||
|
}): Promise<MarketData> {
|
||||||
|
return await this.prisma.marketData.findFirst({
|
||||||
|
where: {
|
||||||
|
symbol,
|
||||||
|
date: resetHours(date)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getRange({
|
||||||
|
dateQuery,
|
||||||
|
symbols
|
||||||
|
}: {
|
||||||
|
dateQuery: DateQuery;
|
||||||
|
symbols: string[];
|
||||||
|
}): Promise<MarketData[]> {
|
||||||
|
return await this.prisma.marketData.findMany({
|
||||||
|
orderBy: [
|
||||||
|
{
|
||||||
|
date: 'asc'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
symbol: 'asc'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
where: {
|
||||||
|
date: dateQuery,
|
||||||
|
symbol: {
|
||||||
|
in: symbols
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
1830
apps/api/src/app/core/portfolio-calculator.spec.ts
Normal file
1830
apps/api/src/app/core/portfolio-calculator.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
563
apps/api/src/app/core/portfolio-calculator.ts
Normal file
563
apps/api/src/app/core/portfolio-calculator.ts
Normal file
@ -0,0 +1,563 @@
|
|||||||
|
import { CurrentRateService } from '@ghostfolio/api/app/core/current-rate.service';
|
||||||
|
import { CurrentPositions } from '@ghostfolio/api/app/core/interfaces/current-positions.interface';
|
||||||
|
import { GetValueObject } from '@ghostfolio/api/app/core/interfaces/get-value-object.interface';
|
||||||
|
import { PortfolioOrder } from '@ghostfolio/api/app/core/interfaces/portfolio-order.interface';
|
||||||
|
import { TimelinePeriod } from '@ghostfolio/api/app/core/interfaces/timeline-period.interface';
|
||||||
|
import {
|
||||||
|
Accuracy,
|
||||||
|
TimelineSpecification
|
||||||
|
} from '@ghostfolio/api/app/core/interfaces/timeline-specification.interface';
|
||||||
|
import { TransactionPointSymbol } from '@ghostfolio/api/app/core/interfaces/transaction-point-symbol.interface';
|
||||||
|
import { TransactionPoint } from '@ghostfolio/api/app/core/interfaces/transaction-point.interface';
|
||||||
|
import { OrderType } from '@ghostfolio/api/models/order-type';
|
||||||
|
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||||
|
import { TimelinePosition } from '@ghostfolio/common/interfaces';
|
||||||
|
import { Currency } from '@prisma/client';
|
||||||
|
import Big from 'big.js';
|
||||||
|
import {
|
||||||
|
addDays,
|
||||||
|
addMonths,
|
||||||
|
addYears,
|
||||||
|
endOfDay,
|
||||||
|
format,
|
||||||
|
isAfter,
|
||||||
|
isBefore,
|
||||||
|
max,
|
||||||
|
min,
|
||||||
|
subDays
|
||||||
|
} from 'date-fns';
|
||||||
|
import { flatten } from 'lodash';
|
||||||
|
|
||||||
|
export class PortfolioCalculator {
|
||||||
|
private transactionPoints: TransactionPoint[];
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private currentRateService: CurrentRateService,
|
||||||
|
private currency: Currency
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public computeTransactionPoints(orders: PortfolioOrder[]) {
|
||||||
|
orders.sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
|
||||||
|
this.transactionPoints = [];
|
||||||
|
const symbols: { [symbol: string]: TransactionPointSymbol } = {};
|
||||||
|
|
||||||
|
let lastDate: string = null;
|
||||||
|
let lastTransactionPoint: TransactionPoint = null;
|
||||||
|
for (const order of 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) {
|
||||||
|
currentTransactionPointItem = {
|
||||||
|
currency: order.currency,
|
||||||
|
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
|
||||||
|
investment: unitPrice
|
||||||
|
.mul(order.quantity)
|
||||||
|
.mul(factor)
|
||||||
|
.add(oldAccumulatedSymbol.investment),
|
||||||
|
name: order.name,
|
||||||
|
quantity: order.quantity
|
||||||
|
.mul(factor)
|
||||||
|
.plus(oldAccumulatedSymbol.quantity),
|
||||||
|
symbol: order.symbol,
|
||||||
|
transactionCount: oldAccumulatedSymbol.transactionCount + 1
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
currentTransactionPointItem = {
|
||||||
|
currency: order.currency,
|
||||||
|
firstBuyDate: order.date,
|
||||||
|
investment: unitPrice.mul(order.quantity).mul(factor),
|
||||||
|
name: order.name,
|
||||||
|
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
|
||||||
|
);
|
||||||
|
if (!currentTransactionPointItem.quantity.eq(0)) {
|
||||||
|
newItems.push(currentTransactionPointItem);
|
||||||
|
} else {
|
||||||
|
delete symbols[order.symbol];
|
||||||
|
}
|
||||||
|
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 getTransactionPoints(): TransactionPoint[] {
|
||||||
|
return this.transactionPoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setTransactionPoints(transactionPoints: TransactionPoint[]) {
|
||||||
|
this.transactionPoints = transactionPoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getCurrentPositions(start: Date): Promise<CurrentPositions> {
|
||||||
|
if (!this.transactionPoints?.length) {
|
||||||
|
return {
|
||||||
|
hasErrors: false,
|
||||||
|
positions: [],
|
||||||
|
grossPerformance: new Big(0),
|
||||||
|
grossPerformancePercentage: new Big(0),
|
||||||
|
currentValue: new Big(0),
|
||||||
|
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 symbols = new Set<string>();
|
||||||
|
const currencies: { [symbol: string]: Currency } = {};
|
||||||
|
|
||||||
|
dates.push(resetHours(start));
|
||||||
|
for (const item of this.transactionPoints[firstIndex - 1].items) {
|
||||||
|
symbols.add(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,
|
||||||
|
dateQuery: {
|
||||||
|
in: dates
|
||||||
|
},
|
||||||
|
symbols: Array.from(symbols),
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasErrors = false;
|
||||||
|
const startString = format(start, DATE_FORMAT);
|
||||||
|
|
||||||
|
const holdingPeriodReturns: { [symbol: string]: Big } = {};
|
||||||
|
const grossPerformance: { [symbol: string]: Big } = {};
|
||||||
|
const todayString = format(today, DATE_FORMAT);
|
||||||
|
|
||||||
|
if (firstIndex > 0) {
|
||||||
|
firstIndex--;
|
||||||
|
}
|
||||||
|
const invalidSymbols = [];
|
||||||
|
const lastInvestments: { [symbol: string]: Big } = {};
|
||||||
|
const lastQuantities: { [symbol: string]: Big } = {};
|
||||||
|
const initialValues: { [symbol: string]: Big } = {};
|
||||||
|
|
||||||
|
for (let i = firstIndex; i < this.transactionPoints.length; i++) {
|
||||||
|
const currentDate =
|
||||||
|
i === firstIndex ? startString : this.transactionPoints[i].date;
|
||||||
|
const nextDate =
|
||||||
|
i + 1 < this.transactionPoints.length
|
||||||
|
? this.transactionPoints[i + 1].date
|
||||||
|
: todayString;
|
||||||
|
|
||||||
|
const items = this.transactionPoints[i].items;
|
||||||
|
for (const item of items) {
|
||||||
|
let oldHoldingPeriodReturn = holdingPeriodReturns[item.symbol];
|
||||||
|
if (!oldHoldingPeriodReturn) {
|
||||||
|
oldHoldingPeriodReturn = new Big(1);
|
||||||
|
}
|
||||||
|
if (!marketSymbolMap[nextDate]?.[item.symbol]) {
|
||||||
|
invalidSymbols.push(item.symbol);
|
||||||
|
hasErrors = true;
|
||||||
|
console.error(
|
||||||
|
`Missing value for symbol ${item.symbol} at ${nextDate}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let lastInvestment: Big = new Big(0);
|
||||||
|
let lastQuantity: Big = item.quantity;
|
||||||
|
if (lastInvestments[item.symbol] && lastQuantities[item.symbol]) {
|
||||||
|
lastInvestment = item.investment.minus(lastInvestments[item.symbol]);
|
||||||
|
lastQuantity = lastQuantities[item.symbol];
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemValue = marketSymbolMap[currentDate]?.[item.symbol];
|
||||||
|
let initialValue = itemValue?.mul(lastQuantity);
|
||||||
|
let investedValue = itemValue?.mul(item.quantity);
|
||||||
|
if (!isAfter(parseDate(currentDate), parseDate(item.firstBuyDate))) {
|
||||||
|
initialValue = item.investment;
|
||||||
|
investedValue = item.investment;
|
||||||
|
}
|
||||||
|
if (i === firstIndex || !initialValues[item.symbol]) {
|
||||||
|
initialValues[item.symbol] = initialValue;
|
||||||
|
}
|
||||||
|
if (!initialValue) {
|
||||||
|
invalidSymbols.push(item.symbol);
|
||||||
|
hasErrors = true;
|
||||||
|
console.error(
|
||||||
|
`Missing value for symbol ${item.symbol} at ${currentDate}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cashFlow = lastInvestment;
|
||||||
|
const endValue = marketSymbolMap[nextDate][item.symbol].mul(
|
||||||
|
item.quantity
|
||||||
|
);
|
||||||
|
|
||||||
|
const holdingPeriodReturn = endValue.div(initialValue.plus(cashFlow));
|
||||||
|
holdingPeriodReturns[item.symbol] =
|
||||||
|
oldHoldingPeriodReturn.mul(holdingPeriodReturn);
|
||||||
|
let oldGrossPerformance = grossPerformance[item.symbol];
|
||||||
|
if (!oldGrossPerformance) {
|
||||||
|
oldGrossPerformance = new Big(0);
|
||||||
|
}
|
||||||
|
const currentPerformance = endValue.minus(investedValue);
|
||||||
|
grossPerformance[item.symbol] =
|
||||||
|
oldGrossPerformance.plus(currentPerformance);
|
||||||
|
|
||||||
|
lastInvestments[item.symbol] = item.investment;
|
||||||
|
lastQuantities[item.symbol] = item.quantity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const positions: TimelinePosition[] = [];
|
||||||
|
for (const item of lastTransactionPoint.items) {
|
||||||
|
const marketValue = marketSymbolMap[todayString]?.[item.symbol];
|
||||||
|
const isValid = invalidSymbols.indexOf(item.symbol) === -1;
|
||||||
|
positions.push({
|
||||||
|
averagePrice: item.investment.div(item.quantity),
|
||||||
|
currency: item.currency,
|
||||||
|
firstBuyDate: item.firstBuyDate,
|
||||||
|
grossPerformance: isValid
|
||||||
|
? grossPerformance[item.symbol] ?? null
|
||||||
|
: null,
|
||||||
|
grossPerformancePercentage:
|
||||||
|
isValid && holdingPeriodReturns[item.symbol]
|
||||||
|
? holdingPeriodReturns[item.symbol].minus(1)
|
||||||
|
: null,
|
||||||
|
investment: item.investment,
|
||||||
|
marketPrice: marketValue?.toNumber() ?? null,
|
||||||
|
name: item.name,
|
||||||
|
quantity: item.quantity,
|
||||||
|
symbol: item.symbol,
|
||||||
|
transactionCount: item.transactionCount
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const overall = this.calculateOverallGrossPerformance(
|
||||||
|
positions,
|
||||||
|
initialValues
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...overall,
|
||||||
|
positions,
|
||||||
|
hasErrors: hasErrors || overall.hasErrors
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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<TimelinePeriod[]> {
|
||||||
|
if (timelineSpecification.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = timelineSpecification[0].start;
|
||||||
|
const start = parseDate(startDate);
|
||||||
|
const end = parseDate(endDate);
|
||||||
|
|
||||||
|
const timelinePeriodPromises: Promise<TimelinePeriod[]>[] = [];
|
||||||
|
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 timelinePeriods: TimelinePeriod[][] = await Promise.all(
|
||||||
|
timelinePeriodPromises
|
||||||
|
);
|
||||||
|
|
||||||
|
return flatten(timelinePeriods);
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateOverallGrossPerformance(
|
||||||
|
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 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
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
currentPosition.grossPerformancePercentage &&
|
||||||
|
initialValues[currentPosition.symbol]
|
||||||
|
) {
|
||||||
|
const currentInitialValue = initialValues[currentPosition.symbol];
|
||||||
|
completeInitialValue = completeInitialValue.plus(currentInitialValue);
|
||||||
|
grossPerformancePercentage = grossPerformancePercentage.plus(
|
||||||
|
currentPosition.grossPerformancePercentage.mul(currentInitialValue)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
`Initial value is missing for symbol ${currentPosition.symbol}`
|
||||||
|
);
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
currentValue,
|
||||||
|
grossPerformance,
|
||||||
|
hasErrors,
|
||||||
|
totalInvestment,
|
||||||
|
grossPerformancePercentage:
|
||||||
|
grossPerformancePercentage.div(completeInitialValue)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getTimePeriodForDate(
|
||||||
|
j: number,
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date
|
||||||
|
): Promise<TimelinePeriod[]> {
|
||||||
|
let investment: Big = new Big(0);
|
||||||
|
|
||||||
|
const marketSymbolMap: {
|
||||||
|
[date: string]: { [symbol: string]: Big };
|
||||||
|
} = {};
|
||||||
|
if (j >= 0) {
|
||||||
|
const currencies: { [name: string]: Currency } = {};
|
||||||
|
const symbols: string[] = [];
|
||||||
|
|
||||||
|
for (const item of this.transactionPoints[j].items) {
|
||||||
|
currencies[item.symbol] = item.currency;
|
||||||
|
symbols.push(item.symbol);
|
||||||
|
investment = investment.add(item.investment);
|
||||||
|
}
|
||||||
|
|
||||||
|
let marketSymbols: GetValueObject[] = [];
|
||||||
|
if (symbols.length > 0) {
|
||||||
|
try {
|
||||||
|
marketSymbols = await this.currentRateService.getValues({
|
||||||
|
dateQuery: {
|
||||||
|
gte: startDate,
|
||||||
|
lt: endOfDay(endDate)
|
||||||
|
},
|
||||||
|
symbols,
|
||||||
|
currencies,
|
||||||
|
userCurrency: this.currency
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.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 = [];
|
||||||
|
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 result = {
|
||||||
|
date: currentDateAsString,
|
||||||
|
grossPerformance: value.minus(investment),
|
||||||
|
investment,
|
||||||
|
value
|
||||||
|
};
|
||||||
|
results.push(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFactor(type: OrderType) {
|
||||||
|
let factor: number;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case OrderType.Buy:
|
||||||
|
factor = 1;
|
||||||
|
break;
|
||||||
|
case OrderType.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,4 +1,5 @@
|
|||||||
import { baseCurrency, benchmarks } from '@ghostfolio/common/config';
|
import { baseCurrency, benchmarks } from '@ghostfolio/common/config';
|
||||||
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
import { isApiTokenAuthorized } from '@ghostfolio/common/permissions';
|
import { isApiTokenAuthorized } from '@ghostfolio/common/permissions';
|
||||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
@ -65,26 +66,4 @@ export class ExperimentalController {
|
|||||||
|
|
||||||
return marketData;
|
return marketData;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('value/:dateString?')
|
|
||||||
public async getValue(
|
|
||||||
@Body() orders: CreateOrderDto[],
|
|
||||||
@Headers('Authorization') apiToken: string,
|
|
||||||
@Param('dateString') dateString: string
|
|
||||||
): Promise<Data> {
|
|
||||||
if (!isApiTokenAuthorized(apiToken)) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let date = new Date();
|
|
||||||
|
|
||||||
if (dateString) {
|
|
||||||
date = parse(dateString, 'yyyy-MM-dd', new Date());
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.experimentalService.getValue(orders, date, baseCurrency);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
||||||
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
||||||
@ -13,9 +15,10 @@ import { ExperimentalController } from './experimental.controller';
|
|||||||
import { ExperimentalService } from './experimental.service';
|
import { ExperimentalService } from './experimental.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [],
|
imports: [RedisCacheModule],
|
||||||
controllers: [ExperimentalController],
|
controllers: [ExperimentalController],
|
||||||
providers: [
|
providers: [
|
||||||
|
AccountService,
|
||||||
AlphaVantageService,
|
AlphaVantageService,
|
||||||
ConfigurationService,
|
ConfigurationService,
|
||||||
DataProviderService,
|
DataProviderService,
|
||||||
|
@ -1,19 +1,14 @@
|
|||||||
import { Portfolio } from '@ghostfolio/api/models/portfolio';
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/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 { RulesService } from '@ghostfolio/api/services/rules.service';
|
import { RulesService } from '@ghostfolio/api/services/rules.service';
|
||||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Currency, Type } from '@prisma/client';
|
|
||||||
import { parseISO } from 'date-fns';
|
|
||||||
|
|
||||||
import { CreateOrderDto } from './create-order.dto';
|
|
||||||
import { Data } from './interfaces/data.interface';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExperimentalService {
|
export class ExperimentalService {
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly accountService: AccountService,
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
@ -27,40 +22,4 @@ export class ExperimentalService {
|
|||||||
where: { symbol: aSymbol }
|
where: { symbol: aSymbol }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getValue(
|
|
||||||
aOrders: CreateOrderDto[],
|
|
||||||
aDate: Date,
|
|
||||||
aBaseCurrency: Currency
|
|
||||||
): Promise<Data> {
|
|
||||||
const ordersWithPlatform: OrderWithAccount[] = aOrders.map((order) => {
|
|
||||||
return {
|
|
||||||
...order,
|
|
||||||
accountId: undefined,
|
|
||||||
accountUserId: undefined,
|
|
||||||
createdAt: new Date(),
|
|
||||||
dataSource: undefined,
|
|
||||||
date: parseISO(order.date),
|
|
||||||
fee: 0,
|
|
||||||
id: undefined,
|
|
||||||
platformId: undefined,
|
|
||||||
symbolProfileId: undefined,
|
|
||||||
type: Type.BUY,
|
|
||||||
updatedAt: undefined,
|
|
||||||
userId: undefined
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const portfolio = new Portfolio(
|
|
||||||
this.dataProviderService,
|
|
||||||
this.exchangeRateDataService,
|
|
||||||
this.rulesService
|
|
||||||
);
|
|
||||||
await portfolio.setOrders(ordersWithPlatform);
|
|
||||||
|
|
||||||
return {
|
|
||||||
currency: aBaseCurrency,
|
|
||||||
value: portfolio.getValue(aDate)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
23
apps/api/src/app/export/export.controller.ts
Normal file
23
apps/api/src/app/export/export.controller.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { Export } from '@ghostfolio/common/interfaces';
|
||||||
|
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
|
||||||
|
import { REQUEST } from '@nestjs/core';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
|
import { ExportService } from './export.service';
|
||||||
|
|
||||||
|
@Controller('export')
|
||||||
|
export class ExportController {
|
||||||
|
public constructor(
|
||||||
|
private readonly exportService: ExportService,
|
||||||
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async export(): Promise<Export> {
|
||||||
|
return await this.exportService.export({
|
||||||
|
userId: this.request.user.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
32
apps/api/src/app/export/export.module.ts
Normal file
32
apps/api/src/app/export/export.module.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
||||||
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||||
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
||||||
|
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
||||||
|
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-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 { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { ExportController } from './export.controller';
|
||||||
|
import { ExportService } from './export.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [RedisCacheModule],
|
||||||
|
controllers: [ExportController],
|
||||||
|
providers: [
|
||||||
|
AlphaVantageService,
|
||||||
|
CacheService,
|
||||||
|
ConfigurationService,
|
||||||
|
DataGatheringService,
|
||||||
|
DataProviderService,
|
||||||
|
ExportService,
|
||||||
|
GhostfolioScraperApiService,
|
||||||
|
PrismaService,
|
||||||
|
RakutenRapidApiService,
|
||||||
|
YahooFinanceService
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class ExportModule {}
|
31
apps/api/src/app/export/export.service.ts
Normal file
31
apps/api/src/app/export/export.service.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { environment } from '@ghostfolio/api/environments/environment';
|
||||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { Export } from '@ghostfolio/common/interfaces';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ExportService {
|
||||||
|
public constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
public async export({ userId }: { userId: string }): Promise<Export> {
|
||||||
|
const orders = await this.prisma.order.findMany({
|
||||||
|
orderBy: { date: 'desc' },
|
||||||
|
select: {
|
||||||
|
currency: true,
|
||||||
|
dataSource: true,
|
||||||
|
date: true,
|
||||||
|
fee: true,
|
||||||
|
quantity: true,
|
||||||
|
symbol: true,
|
||||||
|
type: true,
|
||||||
|
unitPrice: true
|
||||||
|
},
|
||||||
|
where: { userId }
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
meta: { date: new Date().toISOString(), version: environment.version },
|
||||||
|
orders
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
7
apps/api/src/app/import/import-data.dto.ts
Normal file
7
apps/api/src/app/import/import-data.dto.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { Order } from '@prisma/client';
|
||||||
|
import { IsArray } from 'class-validator';
|
||||||
|
|
||||||
|
export class ImportDataDto {
|
||||||
|
@IsArray()
|
||||||
|
orders: Partial<Order>[];
|
||||||
|
}
|
50
apps/api/src/app/import/import.controller.ts
Normal file
50
apps/api/src/app/import/import.controller.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
|
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
HttpException,
|
||||||
|
Inject,
|
||||||
|
Post,
|
||||||
|
UseGuards
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { REQUEST } from '@nestjs/core';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
|
import { ImportDataDto } from './import-data.dto';
|
||||||
|
import { ImportService } from './import.service';
|
||||||
|
|
||||||
|
@Controller('import')
|
||||||
|
export class ImportController {
|
||||||
|
public constructor(
|
||||||
|
private readonly configurationService: ConfigurationService,
|
||||||
|
private readonly importService: ImportService,
|
||||||
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async import(@Body() importData: ImportDataDto): Promise<void> {
|
||||||
|
if (!this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.importService.import({
|
||||||
|
orders: importData.orders,
|
||||||
|
userId: this.request.user.id
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||||
|
StatusCodes.BAD_REQUEST
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
34
apps/api/src/app/import/import.module.ts
Normal file
34
apps/api/src/app/import/import.module.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
||||||
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||||
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||||
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
||||||
|
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
||||||
|
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-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 { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { ImportController } from './import.controller';
|
||||||
|
import { ImportService } from './import.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [RedisCacheModule],
|
||||||
|
controllers: [ImportController],
|
||||||
|
providers: [
|
||||||
|
AlphaVantageService,
|
||||||
|
CacheService,
|
||||||
|
ConfigurationService,
|
||||||
|
DataGatheringService,
|
||||||
|
DataProviderService,
|
||||||
|
GhostfolioScraperApiService,
|
||||||
|
ImportService,
|
||||||
|
OrderService,
|
||||||
|
PrismaService,
|
||||||
|
RakutenRapidApiService,
|
||||||
|
YahooFinanceService
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class ImportModule {}
|
43
apps/api/src/app/import/import.service.ts
Normal file
43
apps/api/src/app/import/import.service.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Order } from '@prisma/client';
|
||||||
|
import { parseISO } from 'date-fns';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ImportService {
|
||||||
|
public constructor(private readonly orderService: OrderService) {}
|
||||||
|
|
||||||
|
public async import({
|
||||||
|
orders,
|
||||||
|
userId
|
||||||
|
}: {
|
||||||
|
orders: Partial<Order>[];
|
||||||
|
userId: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
for (const {
|
||||||
|
currency,
|
||||||
|
dataSource,
|
||||||
|
date,
|
||||||
|
fee,
|
||||||
|
quantity,
|
||||||
|
symbol,
|
||||||
|
type,
|
||||||
|
unitPrice
|
||||||
|
} of orders) {
|
||||||
|
await this.orderService.createOrder(
|
||||||
|
{
|
||||||
|
currency,
|
||||||
|
dataSource,
|
||||||
|
fee,
|
||||||
|
quantity,
|
||||||
|
symbol,
|
||||||
|
type,
|
||||||
|
unitPrice,
|
||||||
|
date: parseISO(<string>(<unknown>date)),
|
||||||
|
User: { connect: { id: userId } }
|
||||||
|
},
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -20,6 +20,7 @@ export class InfoService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async get(): Promise<InfoItem> {
|
public async get(): Promise<InfoItem> {
|
||||||
|
const info: Partial<InfoItem> = {};
|
||||||
const platforms = await this.prisma.platform.findMany({
|
const platforms = await this.prisma.platform.findMany({
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: 'asc' },
|
||||||
select: { id: true, name: true }
|
select: { id: true, name: true }
|
||||||
@ -27,6 +28,14 @@ export class InfoService {
|
|||||||
|
|
||||||
const globalPermissions: string[] = [];
|
const globalPermissions: string[] = [];
|
||||||
|
|
||||||
|
if (this.configurationService.get('ENABLE_FEATURE_BLOG')) {
|
||||||
|
globalPermissions.push(permissions.enableBlog);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
|
||||||
|
globalPermissions.push(permissions.enableImport);
|
||||||
|
}
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_SOCIAL_LOGIN')) {
|
if (this.configurationService.get('ENABLE_FEATURE_SOCIAL_LOGIN')) {
|
||||||
globalPermissions.push(permissions.enableSocialLogin);
|
globalPermissions.push(permissions.enableSocialLogin);
|
||||||
}
|
}
|
||||||
@ -37,9 +46,12 @@ export class InfoService {
|
|||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
globalPermissions.push(permissions.enableSubscription);
|
globalPermissions.push(permissions.enableSubscription);
|
||||||
|
|
||||||
|
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
...info,
|
||||||
globalPermissions,
|
globalPermissions,
|
||||||
platforms,
|
platforms,
|
||||||
currencies: Object.values(Currency),
|
currencies: Object.values(Currency),
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { Currency, DataSource, Type } from '@prisma/client';
|
import { Currency, DataSource, Type } from '@prisma/client';
|
||||||
import { IsISO8601, IsNumber, IsString, ValidateIf } from 'class-validator';
|
import { IsISO8601, IsNumber, IsString } from 'class-validator';
|
||||||
|
|
||||||
export class CreateOrderDto {
|
export class CreateOrderDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@ -62,6 +62,8 @@ export class OrderService {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.dataGatheringService.gatherProfileData([data.symbol]);
|
||||||
|
|
||||||
await this.cacheService.flush(aUserId);
|
await this.cacheService.flush(aUserId);
|
||||||
|
|
||||||
return this.prisma.order.create({
|
return this.prisma.order.create({
|
||||||
|
@ -0,0 +1,5 @@
|
|||||||
|
import { Position } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
|
export interface PortfolioPositions {
|
||||||
|
positions: Position[];
|
||||||
|
}
|
@ -11,6 +11,7 @@ import {
|
|||||||
PortfolioPosition,
|
PortfolioPosition,
|
||||||
PortfolioReport
|
PortfolioReport
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
|
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||||
import {
|
import {
|
||||||
getPermissions,
|
getPermissions,
|
||||||
hasPermission,
|
hasPermission,
|
||||||
@ -37,6 +38,7 @@ import {
|
|||||||
HistoricalDataItem,
|
HistoricalDataItem,
|
||||||
PortfolioPositionDetail
|
PortfolioPositionDetail
|
||||||
} from './interfaces/portfolio-position-detail.interface';
|
} from './interfaces/portfolio-position-detail.interface';
|
||||||
|
import { PortfolioPositions } from './interfaces/portfolio-positions.interface';
|
||||||
import { PortfolioService } from './portfolio.service';
|
import { PortfolioService } from './portfolio.service';
|
||||||
|
|
||||||
@Controller('portfolio')
|
@Controller('portfolio')
|
||||||
@ -48,12 +50,14 @@ export class PortfolioController {
|
|||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get('/investments')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async findAll(
|
public async findAll(
|
||||||
@Headers('impersonation-id') impersonationId
|
@Headers('impersonation-id') impersonationId
|
||||||
): Promise<PortfolioItem[]> {
|
): Promise<InvestmentItem[]> {
|
||||||
let portfolio = await this.portfolioService.findAll(impersonationId);
|
let investments = await this.portfolioService.getInvestments(
|
||||||
|
impersonationId
|
||||||
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationId &&
|
impersonationId &&
|
||||||
@ -62,25 +66,18 @@ export class PortfolioController {
|
|||||||
permissions.readForeignPortfolio
|
permissions.readForeignPortfolio
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
portfolio = portfolio.map((portfolioItem) => {
|
const maxInvestment = investments.reduce(
|
||||||
Object.keys(portfolioItem.positions).forEach((symbol) => {
|
(investment, item) => Math.max(investment, item.investment),
|
||||||
portfolioItem.positions[symbol].investment =
|
1
|
||||||
portfolioItem.positions[symbol].investment > 0 ? 1 : 0;
|
);
|
||||||
portfolioItem.positions[symbol].investmentInOriginalCurrency =
|
|
||||||
portfolioItem.positions[symbol].investmentInOriginalCurrency > 0
|
|
||||||
? 1
|
|
||||||
: 0;
|
|
||||||
portfolioItem.positions[symbol].quantity =
|
|
||||||
portfolioItem.positions[symbol].quantity > 0 ? 1 : 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
portfolioItem.investment = null;
|
investments = investments.map((item) => ({
|
||||||
|
date: item.date,
|
||||||
return portfolioItem;
|
investment: item.investment / maxInvestment
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
return portfolio;
|
return investments;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('chart')
|
@Get('chart')
|
||||||
@ -142,17 +139,17 @@ export class PortfolioController {
|
|||||||
): Promise<{ [symbol: string]: PortfolioPosition }> {
|
): Promise<{ [symbol: string]: PortfolioPosition }> {
|
||||||
let details: { [symbol: string]: PortfolioPosition } = {};
|
let details: { [symbol: string]: PortfolioPosition } = {};
|
||||||
|
|
||||||
const impersonationUserId = await this.impersonationService.validateImpersonationId(
|
const impersonationUserId =
|
||||||
impersonationId,
|
await this.impersonationService.validateImpersonationId(
|
||||||
this.request.user.id
|
impersonationId,
|
||||||
);
|
this.request.user.id
|
||||||
|
);
|
||||||
const portfolio = await this.portfolioService.createPortfolio(
|
|
||||||
impersonationUserId || this.request.user.id
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
details = await portfolio.getDetails(range);
|
details = await this.portfolioService.getDetails(
|
||||||
|
impersonationUserId,
|
||||||
|
range
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
||||||
@ -221,6 +218,7 @@ export class PortfolioController {
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
overview = nullifyValuesInObject(overview, [
|
overview = nullifyValuesInObject(overview, [
|
||||||
|
'cash',
|
||||||
'committedFunds',
|
'committedFunds',
|
||||||
'fees',
|
'fees',
|
||||||
'totalBuy',
|
'totalBuy',
|
||||||
@ -238,21 +236,16 @@ export class PortfolioController {
|
|||||||
@Query('range') range,
|
@Query('range') range,
|
||||||
@Res() res: Response
|
@Res() res: Response
|
||||||
): Promise<PortfolioPerformance> {
|
): Promise<PortfolioPerformance> {
|
||||||
const impersonationUserId = await this.impersonationService.validateImpersonationId(
|
const performanceInformation = await this.portfolioService.getPerformance(
|
||||||
impersonationId,
|
impersonationId,
|
||||||
this.request.user.id
|
range
|
||||||
);
|
);
|
||||||
|
|
||||||
const portfolio = await this.portfolioService.createPortfolio(
|
if (performanceInformation?.hasErrors) {
|
||||||
impersonationUserId || this.request.user.id
|
|
||||||
);
|
|
||||||
|
|
||||||
let performance = await portfolio.getPerformance(range);
|
|
||||||
|
|
||||||
if (hasNotDefinedValuesInObject(performance)) {
|
|
||||||
res.status(StatusCodes.ACCEPTED);
|
res.status(StatusCodes.ACCEPTED);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let performance = performanceInformation.performance;
|
||||||
if (
|
if (
|
||||||
impersonationId &&
|
impersonationId &&
|
||||||
!hasPermission(
|
!hasPermission(
|
||||||
@ -270,6 +263,25 @@ export class PortfolioController {
|
|||||||
return <any>res.json(performance);
|
return <any>res.json(performance);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('positions')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async getPositions(
|
||||||
|
@Headers('impersonation-id') impersonationId,
|
||||||
|
@Query('range') range,
|
||||||
|
@Res() res: Response
|
||||||
|
): Promise<PortfolioPositions> {
|
||||||
|
const result = await this.portfolioService.getPositions(
|
||||||
|
impersonationId,
|
||||||
|
range
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result?.hasErrors) {
|
||||||
|
res.status(StatusCodes.ACCEPTED);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <any>res.json(result);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('position/:symbol')
|
@Get('position/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getPosition(
|
public async getPosition(
|
||||||
@ -306,15 +318,6 @@ export class PortfolioController {
|
|||||||
public async getReport(
|
public async getReport(
|
||||||
@Headers('impersonation-id') impersonationId
|
@Headers('impersonation-id') impersonationId
|
||||||
): Promise<PortfolioReport> {
|
): Promise<PortfolioReport> {
|
||||||
const impersonationUserId = await this.impersonationService.validateImpersonationId(
|
return await this.portfolioService.getReport(impersonationId);
|
||||||
impersonationId,
|
|
||||||
this.request.user.id
|
|
||||||
);
|
|
||||||
|
|
||||||
const portfolio = await this.portfolioService.createPortfolio(
|
|
||||||
impersonationUserId || this.request.user.id
|
|
||||||
);
|
|
||||||
|
|
||||||
return await portfolio.getReport();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,10 @@
|
|||||||
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
|
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
||||||
|
import { CurrentRateService } from '@ghostfolio/api/app/core/current-rate.service';
|
||||||
|
import { MarketDataService } from '@ghostfolio/api/app/core/market-data.service';
|
||||||
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||||
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
|
import { UserService } from '@ghostfolio/api/app/user/user.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.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
||||||
@ -9,12 +16,9 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
|
|||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { RulesService } from '@ghostfolio/api/services/rules.service';
|
import { RulesService } from '@ghostfolio/api/services/rules.service';
|
||||||
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { CacheService } from '../cache/cache.service';
|
|
||||||
import { OrderService } from '../order/order.service';
|
|
||||||
import { RedisCacheModule } from '../redis-cache/redis-cache.module';
|
|
||||||
import { UserService } from '../user/user.service';
|
|
||||||
import { PortfolioController } from './portfolio.controller';
|
import { PortfolioController } from './portfolio.controller';
|
||||||
import { PortfolioService } from './portfolio.service';
|
import { PortfolioService } from './portfolio.service';
|
||||||
|
|
||||||
@ -22,19 +26,23 @@ import { PortfolioService } from './portfolio.service';
|
|||||||
imports: [RedisCacheModule],
|
imports: [RedisCacheModule],
|
||||||
controllers: [PortfolioController],
|
controllers: [PortfolioController],
|
||||||
providers: [
|
providers: [
|
||||||
|
AccountService,
|
||||||
AlphaVantageService,
|
AlphaVantageService,
|
||||||
CacheService,
|
CacheService,
|
||||||
|
CurrentRateService,
|
||||||
ConfigurationService,
|
ConfigurationService,
|
||||||
DataGatheringService,
|
DataGatheringService,
|
||||||
DataProviderService,
|
DataProviderService,
|
||||||
ExchangeRateDataService,
|
ExchangeRateDataService,
|
||||||
GhostfolioScraperApiService,
|
GhostfolioScraperApiService,
|
||||||
ImpersonationService,
|
ImpersonationService,
|
||||||
|
MarketDataService,
|
||||||
OrderService,
|
OrderService,
|
||||||
PortfolioService,
|
PortfolioService,
|
||||||
PrismaService,
|
PrismaService,
|
||||||
RakutenRapidApiService,
|
RakutenRapidApiService,
|
||||||
RulesService,
|
RulesService,
|
||||||
|
SymbolProfileService,
|
||||||
UserService,
|
UserService,
|
||||||
YahooFinanceService
|
YahooFinanceService
|
||||||
]
|
]
|
||||||
|
@ -1,38 +1,60 @@
|
|||||||
import { Portfolio } from '@ghostfolio/api/models/portfolio';
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
|
import { CurrentRateService } from '@ghostfolio/api/app/core/current-rate.service';
|
||||||
|
import { PortfolioOrder } from '@ghostfolio/api/app/core/interfaces/portfolio-order.interface';
|
||||||
|
import { TimelineSpecification } from '@ghostfolio/api/app/core/interfaces/timeline-specification.interface';
|
||||||
|
import { TransactionPoint } from '@ghostfolio/api/app/core/interfaces/transaction-point.interface';
|
||||||
|
import { PortfolioCalculator } from '@ghostfolio/api/app/core/portfolio-calculator';
|
||||||
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||||
|
import { OrderType } from '@ghostfolio/api/models/order-type';
|
||||||
|
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
|
||||||
|
import { AccountClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/initial-investment';
|
||||||
|
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account';
|
||||||
|
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment';
|
||||||
|
import { CurrencyClusterRiskBaseCurrencyInitialInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-initial-investment';
|
||||||
|
import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment';
|
||||||
|
import { CurrencyClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/initial-investment';
|
||||||
|
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||||
import { IOrder } from '@ghostfolio/api/services/interfaces/interfaces';
|
import { Type } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
|
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
|
||||||
import { RulesService } from '@ghostfolio/api/services/rules.service';
|
import { RulesService } from '@ghostfolio/api/services/rules.service';
|
||||||
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
|
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||||
|
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
PortfolioItem,
|
PortfolioOverview,
|
||||||
PortfolioOverview
|
PortfolioPerformance,
|
||||||
|
PortfolioPosition,
|
||||||
|
PortfolioReport,
|
||||||
|
Position,
|
||||||
|
TimelinePosition
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { DateRange, RequestWithUser } from '@ghostfolio/common/types';
|
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||||
|
import {
|
||||||
|
DateRange,
|
||||||
|
OrderWithAccount,
|
||||||
|
RequestWithUser
|
||||||
|
} from '@ghostfolio/common/types';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { DataSource } from '@prisma/client';
|
import { Currency, DataSource, Type as TypeOfOrder } from '@prisma/client';
|
||||||
|
import Big from 'big.js';
|
||||||
import {
|
import {
|
||||||
add,
|
|
||||||
endOfToday,
|
endOfToday,
|
||||||
format,
|
format,
|
||||||
getDate,
|
|
||||||
getMonth,
|
|
||||||
getYear,
|
|
||||||
isAfter,
|
isAfter,
|
||||||
isSameDay,
|
isBefore,
|
||||||
|
max,
|
||||||
parse,
|
parse,
|
||||||
parseISO,
|
parseISO,
|
||||||
setDate,
|
setDayOfYear,
|
||||||
setMonth,
|
subDays,
|
||||||
sub
|
subYears
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
import { isEmpty } from 'lodash';
|
import { isEmpty } from 'lodash';
|
||||||
import * as roundTo from 'round-to';
|
|
||||||
|
|
||||||
import { OrderService } from '../order/order.service';
|
|
||||||
import { RedisCacheService } from '../redis-cache/redis-cache.service';
|
|
||||||
import { UserService } from '../user/user.service';
|
|
||||||
import {
|
import {
|
||||||
HistoricalDataItem,
|
HistoricalDataItem,
|
||||||
PortfolioPositionDetail
|
PortfolioPositionDetail
|
||||||
@ -41,187 +63,242 @@ import {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class PortfolioService {
|
export class PortfolioService {
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly accountService: AccountService,
|
||||||
|
private readonly currentRateService: CurrentRateService,
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly impersonationService: ImpersonationService,
|
private readonly impersonationService: ImpersonationService,
|
||||||
private readonly orderService: OrderService,
|
private readonly orderService: OrderService,
|
||||||
private readonly redisCacheService: RedisCacheService,
|
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||||
private readonly rulesService: RulesService,
|
private readonly rulesService: RulesService,
|
||||||
private readonly userService: UserService
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async createPortfolio(aUserId: string): Promise<Portfolio> {
|
public async getInvestments(
|
||||||
let portfolio: Portfolio;
|
aImpersonationId: string
|
||||||
const stringifiedPortfolio = await this.redisCacheService.get(
|
): Promise<InvestmentItem[]> {
|
||||||
`${aUserId}.portfolio`
|
const userId = await this.getUserId(aImpersonationId);
|
||||||
|
|
||||||
|
const portfolioCalculator = new PortfolioCalculator(
|
||||||
|
this.currentRateService,
|
||||||
|
this.request.user.Settings.currency
|
||||||
);
|
);
|
||||||
|
|
||||||
const user = await this.userService.user({ id: aUserId });
|
const { transactionPoints } = await this.getTransactionPoints(userId);
|
||||||
|
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||||
|
if (transactionPoints.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
if (stringifiedPortfolio) {
|
return portfolioCalculator.getInvestments().map((item) => {
|
||||||
// Get portfolio from redis
|
return {
|
||||||
const {
|
date: item.date,
|
||||||
orders,
|
investment: item.investment.toNumber()
|
||||||
portfolioItems
|
|
||||||
}: { orders: IOrder[]; portfolioItems: PortfolioItem[] } =
|
|
||||||
JSON.parse(stringifiedPortfolio);
|
|
||||||
|
|
||||||
portfolio = new Portfolio(
|
|
||||||
this.dataProviderService,
|
|
||||||
this.exchangeRateDataService,
|
|
||||||
this.rulesService
|
|
||||||
).createFromData({ orders, portfolioItems, user });
|
|
||||||
} else {
|
|
||||||
// Get portfolio from database
|
|
||||||
const orders = await this.orderService.orders({
|
|
||||||
include: {
|
|
||||||
Account: true,
|
|
||||||
SymbolProfile: true
|
|
||||||
},
|
|
||||||
orderBy: { date: 'asc' },
|
|
||||||
where: { userId: aUserId }
|
|
||||||
});
|
|
||||||
|
|
||||||
portfolio = new Portfolio(
|
|
||||||
this.dataProviderService,
|
|
||||||
this.exchangeRateDataService,
|
|
||||||
this.rulesService
|
|
||||||
);
|
|
||||||
portfolio.setUser(user);
|
|
||||||
await portfolio.setOrders(orders);
|
|
||||||
|
|
||||||
// Cache data for the next time...
|
|
||||||
const portfolioData = {
|
|
||||||
orders: portfolio.getOrders(),
|
|
||||||
portfolioItems: portfolio.getPortfolioItems()
|
|
||||||
};
|
};
|
||||||
|
});
|
||||||
await this.redisCacheService.set(
|
|
||||||
`${aUserId}.portfolio`,
|
|
||||||
JSON.stringify(portfolioData)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enrich portfolio with current data
|
|
||||||
await portfolio.addCurrentPortfolioItems();
|
|
||||||
|
|
||||||
// Enrich portfolio with future data
|
|
||||||
await portfolio.addFuturePortfolioItems();
|
|
||||||
|
|
||||||
return portfolio;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async findAll(aImpersonationId: string): Promise<PortfolioItem[]> {
|
|
||||||
try {
|
|
||||||
const impersonationUserId =
|
|
||||||
await this.impersonationService.validateImpersonationId(
|
|
||||||
aImpersonationId,
|
|
||||||
this.request.user.id
|
|
||||||
);
|
|
||||||
|
|
||||||
const portfolio = await this.createPortfolio(
|
|
||||||
impersonationUserId || this.request.user.id
|
|
||||||
);
|
|
||||||
return portfolio.get();
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getChart(
|
public async getChart(
|
||||||
aImpersonationId: string,
|
aImpersonationId: string,
|
||||||
aDateRange: DateRange = 'max'
|
aDateRange: DateRange = 'max'
|
||||||
): Promise<HistoricalDataItem[]> {
|
): Promise<HistoricalDataItem[]> {
|
||||||
const impersonationUserId =
|
const userId = await this.getUserId(aImpersonationId);
|
||||||
await this.impersonationService.validateImpersonationId(
|
|
||||||
aImpersonationId,
|
|
||||||
this.request.user.id
|
|
||||||
);
|
|
||||||
|
|
||||||
const portfolio = await this.createPortfolio(
|
const portfolioCalculator = new PortfolioCalculator(
|
||||||
impersonationUserId || this.request.user.id
|
this.currentRateService,
|
||||||
|
this.request.user.Settings.currency
|
||||||
);
|
);
|
||||||
|
|
||||||
if (portfolio.getOrders().length <= 0) {
|
const { transactionPoints } = await this.getTransactionPoints(userId);
|
||||||
|
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||||
|
if (transactionPoints.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
let portfolioStart = parse(
|
||||||
|
transactionPoints[0].date,
|
||||||
|
DATE_FORMAT,
|
||||||
|
new Date()
|
||||||
|
);
|
||||||
|
portfolioStart = this.getStartDate(aDateRange, portfolioStart);
|
||||||
|
|
||||||
const dateRangeDate = this.convertDateRangeToDate(
|
const timelineSpecification: TimelineSpecification[] = [
|
||||||
aDateRange,
|
{
|
||||||
portfolio.getMinDate()
|
start: format(portfolioStart, DATE_FORMAT),
|
||||||
|
accuracy: 'day'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const timeline = await portfolioCalculator.calculateTimeline(
|
||||||
|
timelineSpecification,
|
||||||
|
format(new Date(), DATE_FORMAT)
|
||||||
);
|
);
|
||||||
|
|
||||||
return portfolio
|
return timeline
|
||||||
.get()
|
.filter((timelineItem) => timelineItem !== null)
|
||||||
.filter((portfolioItem) => {
|
.map((timelineItem) => ({
|
||||||
if (isAfter(parseISO(portfolioItem.date), endOfToday())) {
|
date: timelineItem.date,
|
||||||
// Filter out future dates
|
marketPrice: timelineItem.value,
|
||||||
return false;
|
value: timelineItem.grossPerformance.toNumber()
|
||||||
}
|
}));
|
||||||
|
|
||||||
if (dateRangeDate === undefined) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
isSameDay(parseISO(portfolioItem.date), dateRangeDate) ||
|
|
||||||
isAfter(parseISO(portfolioItem.date), dateRangeDate)
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.map((portfolioItem) => {
|
|
||||||
return {
|
|
||||||
date: format(parseISO(portfolioItem.date), 'yyyy-MM-dd'),
|
|
||||||
grossPerformancePercent: portfolioItem.grossPerformancePercent,
|
|
||||||
marketPrice: portfolioItem.value ?? null,
|
|
||||||
value: portfolioItem.value - portfolioItem.investment ?? null
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getOverview(
|
public async getOverview(
|
||||||
aImpersonationId: string
|
aImpersonationId: string
|
||||||
): Promise<PortfolioOverview> {
|
): Promise<PortfolioOverview> {
|
||||||
const impersonationUserId =
|
const userId = await this.getUserId(aImpersonationId);
|
||||||
await this.impersonationService.validateImpersonationId(
|
|
||||||
aImpersonationId,
|
|
||||||
this.request.user.id
|
|
||||||
);
|
|
||||||
|
|
||||||
const portfolio = await this.createPortfolio(
|
const currency = this.request.user.Settings.currency;
|
||||||
impersonationUserId || this.request.user.id
|
const { balance } = await this.accountService.getCashDetails(
|
||||||
|
userId,
|
||||||
|
currency
|
||||||
|
);
|
||||||
|
const orders = await this.getOrders(userId);
|
||||||
|
const fees = this.getFees(orders);
|
||||||
|
|
||||||
|
const totalBuy = this.getTotalByType(orders, currency, TypeOfOrder.BUY);
|
||||||
|
const totalSell = this.getTotalByType(orders, currency, TypeOfOrder.SELL);
|
||||||
|
return {
|
||||||
|
committedFunds: totalBuy - totalSell,
|
||||||
|
fees,
|
||||||
|
cash: balance,
|
||||||
|
ordersCount: orders.length,
|
||||||
|
totalBuy: totalBuy,
|
||||||
|
totalSell: totalSell
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getDetails(
|
||||||
|
aImpersonationId: string,
|
||||||
|
aDateRange: DateRange = 'max'
|
||||||
|
): Promise<{ [symbol: string]: PortfolioPosition }> {
|
||||||
|
const userId = await this.getUserId(aImpersonationId);
|
||||||
|
|
||||||
|
const userCurrency = this.request.user.Settings.currency;
|
||||||
|
const portfolioCalculator = new PortfolioCalculator(
|
||||||
|
this.currentRateService,
|
||||||
|
userCurrency
|
||||||
);
|
);
|
||||||
|
|
||||||
const committedFunds = portfolio.getCommittedFunds();
|
const { transactionPoints, orders } = await this.getTransactionPoints(
|
||||||
const fees = portfolio.getFees();
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
if (transactionPoints?.length <= 0) {
|
||||||
committedFunds,
|
return {};
|
||||||
fees,
|
}
|
||||||
ordersCount: portfolio.getOrders().length,
|
|
||||||
totalBuy: portfolio.getTotalBuy(),
|
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||||
totalSell: portfolio.getTotalSell()
|
|
||||||
};
|
const portfolioStart = parseDate(transactionPoints[0].date);
|
||||||
|
const startDate = this.getStartDate(aDateRange, portfolioStart);
|
||||||
|
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||||
|
startDate
|
||||||
|
);
|
||||||
|
|
||||||
|
if (currentPositions.hasErrors) {
|
||||||
|
throw new Error('Missing information');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: { [symbol: string]: PortfolioPosition } = {};
|
||||||
|
const totalValue = currentPositions.currentValue;
|
||||||
|
|
||||||
|
const symbols = currentPositions.positions.map(
|
||||||
|
(position) => position.symbol
|
||||||
|
);
|
||||||
|
|
||||||
|
const [dataProviderResponses, symbolProfiles] = await Promise.all([
|
||||||
|
this.dataProviderService.get(symbols),
|
||||||
|
this.symbolProfileService.getSymbolProfiles(symbols)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
|
||||||
|
for (const symbolProfile of symbolProfiles) {
|
||||||
|
symbolProfileMap[symbolProfile.symbol] = symbolProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
const portfolioItemsNow: { [symbol: string]: TimelinePosition } = {};
|
||||||
|
for (const position of currentPositions.positions) {
|
||||||
|
portfolioItemsNow[position.symbol] = position;
|
||||||
|
}
|
||||||
|
const accounts = this.getAccounts(orders, portfolioItemsNow, userCurrency);
|
||||||
|
|
||||||
|
for (const item of currentPositions.positions) {
|
||||||
|
const value = item.quantity.mul(item.marketPrice);
|
||||||
|
const symbolProfile = symbolProfileMap[item.symbol];
|
||||||
|
const dataProviderResponse = dataProviderResponses[item.symbol];
|
||||||
|
result[item.symbol] = {
|
||||||
|
accounts,
|
||||||
|
allocationCurrent: value.div(totalValue).toNumber(),
|
||||||
|
allocationInvestment: item.investment
|
||||||
|
.div(currentPositions.totalInvestment)
|
||||||
|
.toNumber(),
|
||||||
|
countries: symbolProfile.countries,
|
||||||
|
currency: item.currency,
|
||||||
|
exchange: dataProviderResponse.exchange,
|
||||||
|
grossPerformance: item.grossPerformance.toNumber(),
|
||||||
|
grossPerformancePercent: item.grossPerformancePercentage.toNumber(),
|
||||||
|
investment: item.investment.toNumber(),
|
||||||
|
marketPrice: item.marketPrice,
|
||||||
|
marketState: dataProviderResponse.marketState,
|
||||||
|
name: item.name,
|
||||||
|
quantity: item.quantity.toNumber(),
|
||||||
|
sectors: symbolProfile.sectors,
|
||||||
|
symbol: item.symbol,
|
||||||
|
transactionCount: item.transactionCount,
|
||||||
|
type: dataProviderResponse.type,
|
||||||
|
value: value.toNumber()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getPosition(
|
public async getPosition(
|
||||||
aImpersonationId: string,
|
aImpersonationId: string,
|
||||||
aSymbol: string
|
aSymbol: string
|
||||||
): Promise<PortfolioPositionDetail> {
|
): Promise<PortfolioPositionDetail> {
|
||||||
const impersonationUserId =
|
const userId = await this.getUserId(aImpersonationId);
|
||||||
await this.impersonationService.validateImpersonationId(
|
|
||||||
aImpersonationId,
|
|
||||||
this.request.user.id
|
|
||||||
);
|
|
||||||
|
|
||||||
const portfolio = await this.createPortfolio(
|
const portfolioCalculator = new PortfolioCalculator(
|
||||||
impersonationUserId || this.request.user.id
|
this.currentRateService,
|
||||||
|
this.request.user.Settings.currency
|
||||||
);
|
);
|
||||||
|
|
||||||
const positions = portfolio.getPositions(new Date())[aSymbol];
|
const { transactionPoints, orders } = await this.getTransactionPoints(
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
if (positions) {
|
if (transactionPoints?.length <= 0) {
|
||||||
let {
|
return {
|
||||||
|
averagePrice: undefined,
|
||||||
|
currency: undefined,
|
||||||
|
firstBuyDate: undefined,
|
||||||
|
grossPerformance: undefined,
|
||||||
|
grossPerformancePercent: undefined,
|
||||||
|
historicalData: [],
|
||||||
|
investment: undefined,
|
||||||
|
marketPrice: undefined,
|
||||||
|
maxPrice: undefined,
|
||||||
|
minPrice: undefined,
|
||||||
|
quantity: undefined,
|
||||||
|
symbol: aSymbol,
|
||||||
|
transactionCount: undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||||
|
|
||||||
|
const portfolioStart = parseDate(transactionPoints[0].date);
|
||||||
|
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||||
|
portfolioStart
|
||||||
|
);
|
||||||
|
|
||||||
|
const position = currentPositions.positions.find(
|
||||||
|
(item) => item.symbol === aSymbol
|
||||||
|
);
|
||||||
|
|
||||||
|
if (position) {
|
||||||
|
const {
|
||||||
averagePrice,
|
averagePrice,
|
||||||
currency,
|
currency,
|
||||||
firstBuyDate,
|
firstBuyDate,
|
||||||
@ -229,9 +306,7 @@ export class PortfolioService {
|
|||||||
marketPrice,
|
marketPrice,
|
||||||
quantity,
|
quantity,
|
||||||
transactionCount
|
transactionCount
|
||||||
} = portfolio.getPositions(new Date())[aSymbol];
|
} = position;
|
||||||
|
|
||||||
const orders = portfolio.getOrders(aSymbol);
|
|
||||||
|
|
||||||
const historicalData = await this.dataProviderService.getHistorical(
|
const historicalData = await this.dataProviderService.getHistorical(
|
||||||
[aSymbol],
|
[aSymbol],
|
||||||
@ -240,32 +315,29 @@ export class PortfolioService {
|
|||||||
new Date()
|
new Date()
|
||||||
);
|
);
|
||||||
|
|
||||||
if (marketPrice === 0) {
|
|
||||||
marketPrice = averagePrice;
|
|
||||||
}
|
|
||||||
|
|
||||||
const historicalDataArray: HistoricalDataItem[] = [];
|
const historicalDataArray: HistoricalDataItem[] = [];
|
||||||
let currentAveragePrice: number;
|
|
||||||
let maxPrice = marketPrice;
|
let maxPrice = marketPrice;
|
||||||
let minPrice = marketPrice;
|
let minPrice = marketPrice;
|
||||||
|
|
||||||
if (historicalData[aSymbol]) {
|
if (historicalData[aSymbol]) {
|
||||||
|
let j = -1;
|
||||||
for (const [date, { marketPrice }] of Object.entries(
|
for (const [date, { marketPrice }] of Object.entries(
|
||||||
historicalData[aSymbol]
|
historicalData[aSymbol]
|
||||||
)) {
|
)) {
|
||||||
const currentDate = parse(date, 'yyyy-MM-dd', new Date());
|
while (
|
||||||
if (
|
j + 1 < transactionPoints.length &&
|
||||||
isSameDay(currentDate, parseISO(orders[0]?.getDate())) ||
|
!isAfter(parseDate(transactionPoints[j + 1].date), parseDate(date))
|
||||||
isAfter(currentDate, parseISO(orders[0]?.getDate()))
|
|
||||||
) {
|
) {
|
||||||
// Get snapshot of first day of month
|
j++;
|
||||||
const snapshot = portfolio.get(setDate(currentDate, 1))[0]
|
}
|
||||||
.positions[aSymbol];
|
let currentAveragePrice = 0;
|
||||||
orders.shift();
|
const currentSymbol = transactionPoints[j].items.find(
|
||||||
|
(item) => item.symbol === aSymbol
|
||||||
if (snapshot?.averagePrice) {
|
);
|
||||||
currentAveragePrice = snapshot?.averagePrice;
|
if (currentSymbol) {
|
||||||
}
|
currentAveragePrice = currentSymbol.investment
|
||||||
|
.div(currentSymbol.quantity)
|
||||||
|
.toNumber();
|
||||||
}
|
}
|
||||||
|
|
||||||
historicalDataArray.push({
|
historicalDataArray.push({
|
||||||
@ -274,58 +346,40 @@ export class PortfolioService {
|
|||||||
value: marketPrice
|
value: marketPrice
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
maxPrice = Math.max(marketPrice ?? 0, maxPrice);
|
||||||
marketPrice &&
|
minPrice = Math.min(marketPrice ?? Number.MAX_SAFE_INTEGER, minPrice);
|
||||||
(marketPrice > maxPrice || maxPrice === undefined)
|
|
||||||
) {
|
|
||||||
maxPrice = marketPrice;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
marketPrice &&
|
|
||||||
(marketPrice < minPrice || minPrice === undefined)
|
|
||||||
) {
|
|
||||||
minPrice = marketPrice;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
averagePrice,
|
averagePrice: averagePrice.toNumber(),
|
||||||
currency,
|
currency,
|
||||||
firstBuyDate,
|
firstBuyDate,
|
||||||
investment,
|
investment: investment.toNumber(),
|
||||||
marketPrice,
|
marketPrice,
|
||||||
maxPrice,
|
maxPrice,
|
||||||
minPrice,
|
minPrice,
|
||||||
quantity,
|
quantity: quantity.toNumber(),
|
||||||
transactionCount,
|
transactionCount,
|
||||||
grossPerformance: this.exchangeRateDataService.toCurrency(
|
grossPerformance: position.grossPerformance.toNumber(),
|
||||||
marketPrice - averagePrice,
|
grossPerformancePercent: position.grossPerformancePercentage.toNumber(),
|
||||||
currency,
|
|
||||||
this.request.user.Settings.currency
|
|
||||||
),
|
|
||||||
grossPerformancePercent: roundTo(
|
|
||||||
(marketPrice - averagePrice) / averagePrice,
|
|
||||||
4
|
|
||||||
),
|
|
||||||
historicalData: historicalDataArray,
|
historicalData: historicalDataArray,
|
||||||
symbol: aSymbol
|
symbol: aSymbol
|
||||||
};
|
};
|
||||||
} else if (portfolio.getMinDate()) {
|
} else {
|
||||||
const currentData = await this.dataProviderService.get([aSymbol]);
|
const currentData = await this.dataProviderService.get([aSymbol]);
|
||||||
|
|
||||||
let historicalData = await this.dataProviderService.getHistorical(
|
let historicalData = await this.dataProviderService.getHistorical(
|
||||||
[aSymbol],
|
[aSymbol],
|
||||||
'day',
|
'day',
|
||||||
portfolio.getMinDate(),
|
portfolioStart,
|
||||||
new Date()
|
new Date()
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isEmpty(historicalData)) {
|
if (isEmpty(historicalData)) {
|
||||||
historicalData = await this.dataProviderService.getHistoricalRaw(
|
historicalData = await this.dataProviderService.getHistoricalRaw(
|
||||||
[{ dataSource: DataSource.YAHOO, symbol: aSymbol }],
|
[{ dataSource: DataSource.YAHOO, symbol: aSymbol }],
|
||||||
portfolio.getMinDate(),
|
portfolioStart,
|
||||||
new Date()
|
new Date()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -343,13 +397,13 @@ export class PortfolioService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
averagePrice: undefined,
|
averagePrice: undefined,
|
||||||
currency: currentData[aSymbol].currency,
|
currency: currentData[aSymbol]?.currency,
|
||||||
firstBuyDate: undefined,
|
firstBuyDate: undefined,
|
||||||
grossPerformance: undefined,
|
grossPerformance: undefined,
|
||||||
grossPerformancePercent: undefined,
|
grossPerformancePercent: undefined,
|
||||||
historicalData: historicalDataArray,
|
historicalData: historicalDataArray,
|
||||||
investment: undefined,
|
investment: undefined,
|
||||||
marketPrice: currentData[aSymbol].marketPrice,
|
marketPrice: currentData[aSymbol]?.marketPrice,
|
||||||
maxPrice: undefined,
|
maxPrice: undefined,
|
||||||
minPrice: undefined,
|
minPrice: undefined,
|
||||||
quantity: undefined,
|
quantity: undefined,
|
||||||
@ -357,68 +411,334 @@ export class PortfolioService {
|
|||||||
transactionCount: undefined
|
transactionCount: undefined
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getPositions(
|
||||||
|
aImpersonationId: string,
|
||||||
|
aDateRange: DateRange = 'max'
|
||||||
|
): Promise<{ hasErrors: boolean; positions: Position[] }> {
|
||||||
|
const userId = await this.getUserId(aImpersonationId);
|
||||||
|
|
||||||
|
const portfolioCalculator = new PortfolioCalculator(
|
||||||
|
this.currentRateService,
|
||||||
|
this.request.user.Settings.currency
|
||||||
|
);
|
||||||
|
|
||||||
|
const { transactionPoints } = await this.getTransactionPoints(userId);
|
||||||
|
|
||||||
|
if (transactionPoints?.length <= 0) {
|
||||||
|
return {
|
||||||
|
hasErrors: false,
|
||||||
|
positions: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||||
|
|
||||||
|
const portfolioStart = parseDate(transactionPoints[0].date);
|
||||||
|
const startDate = this.getStartDate(aDateRange, portfolioStart);
|
||||||
|
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||||
|
startDate
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
averagePrice: undefined,
|
hasErrors: currentPositions.hasErrors,
|
||||||
currency: undefined,
|
positions: currentPositions.positions.map((position) => {
|
||||||
firstBuyDate: undefined,
|
return {
|
||||||
grossPerformance: undefined,
|
...position,
|
||||||
grossPerformancePercent: undefined,
|
averagePrice: new Big(position.averagePrice).toNumber(),
|
||||||
historicalData: [],
|
grossPerformance: position.grossPerformance?.toNumber() ?? null,
|
||||||
investment: undefined,
|
grossPerformancePercentage:
|
||||||
marketPrice: undefined,
|
position.grossPerformancePercentage?.toNumber() ?? null,
|
||||||
maxPrice: undefined,
|
investment: new Big(position.investment).toNumber(),
|
||||||
minPrice: undefined,
|
name: position.name,
|
||||||
quantity: undefined,
|
quantity: new Big(position.quantity).toNumber(),
|
||||||
symbol: aSymbol,
|
type: Type.Unknown, // TODO
|
||||||
transactionCount: undefined
|
url: '' // TODO
|
||||||
|
};
|
||||||
|
})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private convertDateRangeToDate(aDateRange: DateRange, aMinDate: Date) {
|
public async getPerformance(
|
||||||
let currentDate = new Date();
|
aImpersonationId: string,
|
||||||
|
aDateRange: DateRange = 'max'
|
||||||
|
): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> {
|
||||||
|
const userId = await this.getUserId(aImpersonationId);
|
||||||
|
|
||||||
const normalizedMinDate =
|
const portfolioCalculator = new PortfolioCalculator(
|
||||||
getDate(aMinDate) === 1
|
this.currentRateService,
|
||||||
? aMinDate
|
this.request.user.Settings.currency
|
||||||
: add(setDate(aMinDate, 1), { months: 1 });
|
);
|
||||||
|
|
||||||
const year = getYear(currentDate);
|
const { transactionPoints } = await this.getTransactionPoints(userId);
|
||||||
const month = getMonth(currentDate);
|
|
||||||
const day = getDate(currentDate);
|
|
||||||
|
|
||||||
currentDate = new Date(Date.UTC(year, month, day, 0));
|
if (transactionPoints?.length <= 0) {
|
||||||
|
return {
|
||||||
|
hasErrors: false,
|
||||||
|
performance: {
|
||||||
|
currentGrossPerformance: 0,
|
||||||
|
currentGrossPerformancePercent: 0,
|
||||||
|
currentNetPerformance: 0,
|
||||||
|
currentNetPerformancePercent: 0,
|
||||||
|
currentValue: 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||||
|
|
||||||
|
const portfolioStart = parseDate(transactionPoints[0].date);
|
||||||
|
const startDate = this.getStartDate(aDateRange, portfolioStart);
|
||||||
|
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||||
|
startDate
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasErrors = currentPositions.hasErrors;
|
||||||
|
const currentValue = currentPositions.currentValue.toNumber();
|
||||||
|
const currentGrossPerformance =
|
||||||
|
currentPositions.grossPerformance.toNumber();
|
||||||
|
const currentGrossPerformancePercent =
|
||||||
|
currentPositions.grossPerformancePercentage.toNumber();
|
||||||
|
return {
|
||||||
|
hasErrors: currentPositions.hasErrors || hasErrors,
|
||||||
|
performance: {
|
||||||
|
currentGrossPerformance,
|
||||||
|
currentGrossPerformancePercent,
|
||||||
|
// TODO: the next two should include fees
|
||||||
|
currentNetPerformance: currentGrossPerformance,
|
||||||
|
currentNetPerformancePercent: currentGrossPerformancePercent,
|
||||||
|
currentValue: currentValue
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
||||||
|
const userId = await this.getUserId(impersonationId);
|
||||||
|
const baseCurrency = this.request.user.Settings.currency;
|
||||||
|
|
||||||
|
const { transactionPoints, orders } = await this.getTransactionPoints(
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isEmpty(orders)) {
|
||||||
|
return {
|
||||||
|
rules: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const portfolioCalculator = new PortfolioCalculator(
|
||||||
|
this.currentRateService,
|
||||||
|
this.request.user.Settings.currency
|
||||||
|
);
|
||||||
|
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||||
|
|
||||||
|
const portfolioStart = parseDate(transactionPoints[0].date);
|
||||||
|
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||||
|
portfolioStart
|
||||||
|
);
|
||||||
|
|
||||||
|
const portfolioItemsNow: { [symbol: string]: TimelinePosition } = {};
|
||||||
|
for (const position of currentPositions.positions) {
|
||||||
|
portfolioItemsNow[position.symbol] = position;
|
||||||
|
}
|
||||||
|
const accounts = this.getAccounts(orders, portfolioItemsNow, baseCurrency);
|
||||||
|
return {
|
||||||
|
rules: {
|
||||||
|
accountClusterRisk: await this.rulesService.evaluate(
|
||||||
|
[
|
||||||
|
new AccountClusterRiskInitialInvestment(
|
||||||
|
this.exchangeRateDataService,
|
||||||
|
accounts
|
||||||
|
),
|
||||||
|
new AccountClusterRiskCurrentInvestment(
|
||||||
|
this.exchangeRateDataService,
|
||||||
|
accounts
|
||||||
|
),
|
||||||
|
new AccountClusterRiskSingleAccount(
|
||||||
|
this.exchangeRateDataService,
|
||||||
|
accounts
|
||||||
|
)
|
||||||
|
],
|
||||||
|
{ baseCurrency }
|
||||||
|
),
|
||||||
|
currencyClusterRisk: await this.rulesService.evaluate(
|
||||||
|
[
|
||||||
|
new CurrencyClusterRiskBaseCurrencyInitialInvestment(
|
||||||
|
this.exchangeRateDataService,
|
||||||
|
currentPositions
|
||||||
|
),
|
||||||
|
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
|
||||||
|
this.exchangeRateDataService,
|
||||||
|
currentPositions
|
||||||
|
),
|
||||||
|
new CurrencyClusterRiskInitialInvestment(
|
||||||
|
this.exchangeRateDataService,
|
||||||
|
currentPositions
|
||||||
|
),
|
||||||
|
new CurrencyClusterRiskCurrentInvestment(
|
||||||
|
this.exchangeRateDataService,
|
||||||
|
currentPositions
|
||||||
|
)
|
||||||
|
],
|
||||||
|
{ baseCurrency }
|
||||||
|
),
|
||||||
|
fees: await this.rulesService.evaluate(
|
||||||
|
[
|
||||||
|
new FeeRatioInitialInvestment(
|
||||||
|
this.exchangeRateDataService,
|
||||||
|
currentPositions.totalInvestment.toNumber(),
|
||||||
|
this.getFees(orders)
|
||||||
|
)
|
||||||
|
],
|
||||||
|
{ baseCurrency }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getStartDate(aDateRange: DateRange, portfolioStart: Date) {
|
||||||
switch (aDateRange) {
|
switch (aDateRange) {
|
||||||
case '1d':
|
case '1d':
|
||||||
return sub(currentDate, {
|
portfolioStart = max([portfolioStart, subDays(new Date(), 1)]);
|
||||||
days: 1
|
break;
|
||||||
});
|
|
||||||
case 'ytd':
|
case 'ytd':
|
||||||
currentDate = setDate(currentDate, 1);
|
portfolioStart = max([portfolioStart, setDayOfYear(new Date(), 1)]);
|
||||||
currentDate = setMonth(currentDate, 0);
|
break;
|
||||||
return isAfter(currentDate, normalizedMinDate)
|
|
||||||
? currentDate
|
|
||||||
: undefined;
|
|
||||||
case '1y':
|
case '1y':
|
||||||
currentDate = setDate(currentDate, 1);
|
portfolioStart = max([portfolioStart, subYears(new Date(), 1)]);
|
||||||
currentDate = sub(currentDate, {
|
break;
|
||||||
years: 1
|
|
||||||
});
|
|
||||||
return isAfter(currentDate, normalizedMinDate)
|
|
||||||
? currentDate
|
|
||||||
: undefined;
|
|
||||||
case '5y':
|
case '5y':
|
||||||
currentDate = setDate(currentDate, 1);
|
portfolioStart = max([portfolioStart, subYears(new Date(), 5)]);
|
||||||
currentDate = sub(currentDate, {
|
break;
|
||||||
years: 5
|
|
||||||
});
|
|
||||||
return isAfter(currentDate, normalizedMinDate)
|
|
||||||
? currentDate
|
|
||||||
: undefined;
|
|
||||||
default:
|
|
||||||
// Gets handled as all data
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
return portfolioStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getTransactionPoints(userId: string): Promise<{
|
||||||
|
transactionPoints: TransactionPoint[];
|
||||||
|
orders: OrderWithAccount[];
|
||||||
|
}> {
|
||||||
|
const orders = await this.getOrders(userId);
|
||||||
|
|
||||||
|
if (orders.length <= 0) {
|
||||||
|
return { transactionPoints: [], orders: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
|
||||||
|
currency: order.currency,
|
||||||
|
date: format(order.date, DATE_FORMAT),
|
||||||
|
name: order.SymbolProfile?.name,
|
||||||
|
quantity: new Big(order.quantity),
|
||||||
|
symbol: order.symbol,
|
||||||
|
type: <OrderType>order.type,
|
||||||
|
unitPrice: new Big(order.unitPrice)
|
||||||
|
}));
|
||||||
|
|
||||||
|
const portfolioCalculator = new PortfolioCalculator(
|
||||||
|
this.currentRateService,
|
||||||
|
this.request.user.Settings.currency
|
||||||
|
);
|
||||||
|
portfolioCalculator.computeTransactionPoints(portfolioOrders);
|
||||||
|
return {
|
||||||
|
transactionPoints: portfolioCalculator.getTransactionPoints(),
|
||||||
|
orders
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAccounts(
|
||||||
|
orders: OrderWithAccount[],
|
||||||
|
portfolioItemsNow: { [p: string]: TimelinePosition },
|
||||||
|
userCurrency
|
||||||
|
) {
|
||||||
|
const accounts: PortfolioPosition['accounts'] = {};
|
||||||
|
for (const order of orders) {
|
||||||
|
let currentValueOfSymbol = this.exchangeRateDataService.toCurrency(
|
||||||
|
order.quantity * portfolioItemsNow[order.symbol].marketPrice,
|
||||||
|
order.currency,
|
||||||
|
userCurrency
|
||||||
|
);
|
||||||
|
let originalValueOfSymbol = this.exchangeRateDataService.toCurrency(
|
||||||
|
order.quantity * order.unitPrice,
|
||||||
|
order.currency,
|
||||||
|
userCurrency
|
||||||
|
);
|
||||||
|
|
||||||
|
if (order.type === 'SELL') {
|
||||||
|
currentValueOfSymbol *= -1;
|
||||||
|
originalValueOfSymbol *= -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accounts[order.Account?.name || UNKNOWN_KEY]?.current) {
|
||||||
|
accounts[order.Account?.name || UNKNOWN_KEY].current +=
|
||||||
|
currentValueOfSymbol;
|
||||||
|
accounts[order.Account?.name || UNKNOWN_KEY].original +=
|
||||||
|
originalValueOfSymbol;
|
||||||
|
} else {
|
||||||
|
accounts[order.Account?.name || UNKNOWN_KEY] = {
|
||||||
|
current: currentValueOfSymbol,
|
||||||
|
original: originalValueOfSymbol
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return accounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOrders(aUserId: string) {
|
||||||
|
return this.orderService.orders({
|
||||||
|
include: {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
Account: true,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
SymbolProfile: true
|
||||||
|
},
|
||||||
|
orderBy: { date: 'asc' },
|
||||||
|
where: { userId: aUserId }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getUserId(aImpersonationId: string) {
|
||||||
|
const impersonationUserId =
|
||||||
|
await this.impersonationService.validateImpersonationId(
|
||||||
|
aImpersonationId,
|
||||||
|
this.request.user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
return impersonationUserId || this.request.user.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTotalByType(
|
||||||
|
orders: OrderWithAccount[],
|
||||||
|
currency: Currency,
|
||||||
|
type: TypeOfOrder
|
||||||
|
) {
|
||||||
|
return orders
|
||||||
|
.filter(
|
||||||
|
(order) => !isAfter(order.date, endOfToday()) && order.type === type
|
||||||
|
)
|
||||||
|
.map((order) => {
|
||||||
|
return this.exchangeRateDataService.toCurrency(
|
||||||
|
order.quantity * order.unitPrice,
|
||||||
|
order.currency,
|
||||||
|
currency
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.reduce((previous, current) => previous + current, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
export const environment = {
|
export const environment = {
|
||||||
production: true
|
production: true,
|
||||||
|
version: `v${require('../../../../package.json').version}`
|
||||||
};
|
};
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
export const environment = {
|
export const environment = {
|
||||||
production: false
|
production: false,
|
||||||
|
version: 'dev'
|
||||||
};
|
};
|
||||||
|
@ -5,13 +5,9 @@ import { Order } from '../order';
|
|||||||
export interface PortfolioInterface {
|
export interface PortfolioInterface {
|
||||||
get(aDate?: Date): PortfolioItem[];
|
get(aDate?: Date): PortfolioItem[];
|
||||||
|
|
||||||
getCommittedFunds(): number;
|
|
||||||
|
|
||||||
getFees(): number;
|
getFees(): number;
|
||||||
|
|
||||||
getPositions(
|
getPositions(aDate: Date): {
|
||||||
aDate: Date
|
|
||||||
): {
|
|
||||||
[symbol: string]: Position;
|
[symbol: string]: Position;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -0,0 +1,3 @@
|
|||||||
|
export interface RuleSettings {
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
@ -1,15 +1,10 @@
|
|||||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
|
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||||
|
|
||||||
import { EvaluationResult } from './evaluation-result.interface';
|
import { EvaluationResult } from './evaluation-result.interface';
|
||||||
|
|
||||||
export interface RuleInterface {
|
export interface RuleInterface<T extends RuleSettings> {
|
||||||
evaluate(
|
evaluate(aRuleSettings: T): EvaluationResult;
|
||||||
aPortfolioPositionMap: {
|
|
||||||
[symbol: string]: PortfolioPosition;
|
getSettings(aUserSettings: UserSettings): T;
|
||||||
},
|
|
||||||
aFees: number,
|
|
||||||
aRuleSettingsMap: {
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
): EvaluationResult;
|
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,5 @@
|
|||||||
|
import { Currency } from '@prisma/client';
|
||||||
|
|
||||||
|
export interface UserSettings {
|
||||||
|
baseCurrency: Currency;
|
||||||
|
}
|
@ -1,648 +0,0 @@
|
|||||||
import { UNKNOWN_KEY, baseCurrency } from '@ghostfolio/common/config';
|
|
||||||
import { getUtc, getYesterday } from '@ghostfolio/common/helper';
|
|
||||||
import {
|
|
||||||
AccountType,
|
|
||||||
Currency,
|
|
||||||
DataSource,
|
|
||||||
Role,
|
|
||||||
Type,
|
|
||||||
ViewMode
|
|
||||||
} from '@prisma/client';
|
|
||||||
import { format } from 'date-fns';
|
|
||||||
|
|
||||||
import { DataProviderService } from '../services/data-provider.service';
|
|
||||||
import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
|
|
||||||
import { MarketState } from '../services/interfaces/interfaces';
|
|
||||||
import { RulesService } from '../services/rules.service';
|
|
||||||
import { Portfolio } from './portfolio';
|
|
||||||
|
|
||||||
jest.mock('../services/data-provider.service', () => {
|
|
||||||
return {
|
|
||||||
DataProviderService: jest.fn().mockImplementation(() => {
|
|
||||||
const today = format(new Date(), 'yyyy-MM-dd');
|
|
||||||
const yesterday = format(getYesterday(), 'yyyy-MM-dd');
|
|
||||||
|
|
||||||
return {
|
|
||||||
get: () => {
|
|
||||||
return Promise.resolve({
|
|
||||||
BTCUSD: {
|
|
||||||
currency: Currency.USD,
|
|
||||||
dataSource: DataSource.YAHOO,
|
|
||||||
exchange: UNKNOWN_KEY,
|
|
||||||
marketPrice: 57973.008,
|
|
||||||
marketState: MarketState.open,
|
|
||||||
name: 'Bitcoin USD',
|
|
||||||
type: 'Cryptocurrency'
|
|
||||||
},
|
|
||||||
ETHUSD: {
|
|
||||||
currency: Currency.USD,
|
|
||||||
dataSource: DataSource.YAHOO,
|
|
||||||
exchange: UNKNOWN_KEY,
|
|
||||||
marketPrice: 3915.337,
|
|
||||||
marketState: MarketState.open,
|
|
||||||
name: 'Ethereum USD',
|
|
||||||
type: 'Cryptocurrency'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
getHistorical: () => {
|
|
||||||
return Promise.resolve({
|
|
||||||
BTCUSD: {
|
|
||||||
[yesterday]: 56710.122,
|
|
||||||
[today]: 57973.008
|
|
||||||
},
|
|
||||||
ETHUSD: {
|
|
||||||
[yesterday]: 3641.984,
|
|
||||||
[today]: 3915.337
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
})
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock('../services/exchange-rate-data.service', () => {
|
|
||||||
return {
|
|
||||||
ExchangeRateDataService: jest.fn().mockImplementation(() => {
|
|
||||||
return {
|
|
||||||
initialize: () => Promise.resolve(),
|
|
||||||
toCurrency: (value: number) => value
|
|
||||||
};
|
|
||||||
})
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock('../services/data-provider.service');
|
|
||||||
jest.mock('../services/exchange-rate-data.service');
|
|
||||||
jest.mock('../services/rules.service');
|
|
||||||
|
|
||||||
const DEFAULT_ACCOUNT_ID = '693a834b-eb89-42c9-ae47-35196c25d269';
|
|
||||||
const USER_ID = 'ca6ce867-5d31-495a-bce9-5942bbca9237';
|
|
||||||
|
|
||||||
describe('Portfolio', () => {
|
|
||||||
let dataProviderService: DataProviderService;
|
|
||||||
let exchangeRateDataService: ExchangeRateDataService;
|
|
||||||
let portfolio: Portfolio;
|
|
||||||
let rulesService: RulesService;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
dataProviderService = new DataProviderService(
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
exchangeRateDataService = new ExchangeRateDataService(null);
|
|
||||||
rulesService = new RulesService();
|
|
||||||
|
|
||||||
await exchangeRateDataService.initialize();
|
|
||||||
|
|
||||||
portfolio = new Portfolio(
|
|
||||||
dataProviderService,
|
|
||||||
exchangeRateDataService,
|
|
||||||
rulesService
|
|
||||||
);
|
|
||||||
portfolio.setUser({
|
|
||||||
accessToken: null,
|
|
||||||
Account: [
|
|
||||||
{
|
|
||||||
accountType: AccountType.SECURITIES,
|
|
||||||
createdAt: new Date(),
|
|
||||||
id: DEFAULT_ACCOUNT_ID,
|
|
||||||
isDefault: true,
|
|
||||||
name: 'Default Account',
|
|
||||||
platformId: null,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
userId: USER_ID
|
|
||||||
}
|
|
||||||
],
|
|
||||||
alias: 'Test',
|
|
||||||
authChallenge: null,
|
|
||||||
createdAt: new Date(),
|
|
||||||
id: USER_ID,
|
|
||||||
provider: null,
|
|
||||||
role: Role.USER,
|
|
||||||
Settings: {
|
|
||||||
currency: Currency.CHF,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
userId: USER_ID,
|
|
||||||
viewMode: ViewMode.DEFAULT
|
|
||||||
},
|
|
||||||
thirdPartyId: null,
|
|
||||||
updatedAt: new Date()
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('works with no orders', () => {
|
|
||||||
it('should return []', () => {
|
|
||||||
expect(portfolio.get(new Date())).toEqual([]);
|
|
||||||
expect(portfolio.getFees()).toEqual(0);
|
|
||||||
expect(portfolio.getPositions(new Date())).toEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return empty details', async () => {
|
|
||||||
const details = await portfolio.getDetails('1d');
|
|
||||||
expect(details).toEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return empty details', async () => {
|
|
||||||
const details = await portfolio.getDetails('max');
|
|
||||||
expect(details).toEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return zero performance for 1d', async () => {
|
|
||||||
const performance = await portfolio.getPerformance('1d');
|
|
||||||
expect(performance).toEqual({
|
|
||||||
currentGrossPerformance: 0,
|
|
||||||
currentGrossPerformancePercent: 0,
|
|
||||||
currentNetPerformance: 0,
|
|
||||||
currentNetPerformancePercent: 0,
|
|
||||||
currentValue: 0
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return zero performance for max', async () => {
|
|
||||||
const performance = await portfolio.getPerformance('max');
|
|
||||||
expect(performance).toEqual({
|
|
||||||
currentGrossPerformance: 0,
|
|
||||||
currentGrossPerformancePercent: 0,
|
|
||||||
currentNetPerformance: 0,
|
|
||||||
currentNetPerformancePercent: 0,
|
|
||||||
currentValue: 0
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe(`works with today's orders`, () => {
|
|
||||||
it('should return ["BTC"]', async () => {
|
|
||||||
await portfolio.setOrders([
|
|
||||||
{
|
|
||||||
accountId: DEFAULT_ACCOUNT_ID,
|
|
||||||
accountUserId: USER_ID,
|
|
||||||
createdAt: null,
|
|
||||||
currency: Currency.USD,
|
|
||||||
dataSource: DataSource.YAHOO,
|
|
||||||
fee: 0,
|
|
||||||
date: new Date(),
|
|
||||||
id: '8d999347-dee2-46ee-88e1-26b344e71fcc',
|
|
||||||
quantity: 1,
|
|
||||||
symbol: 'BTCUSD',
|
|
||||||
symbolProfileId: null,
|
|
||||||
type: Type.BUY,
|
|
||||||
unitPrice: 49631.24,
|
|
||||||
updatedAt: null,
|
|
||||||
userId: USER_ID
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(portfolio.getCommittedFunds()).toEqual(
|
|
||||||
exchangeRateDataService.toCurrency(
|
|
||||||
1 * 49631.24,
|
|
||||||
Currency.USD,
|
|
||||||
baseCurrency
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const details = await portfolio.getDetails('1d');
|
|
||||||
expect(details).toMatchObject({
|
|
||||||
BTCUSD: {
|
|
||||||
accounts: {
|
|
||||||
[UNKNOWN_KEY]: {
|
|
||||||
/*current: exchangeRateDataService.toCurrency(
|
|
||||||
1 * 49631.24,
|
|
||||||
Currency.USD,
|
|
||||||
baseCurrency
|
|
||||||
),*/
|
|
||||||
original: exchangeRateDataService.toCurrency(
|
|
||||||
1 * 49631.24,
|
|
||||||
Currency.USD,
|
|
||||||
baseCurrency
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
allocationCurrent: 1,
|
|
||||||
allocationInvestment: 1,
|
|
||||||
countries: [],
|
|
||||||
currency: Currency.USD,
|
|
||||||
exchange: UNKNOWN_KEY,
|
|
||||||
grossPerformance: 0,
|
|
||||||
grossPerformancePercent: 0,
|
|
||||||
investment: exchangeRateDataService.toCurrency(
|
|
||||||
1 * 49631.24,
|
|
||||||
Currency.USD,
|
|
||||||
baseCurrency
|
|
||||||
),
|
|
||||||
marketPrice: 57973.008,
|
|
||||||
marketState: MarketState.open,
|
|
||||||
name: 'Bitcoin USD',
|
|
||||||
quantity: 1,
|
|
||||||
symbol: 'BTCUSD',
|
|
||||||
transactionCount: 1,
|
|
||||||
type: 'Cryptocurrency'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(portfolio.getFees()).toEqual(0);
|
|
||||||
|
|
||||||
/*const performance1d = await portfolio.getPerformance('1d');
|
|
||||||
expect(performance1d).toEqual({
|
|
||||||
currentGrossPerformance: 0,
|
|
||||||
currentGrossPerformancePercent: 0,
|
|
||||||
currentNetPerformance: 0,
|
|
||||||
currentNetPerformancePercent: 0,
|
|
||||||
currentValue: exchangeRateDataService.toBaseCurrency(
|
|
||||||
1 * 49631.24,
|
|
||||||
Currency.USD,
|
|
||||||
baseCurrency
|
|
||||||
)
|
|
||||||
});*/
|
|
||||||
|
|
||||||
/*const performanceMax = await portfolio.getPerformance('max');
|
|
||||||
expect(performanceMax).toEqual({
|
|
||||||
currentGrossPerformance: 0,
|
|
||||||
currentGrossPerformancePercent: 0,
|
|
||||||
currentNetPerformance: 0,
|
|
||||||
currentNetPerformancePercent: 0,
|
|
||||||
currentValue: exchangeRateDataService.toBaseCurrency(
|
|
||||||
1 * 49631.24,
|
|
||||||
Currency.USD,
|
|
||||||
baseCurrency
|
|
||||||
)
|
|
||||||
});*/
|
|
||||||
|
|
||||||
expect(portfolio.getPositions(getYesterday())).toMatchObject({});
|
|
||||||
|
|
||||||
expect(portfolio.getSymbols(getYesterday())).toEqual([]);
|
|
||||||
|
|
||||||
expect(portfolio.getSymbols(new Date())).toEqual(['BTCUSD']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('works with orders', () => {
|
|
||||||
it('should return ["ETHUSD"]', async () => {
|
|
||||||
await portfolio.setOrders([
|
|
||||||
{
|
|
||||||
accountId: DEFAULT_ACCOUNT_ID,
|
|
||||||
accountUserId: USER_ID,
|
|
||||||
createdAt: null,
|
|
||||||
currency: Currency.USD,
|
|
||||||
dataSource: DataSource.YAHOO,
|
|
||||||
fee: 0,
|
|
||||||
date: new Date(getUtc('2018-01-05')),
|
|
||||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
|
|
||||||
quantity: 0.2,
|
|
||||||
symbol: 'ETHUSD',
|
|
||||||
symbolProfileId: null,
|
|
||||||
type: Type.BUY,
|
|
||||||
unitPrice: 991.49,
|
|
||||||
updatedAt: null,
|
|
||||||
userId: USER_ID
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(portfolio.getCommittedFunds()).toEqual(
|
|
||||||
exchangeRateDataService.toCurrency(
|
|
||||||
0.2 * 991.49,
|
|
||||||
Currency.USD,
|
|
||||||
baseCurrency
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
/*const details = await portfolio.getDetails('1d');
|
|
||||||
expect(details).toMatchObject({
|
|
||||||
ETHUSD: {
|
|
||||||
accounts: {
|
|
||||||
[UNKNOWN_KEY]: {
|
|
||||||
current: exchangeRateDataService.toCurrency(
|
|
||||||
0.2 * 991.49,
|
|
||||||
Currency.USD,
|
|
||||||
baseCurrency
|
|
||||||
),
|
|
||||||
original: exchangeRateDataService.toCurrency(
|
|
||||||
0.2 * 991.49,
|
|
||||||
Currency.USD,
|
|
||||||
baseCurrency
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// allocationCurrent: 1,
|
|
||||||
allocationInvestment: 1,
|
|
||||||
countries: [],
|
|
||||||
currency: Currency.USD,
|
|
||||||
exchange: UNKNOWN_KEY,
|
|
||||||
// grossPerformance: 0,
|
|
||||||
// grossPerformancePercent: 0,
|
|
||||||
investment: exchangeRateDataService.toCurrency(
|
|
||||||
0.2 * 991.49,
|
|
||||||
Currency.USD,
|
|
||||||
baseCurrency
|
|
||||||
),
|
|
||||||
marketPrice: 3915.337,
|
|
||||||
name: 'Ethereum USD',
|
|
||||||
quantity: 0.2,
|
|
||||||
transactionCount: 1,
|
|
||||||
symbol: 'ETHUSD',
|
|
||||||
type: 'Cryptocurrency'
|
|
||||||
}
|
|
||||||
});*/
|
|
||||||
|
|
||||||
expect(portfolio.getFees()).toEqual(0);
|
|
||||||
|
|
||||||
/*const performance = await portfolio.getPerformance('max');
|
|
||||||
expect(performance).toEqual({
|
|
||||||
currentGrossPerformance: 0,
|
|
||||||
currentGrossPerformancePercent: 0,
|
|
||||||
currentNetPerformance: 0,
|
|
||||||
currentNetPerformancePercent: 0,
|
|
||||||
currentValue: 0
|
|
||||||
});*/
|
|
||||||
|
|
||||||
expect(portfolio.getPositions(getYesterday())).toMatchObject({
|
|
||||||
ETHUSD: {
|
|
||||||
averagePrice: 991.49,
|
|
||||||
currency: Currency.USD,
|
|
||||||
firstBuyDate: '2018-01-05T00:00:00.000Z',
|
|
||||||
investment: exchangeRateDataService.toCurrency(
|
|
||||||
0.2 * 991.49,
|
|
||||||
Currency.USD,
|
|
||||||
baseCurrency
|
|
||||||
),
|
|
||||||
investmentInOriginalCurrency: 0.2 * 991.49,
|
|
||||||
// marketPrice: 3915.337,
|
|
||||||
quantity: 0.2
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(portfolio.getSymbols(getYesterday())).toEqual(['ETHUSD']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return ["ETHUSD"]', async () => {
|
|
||||||
await portfolio.setOrders([
|
|
||||||
{
|
|
||||||
accountId: DEFAULT_ACCOUNT_ID,
|
|
||||||
accountUserId: USER_ID,
|
|
||||||
createdAt: null,
|
|
||||||
currency: Currency.USD,
|
|
||||||
dataSource: DataSource.YAHOO,
|
|
||||||
fee: 0,
|
|
||||||
date: new Date(getUtc('2018-01-05')),
|
|
||||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
|
|
||||||
quantity: 0.2,
|
|
||||||
symbol: 'ETHUSD',
|
|
||||||
symbolProfileId: null,
|
|
||||||
type: Type.BUY,
|
|
||||||
unitPrice: 991.49,
|
|
||||||
updatedAt: null,
|
|
||||||
userId: USER_ID
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accountId: DEFAULT_ACCOUNT_ID,
|
|
||||||
accountUserId: USER_ID,
|
|
||||||
createdAt: null,
|
|
||||||
currency: Currency.USD,
|
|
||||||
dataSource: DataSource.YAHOO,
|
|
||||||
fee: 0,
|
|
||||||
date: new Date(getUtc('2018-01-28')),
|
|
||||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
|
|
||||||
quantity: 0.3,
|
|
||||||
symbol: 'ETHUSD',
|
|
||||||
symbolProfileId: null,
|
|
||||||
type: Type.BUY,
|
|
||||||
unitPrice: 1050,
|
|
||||||
updatedAt: null,
|
|
||||||
userId: USER_ID
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(portfolio.getCommittedFunds()).toEqual(
|
|
||||||
exchangeRateDataService.toCurrency(
|
|
||||||
0.2 * 991.49,
|
|
||||||
Currency.USD,
|
|
||||||
baseCurrency
|
|
||||||
) +
|
|
||||||
exchangeRateDataService.toCurrency(
|
|
||||||
0.3 * 1050,
|
|
||||||
Currency.USD,
|
|
||||||
baseCurrency
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(portfolio.getFees()).toEqual(0);
|
|
||||||
|
|
||||||
expect(portfolio.getPositions(getYesterday())).toMatchObject({
|
|
||||||
ETHUSD: {
|
|
||||||
averagePrice: (0.2 * 991.49 + 0.3 * 1050) / (0.2 + 0.3),
|
|
||||||
currency: Currency.USD,
|
|
||||||
firstBuyDate: '2018-01-05T00:00:00.000Z',
|
|
||||||
investment:
|
|
||||||
exchangeRateDataService.toCurrency(
|
|
||||||
0.2 * 991.49,
|
|
||||||
Currency.USD,
|
|
||||||
baseCurrency
|
|
||||||
) +
|
|
||||||
exchangeRateDataService.toCurrency(
|
|
||||||
0.3 * 1050,
|
|
||||||
Currency.USD,
|
|
||||||
baseCurrency
|
|
||||||
),
|
|
||||||
investmentInOriginalCurrency: 0.2 * 991.49 + 0.3 * 1050,
|
|
||||||
// marketPrice: 3641.984,
|
|
||||||
quantity: 0.5
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(portfolio.getSymbols(getYesterday())).toEqual(['ETHUSD']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return ["BTCUSD", "ETHUSD"]', async () => {
|
|
||||||
await portfolio.setOrders([
|
|
||||||
{
|
|
||||||
accountId: DEFAULT_ACCOUNT_ID,
|
|
||||||
accountUserId: USER_ID,
|
|
||||||
createdAt: null,
|
|
||||||
currency: Currency.EUR,
|
|
||||||
dataSource: DataSource.YAHOO,
|
|
||||||
date: new Date(getUtc('2017-08-16')),
|
|
||||||
fee: 2.99,
|
|
||||||
id: 'd96795b2-6ae6-420e-aa21-fabe5e45d475',
|
|
||||||
quantity: 0.05614682,
|
|
||||||
symbol: 'BTCUSD',
|
|
||||||
symbolProfileId: null,
|
|
||||||
type: Type.BUY,
|
|
||||||
unitPrice: 3562.089535970158,
|
|
||||||
updatedAt: null,
|
|
||||||
userId: USER_ID
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accountId: DEFAULT_ACCOUNT_ID,
|
|
||||||
accountUserId: USER_ID,
|
|
||||||
createdAt: null,
|
|
||||||
currency: Currency.USD,
|
|
||||||
dataSource: DataSource.YAHOO,
|
|
||||||
fee: 2.99,
|
|
||||||
date: new Date(getUtc('2018-01-05')),
|
|
||||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
|
|
||||||
quantity: 0.2,
|
|
||||||
symbol: 'ETHUSD',
|
|
||||||
symbolProfileId: null,
|
|
||||||
type: Type.BUY,
|
|
||||||
unitPrice: 991.49,
|
|
||||||
updatedAt: null,
|
|
||||||
userId: USER_ID
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(portfolio.getCommittedFunds()).toEqual(
|
|
||||||
exchangeRateDataService.toCurrency(
|
|
||||||
0.05614682 * 3562.089535970158,
|
|
||||||
Currency.EUR,
|
|
||||||
baseCurrency
|
|
||||||
) +
|
|
||||||
exchangeRateDataService.toCurrency(
|
|
||||||
0.2 * 991.49,
|
|
||||||
Currency.USD,
|
|
||||||
baseCurrency
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(portfolio.getFees()).toEqual(
|
|
||||||
exchangeRateDataService.toCurrency(2.99, Currency.EUR, baseCurrency) +
|
|
||||||
exchangeRateDataService.toCurrency(2.99, Currency.USD, baseCurrency)
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(portfolio.getPositions(getYesterday())).toMatchObject({
|
|
||||||
BTCUSD: {
|
|
||||||
averagePrice: 3562.089535970158,
|
|
||||||
currency: Currency.EUR,
|
|
||||||
firstBuyDate: '2017-08-16T00:00:00.000Z',
|
|
||||||
investment: exchangeRateDataService.toCurrency(
|
|
||||||
0.05614682 * 3562.089535970158,
|
|
||||||
Currency.EUR,
|
|
||||||
baseCurrency
|
|
||||||
),
|
|
||||||
investmentInOriginalCurrency: 0.05614682 * 3562.089535970158,
|
|
||||||
// marketPrice: 0,
|
|
||||||
quantity: 0.05614682
|
|
||||||
},
|
|
||||||
ETHUSD: {
|
|
||||||
averagePrice: 991.49,
|
|
||||||
currency: Currency.USD,
|
|
||||||
firstBuyDate: '2018-01-05T00:00:00.000Z',
|
|
||||||
investment: exchangeRateDataService.toCurrency(
|
|
||||||
0.2 * 991.49,
|
|
||||||
Currency.USD,
|
|
||||||
baseCurrency
|
|
||||||
),
|
|
||||||
investmentInOriginalCurrency: 0.2 * 991.49,
|
|
||||||
// marketPrice: 0,
|
|
||||||
quantity: 0.2
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(portfolio.getSymbols(getYesterday())).toEqual([
|
|
||||||
'BTCUSD',
|
|
||||||
'ETHUSD'
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should work with buy and sell', async () => {
|
|
||||||
await portfolio.setOrders([
|
|
||||||
{
|
|
||||||
accountId: DEFAULT_ACCOUNT_ID,
|
|
||||||
accountUserId: USER_ID,
|
|
||||||
createdAt: null,
|
|
||||||
currency: Currency.USD,
|
|
||||||
dataSource: DataSource.YAHOO,
|
|
||||||
fee: 1.0,
|
|
||||||
date: new Date(getUtc('2018-01-05')),
|
|
||||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
|
|
||||||
quantity: 0.2,
|
|
||||||
symbol: 'ETHUSD',
|
|
||||||
symbolProfileId: null,
|
|
||||||
type: Type.BUY,
|
|
||||||
unitPrice: 991.49,
|
|
||||||
updatedAt: null,
|
|
||||||
userId: USER_ID
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accountId: DEFAULT_ACCOUNT_ID,
|
|
||||||
accountUserId: USER_ID,
|
|
||||||
createdAt: null,
|
|
||||||
currency: Currency.USD,
|
|
||||||
dataSource: DataSource.YAHOO,
|
|
||||||
fee: 1.0,
|
|
||||||
date: new Date(getUtc('2018-01-28')),
|
|
||||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
|
|
||||||
quantity: 0.1,
|
|
||||||
symbol: 'ETHUSD',
|
|
||||||
symbolProfileId: null,
|
|
||||||
type: Type.SELL,
|
|
||||||
unitPrice: 1050,
|
|
||||||
updatedAt: null,
|
|
||||||
userId: USER_ID
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accountId: DEFAULT_ACCOUNT_ID,
|
|
||||||
accountUserId: USER_ID,
|
|
||||||
createdAt: null,
|
|
||||||
currency: Currency.USD,
|
|
||||||
dataSource: DataSource.YAHOO,
|
|
||||||
fee: 1.0,
|
|
||||||
date: new Date(getUtc('2018-01-31')),
|
|
||||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
|
|
||||||
quantity: 0.2,
|
|
||||||
symbol: 'ETHUSD',
|
|
||||||
symbolProfileId: null,
|
|
||||||
type: Type.BUY,
|
|
||||||
unitPrice: 1050,
|
|
||||||
updatedAt: null,
|
|
||||||
userId: USER_ID
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(portfolio.getCommittedFunds()).toEqual(
|
|
||||||
exchangeRateDataService.toCurrency(
|
|
||||||
0.2 * 991.49,
|
|
||||||
Currency.USD,
|
|
||||||
baseCurrency
|
|
||||||
) -
|
|
||||||
exchangeRateDataService.toCurrency(
|
|
||||||
0.1 * 1050,
|
|
||||||
Currency.USD,
|
|
||||||
baseCurrency
|
|
||||||
) +
|
|
||||||
exchangeRateDataService.toCurrency(
|
|
||||||
0.2 * 1050,
|
|
||||||
Currency.USD,
|
|
||||||
baseCurrency
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(portfolio.getFees()).toEqual(
|
|
||||||
exchangeRateDataService.toCurrency(3, Currency.USD, baseCurrency)
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(portfolio.getPositions(getYesterday())).toMatchObject({
|
|
||||||
ETHUSD: {
|
|
||||||
averagePrice:
|
|
||||||
(0.2 * 991.49 - 0.1 * 1050 + 0.2 * 1050) / (0.2 - 0.1 + 0.2),
|
|
||||||
currency: Currency.USD,
|
|
||||||
firstBuyDate: '2018-01-05T00:00:00.000Z',
|
|
||||||
investment: exchangeRateDataService.toCurrency(
|
|
||||||
0.2 * 991.49 - 0.1 * 1050 + 0.2 * 1050,
|
|
||||||
Currency.USD,
|
|
||||||
baseCurrency
|
|
||||||
),
|
|
||||||
investmentInOriginalCurrency: 0.2 * 991.49 - 0.1 * 1050 + 0.2 * 1050,
|
|
||||||
// marketPrice: 0,
|
|
||||||
quantity: 0.2 - 0.1 + 0.2
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(portfolio.getSymbols(getYesterday())).toEqual(['ETHUSD']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,908 +0,0 @@
|
|||||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
|
||||||
import { getToday, getYesterday, resetHours } from '@ghostfolio/common/helper';
|
|
||||||
import {
|
|
||||||
PortfolioItem,
|
|
||||||
PortfolioPerformance,
|
|
||||||
PortfolioPosition,
|
|
||||||
PortfolioReport,
|
|
||||||
Position,
|
|
||||||
UserWithSettings
|
|
||||||
} from '@ghostfolio/common/interfaces';
|
|
||||||
import { Country } from '@ghostfolio/common/interfaces/country.interface';
|
|
||||||
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
|
|
||||||
import { DateRange, OrderWithAccount } from '@ghostfolio/common/types';
|
|
||||||
import { Prisma } from '@prisma/client';
|
|
||||||
import { continents, countries } from 'countries-list';
|
|
||||||
import {
|
|
||||||
add,
|
|
||||||
format,
|
|
||||||
getDate,
|
|
||||||
getMonth,
|
|
||||||
getYear,
|
|
||||||
isAfter,
|
|
||||||
isBefore,
|
|
||||||
isSameDay,
|
|
||||||
isToday,
|
|
||||||
isYesterday,
|
|
||||||
parseISO,
|
|
||||||
setDate,
|
|
||||||
setMonth,
|
|
||||||
sub
|
|
||||||
} from 'date-fns';
|
|
||||||
import { cloneDeep, isEmpty } from 'lodash';
|
|
||||||
import * as roundTo from 'round-to';
|
|
||||||
|
|
||||||
import { DataProviderService } from '../services/data-provider.service';
|
|
||||||
import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
|
|
||||||
import { IOrder } from '../services/interfaces/interfaces';
|
|
||||||
import { RulesService } from '../services/rules.service';
|
|
||||||
import { PortfolioInterface } from './interfaces/portfolio.interface';
|
|
||||||
import { Order } from './order';
|
|
||||||
import { OrderType } from './order-type';
|
|
||||||
import { AccountClusterRiskCurrentInvestment } from './rules/account-cluster-risk/current-investment';
|
|
||||||
import { AccountClusterRiskInitialInvestment } from './rules/account-cluster-risk/initial-investment';
|
|
||||||
import { AccountClusterRiskSingleAccount } from './rules/account-cluster-risk/single-account';
|
|
||||||
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from './rules/currency-cluster-risk/base-currency-current-investment';
|
|
||||||
import { CurrencyClusterRiskBaseCurrencyInitialInvestment } from './rules/currency-cluster-risk/base-currency-initial-investment';
|
|
||||||
import { CurrencyClusterRiskCurrentInvestment } from './rules/currency-cluster-risk/current-investment';
|
|
||||||
import { CurrencyClusterRiskInitialInvestment } from './rules/currency-cluster-risk/initial-investment';
|
|
||||||
import { FeeRatioInitialInvestment } from './rules/fees/fee-ratio-initial-investment';
|
|
||||||
|
|
||||||
export class Portfolio implements PortfolioInterface {
|
|
||||||
private orders: Order[] = [];
|
|
||||||
private portfolioItems: PortfolioItem[] = [];
|
|
||||||
private user: UserWithSettings;
|
|
||||||
|
|
||||||
public constructor(
|
|
||||||
private dataProviderService: DataProviderService,
|
|
||||||
private exchangeRateDataService: ExchangeRateDataService,
|
|
||||||
private rulesService: RulesService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public async addCurrentPortfolioItems() {
|
|
||||||
const currentData = await this.dataProviderService.get(this.getSymbols());
|
|
||||||
|
|
||||||
const currentDate = new Date();
|
|
||||||
|
|
||||||
const year = getYear(currentDate);
|
|
||||||
const month = getMonth(currentDate);
|
|
||||||
const day = getDate(currentDate);
|
|
||||||
|
|
||||||
const today = new Date(Date.UTC(year, month, day));
|
|
||||||
const yesterday = getYesterday();
|
|
||||||
|
|
||||||
const [portfolioItemsYesterday] = this.get(yesterday);
|
|
||||||
|
|
||||||
const positions: { [symbol: string]: Position } = {};
|
|
||||||
|
|
||||||
this.getSymbols().forEach((symbol) => {
|
|
||||||
positions[symbol] = {
|
|
||||||
averagePrice: portfolioItemsYesterday?.positions[symbol]?.averagePrice,
|
|
||||||
currency: portfolioItemsYesterday?.positions[symbol]?.currency,
|
|
||||||
firstBuyDate: portfolioItemsYesterday?.positions[symbol]?.firstBuyDate,
|
|
||||||
investment: portfolioItemsYesterday?.positions[symbol]?.investment,
|
|
||||||
investmentInOriginalCurrency:
|
|
||||||
portfolioItemsYesterday?.positions[symbol]
|
|
||||||
?.investmentInOriginalCurrency,
|
|
||||||
marketPrice:
|
|
||||||
currentData[symbol]?.marketPrice ??
|
|
||||||
portfolioItemsYesterday.positions[symbol]?.marketPrice,
|
|
||||||
quantity: portfolioItemsYesterday?.positions[symbol]?.quantity,
|
|
||||||
transactionCount:
|
|
||||||
portfolioItemsYesterday?.positions[symbol]?.transactionCount
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
if (portfolioItemsYesterday?.investment) {
|
|
||||||
const portfolioItemsLength = this.portfolioItems.push(
|
|
||||||
cloneDeep({
|
|
||||||
date: today.toISOString(),
|
|
||||||
grossPerformancePercent: 0,
|
|
||||||
investment: portfolioItemsYesterday?.investment,
|
|
||||||
positions: positions,
|
|
||||||
value: 0
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Set value after pushing today's portfolio items
|
|
||||||
this.portfolioItems[portfolioItemsLength - 1].value =
|
|
||||||
this.getValue(today);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async addFuturePortfolioItems() {
|
|
||||||
let investment = this.getInvestment(new Date());
|
|
||||||
|
|
||||||
this.getOrders()
|
|
||||||
.filter((order) => order.getIsDraft() === true)
|
|
||||||
.forEach((order) => {
|
|
||||||
investment += this.exchangeRateDataService.toCurrency(
|
|
||||||
order.getTotal(),
|
|
||||||
order.getCurrency(),
|
|
||||||
this.user.Settings.currency
|
|
||||||
);
|
|
||||||
|
|
||||||
const portfolioItem = this.portfolioItems.find((item) => {
|
|
||||||
return item.date === order.getDate();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (portfolioItem) {
|
|
||||||
portfolioItem.investment = investment;
|
|
||||||
} else {
|
|
||||||
this.portfolioItems.push({
|
|
||||||
investment,
|
|
||||||
date: order.getDate(),
|
|
||||||
grossPerformancePercent: 0,
|
|
||||||
positions: {},
|
|
||||||
value: 0
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public createFromData({
|
|
||||||
orders,
|
|
||||||
portfolioItems,
|
|
||||||
user
|
|
||||||
}: {
|
|
||||||
orders: IOrder[];
|
|
||||||
portfolioItems: PortfolioItem[];
|
|
||||||
user: UserWithSettings;
|
|
||||||
}): Portfolio {
|
|
||||||
orders.forEach(
|
|
||||||
({
|
|
||||||
account,
|
|
||||||
currency,
|
|
||||||
fee,
|
|
||||||
date,
|
|
||||||
id,
|
|
||||||
quantity,
|
|
||||||
symbol,
|
|
||||||
symbolProfile,
|
|
||||||
type,
|
|
||||||
unitPrice
|
|
||||||
}) => {
|
|
||||||
this.orders.push(
|
|
||||||
new Order({
|
|
||||||
account,
|
|
||||||
currency,
|
|
||||||
fee,
|
|
||||||
date,
|
|
||||||
id,
|
|
||||||
quantity,
|
|
||||||
symbol,
|
|
||||||
symbolProfile,
|
|
||||||
type,
|
|
||||||
unitPrice
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
portfolioItems.forEach(
|
|
||||||
({ date, grossPerformancePercent, investment, positions, value }) => {
|
|
||||||
this.portfolioItems.push({
|
|
||||||
date,
|
|
||||||
grossPerformancePercent,
|
|
||||||
investment,
|
|
||||||
positions,
|
|
||||||
value
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
this.setUser(user);
|
|
||||||
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get(aDate?: Date): PortfolioItem[] {
|
|
||||||
if (aDate) {
|
|
||||||
const filteredPortfolio = this.portfolioItems.find((item) => {
|
|
||||||
return isSameDay(aDate, new Date(item.date));
|
|
||||||
});
|
|
||||||
|
|
||||||
if (filteredPortfolio) {
|
|
||||||
return [cloneDeep(filteredPortfolio)];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return cloneDeep(this.portfolioItems);
|
|
||||||
}
|
|
||||||
|
|
||||||
public getCommittedFunds() {
|
|
||||||
return this.getTotalBuy() - this.getTotalSell();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getDetails(
|
|
||||||
aDateRange: DateRange = 'max'
|
|
||||||
): Promise<{ [symbol: string]: PortfolioPosition }> {
|
|
||||||
const dateRangeDate = this.convertDateRangeToDate(
|
|
||||||
aDateRange,
|
|
||||||
this.getMinDate()
|
|
||||||
);
|
|
||||||
|
|
||||||
const [portfolioItemsBefore] = this.get(dateRangeDate);
|
|
||||||
|
|
||||||
const [portfolioItemsNow] = await this.get(new Date());
|
|
||||||
|
|
||||||
const investment = this.getInvestment(new Date());
|
|
||||||
const portfolioItems = this.get(new Date());
|
|
||||||
const symbols = this.getSymbols(new Date());
|
|
||||||
const value = this.getValue();
|
|
||||||
|
|
||||||
const details: { [symbol: string]: PortfolioPosition } = {};
|
|
||||||
|
|
||||||
const data = await this.dataProviderService.get(symbols);
|
|
||||||
|
|
||||||
symbols.forEach((symbol) => {
|
|
||||||
const accounts: PortfolioPosition['accounts'] = {};
|
|
||||||
let countriesOfSymbol: Country[];
|
|
||||||
let sectorsOfSymbol: Sector[];
|
|
||||||
const [portfolioItem] = portfolioItems;
|
|
||||||
|
|
||||||
const ordersBySymbol = this.getOrders().filter((order) => {
|
|
||||||
return order.getSymbol() === symbol;
|
|
||||||
});
|
|
||||||
|
|
||||||
ordersBySymbol.forEach((orderOfSymbol) => {
|
|
||||||
let currentValueOfSymbol = this.exchangeRateDataService.toCurrency(
|
|
||||||
orderOfSymbol.getQuantity() *
|
|
||||||
portfolioItemsNow.positions[symbol].marketPrice,
|
|
||||||
orderOfSymbol.getCurrency(),
|
|
||||||
this.user.Settings.currency
|
|
||||||
);
|
|
||||||
let originalValueOfSymbol = this.exchangeRateDataService.toCurrency(
|
|
||||||
orderOfSymbol.getQuantity() * orderOfSymbol.getUnitPrice(),
|
|
||||||
orderOfSymbol.getCurrency(),
|
|
||||||
this.user.Settings.currency
|
|
||||||
);
|
|
||||||
|
|
||||||
if (orderOfSymbol.getType() === 'SELL') {
|
|
||||||
currentValueOfSymbol *= -1;
|
|
||||||
originalValueOfSymbol *= -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY]?.current
|
|
||||||
) {
|
|
||||||
accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY].current +=
|
|
||||||
currentValueOfSymbol;
|
|
||||||
accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY].original +=
|
|
||||||
originalValueOfSymbol;
|
|
||||||
} else {
|
|
||||||
accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY] = {
|
|
||||||
current: currentValueOfSymbol,
|
|
||||||
original: originalValueOfSymbol
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
countriesOfSymbol = (
|
|
||||||
(orderOfSymbol.getSymbolProfile()?.countries as Prisma.JsonArray) ??
|
|
||||||
[]
|
|
||||||
).map((country) => {
|
|
||||||
const { code, weight } = country as Prisma.JsonObject;
|
|
||||||
|
|
||||||
return {
|
|
||||||
code: code as string,
|
|
||||||
continent:
|
|
||||||
continents[countries[code as string]?.continent] ?? UNKNOWN_KEY,
|
|
||||||
name: countries[code as string]?.name ?? UNKNOWN_KEY,
|
|
||||||
weight: weight as number
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
sectorsOfSymbol = (
|
|
||||||
(orderOfSymbol.getSymbolProfile()?.sectors as Prisma.JsonArray) ?? []
|
|
||||||
).map((sector) => {
|
|
||||||
const { name, weight } = sector as Prisma.JsonObject;
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: (name as string) ?? UNKNOWN_KEY,
|
|
||||||
weight: weight as number
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
let now = portfolioItemsNow.positions[symbol].marketPrice;
|
|
||||||
|
|
||||||
// 1d
|
|
||||||
let before = portfolioItemsBefore?.positions[symbol].marketPrice;
|
|
||||||
|
|
||||||
if (aDateRange === 'ytd') {
|
|
||||||
before =
|
|
||||||
portfolioItemsBefore.positions[symbol].marketPrice ||
|
|
||||||
portfolioItemsNow.positions[symbol].averagePrice;
|
|
||||||
} else if (
|
|
||||||
aDateRange === '1y' ||
|
|
||||||
aDateRange === '5y' ||
|
|
||||||
aDateRange === 'max'
|
|
||||||
) {
|
|
||||||
before = portfolioItemsNow.positions[symbol].averagePrice;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
!isBefore(
|
|
||||||
parseISO(portfolioItemsNow.positions[symbol].firstBuyDate),
|
|
||||||
parseISO(portfolioItemsBefore?.date)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
// Trade was not before the date of portfolioItemsBefore, then override it with average price
|
|
||||||
// (e.g. on same day)
|
|
||||||
before = portfolioItemsNow.positions[symbol].averagePrice;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isToday(parseISO(portfolioItemsNow.positions[symbol].firstBuyDate))) {
|
|
||||||
now = portfolioItemsNow.positions[symbol].averagePrice;
|
|
||||||
}
|
|
||||||
|
|
||||||
details[symbol] = {
|
|
||||||
...data[symbol],
|
|
||||||
accounts,
|
|
||||||
symbol,
|
|
||||||
allocationCurrent:
|
|
||||||
this.exchangeRateDataService.toCurrency(
|
|
||||||
portfolioItem.positions[symbol].quantity * now,
|
|
||||||
data[symbol]?.currency,
|
|
||||||
this.user.Settings.currency
|
|
||||||
) / value,
|
|
||||||
allocationInvestment:
|
|
||||||
portfolioItem.positions[symbol].investment / investment,
|
|
||||||
countries: countriesOfSymbol,
|
|
||||||
grossPerformance: roundTo(
|
|
||||||
portfolioItemsNow.positions[symbol].quantity * (now - before),
|
|
||||||
2
|
|
||||||
),
|
|
||||||
grossPerformancePercent: roundTo((now - before) / before, 4),
|
|
||||||
investment: portfolioItem.positions[symbol].investment,
|
|
||||||
quantity: portfolioItem.positions[symbol].quantity,
|
|
||||||
sectors: sectorsOfSymbol,
|
|
||||||
transactionCount: portfolioItem.positions[symbol].transactionCount,
|
|
||||||
value: this.exchangeRateDataService.toCurrency(
|
|
||||||
portfolioItem.positions[symbol].quantity * now,
|
|
||||||
data[symbol]?.currency,
|
|
||||||
this.user.Settings.currency
|
|
||||||
)
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return details;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getFees(aDate = new Date(0)) {
|
|
||||||
return this.orders
|
|
||||||
.filter((order) => {
|
|
||||||
// Filter out all orders before given date
|
|
||||||
return isBefore(aDate, new Date(order.getDate()));
|
|
||||||
})
|
|
||||||
.map((order) => {
|
|
||||||
return this.exchangeRateDataService.toCurrency(
|
|
||||||
order.getFee(),
|
|
||||||
order.getCurrency(),
|
|
||||||
this.user.Settings.currency
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.reduce((previous, current) => previous + current, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
public getInvestment(aDate: Date): number {
|
|
||||||
return this.get(aDate)[0]?.investment || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getMinDate() {
|
|
||||||
const orders = this.getOrders().filter(
|
|
||||||
(order) => order.getIsDraft() === false
|
|
||||||
);
|
|
||||||
|
|
||||||
if (orders.length > 0) {
|
|
||||||
return new Date(this.orders[0].getDate());
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getPerformance(
|
|
||||||
aDateRange: DateRange = 'max'
|
|
||||||
): Promise<PortfolioPerformance> {
|
|
||||||
const dateRangeDate = this.convertDateRangeToDate(
|
|
||||||
aDateRange,
|
|
||||||
this.getMinDate()
|
|
||||||
);
|
|
||||||
|
|
||||||
const currentInvestment = this.getInvestment(new Date());
|
|
||||||
const currentValue = await this.getValue();
|
|
||||||
|
|
||||||
let originalInvestment = currentInvestment;
|
|
||||||
let originalValue = this.getCommittedFunds();
|
|
||||||
|
|
||||||
if (dateRangeDate) {
|
|
||||||
originalInvestment = this.getInvestment(dateRangeDate);
|
|
||||||
originalValue = (await this.getValue(dateRangeDate)) || originalValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fees = this.getFees(dateRangeDate);
|
|
||||||
|
|
||||||
const currentGrossPerformance =
|
|
||||||
currentValue - currentInvestment - (originalValue - originalInvestment);
|
|
||||||
|
|
||||||
// https://www.skillsyouneed.com/num/percent-change.html
|
|
||||||
const currentGrossPerformancePercent =
|
|
||||||
currentGrossPerformance / originalInvestment || 0;
|
|
||||||
|
|
||||||
const currentNetPerformance = currentGrossPerformance - fees;
|
|
||||||
|
|
||||||
// https://www.skillsyouneed.com/num/percent-change.html
|
|
||||||
const currentNetPerformancePercent =
|
|
||||||
currentNetPerformance / originalInvestment || 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
currentGrossPerformance,
|
|
||||||
currentGrossPerformancePercent,
|
|
||||||
currentNetPerformance,
|
|
||||||
currentNetPerformancePercent,
|
|
||||||
currentValue
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public getPositions(aDate: Date) {
|
|
||||||
const [portfolioItem] = this.get(aDate);
|
|
||||||
|
|
||||||
if (portfolioItem) {
|
|
||||||
return portfolioItem.positions;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
public getPortfolioItems() {
|
|
||||||
return this.portfolioItems;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getReport(): Promise<PortfolioReport> {
|
|
||||||
const details = await this.getDetails();
|
|
||||||
|
|
||||||
if (isEmpty(details)) {
|
|
||||||
return {
|
|
||||||
rules: {}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
rules: {
|
|
||||||
accountClusterRisk: await this.rulesService.evaluate(
|
|
||||||
this,
|
|
||||||
[
|
|
||||||
new AccountClusterRiskInitialInvestment(
|
|
||||||
this.exchangeRateDataService
|
|
||||||
),
|
|
||||||
new AccountClusterRiskCurrentInvestment(
|
|
||||||
this.exchangeRateDataService
|
|
||||||
),
|
|
||||||
new AccountClusterRiskSingleAccount(this.exchangeRateDataService)
|
|
||||||
],
|
|
||||||
{ baseCurrency: this.user.Settings.currency }
|
|
||||||
),
|
|
||||||
currencyClusterRisk: await this.rulesService.evaluate(
|
|
||||||
this,
|
|
||||||
[
|
|
||||||
new CurrencyClusterRiskBaseCurrencyInitialInvestment(
|
|
||||||
this.exchangeRateDataService
|
|
||||||
),
|
|
||||||
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
|
|
||||||
this.exchangeRateDataService
|
|
||||||
),
|
|
||||||
new CurrencyClusterRiskInitialInvestment(
|
|
||||||
this.exchangeRateDataService
|
|
||||||
),
|
|
||||||
new CurrencyClusterRiskCurrentInvestment(
|
|
||||||
this.exchangeRateDataService
|
|
||||||
)
|
|
||||||
],
|
|
||||||
{ baseCurrency: this.user.Settings.currency }
|
|
||||||
),
|
|
||||||
fees: await this.rulesService.evaluate(
|
|
||||||
this,
|
|
||||||
[new FeeRatioInitialInvestment(this.exchangeRateDataService)],
|
|
||||||
{ baseCurrency: this.user.Settings.currency }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public getSymbols(aDate?: Date) {
|
|
||||||
let symbols: string[] = [];
|
|
||||||
|
|
||||||
if (aDate) {
|
|
||||||
const positions = this.getPositions(aDate);
|
|
||||||
|
|
||||||
for (const symbol in positions) {
|
|
||||||
if (positions[symbol].quantity > 0) {
|
|
||||||
symbols.push(symbol);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
symbols = this.orders
|
|
||||||
.filter((order) => order.getIsDraft() === false)
|
|
||||||
.map((order) => {
|
|
||||||
return order.getSymbol();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// unique values
|
|
||||||
return Array.from(new Set(symbols));
|
|
||||||
}
|
|
||||||
|
|
||||||
public getTotalBuy() {
|
|
||||||
return this.orders
|
|
||||||
.filter(
|
|
||||||
(order) => order.getIsDraft() === false && order.getType() === 'BUY'
|
|
||||||
)
|
|
||||||
.map((order) => {
|
|
||||||
return this.exchangeRateDataService.toCurrency(
|
|
||||||
order.getTotal(),
|
|
||||||
order.getCurrency(),
|
|
||||||
this.user.Settings.currency
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.reduce((previous, current) => previous + current, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
public getTotalSell() {
|
|
||||||
return this.orders
|
|
||||||
.filter(
|
|
||||||
(order) => order.getIsDraft() === false && order.getType() === 'SELL'
|
|
||||||
)
|
|
||||||
.map((order) => {
|
|
||||||
return this.exchangeRateDataService.toCurrency(
|
|
||||||
order.getTotal(),
|
|
||||||
order.getCurrency(),
|
|
||||||
this.user.Settings.currency
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.reduce((previous, current) => previous + current, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
public getOrders(aSymbol?: string) {
|
|
||||||
if (aSymbol) {
|
|
||||||
return this.orders.filter((order) => {
|
|
||||||
return order.getSymbol() === aSymbol;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.orders;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getValue(aDate = getToday()) {
|
|
||||||
const positions = this.getPositions(aDate);
|
|
||||||
let value = 0;
|
|
||||||
|
|
||||||
const [portfolioItem] = this.get(aDate);
|
|
||||||
|
|
||||||
for (const symbol in positions) {
|
|
||||||
if (portfolioItem.positions[symbol]?.quantity > 0) {
|
|
||||||
if (
|
|
||||||
isBefore(
|
|
||||||
aDate,
|
|
||||||
parseISO(portfolioItem.positions[symbol]?.firstBuyDate)
|
|
||||||
) ||
|
|
||||||
portfolioItem.positions[symbol]?.marketPrice === 0
|
|
||||||
) {
|
|
||||||
value += this.exchangeRateDataService.toCurrency(
|
|
||||||
portfolioItem.positions[symbol]?.quantity *
|
|
||||||
portfolioItem.positions[symbol]?.averagePrice,
|
|
||||||
portfolioItem.positions[symbol]?.currency,
|
|
||||||
this.user.Settings.currency
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
value += this.exchangeRateDataService.toCurrency(
|
|
||||||
portfolioItem.positions[symbol]?.quantity *
|
|
||||||
portfolioItem.positions[symbol]?.marketPrice,
|
|
||||||
portfolioItem.positions[symbol]?.currency,
|
|
||||||
this.user.Settings.currency
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return isFinite(value) ? value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async setOrders(aOrders: OrderWithAccount[]) {
|
|
||||||
this.orders = [];
|
|
||||||
|
|
||||||
// Map data
|
|
||||||
aOrders.forEach((order) => {
|
|
||||||
this.orders.push(
|
|
||||||
new Order({
|
|
||||||
account: order.Account,
|
|
||||||
currency: order.currency,
|
|
||||||
date: order.date.toISOString(),
|
|
||||||
fee: order.fee,
|
|
||||||
quantity: order.quantity,
|
|
||||||
symbol: order.symbol,
|
|
||||||
symbolProfile: order.SymbolProfile,
|
|
||||||
type: <OrderType>order.type,
|
|
||||||
unitPrice: order.unitPrice
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.update();
|
|
||||||
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public setUser(aUser: UserWithSettings) {
|
|
||||||
this.user = aUser;
|
|
||||||
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TODO: Refactor
|
|
||||||
*/
|
|
||||||
private async update() {
|
|
||||||
this.portfolioItems = [];
|
|
||||||
|
|
||||||
let currentDate = this.getMinDate();
|
|
||||||
|
|
||||||
if (!currentDate) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set current date to first of month
|
|
||||||
currentDate = setDate(currentDate, 1);
|
|
||||||
|
|
||||||
const historicalData = await this.dataProviderService.getHistorical(
|
|
||||||
this.getSymbols(),
|
|
||||||
'month',
|
|
||||||
currentDate,
|
|
||||||
new Date()
|
|
||||||
);
|
|
||||||
|
|
||||||
while (isBefore(currentDate, Date.now())) {
|
|
||||||
const positions: { [symbol: string]: Position } = {};
|
|
||||||
this.getSymbols().forEach((symbol) => {
|
|
||||||
positions[symbol] = {
|
|
||||||
averagePrice: 0,
|
|
||||||
currency: undefined,
|
|
||||||
firstBuyDate: null,
|
|
||||||
investment: 0,
|
|
||||||
investmentInOriginalCurrency: 0,
|
|
||||||
marketPrice:
|
|
||||||
historicalData[symbol]?.[format(currentDate, 'yyyy-MM-dd')]
|
|
||||||
?.marketPrice || 0,
|
|
||||||
quantity: 0,
|
|
||||||
transactionCount: 0
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!isYesterday(currentDate) && !isToday(currentDate)) {
|
|
||||||
// Add to portfolio (ignore yesterday and today because they are added later)
|
|
||||||
this.portfolioItems.push(
|
|
||||||
cloneDeep({
|
|
||||||
date: currentDate.toISOString(),
|
|
||||||
grossPerformancePercent: 0,
|
|
||||||
investment: 0,
|
|
||||||
positions: positions,
|
|
||||||
value: 0
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const year = getYear(currentDate);
|
|
||||||
const month = getMonth(currentDate);
|
|
||||||
const day = getDate(currentDate);
|
|
||||||
|
|
||||||
// Count month one up for iteration
|
|
||||||
currentDate = new Date(Date.UTC(year, month + 1, day, 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
const yesterday = getYesterday();
|
|
||||||
|
|
||||||
let positions: { [symbol: string]: Position } = {};
|
|
||||||
|
|
||||||
if (isAfter(yesterday, this.getMinDate())) {
|
|
||||||
// Add yesterday
|
|
||||||
this.getSymbols().forEach((symbol) => {
|
|
||||||
positions[symbol] = {
|
|
||||||
averagePrice: 0,
|
|
||||||
currency: undefined,
|
|
||||||
firstBuyDate: null,
|
|
||||||
investment: 0,
|
|
||||||
investmentInOriginalCurrency: 0,
|
|
||||||
marketPrice:
|
|
||||||
historicalData[symbol]?.[format(yesterday, 'yyyy-MM-dd')]
|
|
||||||
?.marketPrice || 0,
|
|
||||||
quantity: 0,
|
|
||||||
transactionCount: 0
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
this.portfolioItems.push(
|
|
||||||
cloneDeep({
|
|
||||||
positions,
|
|
||||||
date: yesterday.toISOString(),
|
|
||||||
grossPerformancePercent: 0,
|
|
||||||
investment: 0,
|
|
||||||
value: 0
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.updatePortfolioItems();
|
|
||||||
}
|
|
||||||
|
|
||||||
private convertDateRangeToDate(aDateRange: DateRange, aMinDate: Date) {
|
|
||||||
let currentDate = new Date();
|
|
||||||
|
|
||||||
const normalizedMinDate =
|
|
||||||
getDate(aMinDate) === 1
|
|
||||||
? aMinDate
|
|
||||||
: add(setDate(aMinDate, 1), { months: 1 });
|
|
||||||
|
|
||||||
const year = getYear(currentDate);
|
|
||||||
const month = getMonth(currentDate);
|
|
||||||
const day = getDate(currentDate);
|
|
||||||
|
|
||||||
currentDate = new Date(Date.UTC(year, month, day, 0));
|
|
||||||
|
|
||||||
switch (aDateRange) {
|
|
||||||
case '1d':
|
|
||||||
return sub(currentDate, {
|
|
||||||
days: 1
|
|
||||||
});
|
|
||||||
case 'ytd':
|
|
||||||
currentDate = setDate(currentDate, 1);
|
|
||||||
currentDate = setMonth(currentDate, 0);
|
|
||||||
return isAfter(currentDate, normalizedMinDate)
|
|
||||||
? currentDate
|
|
||||||
: undefined;
|
|
||||||
case '1y':
|
|
||||||
currentDate = setDate(currentDate, 1);
|
|
||||||
currentDate = sub(currentDate, {
|
|
||||||
years: 1
|
|
||||||
});
|
|
||||||
return isAfter(currentDate, normalizedMinDate)
|
|
||||||
? currentDate
|
|
||||||
: undefined;
|
|
||||||
case '5y':
|
|
||||||
currentDate = setDate(currentDate, 1);
|
|
||||||
currentDate = sub(currentDate, {
|
|
||||||
years: 5
|
|
||||||
});
|
|
||||||
return isAfter(currentDate, normalizedMinDate)
|
|
||||||
? currentDate
|
|
||||||
: undefined;
|
|
||||||
default:
|
|
||||||
// Gets handled as all data
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private updatePortfolioItems() {
|
|
||||||
let currentDate = new Date();
|
|
||||||
|
|
||||||
const year = getYear(currentDate);
|
|
||||||
const month = getMonth(currentDate);
|
|
||||||
const day = getDate(currentDate);
|
|
||||||
|
|
||||||
currentDate = new Date(Date.UTC(year, month, day, 0));
|
|
||||||
|
|
||||||
if (this.portfolioItems?.length === 1) {
|
|
||||||
// At least one portfolio items is needed, keep it but change the date to today.
|
|
||||||
// This happens if there are only orders from today
|
|
||||||
this.portfolioItems[0].date = currentDate.toISOString();
|
|
||||||
} else {
|
|
||||||
// Only keep entries which are not before first buy date
|
|
||||||
this.portfolioItems = this.portfolioItems.filter((portfolioItem) => {
|
|
||||||
return (
|
|
||||||
isSameDay(parseISO(portfolioItem.date), this.getMinDate()) ||
|
|
||||||
isAfter(parseISO(portfolioItem.date), this.getMinDate())
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.orders.forEach((order) => {
|
|
||||||
if (order.getIsDraft() === false) {
|
|
||||||
let index = this.portfolioItems.findIndex((item) => {
|
|
||||||
const dateOfOrder = setDate(parseISO(order.getDate()), 1);
|
|
||||||
return isSameDay(parseISO(item.date), dateOfOrder);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (index === -1) {
|
|
||||||
// if not found, we only have one order, which means we do not loop below
|
|
||||||
index = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = index; i < this.portfolioItems.length; i++) {
|
|
||||||
// Set currency
|
|
||||||
this.portfolioItems[i].positions[order.getSymbol()].currency =
|
|
||||||
order.getCurrency();
|
|
||||||
|
|
||||||
this.portfolioItems[i].positions[
|
|
||||||
order.getSymbol()
|
|
||||||
].transactionCount += 1;
|
|
||||||
|
|
||||||
if (order.getType() === 'BUY') {
|
|
||||||
if (
|
|
||||||
!this.portfolioItems[i].positions[order.getSymbol()].firstBuyDate
|
|
||||||
) {
|
|
||||||
this.portfolioItems[i].positions[order.getSymbol()].firstBuyDate =
|
|
||||||
resetHours(parseISO(order.getDate())).toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.portfolioItems[i].positions[order.getSymbol()].quantity +=
|
|
||||||
order.getQuantity();
|
|
||||||
this.portfolioItems[i].positions[order.getSymbol()].investment +=
|
|
||||||
this.exchangeRateDataService.toCurrency(
|
|
||||||
order.getTotal(),
|
|
||||||
order.getCurrency(),
|
|
||||||
this.user.Settings.currency
|
|
||||||
);
|
|
||||||
this.portfolioItems[i].positions[
|
|
||||||
order.getSymbol()
|
|
||||||
].investmentInOriginalCurrency += order.getTotal();
|
|
||||||
|
|
||||||
this.portfolioItems[i].investment +=
|
|
||||||
this.exchangeRateDataService.toCurrency(
|
|
||||||
order.getTotal(),
|
|
||||||
order.getCurrency(),
|
|
||||||
this.user.Settings.currency
|
|
||||||
);
|
|
||||||
} else if (order.getType() === 'SELL') {
|
|
||||||
this.portfolioItems[i].positions[order.getSymbol()].quantity -=
|
|
||||||
order.getQuantity();
|
|
||||||
|
|
||||||
if (
|
|
||||||
this.portfolioItems[i].positions[order.getSymbol()].quantity === 0
|
|
||||||
) {
|
|
||||||
this.portfolioItems[i].positions[
|
|
||||||
order.getSymbol()
|
|
||||||
].investment = 0;
|
|
||||||
this.portfolioItems[i].positions[
|
|
||||||
order.getSymbol()
|
|
||||||
].investmentInOriginalCurrency = 0;
|
|
||||||
} else {
|
|
||||||
this.portfolioItems[i].positions[order.getSymbol()].investment -=
|
|
||||||
this.exchangeRateDataService.toCurrency(
|
|
||||||
order.getTotal(),
|
|
||||||
order.getCurrency(),
|
|
||||||
this.user.Settings.currency
|
|
||||||
);
|
|
||||||
this.portfolioItems[i].positions[
|
|
||||||
order.getSymbol()
|
|
||||||
].investmentInOriginalCurrency -= order.getTotal();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.portfolioItems[i].investment -=
|
|
||||||
this.exchangeRateDataService.toCurrency(
|
|
||||||
order.getTotal(),
|
|
||||||
order.getCurrency(),
|
|
||||||
this.user.Settings.currency
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.portfolioItems[i].positions[order.getSymbol()].averagePrice =
|
|
||||||
this.portfolioItems[i].positions[order.getSymbol()]
|
|
||||||
.investmentInOriginalCurrency /
|
|
||||||
this.portfolioItems[i].positions[order.getSymbol()].quantity;
|
|
||||||
|
|
||||||
const currentValue = this.getValue(
|
|
||||||
parseISO(this.portfolioItems[i].date)
|
|
||||||
);
|
|
||||||
|
|
||||||
this.portfolioItems[i].grossPerformancePercent =
|
|
||||||
currentValue / this.portfolioItems[i].investment - 1 || 0;
|
|
||||||
this.portfolioItems[i].value = currentValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,16 +1,21 @@
|
|||||||
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
|
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||||
import { groupBy } from '@ghostfolio/common/helper';
|
import { groupBy } from '@ghostfolio/common/helper';
|
||||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
import {
|
||||||
|
PortfolioPosition,
|
||||||
|
TimelinePosition
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { Currency } from '@prisma/client';
|
import { Currency } from '@prisma/client';
|
||||||
|
|
||||||
import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
|
||||||
import { EvaluationResult } from './interfaces/evaluation-result.interface';
|
import { EvaluationResult } from './interfaces/evaluation-result.interface';
|
||||||
import { RuleInterface } from './interfaces/rule.interface';
|
import { RuleInterface } from './interfaces/rule.interface';
|
||||||
|
|
||||||
export abstract class Rule implements RuleInterface {
|
export abstract class Rule<T extends RuleSettings> implements RuleInterface<T> {
|
||||||
private name: string;
|
private name: string;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
public exchangeRateDataService: ExchangeRateDataService,
|
protected exchangeRateDataService: ExchangeRateDataService,
|
||||||
{
|
{
|
||||||
name
|
name
|
||||||
}: {
|
}: {
|
||||||
@ -20,44 +25,38 @@ export abstract class Rule implements RuleInterface {
|
|||||||
this.name = name;
|
this.name = name;
|
||||||
}
|
}
|
||||||
|
|
||||||
public abstract evaluate(
|
public abstract evaluate(aRuleSettings: T): EvaluationResult;
|
||||||
aPortfolioPositionMap: {
|
|
||||||
[symbol: string]: PortfolioPosition;
|
public abstract getSettings(aUserSettings: UserSettings): T;
|
||||||
},
|
|
||||||
aFees: number,
|
|
||||||
aRuleSettingsMap?: {
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
): EvaluationResult;
|
|
||||||
|
|
||||||
public getName() {
|
public getName() {
|
||||||
return this.name;
|
return this.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
public groupPositionsByAttribute(
|
public groupCurrentPositionsByAttribute(
|
||||||
aPositions: { [symbol: string]: PortfolioPosition },
|
positions: TimelinePosition[],
|
||||||
aAttribute: keyof PortfolioPosition,
|
attribute: keyof TimelinePosition,
|
||||||
aBaseCurrency: Currency
|
baseCurrency: Currency
|
||||||
) {
|
) {
|
||||||
return Array.from(
|
return Array.from(groupBy(attribute, positions).entries()).map(
|
||||||
groupBy(aAttribute, Object.values(aPositions)).entries()
|
([attributeValue, objs]) => ({
|
||||||
).map(([attributeValue, objs]) => ({
|
groupKey: attributeValue,
|
||||||
groupKey: attributeValue,
|
investment: objs.reduce(
|
||||||
investment: objs.reduce(
|
(previousValue, currentValue) =>
|
||||||
(previousValue, currentValue) =>
|
previousValue + currentValue.investment.toNumber(),
|
||||||
previousValue + currentValue.investment,
|
0
|
||||||
0
|
),
|
||||||
),
|
value: objs.reduce(
|
||||||
value: objs.reduce(
|
(previousValue, currentValue) =>
|
||||||
(previousValue, currentValue) =>
|
previousValue +
|
||||||
previousValue +
|
this.exchangeRateDataService.toCurrency(
|
||||||
this.exchangeRateDataService.toCurrency(
|
currentValue.quantity.mul(currentValue.marketPrice).toNumber(),
|
||||||
currentValue.quantity * currentValue.marketPrice,
|
currentValue.currency,
|
||||||
currentValue.currency,
|
baseCurrency
|
||||||
aBaseCurrency
|
),
|
||||||
),
|
0
|
||||||
0
|
)
|
||||||
)
|
})
|
||||||
}));
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,43 +1,35 @@
|
|||||||
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
|
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
import { Rule } from '../../rule';
|
||||||
|
|
||||||
export class AccountClusterRiskCurrentInvestment extends Rule {
|
export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||||
public constructor(public exchangeRateDataService: ExchangeRateDataService) {
|
public constructor(
|
||||||
|
protected exchangeRateDataService: ExchangeRateDataService,
|
||||||
|
private accounts: {
|
||||||
|
[account: string]: { current: number; original: number };
|
||||||
|
}
|
||||||
|
) {
|
||||||
super(exchangeRateDataService, {
|
super(exchangeRateDataService, {
|
||||||
name: 'Current Investment'
|
name: 'Current Investment'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public evaluate(
|
public evaluate(ruleSettings: Settings) {
|
||||||
aPositions: { [symbol: string]: PortfolioPosition },
|
|
||||||
aFees: number,
|
|
||||||
aRuleSettingsMap?: {
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
const ruleSettings =
|
|
||||||
aRuleSettingsMap[AccountClusterRiskCurrentInvestment.name];
|
|
||||||
|
|
||||||
const accounts: {
|
const accounts: {
|
||||||
[symbol: string]: Pick<PortfolioPosition, 'name'> & {
|
[symbol: string]: Pick<PortfolioPosition, 'name'> & {
|
||||||
investment: number;
|
investment: number;
|
||||||
};
|
};
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
Object.values(aPositions).forEach((position) => {
|
for (const account of Object.keys(this.accounts)) {
|
||||||
for (const [account, { current }] of Object.entries(position.accounts)) {
|
accounts[account] = {
|
||||||
if (accounts[account]?.investment) {
|
name: account,
|
||||||
accounts[account].investment += current;
|
investment: this.accounts[account].current
|
||||||
} else {
|
};
|
||||||
accounts[account] = {
|
}
|
||||||
investment: current,
|
|
||||||
name: account
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let maxItem;
|
let maxItem;
|
||||||
let totalInvestment = 0;
|
let totalInvestment = 0;
|
||||||
@ -78,4 +70,17 @@ export class AccountClusterRiskCurrentInvestment extends Rule {
|
|||||||
value: true
|
value: true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getSettings(aUserSettings: UserSettings): Settings {
|
||||||
|
return {
|
||||||
|
baseCurrency: aUserSettings.baseCurrency,
|
||||||
|
isActive: true,
|
||||||
|
threshold: 0.5
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Settings extends RuleSettings {
|
||||||
|
baseCurrency: string;
|
||||||
|
threshold: number;
|
||||||
}
|
}
|
||||||
|
@ -1,43 +1,35 @@
|
|||||||
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
|
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
import { Rule } from '../../rule';
|
||||||
|
|
||||||
export class AccountClusterRiskInitialInvestment extends Rule {
|
export class AccountClusterRiskInitialInvestment extends Rule<Settings> {
|
||||||
public constructor(public exchangeRateDataService: ExchangeRateDataService) {
|
public constructor(
|
||||||
|
protected exchangeRateDataService: ExchangeRateDataService,
|
||||||
|
private accounts: {
|
||||||
|
[account: string]: { current: number; original: number };
|
||||||
|
}
|
||||||
|
) {
|
||||||
super(exchangeRateDataService, {
|
super(exchangeRateDataService, {
|
||||||
name: 'Initial Investment'
|
name: 'Initial Investment'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public evaluate(
|
public evaluate(ruleSettings?: Settings) {
|
||||||
aPositions: { [symbol: string]: PortfolioPosition },
|
|
||||||
aFees: number,
|
|
||||||
aRuleSettingsMap?: {
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
const ruleSettings =
|
|
||||||
aRuleSettingsMap[AccountClusterRiskInitialInvestment.name];
|
|
||||||
|
|
||||||
const platforms: {
|
const platforms: {
|
||||||
[symbol: string]: Pick<PortfolioPosition, 'name'> & {
|
[symbol: string]: Pick<PortfolioPosition, 'name'> & {
|
||||||
investment: number;
|
investment: number;
|
||||||
};
|
};
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
Object.values(aPositions).forEach((position) => {
|
for (const account of Object.keys(this.accounts)) {
|
||||||
for (const [account, { original }] of Object.entries(position.accounts)) {
|
platforms[account] = {
|
||||||
if (platforms[account]?.investment) {
|
name: account,
|
||||||
platforms[account].investment += original;
|
investment: this.accounts[account].original
|
||||||
} else {
|
};
|
||||||
platforms[account] = {
|
}
|
||||||
investment: original,
|
|
||||||
name: account
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let maxItem;
|
let maxItem;
|
||||||
let totalInvestment = 0;
|
let totalInvestment = 0;
|
||||||
@ -78,4 +70,18 @@ export class AccountClusterRiskInitialInvestment extends Rule {
|
|||||||
value: true
|
value: true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getSettings(aUserSettings: UserSettings): Settings {
|
||||||
|
return {
|
||||||
|
baseCurrency: aUserSettings.baseCurrency,
|
||||||
|
isActive: true,
|
||||||
|
threshold: 0.5
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Settings extends RuleSettings {
|
||||||
|
baseCurrency: string;
|
||||||
|
isActive: boolean;
|
||||||
|
threshold: number;
|
||||||
}
|
}
|
||||||
|
@ -1,25 +1,23 @@
|
|||||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
|
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
import { Rule } from '../../rule';
|
||||||
|
|
||||||
export class AccountClusterRiskSingleAccount extends Rule {
|
export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
|
||||||
public constructor(public exchangeRateDataService: ExchangeRateDataService) {
|
public constructor(
|
||||||
|
protected exchangeRateDataService: ExchangeRateDataService,
|
||||||
|
private accounts: {
|
||||||
|
[account: string]: { current: number; original: number };
|
||||||
|
}
|
||||||
|
) {
|
||||||
super(exchangeRateDataService, {
|
super(exchangeRateDataService, {
|
||||||
name: 'Single Account'
|
name: 'Single Account'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public evaluate(positions: { [symbol: string]: PortfolioPosition }) {
|
public evaluate() {
|
||||||
const accounts: string[] = [];
|
const accounts: string[] = Object.keys(this.accounts);
|
||||||
|
|
||||||
Object.values(positions).forEach((position) => {
|
|
||||||
for (const [account] of Object.entries(position.accounts)) {
|
|
||||||
if (!accounts.includes(account)) {
|
|
||||||
accounts.push(account);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (accounts.length === 1) {
|
if (accounts.length === 1) {
|
||||||
return {
|
return {
|
||||||
@ -33,4 +31,10 @@ export class AccountClusterRiskSingleAccount extends Rule {
|
|||||||
value: true
|
value: true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getSettings(aUserSettings: UserSettings): RuleSettings {
|
||||||
|
return {
|
||||||
|
isActive: true
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,27 +1,25 @@
|
|||||||
|
import { CurrentPositions } from '@ghostfolio/api/app/core/interfaces/current-positions.interface';
|
||||||
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
|
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||||
|
import { Currency } from '@prisma/client';
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
import { Rule } from '../../rule';
|
||||||
|
|
||||||
export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule {
|
export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Settings> {
|
||||||
public constructor(public exchangeRateDataService: ExchangeRateDataService) {
|
public constructor(
|
||||||
|
protected exchangeRateDataService: ExchangeRateDataService,
|
||||||
|
private currentPositions: CurrentPositions
|
||||||
|
) {
|
||||||
super(exchangeRateDataService, {
|
super(exchangeRateDataService, {
|
||||||
name: 'Current Investment: Base Currency'
|
name: 'Current Investment: Base Currency'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public evaluate(
|
public evaluate(ruleSettings: Settings) {
|
||||||
aPositions: { [symbol: string]: PortfolioPosition },
|
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
|
||||||
aFees: number,
|
this.currentPositions.positions,
|
||||||
aRuleSettingsMap?: {
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
const ruleSettings =
|
|
||||||
aRuleSettingsMap[CurrencyClusterRiskBaseCurrencyCurrentInvestment.name];
|
|
||||||
|
|
||||||
const positionsGroupedByCurrency = this.groupPositionsByAttribute(
|
|
||||||
aPositions,
|
|
||||||
'currency',
|
'currency',
|
||||||
ruleSettings.baseCurrency
|
ruleSettings.baseCurrency
|
||||||
);
|
);
|
||||||
@ -61,4 +59,15 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule {
|
|||||||
value: true
|
value: true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getSettings(aUserSettings: UserSettings): Settings {
|
||||||
|
return {
|
||||||
|
baseCurrency: aUserSettings.baseCurrency,
|
||||||
|
isActive: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Settings extends RuleSettings {
|
||||||
|
baseCurrency: Currency;
|
||||||
}
|
}
|
||||||
|
@ -1,27 +1,24 @@
|
|||||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
import { CurrentPositions } from '@ghostfolio/api/app/core/interfaces/current-positions.interface';
|
||||||
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
|
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||||
|
import { Currency } from '@prisma/client';
|
||||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
import { Rule } from '../../rule';
|
||||||
|
|
||||||
export class CurrencyClusterRiskBaseCurrencyInitialInvestment extends Rule {
|
export class CurrencyClusterRiskBaseCurrencyInitialInvestment extends Rule<Settings> {
|
||||||
public constructor(public exchangeRateDataService: ExchangeRateDataService) {
|
public constructor(
|
||||||
|
protected exchangeRateDataService: ExchangeRateDataService,
|
||||||
|
private currentPositions: CurrentPositions
|
||||||
|
) {
|
||||||
super(exchangeRateDataService, {
|
super(exchangeRateDataService, {
|
||||||
name: 'Initial Investment: Base Currency'
|
name: 'Initial Investment: Base Currency'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public evaluate(
|
public evaluate(ruleSettings: Settings) {
|
||||||
aPositions: { [symbol: string]: PortfolioPosition },
|
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
|
||||||
aFees: number,
|
this.currentPositions.positions,
|
||||||
aRuleSettingsMap?: {
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
const ruleSettings =
|
|
||||||
aRuleSettingsMap[CurrencyClusterRiskBaseCurrencyInitialInvestment.name];
|
|
||||||
|
|
||||||
const positionsGroupedByCurrency = this.groupPositionsByAttribute(
|
|
||||||
aPositions,
|
|
||||||
'currency',
|
'currency',
|
||||||
ruleSettings.baseCurrency
|
ruleSettings.baseCurrency
|
||||||
);
|
);
|
||||||
@ -62,4 +59,15 @@ export class CurrencyClusterRiskBaseCurrencyInitialInvestment extends Rule {
|
|||||||
value: true
|
value: true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getSettings(aUserSettings: UserSettings): Settings {
|
||||||
|
return {
|
||||||
|
baseCurrency: aUserSettings.baseCurrency,
|
||||||
|
isActive: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Settings extends RuleSettings {
|
||||||
|
baseCurrency: Currency;
|
||||||
}
|
}
|
||||||
|
@ -1,27 +1,24 @@
|
|||||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
import { CurrentPositions } from '@ghostfolio/api/app/core/interfaces/current-positions.interface';
|
||||||
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
|
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||||
|
import { Currency } from '@prisma/client';
|
||||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
import { Rule } from '../../rule';
|
||||||
|
|
||||||
export class CurrencyClusterRiskCurrentInvestment extends Rule {
|
export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||||
public constructor(public exchangeRateDataService: ExchangeRateDataService) {
|
public constructor(
|
||||||
|
public exchangeRateDataService: ExchangeRateDataService,
|
||||||
|
private currentPositions: CurrentPositions
|
||||||
|
) {
|
||||||
super(exchangeRateDataService, {
|
super(exchangeRateDataService, {
|
||||||
name: 'Current Investment'
|
name: 'Current Investment'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public evaluate(
|
public evaluate(ruleSettings: Settings) {
|
||||||
aPositions: { [symbol: string]: PortfolioPosition },
|
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
|
||||||
aFees: number,
|
this.currentPositions.positions,
|
||||||
aRuleSettingsMap?: {
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
const ruleSettings =
|
|
||||||
aRuleSettingsMap[CurrencyClusterRiskCurrentInvestment.name];
|
|
||||||
|
|
||||||
const positionsGroupedByCurrency = this.groupPositionsByAttribute(
|
|
||||||
aPositions,
|
|
||||||
'currency',
|
'currency',
|
||||||
ruleSettings.baseCurrency
|
ruleSettings.baseCurrency
|
||||||
);
|
);
|
||||||
@ -61,4 +58,17 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule {
|
|||||||
value: true
|
value: true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getSettings(aUserSettings: UserSettings): Settings {
|
||||||
|
return {
|
||||||
|
baseCurrency: aUserSettings.baseCurrency,
|
||||||
|
isActive: true,
|
||||||
|
threshold: 0.5
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Settings extends RuleSettings {
|
||||||
|
baseCurrency: Currency;
|
||||||
|
threshold: number;
|
||||||
}
|
}
|
||||||
|
@ -1,27 +1,24 @@
|
|||||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
import { CurrentPositions } from '@ghostfolio/api/app/core/interfaces/current-positions.interface';
|
||||||
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
|
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||||
|
import { Currency } from '@prisma/client';
|
||||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
import { Rule } from '../../rule';
|
||||||
|
|
||||||
export class CurrencyClusterRiskInitialInvestment extends Rule {
|
export class CurrencyClusterRiskInitialInvestment extends Rule<Settings> {
|
||||||
public constructor(public exchangeRateDataService: ExchangeRateDataService) {
|
public constructor(
|
||||||
|
protected exchangeRateDataService: ExchangeRateDataService,
|
||||||
|
private currentPositions: CurrentPositions
|
||||||
|
) {
|
||||||
super(exchangeRateDataService, {
|
super(exchangeRateDataService, {
|
||||||
name: 'Initial Investment'
|
name: 'Initial Investment'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public evaluate(
|
public evaluate(ruleSettings: Settings) {
|
||||||
aPositions: { [symbol: string]: PortfolioPosition },
|
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
|
||||||
aFees: number,
|
this.currentPositions.positions,
|
||||||
aRuleSettingsMap?: {
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
const ruleSettings =
|
|
||||||
aRuleSettingsMap[CurrencyClusterRiskInitialInvestment.name];
|
|
||||||
|
|
||||||
const positionsGroupedByCurrency = this.groupPositionsByAttribute(
|
|
||||||
aPositions,
|
|
||||||
'currency',
|
'currency',
|
||||||
ruleSettings.baseCurrency
|
ruleSettings.baseCurrency
|
||||||
);
|
);
|
||||||
@ -61,4 +58,17 @@ export class CurrencyClusterRiskInitialInvestment extends Rule {
|
|||||||
value: true
|
value: true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getSettings(aUserSettings: UserSettings): Settings {
|
||||||
|
return {
|
||||||
|
baseCurrency: aUserSettings.baseCurrency,
|
||||||
|
isActive: true,
|
||||||
|
threshold: 0.5
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Settings extends RuleSettings {
|
||||||
|
baseCurrency: Currency;
|
||||||
|
threshold: number;
|
||||||
}
|
}
|
||||||
|
@ -1,38 +1,23 @@
|
|||||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
|
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||||
|
import { Currency } from '@prisma/client';
|
||||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
import { Rule } from '../../rule';
|
||||||
|
|
||||||
export class FeeRatioInitialInvestment extends Rule {
|
export class FeeRatioInitialInvestment extends Rule<Settings> {
|
||||||
public constructor(public exchangeRateDataService: ExchangeRateDataService) {
|
public constructor(
|
||||||
|
protected exchangeRateDataService: ExchangeRateDataService,
|
||||||
|
private totalInvestment: number,
|
||||||
|
private fees: number
|
||||||
|
) {
|
||||||
super(exchangeRateDataService, {
|
super(exchangeRateDataService, {
|
||||||
name: 'Initial Investment'
|
name: 'Initial Investment'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public evaluate(
|
public evaluate(ruleSettings: Settings) {
|
||||||
aPositions: { [symbol: string]: PortfolioPosition },
|
const feeRatio = this.fees / this.totalInvestment;
|
||||||
aFees: number,
|
|
||||||
aRuleSettingsMap?: {
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
const ruleSettings = aRuleSettingsMap[FeeRatioInitialInvestment.name];
|
|
||||||
|
|
||||||
const positionsGroupedByCurrency = this.groupPositionsByAttribute(
|
|
||||||
aPositions,
|
|
||||||
'currency',
|
|
||||||
ruleSettings.baseCurrency
|
|
||||||
);
|
|
||||||
|
|
||||||
let totalInvestment = 0;
|
|
||||||
|
|
||||||
positionsGroupedByCurrency.forEach((groupItem) => {
|
|
||||||
// Calculate total investment
|
|
||||||
totalInvestment += groupItem.investment;
|
|
||||||
});
|
|
||||||
|
|
||||||
const feeRatio = aFees / totalInvestment;
|
|
||||||
|
|
||||||
if (feeRatio > ruleSettings.threshold) {
|
if (feeRatio > ruleSettings.threshold) {
|
||||||
return {
|
return {
|
||||||
@ -50,4 +35,17 @@ export class FeeRatioInitialInvestment extends Rule {
|
|||||||
value: true
|
value: true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getSettings(aUserSettings: UserSettings): Settings {
|
||||||
|
return {
|
||||||
|
baseCurrency: aUserSettings.baseCurrency,
|
||||||
|
isActive: true,
|
||||||
|
threshold: 0.01
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Settings extends RuleSettings {
|
||||||
|
baseCurrency: Currency;
|
||||||
|
threshold: number;
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
import { bool, cleanEnv, host, json, num, port, str } from 'envalid';
|
import { bool, cleanEnv, host, json, num, port, str } from 'envalid';
|
||||||
|
|
||||||
|
import { environment } from '../environments/environment';
|
||||||
import { Environment } from './interfaces/environment.interface';
|
import { Environment } from './interfaces/environment.interface';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -14,8 +15,10 @@ export class ConfigurationService {
|
|||||||
ALPHA_VANTAGE_API_KEY: str({ default: '' }),
|
ALPHA_VANTAGE_API_KEY: str({ default: '' }),
|
||||||
CACHE_TTL: num({ default: 1 }),
|
CACHE_TTL: num({ default: 1 }),
|
||||||
DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }),
|
DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }),
|
||||||
|
ENABLE_FEATURE_BLOG: bool({ default: false }),
|
||||||
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
|
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
|
||||||
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
|
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
|
||||||
|
ENABLE_FEATURE_IMPORT: bool({ default: !environment.production }),
|
||||||
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),
|
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),
|
||||||
ENABLE_FEATURE_STATISTICS: bool({ default: false }),
|
ENABLE_FEATURE_STATISTICS: bool({ default: false }),
|
||||||
ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }),
|
ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }),
|
||||||
@ -28,6 +31,7 @@ export class ConfigurationService {
|
|||||||
REDIS_HOST: str({ default: 'localhost' }),
|
REDIS_HOST: str({ default: 'localhost' }),
|
||||||
REDIS_PORT: port({ default: 6379 }),
|
REDIS_PORT: port({ default: 6379 }),
|
||||||
ROOT_URL: str({ default: 'http://localhost:4200' }),
|
ROOT_URL: str({ default: 'http://localhost:4200' }),
|
||||||
|
STRIPE_PUBLIC_KEY: str({ default: '' }),
|
||||||
STRIPE_SECRET_KEY: str({ default: '' }),
|
STRIPE_SECRET_KEY: str({ default: '' }),
|
||||||
WEB_AUTH_RP_ID: host({ default: 'localhost' })
|
WEB_AUTH_RP_ID: host({ default: 'localhost' })
|
||||||
});
|
});
|
||||||
|
@ -18,6 +18,7 @@ export class CronService {
|
|||||||
|
|
||||||
@Cron(CronExpression.EVERY_12_HOURS)
|
@Cron(CronExpression.EVERY_12_HOURS)
|
||||||
public async runEveryTwelveHours() {
|
public async runEveryTwelveHours() {
|
||||||
|
await this.dataGatheringService.gatherProfileData();
|
||||||
await this.exchangeRateDataService.loadCurrencies();
|
await this.exchangeRateDataService.loadCurrencies();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
import { benchmarks, currencyPairs } from '@ghostfolio/common/config';
|
import { benchmarks, currencyPairs } from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
|
DATE_FORMAT,
|
||||||
getUtc,
|
getUtc,
|
||||||
isGhostfolioScraperApiSymbol,
|
isGhostfolioScraperApiSymbol,
|
||||||
|
isRakutenRapidApiSymbol,
|
||||||
resetHours
|
resetHours
|
||||||
} from '@ghostfolio/common/helper';
|
} from '@ghostfolio/common/helper';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
@ -37,7 +39,7 @@ export class DataGatheringService {
|
|||||||
|
|
||||||
if (isDataGatheringNeeded) {
|
if (isDataGatheringNeeded) {
|
||||||
console.log('7d data gathering has been started.');
|
console.log('7d data gathering has been started.');
|
||||||
console.time('data-gathering');
|
console.time('7d-data-gathering');
|
||||||
|
|
||||||
await this.prisma.property.create({
|
await this.prisma.property.create({
|
||||||
data: {
|
data: {
|
||||||
@ -70,7 +72,7 @@ export class DataGatheringService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.log('7d data gathering has been completed.');
|
console.log('7d data gathering has been completed.');
|
||||||
console.timeEnd('data-gathering');
|
console.timeEnd('7d-data-gathering');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,7 +83,7 @@ export class DataGatheringService {
|
|||||||
|
|
||||||
if (!isDataGatheringLocked) {
|
if (!isDataGatheringLocked) {
|
||||||
console.log('Max data gathering has been started.');
|
console.log('Max data gathering has been started.');
|
||||||
console.time('data-gathering');
|
console.time('max-data-gathering');
|
||||||
|
|
||||||
await this.prisma.property.create({
|
await this.prisma.property.create({
|
||||||
data: {
|
data: {
|
||||||
@ -114,10 +116,56 @@ export class DataGatheringService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.log('Max data gathering has been completed.');
|
console.log('Max data gathering has been completed.');
|
||||||
console.timeEnd('data-gathering');
|
console.timeEnd('max-data-gathering');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async gatherProfileData(aSymbols?: string[]) {
|
||||||
|
console.log('Profile data gathering has been started.');
|
||||||
|
console.time('profile-data-gathering');
|
||||||
|
|
||||||
|
let symbols = aSymbols;
|
||||||
|
|
||||||
|
if (!symbols) {
|
||||||
|
const dataGatheringItems = await this.getSymbolsProfileData();
|
||||||
|
symbols = dataGatheringItems.map((dataGatheringItem) => {
|
||||||
|
return dataGatheringItem.symbol;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentData = await this.dataProviderService.get(symbols);
|
||||||
|
|
||||||
|
for (const [symbol, { currency, dataSource, name }] of Object.entries(
|
||||||
|
currentData
|
||||||
|
)) {
|
||||||
|
try {
|
||||||
|
await this.prisma.symbolProfile.upsert({
|
||||||
|
create: {
|
||||||
|
currency,
|
||||||
|
dataSource,
|
||||||
|
name,
|
||||||
|
symbol
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
currency,
|
||||||
|
name
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
dataSource_symbol: {
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`${symbol}: ${error?.meta?.cause}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Profile data gathering has been completed.');
|
||||||
|
console.timeEnd('profile-data-gathering');
|
||||||
|
}
|
||||||
|
|
||||||
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
|
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
|
||||||
let hasError = false;
|
let hasError = false;
|
||||||
|
|
||||||
@ -146,11 +194,11 @@ export class DataGatheringService {
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
if (
|
if (
|
||||||
historicalData[symbol]?.[format(currentDate, 'yyyy-MM-dd')]
|
historicalData[symbol]?.[format(currentDate, DATE_FORMAT)]
|
||||||
?.marketPrice
|
?.marketPrice
|
||||||
) {
|
) {
|
||||||
lastMarketPrice =
|
lastMarketPrice =
|
||||||
historicalData[symbol]?.[format(currentDate, 'yyyy-MM-dd')]
|
historicalData[symbol]?.[format(currentDate, DATE_FORMAT)]
|
||||||
?.marketPrice;
|
?.marketPrice;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -303,6 +351,25 @@ export class DataGatheringService {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getSymbolsProfileData(): Promise<IDataGatheringItem[]> {
|
||||||
|
const startDate = subDays(resetHours(new Date()), 7);
|
||||||
|
|
||||||
|
const distinctOrders = await this.prisma.order.findMany({
|
||||||
|
distinct: ['symbol'],
|
||||||
|
orderBy: [{ symbol: 'asc' }],
|
||||||
|
select: { dataSource: true, symbol: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...this.getBenchmarksToGather(startDate), ...distinctOrders].filter(
|
||||||
|
(distinctOrder) => {
|
||||||
|
return (
|
||||||
|
distinctOrder.dataSource !== DataSource.GHOSTFOLIO &&
|
||||||
|
distinctOrder.dataSource !== DataSource.RAKUTEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private async isDataGatheringNeeded() {
|
private async isDataGatheringNeeded() {
|
||||||
const lastDataGathering = await this.prisma.property.findUnique({
|
const lastDataGathering = await this.prisma.property.findUnique({
|
||||||
where: { key: 'LAST_DATA_GATHERING' }
|
where: { key: 'LAST_DATA_GATHERING' }
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
DATE_FORMAT,
|
||||||
isGhostfolioScraperApiSymbol,
|
isGhostfolioScraperApiSymbol,
|
||||||
isRakutenRapidApiSymbol
|
isRakutenRapidApiSymbol
|
||||||
} from '@ghostfolio/common/helper';
|
} from '@ghostfolio/common/helper';
|
||||||
@ -29,7 +30,7 @@ export class DataProviderService {
|
|||||||
private readonly rakutenRapidApiService: RakutenRapidApiService,
|
private readonly rakutenRapidApiService: RakutenRapidApiService,
|
||||||
private readonly yahooFinanceService: YahooFinanceService
|
private readonly yahooFinanceService: YahooFinanceService
|
||||||
) {
|
) {
|
||||||
this.rakutenRapidApiService.setPrisma(this.prisma);
|
this.rakutenRapidApiService?.setPrisma(this.prisma);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async get(
|
public async get(
|
||||||
@ -46,7 +47,10 @@ export class DataProviderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const yahooFinanceSymbols = aSymbols.filter((symbol) => {
|
const yahooFinanceSymbols = aSymbols.filter((symbol) => {
|
||||||
return !isGhostfolioScraperApiSymbol(symbol);
|
return (
|
||||||
|
!isGhostfolioScraperApiSymbol(symbol) &&
|
||||||
|
!isRakutenRapidApiSymbol(symbol)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await this.yahooFinanceService.get(yahooFinanceSymbols);
|
const response = await this.yahooFinanceService.get(yahooFinanceSymbols);
|
||||||
@ -57,13 +61,24 @@ export class DataProviderService {
|
|||||||
|
|
||||||
for (const symbol of ghostfolioScraperApiSymbols) {
|
for (const symbol of ghostfolioScraperApiSymbols) {
|
||||||
if (symbol) {
|
if (symbol) {
|
||||||
const ghostfolioScraperApiResult = await this.ghostfolioScraperApiService.get(
|
const ghostfolioScraperApiResult =
|
||||||
[symbol]
|
await this.ghostfolioScraperApiService.get([symbol]);
|
||||||
);
|
|
||||||
response[symbol] = ghostfolioScraperApiResult[symbol];
|
response[symbol] = ghostfolioScraperApiResult[symbol];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rakutenRapidApiSymbols = aSymbols.filter((symbol) => {
|
||||||
|
return isRakutenRapidApiSymbol(symbol);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const symbol of rakutenRapidApiSymbols) {
|
||||||
|
if (symbol) {
|
||||||
|
const rakutenRapidApiResult =
|
||||||
|
await this.ghostfolioScraperApiService.get([symbol]);
|
||||||
|
response[symbol] = rakutenRapidApiResult[symbol];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -86,9 +101,9 @@ export class DataProviderService {
|
|||||||
|
|
||||||
const rangeQuery =
|
const rangeQuery =
|
||||||
from && to
|
from && to
|
||||||
? `AND date >= '${format(from, 'yyyy-MM-dd')}' AND date <= '${format(
|
? `AND date >= '${format(from, DATE_FORMAT)}' AND date <= '${format(
|
||||||
to,
|
to,
|
||||||
'yyyy-MM-dd'
|
DATE_FORMAT
|
||||||
)}'`
|
)}'`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
@ -106,7 +121,7 @@ export class DataProviderService {
|
|||||||
|
|
||||||
r[symbol] = {
|
r[symbol] = {
|
||||||
...(r[symbol] || {}),
|
...(r[symbol] || {}),
|
||||||
[format(new Date(date), 'yyyy-MM-dd')]: { marketPrice }
|
[format(new Date(date), DATE_FORMAT)]: { marketPrice }
|
||||||
};
|
};
|
||||||
|
|
||||||
return r;
|
return r;
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||||
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
@ -66,8 +67,8 @@ export class AlphaVantageService implements DataProviderInterface {
|
|||||||
historicalData['Time Series (Digital Currency Daily)']
|
historicalData['Time Series (Digital Currency Daily)']
|
||||||
).sort()) {
|
).sort()) {
|
||||||
if (
|
if (
|
||||||
isAfter(from, parse(key, 'yyyy-MM-dd', new Date())) &&
|
isAfter(from, parse(key, DATE_FORMAT, new Date())) &&
|
||||||
isBefore(to, parse(key, 'yyyy-MM-dd', new Date()))
|
isBefore(to, parse(key, DATE_FORMAT, new Date()))
|
||||||
) {
|
) {
|
||||||
response[symbol][key] = {
|
response[symbol][key] = {
|
||||||
marketPrice: parseFloat(timeSeries['4a. close (USD)'])
|
marketPrice: parseFloat(timeSeries['4a. close (USD)'])
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
DATE_FORMAT,
|
||||||
getYesterday,
|
getYesterday,
|
||||||
isGhostfolioScraperApiSymbol
|
isGhostfolioScraperApiSymbol
|
||||||
} from '@ghostfolio/common/helper';
|
} from '@ghostfolio/common/helper';
|
||||||
@ -95,7 +96,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
[symbol]: {
|
[symbol]: {
|
||||||
[format(getYesterday(), 'yyyy-MM-dd')]: {
|
[format(getYesterday(), DATE_FORMAT)]: {
|
||||||
marketPrice: value
|
marketPrice: value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -109,14 +110,13 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
|||||||
|
|
||||||
public async getScraperConfigurations(): Promise<ScraperConfig[]> {
|
public async getScraperConfigurations(): Promise<ScraperConfig[]> {
|
||||||
try {
|
try {
|
||||||
const {
|
const { value: scraperConfigString } =
|
||||||
value: scraperConfigString
|
await this.prisma.property.findFirst({
|
||||||
} = await this.prisma.property.findFirst({
|
select: {
|
||||||
select: {
|
value: true
|
||||||
value: true
|
},
|
||||||
},
|
where: { key: 'SCRAPER_CONFIG' }
|
||||||
where: { key: 'SCRAPER_CONFIG' }
|
});
|
||||||
});
|
|
||||||
|
|
||||||
return JSON.parse(scraperConfigString);
|
return JSON.parse(scraperConfigString);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
DATE_FORMAT,
|
||||||
getToday,
|
getToday,
|
||||||
getYesterday,
|
getYesterday,
|
||||||
isRakutenRapidApiSymbol
|
isRakutenRapidApiSymbol
|
||||||
@ -117,7 +118,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
'GF.FEAR_AND_GREED_INDEX': {
|
'GF.FEAR_AND_GREED_INDEX': {
|
||||||
[format(getYesterday(), 'yyyy-MM-dd')]: {
|
[format(getYesterday(), DATE_FORMAT)]: {
|
||||||
marketPrice: fgi.previousClose.value
|
marketPrice: fgi.previousClose.value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,11 @@
|
|||||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||||
import { isCrypto, isCurrency, parseCurrency } from '@ghostfolio/common/helper';
|
import {
|
||||||
|
DATE_FORMAT,
|
||||||
|
isCrypto,
|
||||||
|
isCurrency,
|
||||||
|
parseCurrency
|
||||||
|
} from '@ghostfolio/common/helper';
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
@ -103,8 +108,8 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
[symbol: string]: IYahooFinanceHistoricalResponse[];
|
[symbol: string]: IYahooFinanceHistoricalResponse[];
|
||||||
} = await yahooFinance.historical({
|
} = await yahooFinance.historical({
|
||||||
symbols: yahooSymbols,
|
symbols: yahooSymbols,
|
||||||
from: format(from, 'yyyy-MM-dd'),
|
from: format(from, DATE_FORMAT),
|
||||||
to: format(to, 'yyyy-MM-dd')
|
to: format(to, DATE_FORMAT)
|
||||||
});
|
});
|
||||||
|
|
||||||
const response: {
|
const response: {
|
||||||
@ -117,7 +122,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
response[symbol] = {};
|
response[symbol] = {};
|
||||||
|
|
||||||
timeSeries.forEach((timeSerie) => {
|
timeSeries.forEach((timeSerie) => {
|
||||||
response[symbol][format(timeSerie.date, 'yyyy-MM-dd')] = {
|
response[symbol][format(timeSerie.date, DATE_FORMAT)] = {
|
||||||
marketPrice: timeSerie.close,
|
marketPrice: timeSerie.close,
|
||||||
performance: timeSerie.open - timeSerie.close
|
performance: timeSerie.open - timeSerie.close
|
||||||
};
|
};
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { getYesterday } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Currency } from '@prisma/client';
|
import { Currency } from '@prisma/client';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
@ -51,7 +51,7 @@ export class ExchangeRateDataService {
|
|||||||
|
|
||||||
this.pairs.forEach((pair) => {
|
this.pairs.forEach((pair) => {
|
||||||
const [currency1, currency2] = pair.match(/.{1,3}/g);
|
const [currency1, currency2] = pair.match(/.{1,3}/g);
|
||||||
const date = format(getYesterday(), 'yyyy-MM-dd');
|
const date = format(getYesterday(), DATE_FORMAT);
|
||||||
|
|
||||||
this.currencies[pair] = resultExtended[pair]?.[date]?.marketPrice;
|
this.currencies[pair] = resultExtended[pair]?.[date]?.marketPrice;
|
||||||
|
|
||||||
|
@ -5,8 +5,10 @@ export interface Environment extends CleanedEnvAccessors {
|
|||||||
ALPHA_VANTAGE_API_KEY: string;
|
ALPHA_VANTAGE_API_KEY: string;
|
||||||
CACHE_TTL: number;
|
CACHE_TTL: number;
|
||||||
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_CUSTOM_SYMBOLS: boolean;
|
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
|
||||||
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
|
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
|
||||||
|
ENABLE_FEATURE_IMPORT: boolean;
|
||||||
ENABLE_FEATURE_SOCIAL_LOGIN: boolean;
|
ENABLE_FEATURE_SOCIAL_LOGIN: boolean;
|
||||||
ENABLE_FEATURE_STATISTICS: boolean;
|
ENABLE_FEATURE_STATISTICS: boolean;
|
||||||
ENABLE_FEATURE_SUBSCRIPTION: boolean;
|
ENABLE_FEATURE_SUBSCRIPTION: boolean;
|
||||||
@ -19,6 +21,7 @@ export interface Environment extends CleanedEnvAccessors {
|
|||||||
REDIS_HOST: string;
|
REDIS_HOST: string;
|
||||||
REDIS_PORT: number;
|
REDIS_PORT: number;
|
||||||
ROOT_URL: string;
|
ROOT_URL: string;
|
||||||
|
STRIPE_PUBLIC_KEY: string;
|
||||||
STRIPE_SECRET_KEY: string;
|
STRIPE_SECRET_KEY: string;
|
||||||
WEB_AUTH_RP_ID: string;
|
WEB_AUTH_RP_ID: string;
|
||||||
}
|
}
|
||||||
|
@ -10,6 +10,7 @@ export const MarketState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const Type = {
|
export const Type = {
|
||||||
|
Cash: 'Cash',
|
||||||
Cryptocurrency: 'Cryptocurrency',
|
Cryptocurrency: 'Cryptocurrency',
|
||||||
ETF: 'ETF',
|
ETF: 'ETF',
|
||||||
Stock: 'Stock',
|
Stock: 'Stock',
|
||||||
|
15
apps/api/src/services/interfaces/symbol-profile.interface.ts
Normal file
15
apps/api/src/services/interfaces/symbol-profile.interface.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { Country } from '@ghostfolio/common/interfaces/country.interface';
|
||||||
|
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
|
||||||
|
import { Currency, DataSource } from '@prisma/client';
|
||||||
|
|
||||||
|
export interface EnhancedSymbolProfile {
|
||||||
|
createdAt: Date;
|
||||||
|
currency: Currency | null;
|
||||||
|
dataSource: DataSource;
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
updatedAt: Date;
|
||||||
|
symbol: string;
|
||||||
|
countries: Country[];
|
||||||
|
sectors: Sector[];
|
||||||
|
}
|
@ -1,78 +1,24 @@
|
|||||||
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Currency } from '@prisma/client';
|
||||||
|
|
||||||
import { Portfolio } from '../models/portfolio';
|
|
||||||
import { Rule } from '../models/rule';
|
import { Rule } from '../models/rule';
|
||||||
import { AccountClusterRiskCurrentInvestment } from '../models/rules/account-cluster-risk/current-investment';
|
|
||||||
import { AccountClusterRiskInitialInvestment } from '../models/rules/account-cluster-risk/initial-investment';
|
|
||||||
import { AccountClusterRiskSingleAccount } from '../models/rules/account-cluster-risk/single-account';
|
|
||||||
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '../models/rules/currency-cluster-risk/base-currency-current-investment';
|
|
||||||
import { CurrencyClusterRiskBaseCurrencyInitialInvestment } from '../models/rules/currency-cluster-risk/base-currency-initial-investment';
|
|
||||||
import { CurrencyClusterRiskCurrentInvestment } from '../models/rules/currency-cluster-risk/current-investment';
|
|
||||||
import { CurrencyClusterRiskInitialInvestment } from '../models/rules/currency-cluster-risk/initial-investment';
|
|
||||||
import { FeeRatioInitialInvestment } from '../models/rules/fees/fee-ratio-initial-investment';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RulesService {
|
export class RulesService {
|
||||||
public constructor() {}
|
public constructor() {}
|
||||||
|
|
||||||
public async evaluate(
|
public async evaluate<T extends RuleSettings>(
|
||||||
aPortfolio: Portfolio,
|
aRules: Rule<T>[],
|
||||||
aRules: Rule[],
|
aUserSettings: { baseCurrency: Currency }
|
||||||
aUserSettings: { baseCurrency: string }
|
|
||||||
) {
|
) {
|
||||||
const defaultSettings = this.getDefaultRuleSettings(aUserSettings);
|
|
||||||
const details = await aPortfolio.getDetails();
|
|
||||||
|
|
||||||
return aRules
|
return aRules
|
||||||
.filter((rule) => {
|
.filter((rule) => {
|
||||||
return defaultSettings[rule.constructor.name]?.isActive;
|
return rule.getSettings(aUserSettings)?.isActive;
|
||||||
})
|
})
|
||||||
.map((rule) => {
|
.map((rule) => {
|
||||||
const evaluationResult = rule.evaluate(
|
const evaluationResult = rule.evaluate(rule.getSettings(aUserSettings));
|
||||||
details,
|
|
||||||
aPortfolio.getFees(),
|
|
||||||
defaultSettings
|
|
||||||
);
|
|
||||||
return { ...evaluationResult, name: rule.getName() };
|
return { ...evaluationResult, name: rule.getName() };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private getDefaultRuleSettings(aUserSettings: { baseCurrency: string }) {
|
|
||||||
return {
|
|
||||||
[AccountClusterRiskCurrentInvestment.name]: {
|
|
||||||
baseCurrency: aUserSettings.baseCurrency,
|
|
||||||
isActive: true,
|
|
||||||
threshold: 0.5
|
|
||||||
},
|
|
||||||
[AccountClusterRiskInitialInvestment.name]: {
|
|
||||||
baseCurrency: aUserSettings.baseCurrency,
|
|
||||||
isActive: true,
|
|
||||||
threshold: 0.5
|
|
||||||
},
|
|
||||||
[AccountClusterRiskSingleAccount.name]: { isActive: true },
|
|
||||||
[CurrencyClusterRiskBaseCurrencyInitialInvestment.name]: {
|
|
||||||
baseCurrency: aUserSettings.baseCurrency,
|
|
||||||
isActive: true
|
|
||||||
},
|
|
||||||
[CurrencyClusterRiskBaseCurrencyCurrentInvestment.name]: {
|
|
||||||
baseCurrency: aUserSettings.baseCurrency,
|
|
||||||
isActive: true
|
|
||||||
},
|
|
||||||
[CurrencyClusterRiskCurrentInvestment.name]: {
|
|
||||||
baseCurrency: aUserSettings.baseCurrency,
|
|
||||||
isActive: true,
|
|
||||||
threshold: 0.5
|
|
||||||
},
|
|
||||||
[CurrencyClusterRiskInitialInvestment.name]: {
|
|
||||||
baseCurrency: aUserSettings.baseCurrency,
|
|
||||||
isActive: true,
|
|
||||||
threshold: 0.5
|
|
||||||
},
|
|
||||||
[FeeRatioInitialInvestment.name]: {
|
|
||||||
baseCurrency: aUserSettings.baseCurrency,
|
|
||||||
isActive: true,
|
|
||||||
threshold: 0.01
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
64
apps/api/src/services/symbol-profile.service.ts
Normal file
64
apps/api/src/services/symbol-profile.service.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
|
||||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||||
|
import { Country } from '@ghostfolio/common/interfaces/country.interface';
|
||||||
|
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Prisma, SymbolProfile } from '@prisma/client';
|
||||||
|
import { continents, countries } from 'countries-list';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SymbolProfileService {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
public async getSymbolProfiles(
|
||||||
|
symbols: string[]
|
||||||
|
): Promise<EnhancedSymbolProfile[]> {
|
||||||
|
return this.prisma.symbolProfile
|
||||||
|
.findMany({
|
||||||
|
where: {
|
||||||
|
symbol: {
|
||||||
|
in: symbols
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then((symbolProfiles) => this.getSymbols(symbolProfiles));
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSymbols(symbolProfiles: SymbolProfile[]): EnhancedSymbolProfile[] {
|
||||||
|
return symbolProfiles.map((symbolProfile) => ({
|
||||||
|
...symbolProfile,
|
||||||
|
countries: this.getCountries(symbolProfile),
|
||||||
|
sectors: this.getSectors(symbolProfile)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCountries(symbolProfile: SymbolProfile): Country[] {
|
||||||
|
return ((symbolProfile?.countries as Prisma.JsonArray) ?? []).map(
|
||||||
|
(country) => {
|
||||||
|
const { code, weight } = country as Prisma.JsonObject;
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: code as string,
|
||||||
|
continent:
|
||||||
|
continents[countries[code as string]?.continent] ?? UNKNOWN_KEY,
|
||||||
|
name: countries[code as string]?.name ?? UNKNOWN_KEY,
|
||||||
|
weight: weight as number
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSectors(symbolProfile: SymbolProfile): Sector[] {
|
||||||
|
return ((symbolProfile?.sectors as Prisma.JsonArray) ?? []).map(
|
||||||
|
(sector) => {
|
||||||
|
const { name, weight } = sector as Prisma.JsonObject;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: (name as string) ?? UNKNOWN_KEY,
|
||||||
|
weight: weight as number
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -5,13 +5,7 @@ module.exports = {
|
|||||||
globals: {
|
globals: {
|
||||||
'ts-jest': {
|
'ts-jest': {
|
||||||
tsconfig: '<rootDir>/tsconfig.spec.json',
|
tsconfig: '<rootDir>/tsconfig.spec.json',
|
||||||
stringifyContentPathRegex: '\\.(html|svg)$',
|
stringifyContentPathRegex: '\\.(html|svg)$'
|
||||||
astTransformers: {
|
|
||||||
before: [
|
|
||||||
'jest-preset-angular/build/InlineFilesTransformer',
|
|
||||||
'jest-preset-angular/build/StripStylesTransformer'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
coverageDirectory: '../../coverage/apps/client',
|
coverageDirectory: '../../coverage/apps/client',
|
||||||
@ -19,5 +13,6 @@ module.exports = {
|
|||||||
'jest-preset-angular/build/serializers/no-ng-attributes',
|
'jest-preset-angular/build/serializers/no-ng-attributes',
|
||||||
'jest-preset-angular/build/serializers/ng-snapshot',
|
'jest-preset-angular/build/serializers/ng-snapshot',
|
||||||
'jest-preset-angular/build/serializers/html-comment'
|
'jest-preset-angular/build/serializers/html-comment'
|
||||||
]
|
],
|
||||||
|
transform: { '^.+\\.(ts|js|html)$': 'jest-preset-angular' }
|
||||||
};
|
};
|
||||||
|
@ -33,6 +33,20 @@ 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: 'de/blog/2021/07/hallo-ghostfolio',
|
||||||
|
loadChildren: () =>
|
||||||
|
import(
|
||||||
|
'./pages/blog/2021/07/hallo-ghostfolio/hallo-ghostfolio-page.module'
|
||||||
|
).then((m) => m.HalloGhostfolioPageModule)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'en/blog/2021/07/hello-ghostfolio',
|
||||||
|
loadChildren: () =>
|
||||||
|
import(
|
||||||
|
'./pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.module'
|
||||||
|
).then((m) => m.HelloGhostfolioPageModule)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'home',
|
path: 'home',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
@ -119,6 +133,7 @@ const routes: Routes = [
|
|||||||
routes,
|
routes,
|
||||||
// Preload all lazy loaded modules with the attribute preload === true
|
// Preload all lazy loaded modules with the attribute preload === true
|
||||||
{
|
{
|
||||||
|
anchorScrolling: 'enabled',
|
||||||
preloadingStrategy: ModulePreloadService,
|
preloadingStrategy: ModulePreloadService,
|
||||||
// enableTracing: true // <-- debugging purposes only
|
// enableTracing: true // <-- debugging purposes only
|
||||||
relativeLinkResolution: 'legacy'
|
relativeLinkResolution: 'legacy'
|
||||||
|
@ -10,16 +10,16 @@
|
|||||||
|
|
||||||
<main role="main">
|
<main role="main">
|
||||||
<div *ngIf="canCreateAccount" class="container create-account-container">
|
<div *ngIf="canCreateAccount" class="container create-account-container">
|
||||||
<div class="row mb-5">
|
<div class="row">
|
||||||
<div class="col-md-6 offset-md-3">
|
<div class="col-md-8 offset-md-2 text-center">
|
||||||
<a [routerLink]="['/']">
|
<a class="text-center" [routerLink]="['/']">
|
||||||
<mat-card
|
<div
|
||||||
class="create-account-box p-2 text-center"
|
class="create-account-box d-inline-block px-3 py-2"
|
||||||
(click)="onCreateAccount()"
|
(click)="onCreateAccount()"
|
||||||
>
|
>
|
||||||
<div class="mt-1" i18n>You are using the Live Demo.</div>
|
<span i18n>You are using the Live Demo.</span>
|
||||||
<button mat-button color="primary" i18n>Create Account</button>
|
<a class="ml-2" href="#" i18n>Create Account</a>
|
||||||
</mat-card></a
|
</div></a
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -28,10 +28,7 @@
|
|||||||
<router-outlet></router-outlet>
|
<router-outlet></router-outlet>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer
|
<footer *ngIf="!user" class="footer d-flex justify-content-center w-100">
|
||||||
*ngIf="currentRoute === 'start' || deviceType !== 'mobile'"
|
|
||||||
class="footer d-flex justify-content-center position-absolute w-100"
|
|
||||||
>
|
|
||||||
<div class="container text-center">
|
<div class="container text-center">
|
||||||
<div>
|
<div>
|
||||||
© {{ currentYear }} <a href="https://ghostfol.io">Ghostfolio</a>
|
© {{ currentYear }} <a href="https://ghostfol.io">Ghostfolio</a>
|
||||||
|
@ -1,21 +1,32 @@
|
|||||||
|
@import '~apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
min-height: 100vh;
|
||||||
|
|
||||||
main {
|
main {
|
||||||
padding: 5rem 0;
|
min-height: 100vh;
|
||||||
|
padding-top: 5rem;
|
||||||
|
|
||||||
.create-account-box {
|
.create-account-container {
|
||||||
cursor: pointer;
|
height: 3.5rem;
|
||||||
font-size: 90%;
|
margin-top: -0.5rem;
|
||||||
|
|
||||||
.link {
|
.create-account-box {
|
||||||
color: rgba(var(--palette-primary-500), 1);
|
background-color: rgba(0, 0, 0, $alpha-hover);
|
||||||
|
border-radius: 2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 80%;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: rgba(var(--palette-primary-500), 1);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
bottom: 0;
|
|
||||||
height: 5rem;
|
height: 5rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
@ -6,7 +6,11 @@ import {
|
|||||||
OnInit
|
OnInit
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { NavigationEnd, PRIMARY_OUTLET, Router } from '@angular/router';
|
import { NavigationEnd, PRIMARY_OUTLET, Router } from '@angular/router';
|
||||||
import { primaryColorHex, secondaryColorHex } from '@ghostfolio/common/config';
|
import {
|
||||||
|
primaryColorHex,
|
||||||
|
secondaryColorHex,
|
||||||
|
warnColorHex
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
import { InfoItem, 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 { MaterialCssVarsService } from 'angular-material-css-vars';
|
import { MaterialCssVarsService } from 'angular-material-css-vars';
|
||||||
@ -52,10 +56,6 @@ export class AppComponent implements OnDestroy, OnInit {
|
|||||||
public ngOnInit() {
|
public ngOnInit() {
|
||||||
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||||
|
|
||||||
this.dataService.fetchInfo().subscribe((info) => {
|
|
||||||
this.info = info;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.router.events
|
this.router.events
|
||||||
.pipe(filter((event) => event instanceof NavigationEnd))
|
.pipe(filter((event) => event instanceof NavigationEnd))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
@ -63,6 +63,8 @@ export class AppComponent implements OnDestroy, OnInit {
|
|||||||
const urlSegmentGroup = urlTree.root.children[PRIMARY_OUTLET];
|
const urlSegmentGroup = urlTree.root.children[PRIMARY_OUTLET];
|
||||||
const urlSegments = urlSegmentGroup.segments;
|
const urlSegments = urlSegmentGroup.segments;
|
||||||
this.currentRoute = urlSegments[0].path;
|
this.currentRoute = urlSegments[0].path;
|
||||||
|
|
||||||
|
this.info = this.dataService.fetchInfo();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.userService.stateChanged
|
this.userService.stateChanged
|
||||||
@ -106,5 +108,6 @@ export class AppComponent implements OnDestroy, OnInit {
|
|||||||
|
|
||||||
this.materialCssVarsService.setPrimaryColor(primaryColorHex);
|
this.materialCssVarsService.setPrimaryColor(primaryColorHex);
|
||||||
this.materialCssVarsService.setAccentColor(secondaryColorHex);
|
this.materialCssVarsService.setAccentColor(secondaryColorHex);
|
||||||
|
this.materialCssVarsService.setWarnColor(warnColorHex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,6 @@
|
|||||||
import { Platform } from '@angular/cdk/platform';
|
import { Platform } from '@angular/cdk/platform';
|
||||||
import { HttpClientModule } from '@angular/common/http';
|
import { HttpClientModule } from '@angular/common/http';
|
||||||
import { NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
|
||||||
import { MatCardModule } from '@angular/material/card';
|
|
||||||
import {
|
import {
|
||||||
DateAdapter,
|
DateAdapter,
|
||||||
MAT_DATE_FORMATS,
|
MAT_DATE_FORMATS,
|
||||||
@ -15,7 +13,7 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
|||||||
import { MaterialCssVarsModule } from 'angular-material-css-vars';
|
import { MaterialCssVarsModule } from 'angular-material-css-vars';
|
||||||
import { MarkdownModule } from 'ngx-markdown';
|
import { MarkdownModule } from 'ngx-markdown';
|
||||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||||
import { NgxStripeModule } from 'ngx-stripe';
|
import { NgxStripeModule, STRIPE_PUBLISHABLE_KEY } from 'ngx-stripe';
|
||||||
|
|
||||||
import { environment } from '../environments/environment';
|
import { environment } from '../environments/environment';
|
||||||
import { CustomDateAdapter } from './adapter/custom-date-adapter';
|
import { CustomDateAdapter } from './adapter/custom-date-adapter';
|
||||||
@ -27,6 +25,10 @@ import { authInterceptorProviders } from './core/auth.interceptor';
|
|||||||
import { httpResponseInterceptorProviders } from './core/http-response.interceptor';
|
import { httpResponseInterceptorProviders } from './core/http-response.interceptor';
|
||||||
import { LanguageService } from './core/language.service';
|
import { LanguageService } from './core/language.service';
|
||||||
|
|
||||||
|
export function NgxStripeFactory(): string {
|
||||||
|
return environment.stripePublicKey;
|
||||||
|
}
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [AppComponent],
|
declarations: [AppComponent],
|
||||||
imports: [
|
imports: [
|
||||||
@ -36,8 +38,6 @@ import { LanguageService } from './core/language.service';
|
|||||||
GfHeaderModule,
|
GfHeaderModule,
|
||||||
HttpClientModule,
|
HttpClientModule,
|
||||||
MarkdownModule.forRoot(),
|
MarkdownModule.forRoot(),
|
||||||
MatButtonModule,
|
|
||||||
MatCardModule,
|
|
||||||
MaterialCssVarsModule.forRoot({
|
MaterialCssVarsModule.forRoot({
|
||||||
darkThemeClass: 'is-dark-theme',
|
darkThemeClass: 'is-dark-theme',
|
||||||
isAutoContrast: true,
|
isAutoContrast: true,
|
||||||
@ -57,7 +57,11 @@ import { LanguageService } from './core/language.service';
|
|||||||
useClass: CustomDateAdapter,
|
useClass: CustomDateAdapter,
|
||||||
deps: [LanguageService, MAT_DATE_LOCALE, Platform]
|
deps: [LanguageService, MAT_DATE_LOCALE, Platform]
|
||||||
},
|
},
|
||||||
{ provide: MAT_DATE_FORMATS, useValue: DateFormats }
|
{ provide: MAT_DATE_FORMATS, useValue: DateFormats },
|
||||||
|
{
|
||||||
|
provide: STRIPE_PUBLISHABLE_KEY,
|
||||||
|
useFactory: NgxStripeFactory
|
||||||
|
}
|
||||||
],
|
],
|
||||||
bootstrap: [AppComponent]
|
bootstrap: [AppComponent]
|
||||||
})
|
})
|
||||||
|
@ -26,6 +26,27 @@
|
|||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="transactions">
|
||||||
|
<th *matHeaderCellDef class="text-right" i18n mat-header-cell>
|
||||||
|
Transactions
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="text-right" mat-cell>
|
||||||
|
{{ element.Order?.length }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="balance">
|
||||||
|
<th *matHeaderCellDef class="text-right" i18n mat-header-cell>Balance</th>
|
||||||
|
<td *matCellDef="let element" class="text-right" mat-cell>
|
||||||
|
<gf-value
|
||||||
|
class="d-inline-block justify-content-end"
|
||||||
|
[currency]="element.currency"
|
||||||
|
[locale]="locale"
|
||||||
|
[value]="element.balance"
|
||||||
|
></gf-value>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="actions">
|
<ng-container matColumnDef="actions">
|
||||||
<th *matHeaderCellDef class="px-1 text-center" i18n mat-header-cell></th>
|
<th *matHeaderCellDef class="px-1 text-center" i18n mat-header-cell></th>
|
||||||
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
|
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
|
||||||
@ -53,15 +74,6 @@
|
|||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="transactions">
|
|
||||||
<th *matHeaderCellDef class="text-right" i18n mat-header-cell>
|
|
||||||
Transactions
|
|
||||||
</th>
|
|
||||||
<td *matCellDef="let element" class="text-right" mat-cell>
|
|
||||||
{{ element.Order?.length }}
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
|
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
|
||||||
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
|
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
|
||||||
</table>
|
</table>
|
||||||
|
@ -28,7 +28,8 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
@Output() accountDeleted = new EventEmitter<string>();
|
@Output() accountDeleted = new EventEmitter<string>();
|
||||||
@Output() accountToUpdate = new EventEmitter<AccountModel>();
|
@Output() accountToUpdate = new EventEmitter<AccountModel>();
|
||||||
|
|
||||||
public dataSource: MatTableDataSource<AccountModel> = new MatTableDataSource();
|
public dataSource: MatTableDataSource<AccountModel> =
|
||||||
|
new MatTableDataSource();
|
||||||
public displayedColumns = [];
|
public displayedColumns = [];
|
||||||
public isLoading = true;
|
public isLoading = true;
|
||||||
public routeQueryParams: Subscription;
|
public routeQueryParams: Subscription;
|
||||||
@ -40,7 +41,7 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
public ngOnInit() {}
|
public ngOnInit() {}
|
||||||
|
|
||||||
public ngOnChanges() {
|
public ngOnChanges() {
|
||||||
this.displayedColumns = ['account', 'platform', 'transactions'];
|
this.displayedColumns = ['account', 'platform', 'transactions', 'balance'];
|
||||||
|
|
||||||
if (this.showActions) {
|
if (this.showActions) {
|
||||||
this.displayedColumns.push('actions');
|
this.displayedColumns.push('actions');
|
||||||
|
@ -106,6 +106,8 @@
|
|||||||
class="no-min-width px-1"
|
class="no-min-width px-1"
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
[matMenuTriggerFor]="accountMenu"
|
[matMenuTriggerFor]="accountMenu"
|
||||||
|
(menuClosed)="onMenuClosed()"
|
||||||
|
(menuOpened)="onMenuOpened()"
|
||||||
>
|
>
|
||||||
<ion-icon
|
<ion-icon
|
||||||
class="d-none d-sm-block"
|
class="d-none d-sm-block"
|
||||||
@ -114,8 +116,8 @@
|
|||||||
></ion-icon>
|
></ion-icon>
|
||||||
<ion-icon
|
<ion-icon
|
||||||
class="d-block d-sm-none"
|
class="d-block d-sm-none"
|
||||||
name="menu-outline"
|
|
||||||
size="large"
|
size="large"
|
||||||
|
[name]="isMenuOpen ? 'close-outline' : 'menu-outline'"
|
||||||
></ion-icon>
|
></ion-icon>
|
||||||
</button>
|
</button>
|
||||||
<mat-menu #accountMenu="matMenu" xPosition="before">
|
<mat-menu #accountMenu="matMenu" xPosition="before">
|
||||||
|
@ -5,10 +5,7 @@
|
|||||||
z-index: 999;
|
z-index: 999;
|
||||||
|
|
||||||
.mat-toolbar {
|
.mat-toolbar {
|
||||||
background-color: rgba(
|
background-color: rgba(var(--light-disabled-text));
|
||||||
var(--light-primary-text),
|
|
||||||
var(--palette-foreground-disabled-alpha)
|
|
||||||
);
|
|
||||||
|
|
||||||
.spacer {
|
.spacer {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
@ -17,6 +14,8 @@
|
|||||||
.mat-flat-button {
|
.mat-flat-button {
|
||||||
&:not(.mat-primary) {
|
&:not(.mat-primary) {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
|
text-decoration-color: rgba(var(--palette-primary-500), 1) !important;
|
||||||
|
text-underline-offset: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
ion-icon {
|
ion-icon {
|
||||||
@ -28,11 +27,6 @@
|
|||||||
|
|
||||||
:host-context(.is-dark-theme) {
|
:host-context(.is-dark-theme) {
|
||||||
.mat-toolbar {
|
.mat-toolbar {
|
||||||
background-color: rgba(
|
background-color: rgba(39, 39, 39, $alpha-disabled-text);
|
||||||
39,
|
|
||||||
39,
|
|
||||||
39,
|
|
||||||
var(--palette-foreground-disabled-alpha)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -38,6 +38,7 @@ export class HeaderComponent implements OnChanges {
|
|||||||
public hasPermissionForSubscription: boolean;
|
public hasPermissionForSubscription: boolean;
|
||||||
public hasPermissionToAccessAdminControl: boolean;
|
public hasPermissionToAccessAdminControl: boolean;
|
||||||
public impersonationId: string;
|
public impersonationId: string;
|
||||||
|
public isMenuOpen: boolean;
|
||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
@ -51,6 +52,7 @@ export class HeaderComponent implements OnChanges {
|
|||||||
) {
|
) {
|
||||||
this.impersonationStorageService
|
this.impersonationStorageService
|
||||||
.onChangeHasImpersonation()
|
.onChangeHasImpersonation()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((id) => {
|
.subscribe((id) => {
|
||||||
this.impersonationId = id;
|
this.impersonationId = id;
|
||||||
});
|
});
|
||||||
@ -83,6 +85,14 @@ export class HeaderComponent implements OnChanges {
|
|||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onMenuClosed() {
|
||||||
|
this.isMenuOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public onMenuOpened() {
|
||||||
|
this.isMenuOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
public onSignOut() {
|
public onSignOut() {
|
||||||
this.signOut.next();
|
this.signOut.next();
|
||||||
}
|
}
|
||||||
@ -98,23 +108,26 @@ export class HeaderComponent implements OnChanges {
|
|||||||
width: '30rem'
|
width: '30rem'
|
||||||
});
|
});
|
||||||
|
|
||||||
dialogRef.afterClosed().subscribe((data) => {
|
dialogRef
|
||||||
if (data?.accessToken) {
|
.afterClosed()
|
||||||
this.dataService
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.loginAnonymous(data?.accessToken)
|
.subscribe((data) => {
|
||||||
.pipe(
|
if (data?.accessToken) {
|
||||||
catchError(() => {
|
this.dataService
|
||||||
alert('Oops! Incorrect Security Token.');
|
.loginAnonymous(data?.accessToken)
|
||||||
|
.pipe(
|
||||||
|
catchError(() => {
|
||||||
|
alert('Oops! Incorrect Security Token.');
|
||||||
|
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
}),
|
}),
|
||||||
takeUntil(this.unsubscribeSubject)
|
takeUntil(this.unsubscribeSubject)
|
||||||
)
|
)
|
||||||
.subscribe(({ authToken }) => {
|
.subscribe(({ authToken }) => {
|
||||||
this.setToken(authToken);
|
this.setToken(authToken);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public setToken(aToken: string) {
|
public setToken(aToken: string) {
|
||||||
@ -125,4 +138,9 @@ export class HeaderComponent implements OnChanges {
|
|||||||
|
|
||||||
this.router.navigate(['/']);
|
this.router.navigate(['/']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.unsubscribeSubject.next();
|
||||||
|
this.unsubscribeSubject.complete();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,8 +5,8 @@ import { MatMenuModule } from '@angular/material/menu';
|
|||||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
import { LoginWithAccessTokenDialogModule } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.module';
|
import { LoginWithAccessTokenDialogModule } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.module';
|
||||||
|
import { GfLogoModule } from '@ghostfolio/client/components/logo/logo.module';
|
||||||
|
|
||||||
import { GfLogoModule } from '../logo/logo.module';
|
|
||||||
import { HeaderComponent } from './header.component';
|
import { HeaderComponent } from './header.component';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
|
@ -10,15 +10,15 @@ import {
|
|||||||
ViewChild
|
ViewChild
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { primaryColorRgb } from '@ghostfolio/common/config';
|
import { primaryColorRgb } from '@ghostfolio/common/config';
|
||||||
import { PortfolioItem } from '@ghostfolio/common/interfaces';
|
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||||
import {
|
import {
|
||||||
|
Chart,
|
||||||
LineController,
|
LineController,
|
||||||
LineElement,
|
LineElement,
|
||||||
LinearScale,
|
LinearScale,
|
||||||
PointElement,
|
PointElement,
|
||||||
TimeScale
|
TimeScale
|
||||||
} from 'chart.js';
|
} from 'chart.js';
|
||||||
import { Chart } from 'chart.js';
|
|
||||||
import { addMonths, isAfter, parseISO, subMonths } from 'date-fns';
|
import { addMonths, isAfter, parseISO, subMonths } from 'date-fns';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@ -28,7 +28,7 @@ import { addMonths, isAfter, parseISO, subMonths } from 'date-fns';
|
|||||||
styleUrls: ['./investment-chart.component.scss']
|
styleUrls: ['./investment-chart.component.scss']
|
||||||
})
|
})
|
||||||
export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
|
export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
|
||||||
@Input() portfolioItems: PortfolioItem[];
|
@Input() investments: InvestmentItem[];
|
||||||
|
|
||||||
@ViewChild('chartCanvas') chartCanvas;
|
@ViewChild('chartCanvas') chartCanvas;
|
||||||
|
|
||||||
@ -48,7 +48,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
public ngOnInit() {}
|
public ngOnInit() {}
|
||||||
|
|
||||||
public ngOnChanges() {
|
public ngOnChanges() {
|
||||||
if (this.portfolioItems) {
|
if (this.investments) {
|
||||||
this.initialize();
|
this.initialize();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -60,32 +60,32 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
private initialize() {
|
private initialize() {
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
|
|
||||||
if (this.portfolioItems?.length > 0) {
|
if (this.investments?.length > 0) {
|
||||||
// Extend chart by three months (before)
|
// Extend chart by three months (before)
|
||||||
const firstItem = this.portfolioItems[0];
|
const firstItem = this.investments[0];
|
||||||
this.portfolioItems.unshift({
|
this.investments.unshift({
|
||||||
...firstItem,
|
...firstItem,
|
||||||
date: subMonths(parseISO(firstItem.date), 3).toISOString(),
|
date: subMonths(parseISO(firstItem.date), 3).toISOString(),
|
||||||
investment: 0
|
investment: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
// Extend chart by three months (after)
|
// Extend chart by three months (after)
|
||||||
const lastItem = this.portfolioItems[this.portfolioItems.length - 1];
|
const lastItem = this.investments[this.investments.length - 1];
|
||||||
this.portfolioItems.push({
|
this.investments.push({
|
||||||
...lastItem,
|
...lastItem,
|
||||||
date: addMonths(parseISO(lastItem.date), 3).toISOString()
|
date: addMonths(parseISO(lastItem.date), 3).toISOString()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
labels: this.portfolioItems.map((position) => {
|
labels: this.investments.map((position) => {
|
||||||
return position.date;
|
return position.date;
|
||||||
}),
|
}),
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
|
borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
data: this.portfolioItems.map((position) => {
|
data: this.investments.map((position) => {
|
||||||
return position.investment;
|
return position.investment;
|
||||||
}),
|
}),
|
||||||
segment: {
|
segment: {
|
||||||
|
@ -2,6 +2,7 @@ import 'chartjs-adapter-date-fns';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
|
ChangeDetectorRef,
|
||||||
Component,
|
Component,
|
||||||
Input,
|
Input,
|
||||||
OnChanges,
|
OnChanges,
|
||||||
@ -44,7 +45,7 @@ export class LineChartComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
public chart: Chart;
|
public chart: Chart;
|
||||||
public isLoading = true;
|
public isLoading = true;
|
||||||
|
|
||||||
public constructor() {
|
public constructor(private changeDetectorRef: ChangeDetectorRef) {
|
||||||
Chart.register(
|
Chart.register(
|
||||||
Filler,
|
Filler,
|
||||||
LineController,
|
LineController,
|
||||||
@ -59,7 +60,12 @@ export class LineChartComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
|
|
||||||
public ngOnChanges() {
|
public ngOnChanges() {
|
||||||
if (this.historicalDataItems) {
|
if (this.historicalDataItems) {
|
||||||
this.initialize();
|
setTimeout(() => {
|
||||||
|
// Wait for the chartCanvas
|
||||||
|
this.initialize();
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,8 +85,6 @@ export class LineChartComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
marketPrices.push(historicalDataItem.value);
|
marketPrices.push(historicalDataItem.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
const canvas = document.getElementById('chartCanvas');
|
|
||||||
|
|
||||||
const gradient = this.chartCanvas?.nativeElement
|
const gradient = this.chartCanvas?.nativeElement
|
||||||
?.getContext('2d')
|
?.getContext('2d')
|
||||||
.createLinearGradient(
|
.createLinearGradient(
|
||||||
@ -89,11 +93,14 @@ export class LineChartComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
0,
|
0,
|
||||||
(this.chartCanvas.nativeElement.parentNode.offsetHeight * 4) / 5
|
(this.chartCanvas.nativeElement.parentNode.offsetHeight * 4) / 5
|
||||||
);
|
);
|
||||||
gradient.addColorStop(
|
|
||||||
0,
|
if (gradient) {
|
||||||
`rgba(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b}, 0.01)`
|
gradient.addColorStop(
|
||||||
);
|
0,
|
||||||
gradient.addColorStop(1, getBackgroundColor());
|
`rgba(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b}, 0.01)`
|
||||||
|
);
|
||||||
|
gradient.addColorStop(1, getBackgroundColor());
|
||||||
|
}
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
labels,
|
labels,
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
<span class="align-items-center d-flex"
|
<span class="align-items-center d-flex"
|
||||||
><span class="d-inline-block logo mr-1"></span>
|
><span class="d-inline-block logo mr-1"></span>
|
||||||
<span class="name">Ghostfolio</span></span
|
<span *ngIf="!hideName" class="name">Ghostfolio</span></span
|
||||||
>
|
>
|
||||||
|
@ -14,10 +14,12 @@ import {
|
|||||||
})
|
})
|
||||||
export class LogoComponent implements OnInit {
|
export class LogoComponent implements OnInit {
|
||||||
@HostBinding('class') @Input() size: 'large' | 'medium';
|
@HostBinding('class') @Input() size: 'large' | 'medium';
|
||||||
|
@Input() hideName: boolean;
|
||||||
|
|
||||||
public constructor() {}
|
public constructor() {}
|
||||||
|
|
||||||
public ngOnInit() {
|
public ngOnInit() {
|
||||||
this.size = this.size || 'medium';
|
this.hideName = this.hideName ?? false;
|
||||||
|
this.size = this.size ?? 'medium';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,13 @@
|
|||||||
<a
|
<div class="p-3">
|
||||||
class="align-items-center justify-content-center"
|
<div class="d-flex justify-content-center mb-1">
|
||||||
color="primary"
|
<gf-logo size="large" [hideName]="true"></gf-logo>
|
||||||
[routerLink]="['/transactions']"
|
</div>
|
||||||
mat-button
|
<a
|
||||||
>
|
class="align-items-center justify-content-center"
|
||||||
<ion-icon class="mr-1" name="time-outline" size="large"></ion-icon>
|
color="primary"
|
||||||
<span i18n>Time to add your first transaction.</span>
|
[routerLink]="['/transactions']"
|
||||||
</a>
|
mat-button
|
||||||
|
>
|
||||||
|
<span i18n>Time to add your first transaction.</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
@ -1,3 +1,13 @@
|
|||||||
:host {
|
:host {
|
||||||
|
border: 1px solid rgba(var(--dark-dividers));
|
||||||
|
border-radius: 0.25rem;
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
|
gf-logo {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.is-dark-theme) {
|
||||||
|
border-color: rgba(var(--light-dividers));
|
||||||
}
|
}
|
||||||
|
@ -2,13 +2,14 @@ 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 { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { GfLogoModule } from '@ghostfolio/client/components/logo/logo.module';
|
||||||
|
|
||||||
import { NoTransactionsInfoComponent } from './no-transactions-info.component';
|
import { NoTransactionsInfoComponent } from './no-transactions-info.component';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [NoTransactionsInfoComponent],
|
declarations: [NoTransactionsInfoComponent],
|
||||||
exports: [NoTransactionsInfoComponent],
|
exports: [NoTransactionsInfoComponent],
|
||||||
imports: [CommonModule, MatButtonModule, RouterModule],
|
imports: [CommonModule, GfLogoModule, MatButtonModule, RouterModule],
|
||||||
providers: [],
|
providers: [],
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
|
@ -6,7 +6,10 @@ import {
|
|||||||
} 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 { isToday, parse } from 'date-fns';
|
import { isToday, parse } from 'date-fns';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
import { LineChartItem } from '../line-chart/interfaces/line-chart.interface';
|
import { LineChartItem } from '../line-chart/interfaces/line-chart.interface';
|
||||||
import { PositionDetailDialogParams } from './interfaces/interfaces';
|
import { PositionDetailDialogParams } from './interfaces/interfaces';
|
||||||
@ -27,6 +30,8 @@ export class PerformanceChartDialog {
|
|||||||
public historicalDataItems: LineChartItem[];
|
public historicalDataItems: LineChartItem[];
|
||||||
public title: string;
|
public title: string;
|
||||||
|
|
||||||
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
private dataService: DataService,
|
private dataService: DataService,
|
||||||
@ -35,6 +40,7 @@ export class PerformanceChartDialog {
|
|||||||
) {
|
) {
|
||||||
this.dataService
|
this.dataService
|
||||||
.fetchPositionDetail(this.benchmarkSymbol)
|
.fetchPositionDetail(this.benchmarkSymbol)
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(({ currency, firstBuyDate, historicalData, marketPrice }) => {
|
.subscribe(({ currency, firstBuyDate, historicalData, marketPrice }) => {
|
||||||
this.benchmarkDataItems = [];
|
this.benchmarkDataItems = [];
|
||||||
this.currency = currency;
|
this.currency = currency;
|
||||||
@ -61,7 +67,7 @@ export class PerformanceChartDialog {
|
|||||||
value: benchmarkItem.value * coefficient
|
value: benchmarkItem.value * coefficient
|
||||||
});
|
});
|
||||||
} else if (
|
} else if (
|
||||||
isToday(parse(historicalDataItem.date, 'yyyy-MM-dd', new Date()))
|
isToday(parse(historicalDataItem.date, DATE_FORMAT, new Date()))
|
||||||
) {
|
) {
|
||||||
this.benchmarkDataItems.push({
|
this.benchmarkDataItems.push({
|
||||||
date: historicalDataItem.date,
|
date: historicalDataItem.date,
|
||||||
@ -84,4 +90,9 @@ export class PerformanceChartDialog {
|
|||||||
public onClose(): void {
|
public onClose(): void {
|
||||||
this.dialogRef.close();
|
this.dialogRef.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.unsubscribeSubject.next();
|
||||||
|
this.unsubscribeSubject.complete();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,18 @@
|
|||||||
<div class="container p-0">
|
<div class="container p-0">
|
||||||
|
<div class="row px-3 py-1">
|
||||||
|
<div class="d-flex flex-grow-1" i18n>Cash</div>
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<gf-value
|
||||||
|
class="justify-content-end"
|
||||||
|
[currency]="baseCurrency"
|
||||||
|
[locale]="locale"
|
||||||
|
[value]="isLoading ? undefined : overview?.cash"
|
||||||
|
></gf-value>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col"><hr /></div>
|
||||||
|
</div>
|
||||||
<div class="row px-3 py-1">
|
<div class="row px-3 py-1">
|
||||||
<div class="d-flex flex-grow-1" i18n>Buy</div>
|
<div class="d-flex flex-grow-1" i18n>Buy</div>
|
||||||
<div class="d-flex justify-content-end">
|
<div class="d-flex justify-content-end">
|
||||||
|
@ -12,16 +12,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row px-3 py-1">
|
<div class="row px-3 py-1">
|
||||||
<div class="d-flex flex-grow-1" i18n>Gross performance</div>
|
<div class="d-flex flex-grow-1" i18n>Absolute Performance</div>
|
||||||
<div class="d-flex flex-column flex-wrap justify-content-end">
|
<div class="d-flex flex-column flex-wrap justify-content-end">
|
||||||
<gf-value
|
<gf-value
|
||||||
class="justify-content-end mb-2"
|
class="justify-content-end"
|
||||||
position="end"
|
position="end"
|
||||||
[colorizeSign]="true"
|
[colorizeSign]="true"
|
||||||
[currency]="baseCurrency"
|
[currency]="baseCurrency"
|
||||||
[locale]="locale"
|
[locale]="locale"
|
||||||
[value]="isLoading ? undefined : performance?.currentGrossPerformance"
|
[value]="isLoading ? undefined : performance?.currentGrossPerformance"
|
||||||
></gf-value>
|
></gf-value>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row px-3 py-1">
|
||||||
|
<div class="d-flex flex-grow-1" i18n>Performance (TWR)</div>
|
||||||
|
<div class="d-flex flex-column flex-wrap justify-content-end">
|
||||||
<gf-value
|
<gf-value
|
||||||
class="justify-content-end"
|
class="justify-content-end"
|
||||||
position="end"
|
position="end"
|
||||||
@ -34,27 +39,29 @@
|
|||||||
></gf-value>
|
></gf-value>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row px-3 py-2">
|
<!--
|
||||||
<div class="d-flex flex-grow-1" i18n>Net performance</div>
|
<div class="row px-3 py-2">
|
||||||
<div class="d-flex flex-column flex-wrap justify-content-end">
|
<div class="d-flex flex-grow-1" i18n>Net performance</div>
|
||||||
<gf-value
|
<div class="d-flex flex-column flex-wrap justify-content-end">
|
||||||
class="justify-content-end mb-2"
|
<gf-value
|
||||||
position="end"
|
class="justify-content-end mb-2"
|
||||||
[colorizeSign]="true"
|
position="end"
|
||||||
[currency]="baseCurrency"
|
[colorizeSign]="true"
|
||||||
[locale]="locale"
|
[currency]="baseCurrency"
|
||||||
[value]="isLoading ? undefined : performance?.currentNetPerformance"
|
[locale]="locale"
|
||||||
></gf-value>
|
[value]="isLoading ? undefined : performance?.currentNetPerformance"
|
||||||
<gf-value
|
></gf-value>
|
||||||
class="justify-content-end"
|
<gf-value
|
||||||
position="end"
|
class="justify-content-end"
|
||||||
[colorizeSign]="true"
|
position="end"
|
||||||
[isPercent]="true"
|
[colorizeSign]="true"
|
||||||
[locale]="locale"
|
[isPercent]="true"
|
||||||
[value]="
|
[locale]="locale"
|
||||||
isLoading ? undefined : performance?.currentNetPerformancePercent
|
[value]="
|
||||||
"
|
isLoading ? undefined : performance?.currentNetPerformancePercent
|
||||||
></gf-value>
|
"
|
||||||
|
></gf-value>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
-->
|
||||||
</div>
|
</div>
|
||||||
|
@ -8,7 +8,7 @@ import {
|
|||||||
ViewChild
|
ViewChild
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||||
import { getCssVariable, getTextColor } from '@ghostfolio/common/helper';
|
import { getTextColor } from '@ghostfolio/common/helper';
|
||||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||||
import { Currency } from '@prisma/client';
|
import { Currency } from '@prisma/client';
|
||||||
import { Tooltip } from 'chart.js';
|
import { Tooltip } from 'chart.js';
|
||||||
@ -43,9 +43,7 @@ export class PortfolioProportionChartComponent
|
|||||||
private colorMap: {
|
private colorMap: {
|
||||||
[symbol: string]: string;
|
[symbol: string]: string;
|
||||||
} = {
|
} = {
|
||||||
[UNKNOWN_KEY]: `rgba(${getTextColor()}, ${getCssVariable(
|
[UNKNOWN_KEY]: `rgba(${getTextColor()}, 0.12)`
|
||||||
'--palette-foreground-divider-alpha'
|
|
||||||
)})`
|
|
||||||
};
|
};
|
||||||
|
|
||||||
public constructor() {
|
public constructor() {
|
||||||
|
@ -2,11 +2,15 @@ import {
|
|||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
ChangeDetectorRef,
|
ChangeDetectorRef,
|
||||||
Component,
|
Component,
|
||||||
Inject
|
Inject,
|
||||||
|
OnDestroy
|
||||||
} 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 { format, isSameMonth, isToday, parseISO } from 'date-fns';
|
import { format, isSameMonth, isToday, parseISO } from 'date-fns';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
import { LineChartItem } from '../../line-chart/interfaces/line-chart.interface';
|
import { LineChartItem } from '../../line-chart/interfaces/line-chart.interface';
|
||||||
import { PositionDetailDialogParams } from './interfaces/interfaces';
|
import { PositionDetailDialogParams } from './interfaces/interfaces';
|
||||||
@ -18,7 +22,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 {
|
export class PositionDetailDialog implements OnDestroy {
|
||||||
public averagePrice: number;
|
public averagePrice: number;
|
||||||
public benchmarkDataItems: LineChartItem[];
|
public benchmarkDataItems: LineChartItem[];
|
||||||
public currency: string;
|
public currency: string;
|
||||||
@ -33,6 +37,8 @@ export class PositionDetailDialog {
|
|||||||
public quantity: number;
|
public quantity: number;
|
||||||
public transactionCount: number;
|
public transactionCount: number;
|
||||||
|
|
||||||
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
private dataService: DataService,
|
private dataService: DataService,
|
||||||
@ -41,6 +47,7 @@ export class PositionDetailDialog {
|
|||||||
) {
|
) {
|
||||||
this.dataService
|
this.dataService
|
||||||
.fetchPositionDetail(data.symbol)
|
.fetchPositionDetail(data.symbol)
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(
|
.subscribe(
|
||||||
({
|
({
|
||||||
averagePrice,
|
averagePrice,
|
||||||
@ -60,7 +67,7 @@ export class PositionDetailDialog {
|
|||||||
this.benchmarkDataItems = [];
|
this.benchmarkDataItems = [];
|
||||||
this.currency = currency;
|
this.currency = currency;
|
||||||
this.firstBuyDate = firstBuyDate;
|
this.firstBuyDate = firstBuyDate;
|
||||||
this.grossPerformance = quantity * grossPerformance;
|
this.grossPerformance = grossPerformance;
|
||||||
this.grossPerformancePercent = grossPerformancePercent;
|
this.grossPerformancePercent = grossPerformancePercent;
|
||||||
this.historicalDataItems = historicalData.map(
|
this.historicalDataItems = historicalData.map(
|
||||||
(historicalDataItem) => {
|
(historicalDataItem) => {
|
||||||
@ -109,13 +116,13 @@ export class PositionDetailDialog {
|
|||||||
} else {
|
} else {
|
||||||
// Add market price
|
// Add market price
|
||||||
this.historicalDataItems.push({
|
this.historicalDataItems.push({
|
||||||
date: format(new Date(), 'yyyy-MM-dd'),
|
date: format(new Date(), DATE_FORMAT),
|
||||||
value: this.marketPrice
|
value: this.marketPrice
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add benchmark
|
// Add benchmark
|
||||||
this.benchmarkDataItems.push({
|
this.benchmarkDataItems.push({
|
||||||
date: format(new Date(), 'yyyy-MM-dd'),
|
date: format(new Date(), DATE_FORMAT),
|
||||||
value: averagePrice
|
value: averagePrice
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -135,4 +142,9 @@ export class PositionDetailDialog {
|
|||||||
public onClose(): void {
|
public onClose(): void {
|
||||||
this.dialogRef.close();
|
this.dialogRef.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.unsubscribeSubject.next();
|
||||||
|
this.unsubscribeSubject.complete();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user