Compare commits

...

63 Commits

Author SHA1 Message Date
80862e5c2a Release 1.146.3 (#899) 2022-05-08 22:54:07 +02:00
904d4db219 Refactor build:all and build:dev scripts (#898) 2022-05-08 22:52:46 +02:00
10f13eec48 Release 1.146.2 (#897) 2022-05-08 22:18:00 +02:00
ea3a9d3b79 Feature/eliminate circular dependencies in common library (#896)
* Eliminate circular dependencies

* Update changelog
2022-05-08 22:16:47 +02:00
e55b05fe3d Release 1.146.1 (#895) 2022-05-08 16:57:23 +02:00
32dd76be5f Fix path to jest files (#894) 2022-05-08 16:55:14 +02:00
ff9b6bb4df Release 1.146.0 (#893) 2022-05-08 16:04:43 +02:00
5be95b7b63 Feature/simplify about page (#892)
* Simplify about page

* Update changelog
2022-05-08 16:02:44 +02:00
b3e07c8446 Feature/support permissions in fire calculator (#891)
* Support hasPermissionToUpdateUserSettings

* Update changelog
2022-05-08 15:59:19 +02:00
eb9cece4e4 Update browserslist database (#890) 2022-05-08 15:56:39 +02:00
b331f5f04d Feature/setup nx cloud (#889)
* Upgrade angular, Nx and storybook

* Setup Nx Cloud

* Update changelog
2022-05-08 15:52:21 +02:00
34cbdd7c2a Upgrade angular, Nx and storybook (#888)
* Upgrade angular, Nx and storybook

* Update changelog
2022-05-08 09:26:33 +02:00
57314d62ee Feature/improve allocations page with no filter (#887)
* Improve accounts for no filters

* Update changelog
2022-05-07 22:33:57 +02:00
40380346e6 Feature/setup bull queue system (#886)
* Setup @nestjs/bull and asset profile data gathering job

* Update changelog
2022-05-07 20:00:51 +02:00
5622c4cf7e Feature/harmonize no data available label (#885)
* Harmonize label for UNKNOWN_KEY

* Update changelog
2022-05-07 14:11:42 +02:00
21173bed21 Release 1.145.0 (#884) 2022-05-07 11:47:28 +02:00
16dd8f7652 Feature/refactor filters with interface (#883)
* Refactor filtering with an interface

* Filter by accounts

* Update changelog
2022-05-07 11:44:29 +02:00
ce6b5fb7cb Bugfix/fix tooltip in proportion chart after update (#882)
* Keep tooltip configuration up to date

* Update changelog
2022-05-01 08:38:57 +02:00
f6f62db830 Feature/add support for private equity (#881)
* Add support for private equity

* Update changelog
2022-04-30 22:16:13 +02:00
01103f3db4 Feature/add asset and asset sub class to wealth items form (#880)
* Add asset and asset sub class

* Update changelog
2022-04-30 21:47:10 +02:00
e9e9f1a124 Release 1.144.0 (#879) 2022-04-30 11:51:49 +02:00
751256f158 Feature/add support for real estate and precious metal (#878)
* Add support for real estate and precious metal

* Update changelog
2022-04-30 11:49:58 +02:00
c2a1cbd20f Feature/improve layout of position detail dialog (#877)
* Improve layout

* Update changelog
2022-04-30 10:48:02 +02:00
04044f8720 Support futures (#845)
* Support futures

* Upgrade yahoo-finance2 to version 2.3.2

* Update changelog
2022-04-30 09:55:24 +02:00
4dc76817ce Bugfix/fix import validation for numbers equal zero (#875)
* Fix import validation for numbers equal 0

* Update changelog
2022-04-29 13:10:45 +02:00
1f0bd5a7db Bugfix/fix color of spinner in filter component (#873)
* Fix color for dark mode

* Update changelog
2022-04-27 17:30:57 +02:00
b6cd007ad4 Release/1.143.0 (#871)
* Release 1.143.0
  * Improve filtering by tags
2022-04-26 22:31:53 +02:00
b4bc72c6f9 Release 1.142.0 (#869) 2022-04-25 22:41:02 +02:00
899fa0370e Feature/improve users table of admin control panel (#866)
* Improve users table

* Update changelog
2022-04-25 22:39:08 +02:00
da27504aa1 Add orderBy statement to make debugging easier (#868) 2022-04-25 22:37:56 +02:00
b7bbc029ac Feature/render tags in dialogs (#864)
* Render tags

* Update changelog
2022-04-25 22:37:34 +02:00
c61a415fb2 Bugfix/change date to utc in data gathering service (#867)
* Change date to UTC

* Update changelog
2022-04-25 18:12:42 +02:00
8ff811ed28 Release/1.141.1 (#863) 2022-04-24 17:27:00 +02:00
9a2ea0a4ed Release 1.141.0 (#862) 2022-04-24 16:24:21 +02:00
bad9d17c44 Setup allocations page and endpoint (#859)
* Setup tagging system

* Update changelog
2022-04-24 16:23:03 +02:00
ea89ca5734 Feature/simplify ids in database schema (#861)
* Simplify ids in database schema
  * Access
  * Order
  * Subscription

* Update changelog
2022-04-24 09:35:01 +02:00
8f61f7c169 Feature/extract activities table filter component (#858)
* Extract activities table component

* Update changelog
2022-04-23 19:22:20 +02:00
edca05f542 Feature/change get started url of public page (#857)
* Change url

* Update changelog
2022-04-23 16:48:23 +02:00
283f054ee2 Feature/upgrade prisma to version 3.12.0 (#856)
* Upgrade prisma to version 3.12.0

* Update changelog
2022-04-23 13:38:41 +02:00
e9a46cb224 Release 1.140.2 (#854) 2022-04-23 09:52:22 +02:00
4a75c6d483 Release 1.140.1 (#853) 2022-04-22 21:45:50 +02:00
bbe9183fb0 Release 1.140.0 (#852) 2022-04-22 21:29:08 +02:00
1b03ddc586 Feature/add symbol profile overrides model (#851)
* Add symbol profile overrides model

* Update changelog
2022-04-22 21:27:55 +02:00
beb12637ce Bugfix/fix total calculation for sell and dividend (#850)
* Fix calculation for sell and dividend activities

* Update changelog
2022-04-22 19:29:18 +02:00
20358d9105 Feature/persist savings rate (#849)
* Persist savings rate

* Update changelog
2022-04-21 23:07:19 +02:00
0e4c39d145 Feature/reuse value component in ghostfolio in numbers section (#846)
* Reuse value component

* Update changelog
2022-04-19 17:06:12 +02:00
83ebacbb06 Add Buy me a coffee link (#848) 2022-04-18 21:32:54 +02:00
7c58c5fb7f Setup funding.yml (#847) 2022-04-18 21:20:30 +02:00
f3271ab1ff Feature/upgrade yahoo finance2 to version 2.3.1 (#844)
* Upgrade yahoo-finance2 to version 2.3.1

* Update changelog
2022-04-18 17:10:00 +02:00
9f597cbff1 Release 1.139.0 (#843) 2022-04-18 11:59:16 +02:00
90efc2ac51 Feature/beautify etf names in asset profile (#842)
* Beautify ETF names

* Update changelog
2022-04-18 11:57:57 +02:00
056b318d86 Bugfix/fix end date in ics files (#841)
* Fix end date

* Update changelog
2022-04-18 11:31:16 +02:00
82ede2fe32 Bugfix/fix fear and greed data source (#840)
* Fix data source of Fear & Greed Index

* Update changelog
2022-04-18 10:49:02 +02:00
8ae041faa0 Bugfix/fix issue in fire calculator after changing investment horizon (#839)
* Properly update chart datasets and improve tooltip

* Update changelog
2022-04-18 10:31:16 +02:00
bd4608e521 Release 1.138.0 (#838) 2022-04-16 21:03:28 +02:00
0d8362ca8f Feature/separate deposit and savings in fire calculator (#837)
* Separate deposit and savings

* Update changelog
2022-04-16 21:01:55 +02:00
638ae3f7fa Feature/add boringly getting rich guide to resources page (#836)
* Add "Boringly Getting Rich" guide

* Update changelog
2022-04-16 15:35:31 +02:00
6e7cf0380b Feature/export single draft (#835)
* Export single draft

* Update changelog
2022-04-16 11:33:01 +02:00
ec2ecab751 Clean up name (#834) 2022-04-16 10:21:32 +02:00
598fe41b8c Release 1.137.0 (#831) 2022-04-15 18:58:33 +02:00
ba7c98d325 Add test case for BUY and SELL (partially) (#826)
* Add test case for BUY and SELL (partially)

* Fix investment calculation for sell activities

* Do not show total value if sell activity

* Update changelog
2022-04-15 18:56:23 +02:00
65e062ad26 Simplify search (#828)
* Simplify search

* Update changelog
2022-04-15 12:39:33 +02:00
8526b5a027 Feature/export draft activities as ics (#830)
* Export draft activities as ICS

* Update changelog
2022-04-15 10:53:40 +02:00
127 changed files with 4080 additions and 2369 deletions

1
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1 @@
custom: ['https://www.buymeacoffee.com/ghostfolio']

View File

@ -5,6 +5,176 @@ 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.146.3 - 08.05.2022
### Added
- Set up a queue for the data gathering jobs
- Set up _Nx Cloud_
### Changed
- Migrated the asset profile data gathering to the queue design pattern
- Improved the allocations page with no filtering
- Harmonized the _No data available_ label in the portfolio proportion chart component
- Improved the _FIRE_ calculator for the _Live Demo_
- Simplified the about page
- Upgraded `angular` from version `13.2.2` to `13.3.6`
- Upgraded `Nx` from version `13.8.5` to `14.1.4`
- Upgraded `storybook` from version `6.4.18` to `6.4.22`
### Fixed
- Eliminated the circular dependencies in the `@ghostfolio/common` library
## 1.145.0 - 07.05.2022
### Added
- Added support for filtering by accounts on the allocations page
- Added support for private equity
- Extended the form to set the asset and asset sub class for (wealth) items
### Changed
- Refactored the filtering (activities table and allocations page)
### Fixed
- Fixed the tooltip update in the portfolio proportion chart component
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.144.0 - 30.04.2022
### Added
- Added support for commodities (via futures)
- Added support for real estate
### Changed
- Improved the layout of the position detail dialog
- Upgraded `yahoo-finance2` from version `2.3.1` to `2.3.2`
### Fixed
- Fixed the import validation for numbers equal 0
- Fixed the color of the spinner in the activities filter component (dark mode)
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.143.0 - 26.04.2022
### Changed
- Improved the filtering by tags
## 1.142.0 - 25.04.2022
### Added
- Added the tags to the create or edit transaction dialog
- Added the tags to the position detail dialog
### Changed
- Changed the date to UTC in the data gathering service
- Reused the value component in the users table of the admin control panel
## 1.141.1 - 24.04.2022
### Added
- Added the database migration
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.141.0 - 24.04.2022
### Added
- Added a tagging system for activities
### Changed
- Extracted the activities table filter to a dedicated component
- Changed the url of the _Get Started_ link to `https://ghostfol.io` on the public page
- Simplified `@@id` using multiple fields with `@id` in the database schema of (`Access`, `Order`, `Subscription`)
- Upgraded `prisma` from version `3.11.1` to `3.12.0`
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.140.2 - 22.04.2022
### Added
- Added support for sub-labels in the value component
- Added a symbol profile overrides model for manual adjustments
### Changed
- Reused the value component in the _Ghostfolio in Numbers_ section of the about page
- Persisted the savings rate in the _FIRE_ calculator
- Upgraded `yahoo-finance2` from version `2.3.0` to `2.3.1`
### Fixed
- Fixed the calculation of the total value for sell and dividend activities in the create or edit transaction dialog
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.139.0 - 18.04.2022
### Added
- Added the total amount to the tooltip in the chart of the _FIRE_ calculator
### Changed
- Beautified the ETF names in the symbol profile
### Fixed
- Fixed an issue with changing the investment horizon in the chart of the _FIRE_ calculator
- Fixed an issue with the end dates in the `.ics` file of the future activities (drafts) export
- Fixed the data source of the _Fear & Greed Index_ (market mood)
## 1.138.0 - 16.04.2022
### Added
- Added support to export a single future activity (draft) as an `.ics` file
- Added the _Boringly Getting Rich_ guide to the resources section
### Changed
- Separated the deposit and savings in the chart of the _FIRE_ calculator
## 1.137.0 - 15.04.2022
### Added
- Added support to export future activities (drafts) as an `.ics` file
### Changed
- Migrated the search functionality to `yahoo-finance2`
### Fixed
- Fixed an issue in the average price / investment calculation for sell activities
## 1.136.0 - 13.04.2022 ## 1.136.0 - 13.04.2022
### Changed ### Changed
@ -267,7 +437,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Upgraded `angular` from version `13.1.2` to `13.2.3` - Upgraded `angular` from version `13.1.2` to `13.2.2`
- Upgraded `Nx` from version `13.4.1` to `13.8.1` - Upgraded `Nx` from version `13.4.1` to `13.8.1`
- Upgraded `storybook` from version `6.4.9` to `6.4.18` - Upgraded `storybook` from version `6.4.9` to `6.4.18`

View File

@ -22,8 +22,8 @@ RUN node decorate-angular-cli.js
COPY ./angular.json angular.json COPY ./angular.json angular.json
COPY ./nx.json nx.json COPY ./nx.json nx.json
COPY ./replace.build.js replace.build.js COPY ./replace.build.js replace.build.js
COPY ./jest.preset.js jest.preset.js COPY ./jest.preset.ts jest.preset.ts
COPY ./jest.config.js jest.config.js COPY ./jest.config.ts jest.config.ts
COPY ./tsconfig.base.json tsconfig.base.json COPY ./tsconfig.base.json tsconfig.base.json
COPY ./libs libs COPY ./libs libs
COPY ./apps apps COPY ./apps apps

View File

@ -246,6 +246,8 @@ Ghostfolio is **100% free** and **open source**. We encourage and support an act
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg), tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you. Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg), tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you.
If you like to support this project, get **[Ghostfolio Premium](https://ghostfol.io/pricing)** or **[Buy me a coffee](https://www.buymeacoffee.com/ghostfolio)**.
## License ## License
© 2022 [Ghostfolio](https://ghostfol.io) © 2022 [Ghostfolio](https://ghostfol.io)

View File

@ -47,7 +47,7 @@
"test": { "test": {
"builder": "@nrwl/jest:jest", "builder": "@nrwl/jest:jest",
"options": { "options": {
"jestConfig": "apps/api/jest.config.js", "jestConfig": "apps/api/jest.config.ts",
"passWithNoTests": true "passWithNoTests": true
}, },
"outputs": ["coverage/apps/api"] "outputs": ["coverage/apps/api"]
@ -180,7 +180,7 @@
"test": { "test": {
"builder": "@nrwl/jest:jest", "builder": "@nrwl/jest:jest",
"options": { "options": {
"jestConfig": "apps/client/jest.config.js", "jestConfig": "apps/client/jest.config.ts",
"passWithNoTests": true "passWithNoTests": true
}, },
"outputs": ["coverage/apps/client"] "outputs": ["coverage/apps/client"]
@ -225,7 +225,7 @@
"builder": "@nrwl/jest:jest", "builder": "@nrwl/jest:jest",
"outputs": ["coverage/libs/common"], "outputs": ["coverage/libs/common"],
"options": { "options": {
"jestConfig": "libs/common/jest.config.js", "jestConfig": "libs/common/jest.config.ts",
"passWithNoTests": true "passWithNoTests": true
} }
} }
@ -247,7 +247,7 @@
"builder": "@nrwl/jest:jest", "builder": "@nrwl/jest:jest",
"outputs": ["coverage/libs/ui"], "outputs": ["coverage/libs/ui"],
"options": { "options": {
"jestConfig": "libs/ui/jest.config.js", "jestConfig": "libs/ui/jest.config.ts",
"passWithNoTests": true "passWithNoTests": true
} }
}, },

View File

@ -1,6 +1,6 @@
module.exports = { module.exports = {
displayName: 'api', displayName: 'api',
preset: '../../jest.preset.js',
globals: { globals: {
'ts-jest': { 'ts-jest': {
tsconfig: '<rootDir>/tsconfig.spec.json' tsconfig: '<rootDir>/tsconfig.spec.json'
@ -12,5 +12,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' testEnvironment: 'node',
preset: '../../jest.preset.ts'
}; };

View File

@ -78,8 +78,12 @@ export class AccessController {
@Delete(':id') @Delete(':id')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async deleteAccess(@Param('id') id: string): Promise<AccessModule> { public async deleteAccess(@Param('id') id: string): Promise<AccessModule> {
const access = await this.accessService.access({ id });
if ( if (
!hasPermission(this.request.user.permissions, permissions.deleteAccess) !hasPermission(this.request.user.permissions, permissions.deleteAccess) ||
!access ||
access.userId !== this.request.user.id
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
@ -88,10 +92,7 @@ export class AccessController {
} }
return this.accessService.deleteAccess({ return this.accessService.deleteAccess({
id_userId: { id
id,
userId: this.request.user.id
}
}); });
} }
} }

View File

@ -1,6 +1,10 @@
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto'; import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import {
DATA_GATHERING_QUEUE,
GATHER_ASSET_PROFILE_PROCESS
} from '@ghostfolio/common/config';
import { import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
@ -8,6 +12,7 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { InjectQueue } from '@nestjs/bull';
import { import {
Body, Body,
Controller, Controller,
@ -23,6 +28,7 @@ import {
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { DataSource, MarketData } from '@prisma/client'; import { DataSource, MarketData } from '@prisma/client';
import { Queue } from 'bull';
import { isDate } from 'date-fns'; import { isDate } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -33,6 +39,8 @@ import { UpdateMarketDataDto } from './update-market-data.dto';
export class AdminController { export class AdminController {
public constructor( public constructor(
private readonly adminService: AdminService, private readonly adminService: AdminService,
@InjectQueue(DATA_GATHERING_QUEUE)
private readonly dataGatheringQueue: Queue,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
@ -71,10 +79,16 @@ export class AdminController {
); );
} }
await this.dataGatheringService.gatherProfileData(); const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
this.dataGatheringService.gatherMax();
return; for (const { dataSource, symbol } of uniqueAssets) {
await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, {
dataSource,
symbol
});
}
this.dataGatheringService.gatherMax();
} }
@Post('gather/profile-data') @Post('gather/profile-data')
@ -92,9 +106,14 @@ export class AdminController {
); );
} }
this.dataGatheringService.gatherProfileData(); const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
return; for (const { dataSource, symbol } of uniqueAssets) {
await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, {
dataSource,
symbol
});
}
} }
@Post('gather/profile-data/:dataSource/:symbol') @Post('gather/profile-data/:dataSource/:symbol')
@ -115,9 +134,10 @@ export class AdminController {
); );
} }
this.dataGatheringService.gatherProfileData([{ dataSource, symbol }]); await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, {
dataSource,
return; symbol
});
} }
@Post('gather/:dataSource/:symbol') @Post('gather/:dataSource/:symbol')

View File

@ -15,7 +15,7 @@ import {
UniqueAsset UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, Property } from '@prisma/client'; import { Property } from '@prisma/client';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
@Injectable() @Injectable()

View File

@ -9,6 +9,7 @@ import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module'; import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
@ -36,6 +37,12 @@ import { UserModule } from './user/user.module';
AccountModule, AccountModule,
AuthDeviceModule, AuthDeviceModule,
AuthModule, AuthModule,
BullModule.forRoot({
redis: {
host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT, 10)
}
}),
CacheModule, CacheModule,
ConfigModule.forRoot(), ConfigModule.forRoot(),
ConfigurationModule, ConfigurationModule,

View File

@ -42,6 +42,7 @@ export class ExportService {
accountId, accountId,
date, date,
fee, fee,
id,
quantity, quantity,
SymbolProfile, SymbolProfile,
type, type,
@ -49,13 +50,14 @@ export class ExportService {
}) => { }) => {
return { return {
accountId, accountId,
date,
fee, fee,
id,
quantity, quantity,
type, type,
unitPrice, unitPrice,
currency: SymbolProfile.currency, currency: SymbolProfile.currency,
dataSource: SymbolProfile.dataSource, dataSource: SymbolProfile.dataSource,
date: date.toISOString(),
symbol: type === 'ITEM' ? SymbolProfile.name : SymbolProfile.symbol symbol: type === 'ITEM' ? SymbolProfile.name : SymbolProfile.symbol
}; };
} }

View File

@ -6,6 +6,7 @@ import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-d
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
@ -26,7 +27,8 @@ import { InfoService } from './info.service';
PrismaModule, PrismaModule,
PropertyModule, PropertyModule,
RedisCacheModule, RedisCacheModule,
SymbolProfileModule SymbolProfileModule,
TagModule
], ],
providers: [InfoService] providers: [InfoService]
}) })

View File

@ -4,6 +4,7 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { import {
DEMO_USER_ID, DEMO_USER_ID,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
@ -33,7 +34,8 @@ export class InfoService {
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService private readonly redisCacheService: RedisCacheService,
private readonly tagService: TagService
) {} ) {}
public async get(): Promise<InfoItem> { public async get(): Promise<InfoItem> {
@ -52,9 +54,15 @@ export class InfoService {
} }
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) { if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
info.fearAndGreedDataSource = encodeDataSource( if (
ghostfolioFearAndGreedIndexDataSource this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true
); ) {
info.fearAndGreedDataSource = encodeDataSource(
ghostfolioFearAndGreedIndexDataSource
);
} else {
info.fearAndGreedDataSource = ghostfolioFearAndGreedIndexDataSource;
}
} }
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) { if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
@ -99,7 +107,8 @@ export class InfoService {
demoAuthToken: this.getDemoAuthToken(), demoAuthToken: this.getDemoAuthToken(),
lastDataGathering: await this.getLastDataGathering(), lastDataGathering: await this.getLastDataGathering(),
statistics: await this.getStatistics(), statistics: await this.getStatistics(),
subscriptions: await this.getSubscriptions() subscriptions: await this.getSubscriptions(),
tags: await this.tagService.get()
}; };
} }

View File

@ -1,4 +1,4 @@
import { DataSource, Type } from '@prisma/client'; import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client';
import { import {
IsEnum, IsEnum,
IsISO8601, IsISO8601,
@ -10,14 +10,22 @@ import {
export class CreateOrderDto { export class CreateOrderDto {
@IsString() @IsString()
@IsOptional() @IsOptional()
accountId: string; accountId?: string;
@IsEnum(AssetClass, { each: true })
@IsOptional()
assetClass?: AssetClass;
@IsEnum(AssetSubClass, { each: true })
@IsOptional()
assetSubClass?: AssetSubClass;
@IsString() @IsString()
currency: string; currency: string;
@IsEnum(DataSource, { each: true }) @IsEnum(DataSource, { each: true })
@IsOptional() @IsOptional()
dataSource: DataSource; dataSource?: DataSource;
@IsISO8601() @IsISO8601()
date: string; date: string;

View File

@ -42,8 +42,12 @@ export class OrderController {
@Delete(':id') @Delete(':id')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> { public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
const order = await this.orderService.order({ id });
if ( if (
!hasPermission(this.request.user.permissions, permissions.deleteOrder) !hasPermission(this.request.user.permissions, permissions.deleteOrder) ||
!order ||
order.userId !== this.request.user.id
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
@ -52,10 +56,7 @@ export class OrderController {
} }
return this.orderService.deleteOrder({ return this.orderService.deleteOrder({
id_userId: { id
id,
userId: this.request.user.id
}
}); });
} }
@ -135,23 +136,15 @@ export class OrderController {
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) { public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) {
if (
!hasPermission(this.request.user.permissions, permissions.updateOrder)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const originalOrder = await this.orderService.order({ const originalOrder = await this.orderService.order({
id_userId: { id
id,
userId: this.request.user.id
}
}); });
if (!originalOrder) { if (
!hasPermission(this.request.user.permissions, permissions.updateOrder) ||
!originalOrder ||
originalOrder.userId !== this.request.user.id
) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
@ -178,15 +171,17 @@ export class OrderController {
dataSource: data.dataSource, dataSource: data.dataSource,
symbol: data.symbol symbol: data.symbol
} }
},
update: {
assetClass: data.assetClass,
assetSubClass: data.assetSubClass,
name: data.symbol
} }
}, },
User: { connect: { id: this.request.user.id } } User: { connect: { id: this.request.user.id } }
}, },
where: { where: {
id_userId: { id
id,
userId: this.request.user.id
}
} }
}); });
} }

View File

@ -4,11 +4,26 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se
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 { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import {
DATA_GATHERING_QUEUE,
GATHER_ASSET_PROFILE_PROCESS
} from '@ghostfolio/common/config';
import { Filter } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { InjectQueue } from '@nestjs/bull';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, Order, Prisma, Type as TypeOfOrder } from '@prisma/client'; import {
AssetClass,
AssetSubClass,
DataSource,
Order,
Prisma,
Type as TypeOfOrder
} from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { Queue } from 'bull';
import { endOfToday, isAfter } from 'date-fns'; import { endOfToday, isAfter } from 'date-fns';
import { groupBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { Activity } from './interfaces/activities.interface'; import { Activity } from './interfaces/activities.interface';
@ -18,6 +33,8 @@ export class OrderService {
public constructor( public constructor(
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly cacheService: CacheService, private readonly cacheService: CacheService,
@InjectQueue(DATA_GATHERING_QUEUE)
private readonly dataGatheringQueue: Queue,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
@ -55,6 +72,8 @@ export class OrderService {
public async createOrder( public async createOrder(
data: Prisma.OrderCreateInput & { data: Prisma.OrderCreateInput & {
accountId?: string; accountId?: string;
assetClass?: AssetClass;
assetSubClass?: AssetSubClass;
currency?: string; currency?: string;
dataSource?: DataSource; dataSource?: DataSource;
symbol?: string; symbol?: string;
@ -77,6 +96,8 @@ export class OrderService {
}; };
if (data.type === 'ITEM') { if (data.type === 'ITEM') {
const assetClass = data.assetClass;
const assetSubClass = data.assetSubClass;
const currency = data.SymbolProfile.connectOrCreate.create.currency; const currency = data.SymbolProfile.connectOrCreate.create.currency;
const dataSource: DataSource = 'MANUAL'; const dataSource: DataSource = 'MANUAL';
const id = uuidv4(); const id = uuidv4();
@ -84,6 +105,8 @@ export class OrderService {
Account = undefined; Account = undefined;
data.id = id; data.id = id;
data.SymbolProfile.connectOrCreate.create.assetClass = assetClass;
data.SymbolProfile.connectOrCreate.create.assetSubClass = assetSubClass;
data.SymbolProfile.connectOrCreate.create.currency = currency; data.SymbolProfile.connectOrCreate.create.currency = currency;
data.SymbolProfile.connectOrCreate.create.dataSource = dataSource; data.SymbolProfile.connectOrCreate.create.dataSource = dataSource;
data.SymbolProfile.connectOrCreate.create.name = name; data.SymbolProfile.connectOrCreate.create.name = name;
@ -97,12 +120,10 @@ export class OrderService {
data.SymbolProfile.connectOrCreate.create.symbol.toUpperCase(); data.SymbolProfile.connectOrCreate.create.symbol.toUpperCase();
} }
await this.dataGatheringService.gatherProfileData([ await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, {
{ dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource, symbol: data.SymbolProfile.connectOrCreate.create.symbol
symbol: data.SymbolProfile.connectOrCreate.create.symbol });
}
]);
const isDraft = isAfter(data.date as Date, endOfToday()); const isDraft = isAfter(data.date as Date, endOfToday());
@ -120,6 +141,8 @@ export class OrderService {
await this.cacheService.flush(); await this.cacheService.flush();
delete data.accountId; delete data.accountId;
delete data.assetClass;
delete data.assetSubClass;
delete data.currency; delete data.currency;
delete data.dataSource; delete data.dataSource;
delete data.symbol; delete data.symbol;
@ -151,11 +174,13 @@ export class OrderService {
} }
public async getOrders({ public async getOrders({
filters,
includeDrafts = false, includeDrafts = false,
types, types,
userCurrency, userCurrency,
userId userId
}: { }: {
filters?: Filter[];
includeDrafts?: boolean; includeDrafts?: boolean;
types?: TypeOfOrder[]; types?: TypeOfOrder[];
userCurrency: string; userCurrency: string;
@ -163,10 +188,35 @@ export class OrderService {
}): Promise<Activity[]> { }): Promise<Activity[]> {
const where: Prisma.OrderWhereInput = { userId }; const where: Prisma.OrderWhereInput = { userId };
const { account: filtersByAccount, tag: filtersByTag } = groupBy(
filters,
(filter) => {
return filter.type;
}
);
if (filtersByAccount?.length > 0) {
where.accountId = {
in: filtersByAccount.map(({ id }) => {
return id;
})
};
}
if (includeDrafts === false) { if (includeDrafts === false) {
where.isDraft = false; where.isDraft = false;
} }
if (filtersByTag?.length > 0) {
where.tags = {
some: {
OR: filtersByTag.map(({ id }) => {
return { id };
})
}
};
}
if (types) { if (types) {
where.OR = types.map((type) => { where.OR = types.map((type) => {
return { return {
@ -188,7 +238,8 @@ export class OrderService {
} }
}, },
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
SymbolProfile: true SymbolProfile: true,
tags: true
}, },
orderBy: { date: 'asc' } orderBy: { date: 'asc' }
}) })
@ -217,6 +268,8 @@ export class OrderService {
where where
}: { }: {
data: Prisma.OrderUpdateInput & { data: Prisma.OrderUpdateInput & {
assetClass?: AssetClass;
assetSubClass?: AssetSubClass;
currency?: string; currency?: string;
dataSource?: DataSource; dataSource?: DataSource;
symbol?: string; symbol?: string;
@ -230,10 +283,10 @@ export class OrderService {
let isDraft = false; let isDraft = false;
if (data.type === 'ITEM') { if (data.type === 'ITEM') {
const name = data.SymbolProfile.connect.dataSource_symbol.symbol; delete data.SymbolProfile.connect;
data.SymbolProfile = { update: { name } };
} else { } else {
delete data.SymbolProfile.update;
isDraft = isAfter(data.date as Date, endOfToday()); isDraft = isAfter(data.date as Date, endOfToday());
if (!isDraft) { if (!isDraft) {
@ -250,6 +303,8 @@ export class OrderService {
await this.cacheService.flush(); await this.cacheService.flush();
delete data.assetClass;
delete data.assetSubClass;
delete data.currency; delete data.currency;
delete data.dataSource; delete data.dataSource;
delete data.symbol; delete data.symbol;

View File

@ -1,10 +1,24 @@
import { DataSource, Type } from '@prisma/client'; import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client';
import { IsISO8601, IsNumber, IsOptional, IsString } from 'class-validator'; import {
IsEnum,
IsISO8601,
IsNumber,
IsOptional,
IsString
} from 'class-validator';
export class UpdateOrderDto { export class UpdateOrderDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
accountId: string; accountId?: string;
@IsEnum(AssetClass, { each: true })
@IsOptional()
assetClass?: AssetClass;
@IsEnum(AssetSubClass, { each: true })
@IsOptional()
assetSubClass?: AssetSubClass;
@IsString() @IsString()
currency: string; currency: string;

View File

@ -20,6 +20,13 @@ function mockGetValue(symbol: string, date: Date) {
return { marketPrice: 0 }; return { marketPrice: 0 };
case 'NOVN.SW':
if (isSameDay(parseDate('2022-04-11'), date)) {
return { marketPrice: 87.8 };
}
return { marketPrice: 0 };
default: default:
return { marketPrice: 0 }; return { marketPrice: 0 };
} }

View File

@ -1,5 +1,7 @@
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface'; import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { Tag } from '@prisma/client';
export interface PortfolioPositionDetail { export interface PortfolioPositionDetail {
averagePrice: number; averagePrice: number;
@ -16,6 +18,7 @@ export interface PortfolioPositionDetail {
orders: OrderWithAccount[]; orders: OrderWithAccount[];
quantity: number; quantity: number;
SymbolProfile: EnhancedSymbolProfile; SymbolProfile: EnhancedSymbolProfile;
tags: Tag[];
transactionCount: number; transactionCount: number;
value: number; value: number;
} }
@ -25,10 +28,3 @@ export interface HistoricalDataContainer {
isAllTimeLow: boolean; isAllTimeLow: boolean;
items: HistoricalDataItem[]; items: HistoricalDataItem[];
} }
export interface HistoricalDataItem {
averagePrice?: number;
date: string;
grossPerformancePercent?: number;
value: number;
}

View File

@ -0,0 +1,96 @@
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculator } from './portfolio-calculator';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let currentRateService: CurrentRateService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null);
});
describe('get current positions', () => {
it.only('with BALN.SW buy and sell', async () => {
const portfolioCalculator = new PortfolioCalculator({
currentRateService,
currency: 'CHF',
orders: [
{
currency: 'CHF',
date: '2022-03-07',
dataSource: 'YAHOO',
fee: new Big(1.3),
name: 'Novartis AG',
quantity: new Big(2),
symbol: 'NOVN.SW',
type: 'BUY',
unitPrice: new Big(75.8)
},
{
currency: 'CHF',
date: '2022-04-08',
dataSource: 'YAHOO',
fee: new Big(2.95),
name: 'Novartis AG',
quantity: new Big(1),
symbol: 'NOVN.SW',
type: 'SELL',
unitPrice: new Big(85.73)
}
]
});
portfolioCalculator.computeTransactionPoints();
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2022-04-11').getTime());
const currentPositions = await portfolioCalculator.getCurrentPositions(
parseDate('2022-03-07')
);
spy.mockRestore();
expect(currentPositions).toEqual({
currentValue: new Big('87.8'),
errors: [],
grossPerformance: new Big('21.93'),
grossPerformancePercentage: new Big('0.14465699208443271768'),
hasErrors: false,
netPerformance: new Big('17.68'),
netPerformancePercentage: new Big('0.11662269129287598945'),
positions: [
{
averagePrice: new Big('75.80'),
currency: 'CHF',
dataSource: 'YAHOO',
firstBuyDate: '2022-03-07',
grossPerformance: new Big('21.93'),
grossPerformancePercentage: new Big('0.14465699208443271768'),
investment: new Big('75.80'),
netPerformance: new Big('17.68'),
netPerformancePercentage: new Big('0.11662269129287598945'),
marketPrice: 87.8,
quantity: new Big('1'),
symbol: 'NOVN.SW',
transactionCount: 2
}
],
totalInvestment: new Big('75.80')
});
});
});
});

View File

@ -77,17 +77,30 @@ export class PortfolioCalculator {
const newQuantity = order.quantity const newQuantity = order.quantity
.mul(factor) .mul(factor)
.plus(oldAccumulatedSymbol.quantity); .plus(oldAccumulatedSymbol.quantity);
let investment = new Big(0);
if (newQuantity.gt(0)) {
if (order.type === 'BUY') {
investment = oldAccumulatedSymbol.investment.plus(
order.quantity.mul(unitPrice)
);
} else if (order.type === 'SELL') {
const averagePrice = oldAccumulatedSymbol.investment.div(
oldAccumulatedSymbol.quantity
);
investment = oldAccumulatedSymbol.investment.minus(
order.quantity.mul(averagePrice)
);
}
}
currentTransactionPointItem = { currentTransactionPointItem = {
investment,
currency: order.currency, currency: order.currency,
dataSource: order.dataSource, dataSource: order.dataSource,
fee: order.fee.plus(oldAccumulatedSymbol.fee), fee: order.fee.plus(oldAccumulatedSymbol.fee),
firstBuyDate: oldAccumulatedSymbol.firstBuyDate, firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
investment: newQuantity.eq(0)
? new Big(0)
: unitPrice
.mul(order.quantity)
.mul(factor)
.plus(oldAccumulatedSymbol.investment),
quantity: newQuantity, quantity: newQuantity,
symbol: order.symbol, symbol: order.symbol,
transactionCount: oldAccumulatedSymbol.transactionCount + 1 transactionCount: oldAccumulatedSymbol.transactionCount + 1

View File

@ -11,6 +11,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { baseCurrency } from '@ghostfolio/common/config'; import { baseCurrency } from '@ghostfolio/common/config';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { import {
Filter,
PortfolioChart, PortfolioChart,
PortfolioDetails, PortfolioDetails,
PortfolioInvestments, PortfolioInvestments,
@ -19,7 +20,7 @@ import {
PortfolioReport, PortfolioReport,
PortfolioSummary PortfolioSummary
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
Get, Get,
@ -105,15 +106,36 @@ export class PortfolioController {
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getDetails( public async getDetails(
@Headers('impersonation-id') impersonationId: string, @Headers('impersonation-id') impersonationId: string,
@Query('range') range @Query('accounts') filterByAccounts?: string,
@Query('range') range?: DateRange,
@Query('tags') filterByTags?: string
): Promise<PortfolioDetails & { hasError: boolean }> { ): Promise<PortfolioDetails & { hasError: boolean }> {
let hasError = false; let hasError = false;
const accountIds = filterByAccounts?.split(',') ?? [];
const tagIds = filterByTags?.split(',') ?? [];
const filters: Filter[] = [
...accountIds.map((accountId) => {
return <Filter>{
id: accountId,
type: 'account'
};
}),
...tagIds.map((tagId) => {
return <Filter>{
id: tagId,
type: 'tag'
};
})
];
const { accounts, holdings, hasErrors } = const { accounts, holdings, hasErrors } =
await this.portfolioService.getDetails( await this.portfolioService.getDetails(
impersonationId, impersonationId,
this.request.user.id, this.request.user.id,
range range,
filters
); );
if (hasErrors || hasNotDefinedValuesInObject(holdings)) { if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
@ -159,7 +181,11 @@ export class PortfolioController {
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'; this.request.user.subscription.type === 'Basic';
return { accounts, hasError, holdings: isBasicUser ? {} : holdings }; return {
hasError,
accounts: filters.length === 0 ? accounts : {},
holdings: isBasicUser ? {} : holdings
};
} }
@Get('investments') @Get('investments')

View File

@ -18,7 +18,6 @@ import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface'; import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { import {
@ -29,6 +28,8 @@ import {
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { import {
Accounts, Accounts,
Filter,
HistoricalDataItem,
PortfolioDetails, PortfolioDetails,
PortfolioPerformanceResponse, PortfolioPerformanceResponse,
PortfolioReport, PortfolioReport,
@ -46,7 +47,12 @@ import type {
} from '@ghostfolio/common/types'; } 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 { AssetClass, DataSource, Type as TypeOfOrder } from '@prisma/client'; import {
AssetClass,
DataSource,
Tag,
Type as TypeOfOrder
} from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { import {
differenceInDays, differenceInDays,
@ -62,11 +68,10 @@ import {
subDays, subDays,
subYears subYears
} from 'date-fns'; } from 'date-fns';
import { isEmpty, sortBy } from 'lodash'; import { isEmpty, sortBy, uniqBy } from 'lodash';
import { import {
HistoricalDataContainer, HistoricalDataContainer,
HistoricalDataItem,
PortfolioPositionDetail PortfolioPositionDetail
} from './interfaces/portfolio-position-detail.interface'; } from './interfaces/portfolio-position-detail.interface';
import { PortfolioCalculator } from './portfolio-calculator'; import { PortfolioCalculator } from './portfolio-calculator';
@ -303,7 +308,8 @@ export class PortfolioService {
public async getDetails( public async getDetails(
aImpersonationId: string, aImpersonationId: string,
aUserId: string, aUserId: string,
aDateRange: DateRange = 'max' aDateRange: DateRange = 'max',
aFilters?: Filter[]
): Promise<PortfolioDetails & { hasErrors: boolean }> { ): Promise<PortfolioDetails & { hasErrors: boolean }> {
const userId = await this.getUserId(aImpersonationId, aUserId); const userId = await this.getUserId(aImpersonationId, aUserId);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
@ -318,7 +324,8 @@ export class PortfolioService {
const { orders, portfolioOrders, transactionPoints } = const { orders, portfolioOrders, transactionPoints } =
await this.getTransactionPoints({ await this.getTransactionPoints({
userId userId,
filters: aFilters
}); });
const portfolioCalculator = new PortfolioCalculator({ const portfolioCalculator = new PortfolioCalculator({
@ -441,8 +448,10 @@ export class PortfolioService {
value: totalValue value: totalValue
}); });
for (const symbol of Object.keys(cashPositions)) { if (aFilters === undefined) {
holdings[symbol] = cashPositions[symbol]; for (const symbol of Object.keys(cashPositions)) {
holdings[symbol] = cashPositions[symbol];
}
} }
const accounts = await this.getValueOfAccounts( const accounts = await this.getValueOfAccounts(
@ -472,8 +481,11 @@ export class PortfolioService {
); );
}); });
let tags: Tag[] = [];
if (orders.length <= 0) { if (orders.length <= 0) {
return { return {
tags,
averagePrice: undefined, averagePrice: undefined,
firstBuyDate: undefined, firstBuyDate: undefined,
grossPerformance: undefined, grossPerformance: undefined,
@ -500,6 +512,8 @@ export class PortfolioService {
const portfolioOrders: PortfolioOrder[] = orders const portfolioOrders: PortfolioOrder[] = orders
.filter((order) => { .filter((order) => {
tags = tags.concat(order.tags);
return order.type === 'BUY' || order.type === 'SELL'; return order.type === 'BUY' || order.type === 'SELL';
}) })
.map((order) => ({ .map((order) => ({
@ -514,6 +528,8 @@ export class PortfolioService {
unitPrice: new Big(order.unitPrice) unitPrice: new Big(order.unitPrice)
})); }));
tags = uniqBy(tags, 'id');
const portfolioCalculator = new PortfolioCalculator({ const portfolioCalculator = new PortfolioCalculator({
currency: positionCurrency, currency: positionCurrency,
currentRateService: this.currentRateService, currentRateService: this.currentRateService,
@ -622,6 +638,7 @@ export class PortfolioService {
netPerformance, netPerformance,
orders, orders,
SymbolProfile, SymbolProfile,
tags,
transactionCount, transactionCount,
averagePrice: averagePrice.toNumber(), averagePrice: averagePrice.toNumber(),
grossPerformancePercent: grossPerformancePercent:
@ -678,6 +695,7 @@ export class PortfolioService {
minPrice, minPrice,
orders, orders,
SymbolProfile, SymbolProfile,
tags,
averagePrice: 0, averagePrice: 0,
firstBuyDate: undefined, firstBuyDate: undefined,
grossPerformance: undefined, grossPerformance: undefined,
@ -758,8 +776,7 @@ export class PortfolioService {
position.grossPerformancePercentage?.toNumber() ?? null, position.grossPerformancePercentage?.toNumber() ?? null,
investment: new Big(position.investment).toNumber(), investment: new Big(position.investment).toNumber(),
marketState: marketState:
dataProviderResponses[position.symbol]?.marketState ?? dataProviderResponses[position.symbol]?.marketState ?? 'delayed',
MarketState.delayed,
name: symbolProfileMap[position.symbol].name, name: symbolProfileMap[position.symbol].name,
netPerformance: position.netPerformance?.toNumber() ?? null, netPerformance: position.netPerformance?.toNumber() ?? null,
netPerformancePercentage: netPerformancePercentage:
@ -1043,7 +1060,7 @@ export class PortfolioService {
grossPerformancePercent: 0, grossPerformancePercent: 0,
investment: convertedBalance, investment: convertedBalance,
marketPrice: 0, marketPrice: 0,
marketState: MarketState.open, marketState: 'open',
name: account.currency, name: account.currency,
netPerformance: 0, netPerformance: 0,
netPerformancePercent: 0, netPerformancePercent: 0,
@ -1177,9 +1194,11 @@ export class PortfolioService {
} }
private async getTransactionPoints({ private async getTransactionPoints({
filters,
includeDrafts = false, includeDrafts = false,
userId userId
}: { }: {
filters?: Filter[];
includeDrafts?: boolean; includeDrafts?: boolean;
userId: string; userId: string;
}): Promise<{ }): Promise<{
@ -1190,6 +1209,7 @@ export class PortfolioService {
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency; const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
const orders = await this.orderService.getOrders({ const orders = await this.orderService.getOrders({
filters,
includeDrafts, includeDrafts,
userCurrency, userCurrency,
userId, userId,

View File

@ -1,4 +1,4 @@
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface'; import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
export interface SymbolItem { export interface SymbolItem {

View File

@ -1,4 +1,3 @@
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { import {
IDataGatheringItem, IDataGatheringItem,
@ -6,6 +5,7 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { format, subDays } from 'date-fns'; import { format, subDays } from 'date-fns';

View File

@ -1,4 +0,0 @@
export interface Access {
alias?: string;
id: string;
}

View File

@ -12,4 +12,8 @@ export class UpdateUserSettingDto {
@IsString() @IsString()
@IsOptional() @IsOptional()
locale?: string; locale?: string;
@IsNumber()
@IsOptional()
savingsRate?: number;
} }

View File

@ -34,7 +34,7 @@ import { UserService } from './user.service';
export class UserController { export class UserController {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private jwtService: JwtService, private readonly jwtService: JwtService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService private readonly userService: UserService

View File

@ -2,6 +2,7 @@ import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscriptio
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
@ -19,7 +20,8 @@ import { UserService } from './user.service';
}), }),
PrismaModule, PrismaModule,
PropertyModule, PropertyModule,
SubscriptionModule SubscriptionModule,
TagModule
], ],
providers: [UserService] providers: [UserService]
}) })

View File

@ -2,6 +2,7 @@ import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscripti
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { import {
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
baseCurrency, baseCurrency,
@ -13,7 +14,6 @@ import {
hasRole, hasRole,
permissions permissions
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/permissions';
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Prisma, Role, User, ViewMode } from '@prisma/client'; import { Prisma, Role, User, ViewMode } from '@prisma/client';
@ -30,7 +30,8 @@ export class UserService {
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService private readonly subscriptionService: SubscriptionService,
private readonly tagService: TagService
) {} ) {}
public async getUser( public async getUser(
@ -51,12 +52,21 @@ export class UserService {
orderBy: { User: { alias: 'asc' } }, orderBy: { User: { alias: 'asc' } },
where: { GranteeUser: { id } } where: { GranteeUser: { id } }
}); });
let tags = await this.tagService.getByUser(id);
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
subscription.type === 'Basic'
) {
tags = [];
}
return { return {
alias, alias,
id, id,
permissions, permissions,
subscription, subscription,
tags,
access: access.map((accessItem) => { access: access.map((accessItem) => {
return { return {
alias: accessItem.User.alias, alias: accessItem.User.alias,

View File

@ -1,5 +1,11 @@
import {
DATA_GATHERING_QUEUE,
GATHER_ASSET_PROFILE_PROCESS
} from '@ghostfolio/common/config';
import { InjectQueue } from '@nestjs/bull';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule'; import { Cron, CronExpression } from '@nestjs/schedule';
import { Queue } from 'bull';
import { DataGatheringService } from './data-gathering.service'; import { DataGatheringService } from './data-gathering.service';
import { ExchangeRateDataService } from './exchange-rate-data.service'; import { ExchangeRateDataService } from './exchange-rate-data.service';
@ -8,6 +14,8 @@ import { TwitterBotService } from './twitter-bot/twitter-bot.service';
@Injectable() @Injectable()
export class CronService { export class CronService {
public constructor( public constructor(
@InjectQueue(DATA_GATHERING_QUEUE)
private readonly dataGatheringQueue: Queue,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly twitterBotService: TwitterBotService private readonly twitterBotService: TwitterBotService
@ -30,6 +38,13 @@ export class CronService {
@Cron(CronExpression.EVERY_WEEKEND) @Cron(CronExpression.EVERY_WEEKEND)
public async runEveryWeekend() { public async runEveryWeekend() {
await this.dataGatheringService.gatherProfileData(); const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
for (const { dataSource, symbol } of uniqueAssets) {
await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, {
dataSource,
symbol
});
}
} }
} }

View File

@ -3,13 +3,19 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se
import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module'; import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { DATA_GATHERING_QUEUE } from '@ghostfolio/common/config';
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { DataGatheringProcessor } from './data-gathering.processor';
import { ExchangeRateDataModule } from './exchange-rate-data.module'; import { ExchangeRateDataModule } from './exchange-rate-data.module';
import { SymbolProfileModule } from './symbol-profile.module'; import { SymbolProfileModule } from './symbol-profile.module';
@Module({ @Module({
imports: [ imports: [
BullModule.registerQueue({
name: DATA_GATHERING_QUEUE
}),
ConfigurationModule, ConfigurationModule,
DataEnhancerModule, DataEnhancerModule,
DataProviderModule, DataProviderModule,
@ -17,7 +23,7 @@ import { SymbolProfileModule } from './symbol-profile.module';
PrismaModule, PrismaModule,
SymbolProfileModule SymbolProfileModule
], ],
providers: [DataGatheringService], providers: [DataGatheringProcessor, DataGatheringService],
exports: [DataEnhancerModule, DataGatheringService] exports: [BullModule, DataEnhancerModule, DataGatheringService]
}) })
export class DataGatheringModule {} export class DataGatheringModule {}

View File

@ -0,0 +1,27 @@
import {
DATA_GATHERING_QUEUE,
GATHER_ASSET_PROFILE_PROCESS
} from '@ghostfolio/common/config';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { Process, Processor } from '@nestjs/bull';
import { Injectable, Logger } from '@nestjs/common';
import { Job } from 'bull';
import { DataGatheringService } from './data-gathering.service';
@Injectable()
@Processor(DATA_GATHERING_QUEUE)
export class DataGatheringProcessor {
public constructor(
private readonly dataGatheringService: DataGatheringService
) {}
@Process(GATHER_ASSET_PROFILE_PROCESS)
public async gatherAssetProfile(job: Job<UniqueAsset>) {
try {
await this.dataGatheringService.gatherAssetProfiles([job.data]);
} catch (error) {
Logger.error(error, 'DataGatheringProcessor');
}
}
}

View File

@ -226,28 +226,29 @@ export class DataGatheringService {
} }
} }
public async gatherProfileData(aDataGatheringItems?: IDataGatheringItem[]) { public async gatherAssetProfiles(aUniqueAssets?: UniqueAsset[]) {
Logger.log( let uniqueAssets = aUniqueAssets?.filter((dataGatheringItem) => {
'Profile data gathering has been started.', return dataGatheringItem.dataSource !== 'MANUAL';
'DataGatheringService' });
);
console.time('data-gathering-profile');
let dataGatheringItems = aDataGatheringItems?.filter( if (!uniqueAssets) {
(dataGatheringItem) => { uniqueAssets = await this.getUniqueAssets();
return dataGatheringItem.dataSource !== 'MANUAL';
}
);
if (!dataGatheringItems) {
dataGatheringItems = await this.getSymbolsProfileData();
} }
Logger.log(
`Asset profile data gathering has been started for ${uniqueAssets
.map(({ dataSource, symbol }) => {
return `${symbol} (${dataSource})`;
})
.join(',')}.`,
'DataGatheringService'
);
const assetProfiles = await this.dataProviderService.getAssetProfiles( const assetProfiles = await this.dataProviderService.getAssetProfiles(
dataGatheringItems uniqueAssets
); );
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles( const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
dataGatheringItems.map(({ symbol }) => { uniqueAssets.map(({ symbol }) => {
return symbol; return symbol;
}) })
); );
@ -322,10 +323,13 @@ export class DataGatheringService {
} }
Logger.log( Logger.log(
'Profile data gathering has been completed.', `Asset profile data gathering has been completed for ${uniqueAssets
.map(({ dataSource, symbol }) => {
return `${symbol} (${dataSource})`;
})
.join(',')}.`,
'DataGatheringService' 'DataGatheringService'
); );
console.timeEnd('data-gathering-profile');
} }
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) { public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
@ -377,7 +381,14 @@ export class DataGatheringService {
data: { data: {
dataSource, dataSource,
symbol, symbol,
date: currentDate, date: new Date(
Date.UTC(
getYear(currentDate),
getMonth(currentDate),
getDate(currentDate),
0
)
),
marketPrice: lastMarketPrice marketPrice: lastMarketPrice
} }
}); });
@ -501,6 +512,27 @@ export class DataGatheringService {
return [...currencyPairsToGather, ...symbolProfilesToGather]; return [...currencyPairsToGather, ...symbolProfilesToGather];
} }
public async getUniqueAssets(): Promise<UniqueAsset[]> {
const symbolProfiles = await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }]
});
return symbolProfiles
.filter(({ dataSource }) => {
return (
dataSource !== DataSource.GHOSTFOLIO &&
dataSource !== DataSource.MANUAL &&
dataSource !== DataSource.RAKUTEN
);
})
.map(({ dataSource, symbol }) => {
return {
dataSource,
symbol
};
});
}
public async reset() { public async reset() {
Logger.log('Data gathering has been reset.', 'DataGatheringService'); Logger.log('Data gathering has been reset.', 'DataGatheringService');
@ -537,6 +569,7 @@ export class DataGatheringService {
await this.prismaService.marketData.groupBy({ await this.prismaService.marketData.groupBy({
_count: true, _count: true,
by: ['symbol'], by: ['symbol'],
orderBy: [{ symbol: 'asc' }],
where: { where: {
date: { gt: startDate } date: { gt: startDate }
} }
@ -576,27 +609,6 @@ export class DataGatheringService {
return [...currencyPairsToGather, ...symbolProfilesToGather]; return [...currencyPairsToGather, ...symbolProfilesToGather];
} }
private async getSymbolsProfileData(): Promise<IDataGatheringItem[]> {
const symbolProfiles = await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }]
});
return symbolProfiles
.filter((symbolProfile) => {
return (
symbolProfile.dataSource !== DataSource.GHOSTFOLIO &&
symbolProfile.dataSource !== DataSource.MANUAL &&
symbolProfile.dataSource !== DataSource.RAKUTEN
);
})
.map((symbolProfile) => {
return {
dataSource: symbolProfile.dataSource,
symbol: symbolProfile.symbol
};
});
}
private async isDataGatheringNeeded() { private async isDataGatheringNeeded() {
const lastDataGathering = await this.getLastDataGathering(); const lastDataGathering = await this.getLastDataGathering();

View File

@ -2,8 +2,7 @@ import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.in
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse, IDataProviderResponse
MarketState
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
@ -133,7 +132,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
marketPrice: marketData.find((marketDataItem) => { marketPrice: marketData.find((marketDataItem) => {
return marketDataItem.symbol === symbolProfile.symbol; return marketDataItem.symbol === symbolProfile.symbol;
}).marketPrice, }).marketPrice,
marketState: MarketState.delayed marketState: 'delayed'
}; };
} }

View File

@ -3,8 +3,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse, IDataProviderResponse
MarketState
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
@ -114,7 +113,7 @@ export class GoogleSheetsService implements DataProviderInterface {
return symbolProfile.symbol === symbol; return symbolProfile.symbol === symbol;
})?.currency, })?.currency,
dataSource: this.getName(), dataSource: this.getName(),
marketState: MarketState.delayed marketState: 'delayed'
}; };
} }
} }

View File

@ -3,8 +3,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse, IDataProviderResponse
MarketState
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config'; import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
@ -17,8 +16,6 @@ import { format, subMonths, subWeeks, subYears } from 'date-fns';
@Injectable() @Injectable()
export class RakutenRapidApiService implements DataProviderInterface { export class RakutenRapidApiService implements DataProviderInterface {
public static FEAR_AND_GREED_INDEX_NAME = 'Fear & Greed Index';
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService private readonly prismaService: PrismaService
@ -120,7 +117,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
currency: undefined, currency: undefined,
dataSource: this.getName(), dataSource: this.getName(),
marketPrice: fgi.now.value, marketPrice: fgi.now.value,
marketState: MarketState.open marketState: 'open'
} }
}; };
} }

View File

@ -3,8 +3,7 @@ import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/c
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse, IDataProviderResponse
MarketState
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { baseCurrency } from '@ghostfolio/common/config'; import { baseCurrency } from '@ghostfolio/common/config';
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper'; import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
@ -16,7 +15,6 @@ import {
DataSource, DataSource,
SymbolProfile SymbolProfile
} from '@prisma/client'; } from '@prisma/client';
import * as bent from 'bent';
import Big from 'big.js'; import Big from 'big.js';
import { countries } from 'countries-list'; import { countries } from 'countries-list';
import { addDays, format, isSameDay } from 'date-fns'; import { addDays, format, isSameDay } from 'date-fns';
@ -25,8 +23,6 @@ import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-ifa
@Injectable() @Injectable()
export class YahooFinanceService implements DataProviderInterface { export class YahooFinanceService implements DataProviderInterface {
private readonly yahooFinanceHostname = 'https://query1.finance.yahoo.com';
public constructor( public constructor(
private readonly cryptocurrencyService: CryptocurrencyService private readonly cryptocurrencyService: CryptocurrencyService
) {} ) {}
@ -92,8 +88,12 @@ export class YahooFinanceService implements DataProviderInterface {
response.assetSubClass = assetSubClass; response.assetSubClass = assetSubClass;
response.currency = assetProfile.price.currency; response.currency = assetProfile.price.currency;
response.dataSource = this.getName(); response.dataSource = this.getName();
response.name = response.name = this.formatName({
assetProfile.price.longName || assetProfile.price.shortName || symbol; longName: assetProfile.price.longName,
quoteType: assetProfile.price.quoteType,
shortName: assetProfile.price.shortName,
symbol: assetProfile.price.symbol
});
response.symbol = aSymbol; response.symbol = aSymbol;
if ( if (
@ -215,8 +215,8 @@ export class YahooFinanceService implements DataProviderInterface {
marketState: marketState:
quote.marketState === 'REGULAR' || quote.marketState === 'REGULAR' ||
this.cryptocurrencyService.isCryptocurrency(symbol) this.cryptocurrencyService.isCryptocurrency(symbol)
? MarketState.open ? 'open'
: MarketState.closed, : 'closed',
marketPrice: quote.regularMarketPrice || 0 marketPrice: quote.regularMarketPrice || 0
}; };
@ -244,20 +244,11 @@ export class YahooFinanceService implements DataProviderInterface {
const items: LookupItem[] = []; const items: LookupItem[] = [];
try { try {
const get = bent( const searchResult = await yahooFinance.search(aQuery);
`${this.yahooFinanceHostname}/v1/finance/search?q=${encodeURIComponent(
aQuery
)}&lang=en-US&region=US&quotesCount=8&newsCount=0&enableFuzzyQuery=false&quotesQueryId=tss_match_phrase_query&multiQuoteQueryId=multi_quote_single_token_query&newsQueryId=news_cie_vespa&enableCb=true&enableNavLinks=false&enableEnhancedTrivialQuery=true`,
'GET',
'json',
200
);
const searchResult = await get();
const quotes = searchResult.quotes const quotes = searchResult.quotes
.filter((quote) => { .filter((quote) => {
// filter out undefined symbols // Filter out undefined symbols
return quote.symbol; return quote.symbol;
}) })
.filter(({ quoteType, symbol }) => { .filter(({ quoteType, symbol }) => {
@ -266,7 +257,7 @@ export class YahooFinanceService implements DataProviderInterface {
this.cryptocurrencyService.isCryptocurrency( this.cryptocurrencyService.isCryptocurrency(
symbol.replace(new RegExp(`-${baseCurrency}$`), baseCurrency) symbol.replace(new RegExp(`-${baseCurrency}$`), baseCurrency)
)) || )) ||
['EQUITY', 'ETF', 'MUTUALFUND'].includes(quoteType) ['EQUITY', 'ETF', 'FUTURE', 'MUTUALFUND'].includes(quoteType)
); );
}) })
.filter(({ quoteType, symbol }) => { .filter(({ quoteType, symbol }) => {
@ -274,27 +265,39 @@ export class YahooFinanceService implements DataProviderInterface {
// Only allow cryptocurrencies in base currency to avoid having redundancy in the database. // Only allow cryptocurrencies in base currency to avoid having redundancy in the database.
// Transactions need to be converted manually to the base currency before // Transactions need to be converted manually to the base currency before
return symbol.includes(baseCurrency); return symbol.includes(baseCurrency);
} else if (quoteType === 'FUTURE') {
// Allow GC=F, but not MGC=F
return symbol.length === 4;
} }
return true; return true;
}); });
const marketData = await this.getQuotes( const marketData = await yahooFinance.quote(
quotes.map(({ symbol }) => { quotes.map(({ symbol }) => {
return symbol; return symbol;
}) })
); );
for (const [symbol, value] of Object.entries(marketData)) { for (const marketDataItem of marketData) {
const quote = quotes.find((currentQuote: any) => { const quote = quotes.find((currentQuote) => {
return currentQuote.symbol === symbol; return currentQuote.symbol === marketDataItem.symbol;
}); });
const symbol = this.convertFromYahooFinanceSymbol(
marketDataItem.symbol
);
items.push({ items.push({
symbol, symbol,
currency: value.currency, currency: marketDataItem.currency,
dataSource: this.getName(), dataSource: this.getName(),
name: quote?.longname || quote?.shortname || symbol name: this.formatName({
longName: quote.longname,
quoteType: quote.quoteType,
shortName: quote.shortname,
symbol: quote.symbol
})
}); });
} }
} catch (error) { } catch (error) {
@ -304,6 +307,40 @@ export class YahooFinanceService implements DataProviderInterface {
return { items }; return { items };
} }
private formatName({
longName,
quoteType,
shortName,
symbol
}: {
longName: Price['longName'];
quoteType: Price['quoteType'];
shortName: Price['shortName'];
symbol: Price['symbol'];
}) {
let name = longName;
if (name) {
name = name.replace('iShares ETF (CH) - ', '');
name = name.replace('iShares III Public Limited Company - ', '');
name = name.replace('iShares VI Public Limited Company - ', '');
name = name.replace('iShares VII PLC - ', '');
name = name.replace('Multi Units Luxembourg - ', '');
name = name.replace('VanEck ETFs N.V. - ', '');
name = name.replace('Vaneck Vectors Ucits Etfs Plc - ', '');
name = name.replace('Vanguard Funds Public Limited Company - ', '');
name = name.replace('Vanguard Index Funds - ', '');
name = name.replace('Xtrackers (IE) Plc - ', '');
}
if (quoteType === 'FUTURE') {
// "Gold Jun 22" -> "Gold"
name = shortName?.slice(0, -6);
}
return name || shortName || symbol;
}
private parseAssetClass(aPrice: Price): { private parseAssetClass(aPrice: Price): {
assetClass: AssetClass; assetClass: AssetClass;
assetSubClass: AssetSubClass; assetSubClass: AssetSubClass;
@ -323,6 +360,20 @@ export class YahooFinanceService implements DataProviderInterface {
case 'etf': case 'etf':
assetClass = AssetClass.EQUITY; assetClass = AssetClass.EQUITY;
assetSubClass = AssetSubClass.ETF; assetSubClass = AssetSubClass.ETF;
break;
case 'future':
assetClass = AssetClass.COMMODITY;
assetSubClass = AssetSubClass.COMMODITY;
if (
aPrice?.shortName?.toLowerCase()?.startsWith('gold') ||
aPrice?.shortName?.toLowerCase()?.startsWith('palladium') ||
aPrice?.shortName?.toLowerCase()?.startsWith('platinum') ||
aPrice?.shortName?.toLowerCase()?.startsWith('silver')
) {
assetSubClass = AssetSubClass.PRECIOUS_METAL;
}
break; break;
case 'mutualfund': case 'mutualfund':
assetClass = AssetClass.EQUITY; assetClass = AssetClass.EQUITY;

View File

@ -1,18 +1,11 @@
import { MarketState } from '@ghostfolio/common/types';
import { import {
Account, Account,
AssetClass,
AssetSubClass,
DataSource, DataSource,
SymbolProfile, SymbolProfile,
Type as TypeOfOrder Type as TypeOfOrder
} from '@prisma/client'; } from '@prisma/client';
export const MarketState = {
closed: 'closed',
delayed: 'delayed',
open: 'open'
};
export interface IOrder { export interface IOrder {
account: Account; account: Account;
currency: string; currency: string;
@ -44,5 +37,3 @@ export interface IDataGatheringItem {
date?: Date; date?: Date;
symbol: string; symbol: string;
} }
export type MarketState = typeof MarketState[keyof typeof MarketState];

View File

@ -4,7 +4,12 @@ import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { Country } from '@ghostfolio/common/interfaces/country.interface'; import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, Prisma, SymbolProfile } from '@prisma/client'; import {
DataSource,
Prisma,
SymbolProfile,
SymbolProfileOverrides
} from '@prisma/client';
import { continents, countries } from 'countries-list'; import { continents, countries } from 'countries-list';
import { ScraperConfiguration } from './data-provider/ghostfolio-scraper-api/interfaces/scraper-configuration.interface'; import { ScraperConfiguration } from './data-provider/ghostfolio-scraper-api/interfaces/scraper-configuration.interface';
@ -36,6 +41,7 @@ export class SymbolProfileService {
): Promise<EnhancedSymbolProfile[]> { ): Promise<EnhancedSymbolProfile[]> {
return this.prismaService.symbolProfile return this.prismaService.symbolProfile
.findMany({ .findMany({
include: { SymbolProfileOverrides: true },
where: { where: {
symbol: { symbol: {
in: symbols in: symbols
@ -45,14 +51,38 @@ export class SymbolProfileService {
.then((symbolProfiles) => this.getSymbols(symbolProfiles)); .then((symbolProfiles) => this.getSymbols(symbolProfiles));
} }
private getSymbols(symbolProfiles: SymbolProfile[]): EnhancedSymbolProfile[] { private getSymbols(
return symbolProfiles.map((symbolProfile) => ({ symbolProfiles: (SymbolProfile & {
...symbolProfile, SymbolProfileOverrides: SymbolProfileOverrides;
countries: this.getCountries(symbolProfile), })[]
scraperConfiguration: this.getScraperConfiguration(symbolProfile), ): EnhancedSymbolProfile[] {
sectors: this.getSectors(symbolProfile), return symbolProfiles.map((symbolProfile) => {
symbolMapping: this.getSymbolMapping(symbolProfile) const item = {
})); ...symbolProfile,
countries: this.getCountries(symbolProfile),
scraperConfiguration: this.getScraperConfiguration(symbolProfile),
sectors: this.getSectors(symbolProfile),
symbolMapping: this.getSymbolMapping(symbolProfile)
};
if (item.SymbolProfileOverrides) {
item.assetClass =
item.SymbolProfileOverrides.assetClass ?? item.assetClass;
item.assetSubClass =
item.SymbolProfileOverrides.assetSubClass ?? item.assetSubClass;
item.countries =
(item.SymbolProfileOverrides.sectors as unknown as Country[]) ??
item.countries;
item.name = item.SymbolProfileOverrides?.name ?? item.name;
item.sectors =
(item.SymbolProfileOverrides.sectors as unknown as Sector[]) ??
item.sectors;
delete item.SymbolProfileOverrides;
}
return item;
});
} }
private getCountries(symbolProfile: SymbolProfile): Country[] { private getCountries(symbolProfile: SymbolProfile): Country[] {

View File

@ -0,0 +1,11 @@
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { Module } from '@nestjs/common';
import { TagService } from './tag.service';
@Module({
exports: [TagService],
imports: [PrismaModule],
providers: [TagService]
})
export class TagModule {}

View File

@ -0,0 +1,30 @@
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable } from '@nestjs/common';
@Injectable()
export class TagService {
public constructor(private readonly prismaService: PrismaService) {}
public async get() {
return this.prismaService.tag.findMany({
orderBy: {
name: 'asc'
}
});
}
public async getByUser(userId: string) {
return this.prismaService.tag.findMany({
orderBy: {
name: 'asc'
},
where: {
orders: {
some: {
userId
}
}
}
});
}
}

View File

@ -6,6 +6,6 @@
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"target": "es2015" "target": "es2015"
}, },
"exclude": ["**/*.spec.ts", "**/*.test.ts"], "exclude": ["**/*.spec.ts", "**/*.test.ts", "jest.config.ts"],
"include": ["**/*.ts"] "include": ["**/*.ts"]
} }

View File

@ -5,5 +5,5 @@
"module": "commonjs", "module": "commonjs",
"types": ["jest", "node"] "types": ["jest", "node"]
}, },
"include": ["**/*.spec.ts", "**/*.test.ts", "**/*.d.ts"] "include": ["**/*.spec.ts", "**/*.test.ts", "**/*.d.ts", "jest.config.ts"]
} }

View File

@ -1,6 +1,6 @@
module.exports = { module.exports = {
displayName: 'client', displayName: 'client',
preset: '../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'], setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
globals: { globals: {
'ts-jest': { 'ts-jest': {
@ -17,5 +17,6 @@ module.exports = {
transform: { transform: {
'^.+.(ts|mjs|js|html)$': 'jest-preset-angular' '^.+.(ts|mjs|js|html)$': 'jest-preset-angular'
}, },
transformIgnorePatterns: ['node_modules/(?!.*.mjs$)'] transformIgnorePatterns: ['node_modules/(?!.*.mjs$)'],
preset: '../../jest.preset.ts'
}; };

View File

@ -194,16 +194,17 @@
<ion-icon name="ellipsis-vertical"></ion-icon> <ion-icon name="ellipsis-vertical"></ion-icon>
</button> </button>
<mat-menu #accountMenu="matMenu" xPosition="before"> <mat-menu #accountMenu="matMenu" xPosition="before">
<button i18n mat-menu-item (click)="onUpdateAccount(element)"> <button mat-menu-item (click)="onUpdateAccount(element)">
Edit <ion-icon class="mr-2" name="create-outline"></ion-icon>
<span i18n>Edit</span>
</button> </button>
<button <button
i18n
mat-menu-item mat-menu-item
[disabled]="element.isDefault || element.Order?.length > 0" [disabled]="element.isDefault || element.Order?.length > 0"
(click)="onDeleteAccount(element.id)" (click)="onDeleteAccount(element.id)"
> >
Delete <ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete</span>
</button> </button>
</mat-menu> </mat-menu>
</td> </td>

View File

@ -1,6 +1,7 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { AdminData } from '@ghostfolio/common/interfaces'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { AdminData, User } from '@ghostfolio/common/interfaces';
import { import {
differenceInSeconds, differenceInSeconds,
formatDistanceToNowStrict, formatDistanceToNowStrict,
@ -15,6 +16,7 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './admin-users.html' templateUrl: './admin-users.html'
}) })
export class AdminUsersComponent implements OnDestroy, OnInit { export class AdminUsersComponent implements OnDestroy, OnInit {
public user: User;
public users: AdminData['users']; public users: AdminData['users'];
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -24,8 +26,17 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
*/ */
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService private dataService: DataService,
) {} private userService: UserService
) {
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
}
});
}
/** /**
* Initializes the controller * Initializes the controller

View File

@ -45,14 +45,27 @@
<td class="mat-cell px-1 py-2 text-right"> <td class="mat-cell px-1 py-2 text-right">
{{ formatDistanceToNow(userItem.createdAt) }} {{ formatDistanceToNow(userItem.createdAt) }}
</td> </td>
<td class="mat-cell px-1 py-2 text-right"> <td class="mat-cell px-1 py-2">
{{ userItem.accountCount }} <gf-value
class="align-items-end"
[locale]="user?.settings?.locale"
[value]="userItem.accountCount"
></gf-value>
</td> </td>
<td class="mat-cell px-1 py-2 text-right"> <td class="mat-cell px-1 py-2">
{{ userItem.transactionCount }} <gf-value
class="align-items-end"
[locale]="user?.settings?.locale"
[value]="userItem.transactionCount"
></gf-value>
</td> </td>
<td class="mat-cell px-1 py-2 text-right"> <td class="mat-cell px-1 py-2">
{{ userItem.engagement | number: '1.0-0' }} <gf-value
class="align-items-end"
[locale]="user?.settings?.locale"
[precision]="0"
[value]="userItem.engagement"
></gf-value>
</td> </td>
<td class="mat-cell px-1 py-2"> <td class="mat-cell px-1 py-2">
{{ formatDistanceToNow(userItem.lastActivity) }} {{ formatDistanceToNow(userItem.lastActivity) }}
@ -68,12 +81,12 @@
</button> </button>
<mat-menu #accountMenu="matMenu" xPosition="before"> <mat-menu #accountMenu="matMenu" xPosition="before">
<button <button
i18n
mat-menu-item mat-menu-item
[disabled]="userItem.id === user?.id" [disabled]="userItem.id === user?.id"
(click)="onDeleteUser(userItem.id)" (click)="onDeleteUser(userItem.id)"
> >
Delete <ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete</span>
</button> </button>
</mat-menu> </mat-menu>
</td> </td>

View File

@ -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 { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { GfValueModule } from '@ghostfolio/ui/value';
import { AdminUsersComponent } from './admin-users.component'; import { AdminUsersComponent } from './admin-users.component';
@NgModule({ @NgModule({
declarations: [AdminUsersComponent], declarations: [AdminUsersComponent],
exports: [], exports: [],
imports: [CommonModule, MatButtonModule, MatMenuModule], imports: [CommonModule, GfValueModule, MatButtonModule, MatMenuModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class GfAdminUsersModule {} export class GfAdminUsersModule {}

View File

@ -1,10 +1,13 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config'; import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
import { resetHours } from '@ghostfolio/common/helper'; import { resetHours } from '@ghostfolio/common/helper';
import { InfoItem, User } from '@ghostfolio/common/interfaces'; import {
HistoricalDataItem,
InfoItem,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';

View File

@ -11,7 +11,7 @@ import { DataService } from '@ghostfolio/client/services/data.service';
import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper'; import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface'; import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
import { SymbolProfile } from '@prisma/client'; import { SymbolProfile, Tag } from '@prisma/client';
import { format, isSameMonth, isToday, parseISO } from 'date-fns'; import { format, isSameMonth, isToday, parseISO } from 'date-fns';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -48,6 +48,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
[name: string]: { name: string; value: number }; [name: string]: { name: string; value: number };
}; };
public SymbolProfile: SymbolProfile; public SymbolProfile: SymbolProfile;
public tags: Tag[];
public transactionCount: number; public transactionCount: number;
public value: number; public value: number;
@ -83,6 +84,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
orders, orders,
quantity, quantity,
SymbolProfile, SymbolProfile,
tags,
transactionCount, transactionCount,
value value
}) => { }) => {
@ -115,6 +117,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
this.quantity = quantity; this.quantity = quantity;
this.sectors = {}; this.sectors = {};
this.SymbolProfile = SymbolProfile; this.SymbolProfile = SymbolProfile;
this.tags = tags;
this.transactionCount = transactionCount; this.transactionCount = transactionCount;
this.value = value; this.value = value;
@ -211,14 +214,14 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
) )
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => { .subscribe((data) => {
downloadAsFile( downloadAsFile({
data, content: data,
`ghostfolio-export-${this.SymbolProfile?.symbol}-${format( fileName: `ghostfolio-export-${this.SymbolProfile?.symbol}-${format(
parseISO(data.meta.date), parseISO(data.meta.date),
'yyyyMMddHHmm' 'yyyyMMddHHmm'
)}.json`, )}.json`,
'text/plain' format: 'json'
); });
}); });
} }

View File

@ -168,7 +168,7 @@
</ng-container> </ng-container>
<ng-template #charts> <ng-template #charts>
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<div class="h4" i18n>Sectors</div> <div class="h5" i18n>Sectors</div>
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="true" [isInPercent]="true"
@ -179,7 +179,7 @@
></gf-portfolio-proportion-chart> ></gf-portfolio-proportion-chart>
</div> </div>
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<div class="h4" i18n>Countries</div> <div class="h5" i18n>Countries</div>
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="true" [isInPercent]="true"
@ -192,23 +192,36 @@
</ng-template> </ng-template>
</ng-container> </ng-container>
</div> </div>
</div>
<gf-activities-table <div *ngIf="orders?.length > 0" class="row">
*ngIf="orders?.length > 0" <div class="col mb-3">
[activities]="orders" <div class="h5 mb-0" i18n>Activities</div>
[baseCurrency]="data.baseCurrency" <gf-activities-table
[deviceType]="data.deviceType" [activities]="orders"
[hasPermissionToCreateActivity]="false" [baseCurrency]="data.baseCurrency"
[hasPermissionToExportActivities]="!hasImpersonationId" [deviceType]="data.deviceType"
[hasPermissionToFilter]="false" [hasPermissionToCreateActivity]="false"
[hasPermissionToImportActivities]="false" [hasPermissionToExportActivities]="!hasImpersonationId"
[hasPermissionToOpenDetails]="false" [hasPermissionToFilter]="false"
[locale]="data.locale" [hasPermissionToImportActivities]="false"
[showActions]="false" [hasPermissionToOpenDetails]="false"
[showSymbolColumn]="false" [locale]="data.locale"
(export)="onExport()" [showActions]="false"
></gf-activities-table> [showSymbolColumn]="false"
(export)="onExport()"
></gf-activities-table>
</div>
</div>
<div *ngIf="tags?.length > 0" class="row">
<div class="col">
<div class="h5" i18n>Tags</div>
<mat-chip-list>
<mat-chip *ngFor="let tag of tags">{{ tag.name }}</mat-chip>
</mat-chip-list>
</div>
</div>
</div>
</div> </div>
<gf-dialog-footer <gf-dialog-footer

View File

@ -1,6 +1,7 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatChipsModule } from '@angular/material/chips';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module'; import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module'; import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
@ -24,6 +25,7 @@ import { PositionDetailDialog } from './position-detail-dialog.component';
GfPortfolioProportionChartModule, GfPortfolioProportionChartModule,
GfValueModule, GfValueModule,
MatButtonModule, MatButtonModule,
MatChipsModule,
MatDialogModule, MatDialogModule,
NgxSkeletonLoaderModule NgxSkeletonLoaderModule
], ],

View File

@ -5,7 +5,6 @@ import {
OnChanges, OnChanges,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
import { Position } from '@ghostfolio/common/interfaces'; import { Position } from '@ghostfolio/common/interfaces';
@Component({ @Component({
@ -42,10 +41,7 @@ export class PositionsComponent implements OnChanges, OnInit {
this.positionsWithPriority = []; this.positionsWithPriority = [];
for (const portfolioPosition of this.positions) { for (const portfolioPosition of this.positions) {
if ( if (portfolioPosition.marketState === 'open' || this.range !== '1d') {
portfolioPosition.marketState === MarketState.open ||
this.range !== '1d'
) {
// Only show positions where the market is open in today's view // Only show positions where the market is open in today's view
this.positionsWithPriority.push(portfolioPosition); this.positionsWithPriority.push(portfolioPosition);
} else { } else {

View File

@ -22,7 +22,6 @@ export class AboutPageComponent implements OnDestroy, OnInit {
public hasPermissionForStatistics: boolean; public hasPermissionForStatistics: boolean;
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
public isLoggedIn: boolean; public isLoggedIn: boolean;
public lastPublish = environment.lastPublish;
public statistics: Statistics; public statistics: Statistics;
public user: User; public user: User;
public version = environment.version; public version = environment.version;

View File

@ -2,103 +2,101 @@
<div class="mb-5 row"> <div class="mb-5 row">
<div class="col"> <div class="col">
<h3 class="d-flex justify-content-center mb-3" i18n>About Ghostfolio</h3> <h3 class="d-flex justify-content-center mb-3" i18n>About Ghostfolio</h3>
<mat-card class="about-container"> <div class="about-container">
<mat-card-content> <p>
<p> <strong>Ghostfolio</strong> is a lightweight wealth management
<strong>Ghostfolio</strong> is a lightweight wealth management application for individuals to keep track of stocks, ETFs or
application for individuals to keep track of their wealth like cryptocurrencies and make solid, data-driven investment decisions. The
stocks, ETFs or cryptocurrencies and make solid, data-driven source code is fully available as open source software (OSS). The
investment decisions. The source code is fully available as open project has been initiated by
source software (OSS). The project has been initiated by <a href="https://dotsilver.ch" title="Website of Thomas Kaul"
<a href="https://dotsilver.ch" title="Website of Thomas Kaul" >Thomas Kaul</a
>Thomas Kaul</a
>
and is driven by the efforts of its
<a
href="https://github.com/ghostfolio/ghostfolio/graphs/contributors"
title="Contributors to Ghostfolio"
>contributors</a
>.
<ng-container *ngIf="lastPublish">
This instance is running Ghostfolio {{ version }} and has been
last published on {{ lastPublish }}.
</ng-container>
<ng-container *ngIf="hasPermissionForStatistics" i18n
>Check the system status at
<a href="https://status.ghostfol.io" title="Ghostfolio status"
>status.ghostfol.io</a
>.</ng-container
>
</p>
<p>
If you encounter a bug or would like to suggest an improvement or a
new <a [routerLink]="['/features']">feature</a>, please join the
Ghostfolio
<a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
title="Join the Ghostfolio Slack community"
>Slack community</a
>, tweet to
<a
href="https://twitter.com/ghostfolio_"
title="Tweet to Ghostfolio on Twitter"
>@ghostfolio_</a
>, send an e-mail to
<a href="mailto:hi@ghostfol.io" title="Send an e-mail"
>hi@ghostfol.io</a
>
or open an issue at
<a
href="https://github.com/ghostfolio/ghostfolio"
title="Find Ghostfolio on GitHub"
>GitHub</a
>.
</p>
<p class="text-center">
<a
class="mx-2"
href="https://twitter.com/ghostfolio_"
mat-icon-button
title="Follow Ghostfolio on Twitter"
>
<ion-icon name="logo-twitter" size="large"></ion-icon>
</a>
<a
class="mx-2"
href="mailto:hi@ghostfol.io"
mat-icon-button
title="Send an e-mail"
>
<ion-icon name="mail" size="large"></ion-icon>
</a>
<a
class="mx-2"
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
mat-icon-button
title="Join the Ghostfolio Slack channel"
>
<ion-icon name="logo-slack" size="large"></ion-icon>
</a>
<a
class="mx-2"
href="https://github.com/ghostfolio/ghostfolio"
mat-icon-button
title="Find Ghostfolio on GitHub"
>
<ion-icon name="logo-github" size="large"></ion-icon>
</a>
</p>
<div
*ngIf="hasPermissionForSubscription"
class="d-flex justify-content-center"
> >
<div and is driven by the efforts of its
class="independent-and-bootstrapped-logo mb-2" <a
title="Ghostfolio is an independent & bootstrapped business" href="https://github.com/ghostfolio/ghostfolio/graphs/contributors"
></div> title="Contributors to Ghostfolio"
</div> >contributors</a
</mat-card-content> >.
</mat-card> <ng-container *ngIf="version">
This instance is running Ghostfolio {{ version }}.
</ng-container>
<ng-container *ngIf="hasPermissionForStatistics" i18n
>Check the system status at
<a href="https://status.ghostfol.io" title="Ghostfolio status"
>status.ghostfol.io</a
>.</ng-container
>
</p>
<p>
If you encounter a bug or would like to suggest an improvement or a
new
<a [routerLink]="['/features']">feature</a>, please join the
Ghostfolio
<a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
title="Join the Ghostfolio Slack community"
>Slack community</a
>, tweet to
<a
href="https://twitter.com/ghostfolio_"
title="Tweet to Ghostfolio on Twitter"
>@ghostfolio_</a
>, send an e-mail to
<a href="mailto:hi@ghostfol.io" title="Send an e-mail"
>hi@ghostfol.io</a
>
or open an issue at
<a
href="https://github.com/ghostfolio/ghostfolio"
title="Find Ghostfolio on GitHub"
>GitHub</a
>.
</p>
<p class="text-center">
<a
class="mx-2"
href="https://twitter.com/ghostfolio_"
mat-icon-button
title="Follow Ghostfolio on Twitter"
>
<ion-icon name="logo-twitter" size="large"></ion-icon>
</a>
<a
class="mx-2"
href="mailto:hi@ghostfol.io"
mat-icon-button
title="Send an e-mail"
>
<ion-icon name="mail" size="large"></ion-icon>
</a>
<a
class="mx-2"
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
mat-icon-button
title="Join the Ghostfolio Slack channel"
>
<ion-icon name="logo-slack" size="large"></ion-icon>
</a>
<a
class="mx-2"
href="https://github.com/ghostfolio/ghostfolio"
mat-icon-button
title="Find Ghostfolio on GitHub"
>
<ion-icon name="logo-github" size="large"></ion-icon>
</a>
</p>
<div
*ngIf="hasPermissionForSubscription"
class="d-flex justify-content-center"
>
<div
class="independent-and-bootstrapped-logo mb-2"
title="Ghostfolio is an independent & bootstrapped business"
></div>
</div>
</div>
</div> </div>
</div> </div>
@ -109,38 +107,39 @@
<mat-card-content> <mat-card-content>
<div class="row"> <div class="row">
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">
<h3 class="mb-0">{{ statistics?.activeUsers1d || '-' }}</h3> <gf-value
<div class="h6 mb-0"> label="Active Users"
<span i18n>Active Users</span>&nbsp;<small class="text-muted" size="large"
>(Last 24 hours)</small subLabel="(Last 24 hours)"
> [value]="statistics?.activeUsers1d ?? '-'"
</div> ></gf-value>
</div> </div>
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">
<h3 class="mb-0">{{ statistics?.newUsers30d ?? '-' }}</h3> <gf-value
<div class="h6 mb-0"> label="New Users"
<span i18n>New Users</span>&nbsp;<small class="text-muted" size="large"
>(Last 30 days)</small subLabel="(Last 30 days)"
> [value]="statistics?.newUsers30d ?? '-'"
</div> ></gf-value>
</div> </div>
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">
<h3 class="mb-0">{{ statistics?.activeUsers30d ?? '-' }}</h3> <gf-value
<div class="h6 mb-0"> label="Active Users"
<span i18n>Active Users</span>&nbsp;<small class="text-muted" size="large"
>(Last 30 days)</small subLabel="(Last 30 days)"
> [value]="statistics?.activeUsers30d ?? '-'"
</div> ></gf-value>
</div> </div>
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">
<a <a
class="d-block" class="d-block"
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg" href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
> >
<h3 class="mb-0"> <gf-value
{{ statistics?.slackCommunityUsers ?? '-' }} label="Users in Slack community"
</h3> size="large"
<div class="h6 mb-0" i18n>Users in Slack community</div> [value]="statistics?.slackCommunityUsers ?? '-'"
></gf-value>
</a> </a>
</div> </div>
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">
@ -148,10 +147,11 @@
class="d-block" class="d-block"
href="https://github.com/ghostfolio/ghostfolio/graphs/contributors" href="https://github.com/ghostfolio/ghostfolio/graphs/contributors"
> >
<h3 class="mb-0"> <gf-value
{{ statistics?.gitHubContributors ?? '-' }} label="Contributors on GitHub"
</h3> size="large"
<div class="h6 mb-0" i18n>Contributors on GitHub</div> [value]="statistics?.gitHubContributors ?? '-'"
></gf-value>
</a> </a>
</div> </div>
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">
@ -159,8 +159,11 @@
class="d-block" class="d-block"
href="https://github.com/ghostfolio/ghostfolio/stargazers" href="https://github.com/ghostfolio/ghostfolio/stargazers"
> >
<h3 class="mb-0">{{ statistics?.gitHubStargazers ?? '-' }}</h3> <gf-value
<div class="h6 mb-0" i18n>Stars on GitHub</div> label="Stars on GitHub"
size="large"
[value]="statistics?.gitHubStargazers ?? '-'"
></gf-value>
</a> </a>
</div> </div>
</div> </div>

View File

@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { GfValueModule } from '@ghostfolio/ui/value';
import { AboutPageRoutingModule } from './about-page-routing.module'; import { AboutPageRoutingModule } from './about-page-routing.module';
import { AboutPageComponent } from './about-page.component'; import { AboutPageComponent } from './about-page.component';
@ -12,6 +13,7 @@ import { AboutPageComponent } from './about-page.component';
imports: [ imports: [
AboutPageRoutingModule, AboutPageRoutingModule,
CommonModule, CommonModule,
GfValueModule,
MatButtonModule, MatButtonModule,
MatCardModule MatCardModule
], ],

View File

@ -2,15 +2,13 @@
color: rgb(var(--dark-primary-text)); color: rgb(var(--dark-primary-text));
display: block; display: block;
.mat-card { .about-container {
&.about-container { a {
a { color: rgba(var(--palette-primary-500), 1);
color: rgba(var(--palette-primary-500), 1); font-weight: 500;
font-weight: 500;
&:hover { &:hover {
color: rgba(var(--palette-primary-300), 1); color: rgba(var(--palette-primary-300), 1);
}
} }
} }
@ -29,7 +27,7 @@
:host-context(.is-dark-theme) { :host-context(.is-dark-theme) {
color: rgb(var(--light-primary-text)); color: rgb(var(--light-primary-text));
.mat-card { .about-container {
.independent-and-bootstrapped-logo { .independent-and-bootstrapped-logo {
background-image: url('/assets/bootstrapped-light.svg'); background-image: url('/assets/bootstrapped-light.svg');
opacity: 1; opacity: 1;

View File

@ -8,6 +8,7 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { prettifySymbol } from '@ghostfolio/common/helper'; import { prettifySymbol } from '@ghostfolio/common/helper';
import { import {
Filter,
PortfolioDetails, PortfolioDetails,
PortfolioPosition, PortfolioPosition,
UniqueAsset, UniqueAsset,
@ -17,7 +18,7 @@ import { Market, ToggleOption } from '@ghostfolio/common/types';
import { Account, AssetClass, DataSource } from '@prisma/client'; import { Account, AssetClass, DataSource } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, Subscription } from 'rxjs'; import { Subject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
@Component({ @Component({
host: { class: 'page' }, host: { class: 'page' },
@ -32,6 +33,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
value: number; value: number;
}; };
}; };
public allFilters: Filter[];
public continents: { public continents: {
[code: string]: { name: string; value: number }; [code: string]: { name: string; value: number };
}; };
@ -39,7 +41,9 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
[code: string]: { name: string; value: number }; [code: string]: { name: string; value: number };
}; };
public deviceType: string; public deviceType: string;
public filters$ = new Subject<Filter[]>();
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public isLoading = false;
public markets: { public markets: {
[key in Market]: { name: string; value: number }; [key in Market]: { name: string; value: number };
}; };
@ -48,6 +52,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
{ label: 'Initial', value: 'original' }, { label: 'Initial', value: 'original' },
{ label: 'Current', value: 'current' } { label: 'Current', value: 'current' }
]; ];
public placeholder = '';
public portfolioDetails: PortfolioDetails; public portfolioDetails: PortfolioDetails;
public positions: { public positions: {
[symbol: string]: Pick< [symbol: string]: Pick<
@ -120,14 +125,23 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
this.hasImpersonationId = !!aId; this.hasImpersonationId = !!aId;
}); });
this.dataService this.filters$
.fetchPortfolioDetails({}) .pipe(
.pipe(takeUntil(this.unsubscribeSubject)) distinctUntilChanged(),
switchMap((filters) => {
this.isLoading = true;
return this.dataService.fetchPortfolioDetails({ filters });
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe((portfolioDetails) => { .subscribe((portfolioDetails) => {
this.portfolioDetails = portfolioDetails; this.portfolioDetails = portfolioDetails;
this.initializeAnalysisData(this.period); this.initializeAnalysisData(this.period);
this.isLoading = false;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
@ -137,12 +151,32 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
const accountFilters: Filter[] = this.user.accounts.map(
({ id, name }) => {
return {
id: id,
label: name,
type: 'account'
};
}
);
const tagFilters: Filter[] = this.user.tags.map(({ id, name }) => {
return {
id,
label: name,
type: 'tag'
};
});
this.allFilters = [...accountFilters, ...tagFilters];
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
}); });
} }
public initializeAnalysisData(aPeriod: string) { public initialize() {
this.accounts = {}; this.accounts = {};
this.continents = { this.continents = {
[UNKNOWN_KEY]: { [UNKNOWN_KEY]: {
@ -185,6 +219,10 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
value: 0 value: 0
} }
}; };
}
public initializeAnalysisData(aPeriod: string) {
this.initialize();
for (const [id, { current, name, original }] of Object.entries( for (const [id, { current, name, original }] of Object.entries(
this.portfolioDetails.accounts this.portfolioDetails.accounts
@ -305,7 +343,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
} }
} }
if (position.assetClass === AssetClass.EQUITY) { if (position.dataSource) {
this.symbols[prettifySymbol(symbol)] = { this.symbols[prettifySymbol(symbol)] = {
dataSource: position.dataSource, dataSource: position.dataSource,
name: position.name, name: position.name,

View File

@ -2,6 +2,12 @@
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<h3 class="d-flex justify-content-center mb-3" i18n>Allocations</h3> <h3 class="d-flex justify-content-center mb-3" i18n>Allocations</h3>
<gf-activities-filter
[allFilters]="allFilters"
[isLoading]="isLoading"
[placeholder]="placeholder"
(valueChanged)="filters$.next($event)"
></gf-activities-filter>
</div> </div>
</div> </div>
<div class="proportion-charts row"> <div class="proportion-charts row">

View File

@ -4,6 +4,7 @@ import { MatCardModule } from '@angular/material/card';
import { GfPositionsTableModule } from '@ghostfolio/client/components/positions-table/positions-table.module'; import { GfPositionsTableModule } from '@ghostfolio/client/components/positions-table/positions-table.module';
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module'; import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
import { GfWorldMapChartModule } from '@ghostfolio/client/components/world-map-chart/world-map-chart.module'; import { GfWorldMapChartModule } from '@ghostfolio/client/components/world-map-chart/world-map-chart.module';
import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module';
import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module'; import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
import { GfValueModule } from '@ghostfolio/ui/value'; import { GfValueModule } from '@ghostfolio/ui/value';
@ -16,6 +17,7 @@ import { AllocationsPageComponent } from './allocations-page.component';
imports: [ imports: [
AllocationsPageRoutingModule, AllocationsPageRoutingModule,
CommonModule, CommonModule,
GfActivitiesFilterModule,
GfPortfolioProportionChartModule, GfPortfolioProportionChartModule,
GfPositionsTableModule, GfPositionsTableModule,
GfToggleModule, GfToggleModule,

View File

@ -2,6 +2,7 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { User } from '@ghostfolio/common/interfaces'; import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import Big from 'big.js'; import Big from 'big.js';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
@ -16,6 +17,7 @@ import { takeUntil } from 'rxjs/operators';
export class FirePageComponent implements OnDestroy, OnInit { export class FirePageComponent implements OnDestroy, OnInit {
public deviceType: string; public deviceType: string;
public fireWealth: Big; public fireWealth: Big;
public hasPermissionToUpdateUserSettings: boolean;
public isLoading = false; public isLoading = false;
public user: User; public user: User;
public withdrawalRatePerMonth: Big; public withdrawalRatePerMonth: Big;
@ -63,11 +65,23 @@ export class FirePageComponent implements OnDestroy, OnInit {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
this.hasPermissionToUpdateUserSettings = hasPermission(
this.user.permissions,
permissions.updateUserSettings
);
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
}); });
} }
public onSavingsRateChange(savingsRate: number) {
this.dataService
.putUserSetting({ savingsRate })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {});
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();

View File

@ -41,7 +41,7 @@
[value]="withdrawalRatePerMonth?.toNumber()" [value]="withdrawalRatePerMonth?.toNumber()"
></gf-value> ></gf-value>
per month</span per month</span
>, based on your investment of >, based on your total assets of
<gf-value <gf-value
class="d-inline-block" class="d-inline-block"
[currency]="user?.settings?.baseCurrency" [currency]="user?.settings?.baseCurrency"
@ -59,7 +59,10 @@
[currency]="user?.settings?.baseCurrency" [currency]="user?.settings?.baseCurrency"
[deviceType]="deviceType" [deviceType]="deviceType"
[fireWealth]="fireWealth?.toNumber()" [fireWealth]="fireWealth?.toNumber()"
[hasPermissionToUpdateUserSettings]="hasPermissionToUpdateUserSettings"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[savingsRate]="user?.settings?.savingsRate"
(savingsRateChanged)="onSavingsRateChange($event)"
></gf-fire-calculator> ></gf-fire-calculator>
</div> </div>
</div> </div>

View File

@ -13,7 +13,7 @@ import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto'; import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { Type } from '@prisma/client'; import { AssetClass, AssetSubClass, Type } from '@prisma/client';
import { isUUID } from 'class-validator'; import { isUUID } from 'class-validator';
import { isString } from 'lodash'; import { isString } from 'lodash';
import { EMPTY, Observable, Subject } from 'rxjs'; import { EMPTY, Observable, Subject } from 'rxjs';
@ -39,13 +39,15 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
@ViewChild('autocomplete') autocomplete; @ViewChild('autocomplete') autocomplete;
public activityForm: FormGroup; public activityForm: FormGroup;
public assetClasses = Object.keys(AssetClass);
public assetSubClasses = Object.keys(AssetSubClass);
public currencies: string[] = []; public currencies: string[] = [];
public currentMarketPrice = null; public currentMarketPrice = null;
public filteredLookupItems: LookupItem[]; public filteredLookupItems: LookupItem[];
public filteredLookupItemsObservable: Observable<LookupItem[]>; public filteredLookupItemsObservable: Observable<LookupItem[]>;
public isLoading = false; public isLoading = false;
public platforms: { id: string; name: string }[]; public platforms: { id: string; name: string }[];
public total = 0;
public Validators = Validators; public Validators = Validators;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -66,6 +68,8 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
this.activityForm = this.formBuilder.group({ this.activityForm = this.formBuilder.group({
accountId: [this.data.activity?.accountId, Validators.required], accountId: [this.data.activity?.accountId, Validators.required],
assetClass: [this.data.activity?.SymbolProfile?.assetClass],
assetSubClass: [this.data.activity?.SymbolProfile?.assetSubClass],
currency: [ currency: [
this.data.activity?.SymbolProfile?.currency, this.data.activity?.SymbolProfile?.currency,
Validators.required Validators.required
@ -85,10 +89,30 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
}, },
Validators.required Validators.required
], ],
tags: [this.data.activity?.tags],
type: [undefined, Validators.required], // Set after value changes subscription type: [undefined, Validators.required], // Set after value changes subscription
unitPrice: [this.data.activity?.unitPrice, Validators.required] unitPrice: [this.data.activity?.unitPrice, Validators.required]
}); });
this.activityForm.valueChanges
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
if (
this.activityForm.controls['type'].value === 'BUY' ||
this.activityForm.controls['type'].value === 'ITEM'
) {
this.total =
this.activityForm.controls['quantity'].value *
this.activityForm.controls['unitPrice'].value +
this.activityForm.controls['fee'].value ?? 0;
} else {
this.total =
this.activityForm.controls['quantity'].value *
this.activityForm.controls['unitPrice'].value -
this.activityForm.controls['fee'].value ?? 0;
}
});
this.filteredLookupItemsObservable = this.activityForm.controls[ this.filteredLookupItemsObservable = this.activityForm.controls[
'searchSymbol' 'searchSymbol'
].valueChanges.pipe( ].valueChanges.pipe(
@ -100,9 +124,11 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
const filteredLookupItemsObservable = const filteredLookupItemsObservable =
this.dataService.fetchSymbols(query); this.dataService.fetchSymbols(query);
filteredLookupItemsObservable.subscribe((filteredLookupItems) => { filteredLookupItemsObservable
this.filteredLookupItems = filteredLookupItems; .pipe(takeUntil(this.unsubscribeSubject))
}); .subscribe((filteredLookupItems) => {
this.filteredLookupItems = filteredLookupItems;
});
return filteredLookupItemsObservable; return filteredLookupItemsObservable;
} }
@ -111,45 +137,47 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
}) })
); );
this.activityForm.controls['type'].valueChanges.subscribe((type: Type) => { this.activityForm.controls['type'].valueChanges
if (type === 'ITEM') { .pipe(takeUntil(this.unsubscribeSubject))
this.activityForm.controls['accountId'].removeValidators( .subscribe((type: Type) => {
Validators.required if (type === 'ITEM') {
); this.activityForm.controls['accountId'].removeValidators(
this.activityForm.controls['accountId'].updateValueAndValidity(); Validators.required
this.activityForm.controls['currency'].setValue( );
this.data.user.settings.baseCurrency this.activityForm.controls['accountId'].updateValueAndValidity();
); this.activityForm.controls['currency'].setValue(
this.activityForm.controls['dataSource'].removeValidators( this.data.user.settings.baseCurrency
Validators.required );
); this.activityForm.controls['dataSource'].removeValidators(
this.activityForm.controls['dataSource'].updateValueAndValidity(); Validators.required
this.activityForm.controls['name'].setValidators(Validators.required); );
this.activityForm.controls['name'].updateValueAndValidity(); this.activityForm.controls['dataSource'].updateValueAndValidity();
this.activityForm.controls['quantity'].setValue(1); this.activityForm.controls['name'].setValidators(Validators.required);
this.activityForm.controls['searchSymbol'].removeValidators( this.activityForm.controls['name'].updateValueAndValidity();
Validators.required this.activityForm.controls['quantity'].setValue(1);
); this.activityForm.controls['searchSymbol'].removeValidators(
this.activityForm.controls['searchSymbol'].updateValueAndValidity(); Validators.required
} else { );
this.activityForm.controls['accountId'].setValidators( this.activityForm.controls['searchSymbol'].updateValueAndValidity();
Validators.required } else {
); this.activityForm.controls['accountId'].setValidators(
this.activityForm.controls['accountId'].updateValueAndValidity(); Validators.required
this.activityForm.controls['dataSource'].setValidators( );
Validators.required this.activityForm.controls['accountId'].updateValueAndValidity();
); this.activityForm.controls['dataSource'].setValidators(
this.activityForm.controls['dataSource'].updateValueAndValidity(); Validators.required
this.activityForm.controls['name'].removeValidators( );
Validators.required this.activityForm.controls['dataSource'].updateValueAndValidity();
); this.activityForm.controls['name'].removeValidators(
this.activityForm.controls['name'].updateValueAndValidity(); Validators.required
this.activityForm.controls['searchSymbol'].setValidators( );
Validators.required this.activityForm.controls['name'].updateValueAndValidity();
); this.activityForm.controls['searchSymbol'].setValidators(
this.activityForm.controls['searchSymbol'].updateValueAndValidity(); Validators.required
} );
}); this.activityForm.controls['searchSymbol'].updateValueAndValidity();
}
});
this.activityForm.controls['type'].setValue(this.data.activity?.type); this.activityForm.controls['type'].setValue(this.data.activity?.type);
@ -209,6 +237,8 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
public onSubmit() { public onSubmit() {
const activity: CreateOrderDto | UpdateOrderDto = { const activity: CreateOrderDto | UpdateOrderDto = {
accountId: this.activityForm.controls['accountId'].value, accountId: this.activityForm.controls['accountId'].value,
assetClass: this.activityForm.controls['assetClass'].value,
assetSubClass: this.activityForm.controls['assetSubClass'].value,
currency: this.activityForm.controls['currency'].value, currency: this.activityForm.controls['currency'].value,
date: this.activityForm.controls['date'].value, date: this.activityForm.controls['date'].value,
dataSource: this.activityForm.controls['dataSource'].value, dataSource: this.activityForm.controls['dataSource'].value,

View File

@ -134,13 +134,55 @@
> >
</mat-form-field> </mat-form-field>
</div> </div>
<div
[ngClass]="{ 'd-none': activityForm.controls['type']?.value !== 'ITEM' }"
>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Asset Class</mat-label>
<mat-select formControlName="assetClass">
<mat-option [value]="null"></mat-option>
<mat-option
*ngFor="let assetClass of assetClasses"
[value]="assetClass"
>{{ assetClass }}</mat-option
>
</mat-select>
</mat-form-field>
</div>
<div
[ngClass]="{ 'd-none': activityForm.controls['type']?.value !== 'ITEM' }"
>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Asset Sub-Class</mat-label>
<mat-select formControlName="assetSubClass">
<mat-option [value]="null"></mat-option>
<mat-option
*ngFor="let assetSubClass of assetSubClasses"
[value]="assetSubClass"
>{{ assetSubClass }}</mat-option
>
</mat-select>
</mat-form-field>
</div>
<div
[ngClass]="{ 'd-none': activityForm.controls['tags']?.value?.length <= 0 }"
>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Tags</mat-label>
<mat-chip-list>
<mat-chip *ngFor="let tag of activityForm.controls['tags']?.value">
{{ tag.name }}
</mat-chip>
</mat-chip-list>
</mat-form-field>
</div>
</div> </div>
<div class="d-flex" mat-dialog-actions> <div class="d-flex" mat-dialog-actions>
<gf-value <gf-value
class="flex-grow-1" class="flex-grow-1"
[currency]="activityForm.controls['currency'].value" [currency]="activityForm.controls['currency']?.value ?? data.user?.settings?.baseCurrency"
[locale]="data.user?.settings?.locale" [locale]="data.user?.settings?.locale"
[value]="activityForm.controls['fee'].value + (activityForm.controls['quantity'].value * activityForm.controls['unitPrice'].value) ?? 0" [value]="total"
></gf-value> ></gf-value>
<div> <div>
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button> <button i18n mat-button type="button" (click)="onCancel()">Cancel</button>

View File

@ -3,6 +3,7 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatChipsModule } from '@angular/material/chips';
import { MatDatepickerModule } from '@angular/material/datepicker'; import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
@ -24,6 +25,7 @@ import { CreateOrUpdateTransactionDialog } from './create-or-update-transaction-
FormsModule, FormsModule,
MatAutocompleteModule, MatAutocompleteModule,
MatButtonModule, MatButtonModule,
MatChipsModule,
MatDatepickerModule, MatDatepickerModule,
MatDialogModule, MatDialogModule,
MatFormFieldModule, MatFormFieldModule,

View File

@ -7,6 +7,7 @@ import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interf
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto'; import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component'; import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { IcsService } from '@ghostfolio/client/services/ics/ics.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { ImportTransactionsService } from '@ghostfolio/client/services/import-transactions.service'; import { ImportTransactionsService } from '@ghostfolio/client/services/import-transactions.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
@ -50,6 +51,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
private dataService: DataService, private dataService: DataService,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private dialog: MatDialog, private dialog: MatDialog,
private icsService: IcsService,
private impersonationStorageService: ImpersonationStorageService, private impersonationStorageService: ImpersonationStorageService,
private importTransactionsService: ImportTransactionsService, private importTransactionsService: ImportTransactionsService,
private route: ActivatedRoute, private route: ActivatedRoute,
@ -152,14 +154,36 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
.fetchExport(activityIds) .fetchExport(activityIds)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => { .subscribe((data) => {
downloadAsFile( for (const activity of data.activities) {
data, delete activity.id;
`ghostfolio-export-${format( }
downloadAsFile({
content: data,
fileName: `ghostfolio-export-${format(
parseISO(data.meta.date), parseISO(data.meta.date),
'yyyyMMddHHmm' 'yyyyMMddHHmm'
)}.json`, )}.json`,
'text/plain' format: 'json'
); });
});
}
public onExportDrafts(activityIds?: string[]) {
this.dataService
.fetchExport(activityIds)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
downloadAsFile({
content: this.icsService.transformActivitiesToIcsContent(
data.activities
),
contentType: 'text/calendar',
fileName: `ghostfolio-draft${
data.activities.length > 1 ? 's' : ''
}-${format(parseISO(data.meta.date), 'yyyyMMddHHmmss')}.ics`,
format: 'string'
});
}); });
} }

View File

@ -15,6 +15,7 @@
(activityToClone)="onCloneTransaction($event)" (activityToClone)="onCloneTransaction($event)"
(activityToUpdate)="onUpdateTransaction($event)" (activityToUpdate)="onUpdateTransaction($event)"
(export)="onExport($event)" (export)="onExport($event)"
(exportDrafts)="onExportDrafts($event)"
(import)="onImport()" (import)="onImport()"
></gf-activities-table> ></gf-activities-table>
</div> </div>

View File

@ -119,7 +119,7 @@
Ghostfolio empowers you to keep track of your wealth. Ghostfolio empowers you to keep track of your wealth.
</p> </p>
<div class="py-2 text-center"> <div class="py-2 text-center">
<a color="primary" i18n mat-flat-button [routerLink]="['/']"> <a color="primary" href="https://ghostfol.io" i18n mat-flat-button>
Get Started Get Started
</a> </a>
</div> </div>

View File

@ -1,105 +1,117 @@
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<h3 class="d-flex justify-content-center mb-3" i18n>Resources</h3> <h1 class="d-flex h3 justify-content-center mb-3" i18n>Resources</h1>
<mat-card class="mb-3"> <h2 class="h4 mb-3">Guides</h2>
<mat-card-content> <div class="mb-5">
<h4 class="mb-3">Market</h4> <div class="mb-4 media">
<div class="mb-5"> <div class="media-body">
<div class="mb-4 media"> <h3 class="h5 mt-0">Boringly Getting Rich</h3>
<div class="media-body"> <div class="mb-1">
<h5 class="mt-0">Fear & Greed Index</h5> The <i>Boringly Getting Rich</i> guide supports you to get started
<div class="mb-1"> with investing. It introduces a strategy utilizing a broadly
The fear and greed index was developed by <i>CNNMoney</i> to diversified, low-cost portfolio excluding the risks of individual
measure the primary emotions (fear and greed) that influence stocks.
how much investors are willing to pay for stocks.
</div>
<div>
<a
href="https://money.cnn.com/data/fear-and-greed/"
target="_blank"
>Fear & Greed Index →</a
>
</div>
</div>
</div> </div>
<div class="media"> <div>
<div class="media-body"> <a href="https://herget.me/investing-guide" target="_blank"
<h5 class="mt-0">Inflation Chart</h5> >Boringly Getting Rich →</a
<div class="mb-1"> >
Inflation Chart helps you find the intrinsic value of stock
markets, stock prices, goods and services by adjusting them to
the amount of the money supply (M0, M1, M2) or price of other
goods (food or oil).
</div>
<div>
<a href="https://inflationchart.com" target="_blank"
>Inflation Chart →</a
>
</div>
</div>
</div> </div>
</div> </div>
<h4 class="mb-3">Glossary</h4> </div>
<div> </div>
<div class="mb-4 media"> <h2 class="h4 mb-3">Market</h2>
<!--<img src="" class="mr-3" />--> <div class="mb-5">
<div class="media-body"> <div class="mb-4 media">
<h5 class="mt-0">Buy and Hold</h5> <div class="media-body">
<div class="mb-1"> <h3 class="h5 mt-0">Fear & Greed Index</h3>
Buy and hold is a passive investment strategy where you buy <div class="mb-1">
assets and hold them for a long period regardless of The fear and greed index was developed by <i>CNNMoney</i> to
fluctuations in the market. measure the primary emotions (fear and greed) that influence how
</div> much investors are willing to pay for stocks.
<div>
<a
href="https://www.investopedia.com/terms/b/buyandhold.asp"
target="_blank"
>Buy and Hold →</a
>
</div>
</div>
</div> </div>
<div class="mb-4 media"> <div>
<!--<img src="" class="mr-3" />--> <a
<div class="media-body"> href="https://money.cnn.com/data/fear-and-greed/"
<h5 class="mt-0">Dollar-Cost Averaging (DCA)</h5> target="_blank"
<div class="mb-1"> >Fear & Greed Index →</a
Dollar-cost averaging is an investment strategy where you >
split the total amount to be invested across periodic
purchases of a target asset to reduce the impact of volatility
on the overall purchase.
</div>
<div>
<a
href="https://www.investopedia.com/terms/d/dollarcostaveraging.asp"
target="_blank"
>Dollar-Cost Averaging →</a
>
</div>
</div>
</div>
<div class="media">
<!--<img src="" class="mr-3" />-->
<div class="media-body">
<h5 class="mt-0">Financial Independence</h5>
<div class="mb-1">
Financial independence is the status of having enough income,
for example with a passive income like dividends, to cover
your living expenses for the rest of your life.
</div>
<div>
<a
href="https://en.wikipedia.org/wiki/Financial_independence"
target="_blank"
>Financial Independence →</a
>
</div>
</div>
</div> </div>
</div> </div>
</mat-card-content> </div>
</mat-card> <div class="media">
<div class="media-body">
<h3 class="h5 mt-0">Inflation Chart</h3>
<div class="mb-1">
Inflation Chart helps you find the intrinsic value of stock
markets, stock prices, goods and services by adjusting them to the
amount of the money supply (M0, M1, M2) or price of other goods
(food or oil).
</div>
<div>
<a href="https://inflationchart.com" target="_blank"
>Inflation Chart →</a
>
</div>
</div>
</div>
</div>
<h2 class="h4 mb-3">Glossary</h2>
<div>
<div class="mb-4 media">
<div class="media-body">
<h3 class="h5 mt-0">Buy and Hold</h3>
<div class="mb-1">
Buy and hold is a passive investment strategy where you buy assets
and hold them for a long period regardless of fluctuations in the
market.
</div>
<div>
<a
href="https://www.investopedia.com/terms/b/buyandhold.asp"
target="_blank"
>Buy and Hold →</a
>
</div>
</div>
</div>
<div class="mb-4 media">
<div class="media-body">
<h3 class="h5 mt-0">Dollar-Cost Averaging (DCA)</h3>
<div class="mb-1">
Dollar-cost averaging is an investment strategy where you split
the total amount to be invested across periodic purchases of a
target asset to reduce the impact of volatility on the overall
purchase.
</div>
<div>
<a
href="https://www.investopedia.com/terms/d/dollarcostaveraging.asp"
target="_blank"
>Dollar-Cost Averaging →</a
>
</div>
</div>
</div>
<div class="media">
<div class="media-body">
<h3 class="h5 mt-0">Financial Independence</h3>
<div class="mb-1">
Financial independence is the status of having enough income, for
example with a passive income like dividends, to cover your living
expenses for the rest of your life.
</div>
<div>
<a
href="https://en.wikipedia.org/wiki/Financial_independence"
target="_blank"
>Financial Independence →</a
>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -2,14 +2,12 @@
color: rgb(var(--dark-primary-text)); color: rgb(var(--dark-primary-text));
display: block; display: block;
.mat-card { a {
a { color: rgba(var(--palette-primary-500), 1);
color: rgba(var(--palette-primary-500), 1); font-weight: 500;
font-weight: 500;
&:hover { &:hover {
color: rgba(var(--palette-primary-300), 1); color: rgba(var(--palette-primary-300), 1);
}
} }
} }
} }

View File

@ -19,6 +19,7 @@ import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
Export, Export,
Filter,
InfoItem, InfoItem,
PortfolioChart, PortfolioChart,
PortfolioDetails, PortfolioDetails,
@ -33,7 +34,7 @@ import { permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types'; import { DateRange } from '@ghostfolio/common/types';
import { DataSource, Order as OrderModel } from '@prisma/client'; import { DataSource, Order as OrderModel } from '@prisma/client';
import { parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
import { cloneDeep } from 'lodash'; import { cloneDeep, groupBy } from 'lodash';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
@ -182,9 +183,42 @@ export class DataService {
); );
} }
public fetchPortfolioDetails(aParams: { [param: string]: any }) { public fetchPortfolioDetails({ filters }: { filters?: Filter[] }) {
let params = new HttpParams();
if (filters?.length > 0) {
const { account: filtersByAccount, tag: filtersByTag } = groupBy(
filters,
(filter) => {
return filter.type;
}
);
if (filtersByAccount) {
params = params.append(
'accounts',
filtersByAccount
.map(({ id }) => {
return id;
})
.join(',')
);
}
if (filtersByTag) {
params = params.append(
'tags',
filtersByTag
.map(({ id }) => {
return id;
})
.join(',')
);
}
}
return this.http.get<PortfolioDetails>('/api/v1/portfolio/details', { return this.http.get<PortfolioDetails>('/api/v1/portfolio/details', {
params: aParams params
}); });
} }

View File

@ -0,0 +1,59 @@
import { Injectable } from '@angular/core';
import { capitalize } from '@ghostfolio/common/helper';
import { Export } from '@ghostfolio/common/interfaces';
import { Type } from '@prisma/client';
import { format, parseISO } from 'date-fns';
@Injectable({
providedIn: 'root'
})
export class IcsService {
private readonly ICS_DATE_FORMAT = 'yyyyMMdd';
private readonly ICS_LINE_BREAK = '\r\n';
public constructor() {}
public transformActivitiesToIcsContent(
aActivities: Export['activities']
): string {
const header = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Ghostfolio//NONSGML v1.0//EN'
];
const events = aActivities.map((activity) => {
return this.getEvent({
date: parseISO(activity.date),
id: activity.id,
symbol: activity.symbol,
type: activity.type
});
});
const footer = ['END:VCALENDAR'];
return [...header, ...events, ...footer].join(this.ICS_LINE_BREAK);
}
private getEvent({
date,
id,
symbol,
type
}: {
date: Date;
id: string;
symbol: string;
type: Type;
}) {
const today = format(new Date(), this.ICS_DATE_FORMAT);
return [
'BEGIN:VEVENT',
`UID:${id}`,
`DTSTAMP:${today}T000000`,
`DTSTART;VALUE=DATE:${format(date, this.ICS_DATE_FORMAT)}`,
`SUMMARY:${capitalize(type)} ${symbol}`,
'END:VEVENT'
].join(this.ICS_LINE_BREAK);
}
}

View File

@ -3,7 +3,7 @@ import { Injectable } from '@angular/core';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Account, DataSource, Type } from '@prisma/client'; import { Account, DataSource, Type } from '@prisma/client';
import { parse } from 'date-fns'; import { parse } from 'date-fns';
import { isNumber } from 'lodash'; import { isFinite } from 'lodash';
import { parse as csvToJson } from 'papaparse'; import { parse as csvToJson } from 'papaparse';
import { EMPTY } from 'rxjs'; import { EMPTY } from 'rxjs';
import { catchError } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
@ -185,7 +185,7 @@ export class ImportTransactionsService {
item = this.lowercaseKeys(item); item = this.lowercaseKeys(item);
for (const key of ImportTransactionsService.FEE_KEYS) { for (const key of ImportTransactionsService.FEE_KEYS) {
if ((item[key] || item[key] === 0) && isNumber(item[key])) { if (isFinite(item[key])) {
return item[key]; return item[key];
} }
} }
@ -208,7 +208,7 @@ export class ImportTransactionsService {
item = this.lowercaseKeys(item); item = this.lowercaseKeys(item);
for (const key of ImportTransactionsService.QUANTITY_KEYS) { for (const key of ImportTransactionsService.QUANTITY_KEYS) {
if (item[key] && isNumber(item[key])) { if (isFinite(item[key])) {
return item[key]; return item[key];
} }
} }
@ -288,7 +288,7 @@ export class ImportTransactionsService {
item = this.lowercaseKeys(item); item = this.lowercaseKeys(item);
for (const key of ImportTransactionsService.UNIT_PRICE_KEYS) { for (const key of ImportTransactionsService.UNIT_PRICE_KEYS) {
if (item[key] && isNumber(item[key])) { if (isFinite(item[key])) {
return item[key]; return item[key];
} }
} }

View File

@ -5,5 +5,6 @@
"types": ["node"], "types": ["node"],
"typeRoots": ["../node_modules/@types"] "typeRoots": ["../node_modules/@types"]
}, },
"files": ["src/main.ts", "src/polyfills.ts"] "files": ["src/main.ts", "src/polyfills.ts"],
"exclude": ["jest.config.ts"]
} }

View File

@ -3,5 +3,6 @@
"include": ["**/*.ts"], "include": ["**/*.ts"],
"compilerOptions": { "compilerOptions": {
"types": ["jest", "node"] "types": ["jest", "node"]
} },
"exclude": ["jest.config.ts"]
} }

View File

@ -6,5 +6,5 @@
"types": ["jest", "node"] "types": ["jest", "node"]
}, },
"files": ["src/test-setup.ts"], "files": ["src/test-setup.ts"],
"include": ["**/*.spec.ts", "**/*.test.ts", "**/*.d.ts"] "include": ["**/*.spec.ts", "**/*.test.ts", "**/*.d.ts", "jest.config.ts"]
} }

View File

@ -1,6 +1,6 @@
module.exports = { module.exports = {
displayName: 'common', displayName: 'common',
preset: '../../jest.preset.js',
globals: { globals: {
'ts-jest': { tsconfig: '<rootDir>/tsconfig.spec.json' } 'ts-jest': { tsconfig: '<rootDir>/tsconfig.spec.json' }
}, },
@ -8,5 +8,6 @@ module.exports = {
'^.+\\.[tj]sx?$': 'ts-jest' '^.+\\.[tj]sx?$': 'ts-jest'
}, },
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
coverageDirectory: '../../coverage/libs/common' coverageDirectory: '../../coverage/libs/common',
preset: '../../jest.preset.ts'
}; };

View File

@ -44,8 +44,12 @@ export const warnColorRgb = {
export const ASSET_SUB_CLASS_EMERGENCY_FUND = 'EMERGENCY_FUND'; export const ASSET_SUB_CLASS_EMERGENCY_FUND = 'EMERGENCY_FUND';
export const DATA_GATHERING_QUEUE = 'DATA_GATHERING_QUEUE';
export const DEFAULT_DATE_FORMAT_MONTH_YEAR = 'MMM yyyy'; export const DEFAULT_DATE_FORMAT_MONTH_YEAR = 'MMM yyyy';
export const GATHER_ASSET_PROFILE_PROCESS = 'GATHER_ASSET_PROFILE';
export const PROPERTY_COUPONS = 'COUPONS'; export const PROPERTY_COUPONS = 'COUPONS';
export const PROPERTY_CURRENCIES = 'CURRENCIES'; export const PROPERTY_CURRENCIES = 'CURRENCIES';
export const PROPERTY_IS_READ_ONLY_MODE = 'IS_READ_ONLY_MODE'; export const PROPERTY_IS_READ_ONLY_MODE = 'IS_READ_ONLY_MODE';

View File

@ -12,17 +12,28 @@ export function decodeDataSource(encodedDataSource: string) {
return Buffer.from(encodedDataSource, 'hex').toString(); return Buffer.from(encodedDataSource, 'hex').toString();
} }
export function downloadAsFile( export function downloadAsFile({
aContent: unknown, content,
aFileName: string, contentType = 'text/plain',
aContentType: string fileName,
) { format
}: {
content: unknown;
contentType?: string;
fileName: string;
format: 'json' | 'string';
}) {
const a = document.createElement('a'); const a = document.createElement('a');
const file = new Blob([JSON.stringify(aContent, undefined, ' ')], {
type: aContentType if (format === 'json') {
content = JSON.stringify(content, undefined, ' ');
}
const file = new Blob([<string>content], {
type: contentType
}); });
a.href = URL.createObjectURL(file); a.href = URL.createObjectURL(file);
a.download = aFileName; a.download = fileName;
a.click(); a.click();
} }

View File

@ -5,5 +5,14 @@ export interface Export {
date: string; date: string;
version: string; version: string;
}; };
activities: Partial<Order>[]; activities: (Omit<
Order,
| 'accountUserId'
| 'createdAt'
| 'date'
| 'isDraft'
| 'symbolProfileId'
| 'updatedAt'
| 'userId'
> & { date: string; symbol: string })[];
} }

View File

@ -0,0 +1,5 @@
export interface Filter {
id: string;
label?: string;
type: 'account' | 'tag';
}

View File

@ -0,0 +1,6 @@
export interface HistoricalDataItem {
averagePrice?: number;
date: string;
grossPerformancePercent?: number;
value: number;
}

View File

@ -8,6 +8,8 @@ import {
} from './admin-market-data.interface'; } from './admin-market-data.interface';
import { Coupon } from './coupon.interface'; import { Coupon } from './coupon.interface';
import { Export } from './export.interface'; import { Export } from './export.interface';
import { Filter } from './filter.interface';
import { HistoricalDataItem } from './historical-data-item.interface';
import { InfoItem } from './info-item.interface'; import { InfoItem } from './info-item.interface';
import { PortfolioChart } from './portfolio-chart.interface'; import { PortfolioChart } from './portfolio-chart.interface';
import { PortfolioDetails } from './portfolio-details.interface'; import { PortfolioDetails } from './portfolio-details.interface';
@ -38,6 +40,8 @@ export {
AdminMarketDataItem, AdminMarketDataItem,
Coupon, Coupon,
Export, Export,
Filter,
HistoricalDataItem,
InfoItem, InfoItem,
PortfolioChart, PortfolioChart,
PortfolioDetails, PortfolioDetails,

View File

@ -1,3 +1,5 @@
import { Tag } from '@prisma/client';
import { Statistics } from './statistics.interface'; import { Statistics } from './statistics.interface';
import { Subscription } from './subscription.interface'; import { Subscription } from './subscription.interface';
@ -13,4 +15,5 @@ export interface InfoItem {
stripePublicKey?: string; stripePublicKey?: string;
subscriptions: Subscription[]; subscriptions: Subscription[];
systemMessage?: string; systemMessage?: string;
tags: Tag[];
} }

View File

@ -1,4 +1,4 @@
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface'; import { HistoricalDataItem } from './historical-data-item.interface';
export interface PortfolioChart { export interface PortfolioChart {
hasError: boolean; hasError: boolean;

View File

@ -1,7 +1,6 @@
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client'; import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
import { Market } from '../types'; import { Market, MarketState } from '../types';
import { Country } from './country.interface'; import { Country } from './country.interface';
import { Sector } from './sector.interface'; import { Sector } from './sector.interface';

View File

@ -1,5 +1,5 @@
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
import { AssetClass, DataSource } from '@prisma/client'; import { AssetClass, DataSource } from '@prisma/client';
import { MarketState } from '../types';
export interface Position { export interface Position {
assetClass: AssetClass; assetClass: AssetClass;

View File

@ -1,10 +1,12 @@
import { Access } from '@ghostfolio/api/app/user/interfaces/access.interface'; import { Account, Tag } from '@prisma/client';
import { Account } from '@prisma/client';
import { UserSettings } from './user-settings.interface'; import { UserSettings } from './user-settings.interface';
export interface User { export interface User {
access: Access[]; access: {
alias?: string;
id: string;
}[];
accounts: Account[]; accounts: Account[];
alias?: string; alias?: string;
id: string; id: string;
@ -14,4 +16,5 @@ export interface User {
expiresAt?: Date; expiresAt?: Date;
type: 'Basic' | 'Premium'; type: 'Basic' | 'Premium';
}; };
tags: Tag[];
} }

View File

@ -2,6 +2,7 @@ import type { AccessWithGranteeUser } from './access-with-grantee-user.type';
import { AccountWithValue } from './account-with-value.type'; import { AccountWithValue } from './account-with-value.type';
import type { DateRange } from './date-range.type'; import type { DateRange } from './date-range.type';
import type { Granularity } from './granularity.type'; import type { Granularity } from './granularity.type';
import { MarketState } from './market-state-type';
import { Market } from './market.type'; import { Market } from './market.type';
import type { OrderWithAccount } from './order-with-account.type'; import type { OrderWithAccount } from './order-with-account.type';
import type { RequestWithUser } from './request-with-user.type'; import type { RequestWithUser } from './request-with-user.type';
@ -13,6 +14,7 @@ export type {
DateRange, DateRange,
Granularity, Granularity,
Market, Market,
MarketState,
OrderWithAccount, OrderWithAccount,
RequestWithUser, RequestWithUser,
ToggleOption ToggleOption

View File

@ -0,0 +1 @@
export type MarketState = 'closed' | 'delayed' | 'open';

View File

@ -1,8 +1,9 @@
import { Account, Order, Platform, SymbolProfile } from '@prisma/client'; import { Account, Order, Platform, SymbolProfile, Tag } from '@prisma/client';
type AccountWithPlatform = Account & { Platform?: Platform }; type AccountWithPlatform = Account & { Platform?: Platform };
export type OrderWithAccount = Order & { export type OrderWithAccount = Order & {
Account?: AccountWithPlatform; Account?: AccountWithPlatform;
SymbolProfile?: SymbolProfile; SymbolProfile?: SymbolProfile;
tags?: Tag[];
}; };

View File

@ -5,5 +5,5 @@
"types": [] "types": []
}, },
"include": ["**/*.ts"], "include": ["**/*.ts"],
"exclude": ["**/*.spec.ts", "**/*.test.ts"] "exclude": ["**/*.spec.ts", "**/*.test.ts", "jest.config.ts"]
} }

View File

@ -14,6 +14,7 @@
"**/*.test.js", "**/*.test.js",
"**/*.spec.jsx", "**/*.spec.jsx",
"**/*.test.jsx", "**/*.test.jsx",
"**/*.d.ts" "**/*.d.ts",
"jest.config.ts"
] ]
} }

View File

@ -3,6 +3,6 @@
"compilerOptions": { "compilerOptions": {
"emitDecoratorMetadata": true "emitDecoratorMetadata": true
}, },
"exclude": ["../**/*.spec.ts", "../**/*.test.ts"], "exclude": ["../**/*.spec.ts", "../**/*.test.ts", "jest.config.ts"],
"include": ["../src/**/*", "*.js"] "include": ["../src/**/*", "*.js"]
} }

View File

@ -1,6 +1,6 @@
module.exports = { module.exports = {
displayName: 'ui', displayName: 'ui',
preset: '../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'], setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
globals: { globals: {
'ts-jest': { 'ts-jest': {
@ -17,5 +17,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'
] ],
preset: '../../jest.preset.ts'
}; };

View File

@ -0,0 +1,38 @@
<mat-form-field appearance="outline" class="w-100">
<ion-icon class="mr-1" matPrefix name="search-outline"></ion-icon>
<mat-chip-list #chipList aria-label="Search keywords">
<mat-chip
*ngFor="let filter of selectedFilters"
class="mx-1 my-0 px-2 py-0"
matChipRemove
[removable]="true"
(removed)="onRemoveFilter(filter)"
>
{{ filter.label | gfSymbol }}
<ion-icon class="ml-2" matPrefix name="close-outline"></ion-icon>
</mat-chip>
<input
#searchInput
name="close-outline"
[formControl]="searchControl"
[matAutocomplete]="autocomplete"
[matChipInputFor]="chipList"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
[placeholder]="placeholder"
(matChipInputTokenEnd)="onAddFilter($event)"
/>
</mat-chip-list>
<mat-autocomplete
#autocomplete="matAutocomplete"
(optionSelected)="onSelectFilter($event)"
>
<mat-option *ngFor="let filter of filters | async" [value]="filter">
{{ filter.label | gfSymbol }}
</mat-option>
</mat-autocomplete>
<mat-spinner
matSuffix
[diameter]="20"
[ngClass]="{ 'd-none': !isLoading }"
></mat-spinner>
</mat-form-field>

View File

@ -0,0 +1,36 @@
@import '~apps/client/src/styles/ghostfolio-style';
:host {
display: block;
::ng-deep {
.mat-form-field-infix {
border-top: 0 solid transparent !important;
}
.mat-spinner {
circle {
stroke: rgba(var(--dark-dividers));
}
}
}
.mat-chip {
cursor: pointer;
min-height: 1.5rem !important;
}
}
:host-context(.is-dark-theme) {
.mat-form-field {
color: rgba(var(--light-primary-text));
}
::ng-deep {
.mat-spinner {
circle {
stroke: rgba(var(--light-dividers));
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More