Compare commits

...

51 Commits

Author SHA1 Message Date
48f6b8d353 Release 1.111.0 (#671) 2022-02-03 21:00:53 +01:00
f369996912 Bugfix/fix symbol selection of 7d data gathering (#670)
* Fix symbol selection of 7d data gathering

* Update changelog
2022-02-03 20:58:59 +01:00
dc424a86ec Feature/support deleting symbol profile data (#669)
* Add support for deleting symbol profile data

* Update changelog
2022-02-03 20:56:39 +01:00
5d8bde5a70 Feature/access data source and symbol from symbol profile (#668)
* Access dataSource and symbol from SymbolProfile

* Update changelog
2022-02-03 19:21:55 +01:00
16360c0c67 Feature/minor code cleanup (#667)
* Sort imports

* Update changelog
2022-02-02 22:06:34 +01:00
526a6b2030 Release 1.110.0 (#665) 2022-02-02 20:31:23 +01:00
5000e9c79b Feature/update database schema of order (#664)
* Add schema migrations

* Update changelog
2022-02-02 20:29:19 +01:00
161cb82820 Bugfix/fix data source of fear and greed index (#663)
* Encode data source

* Update changelog
2022-02-02 20:07:33 +01:00
fed28f29d1 Release 1.109.0 (#662) 2022-02-01 21:05:14 +01:00
8bd9330acc Feature/improve usability of create or edit transaction dialog (#661)
* Move the fee to the bottom

* Update changelog
2022-02-01 20:35:44 +01:00
155c08d665 Transform data source (#658)
* Transform data source

* Update changelog
2022-02-01 20:35:25 +01:00
b8ad6d6662 Feature/improve import (#657)
* Improve import

* Update changelog

Co-Authored-By: Ronald Konjer <ronaldkonjer@gmail.com>
2022-02-01 19:12:00 +01:00
9d6977e3f7 Feature/support cryptocurrency mina protocol (#659)
* Support Mina Protocol (MINA-USD)

* Update changelog
2022-02-01 10:58:34 +01:00
919b20197f import csv with account name or id (#654)
* import csv with account id
2022-01-29 17:27:33 +01:00
62885ea890 Feature/improve consistent use of symbol with data source (#656)
* Improve the consistent use of symbol with dataSource

* Update changelog
2022-01-29 16:51:37 +01:00
035d8ad9eb Update copyright (#655) 2022-01-29 09:15:11 +01:00
9676f96e97 Release 1.108.0 (#653) 2022-01-27 21:33:59 +01:00
65e151151b Feature/increase fear and greed index to 90 days (#652)
* Increase fear and greed index to 90 days

* Update changelog
2022-01-27 21:01:38 +01:00
5d3bbb8f30 Feature/improve annualized performance (#651)
* Improve annualized performance calculation

* Update changelog
2022-01-27 20:56:20 +01:00
b464fefc57 Release 1.107.0 (#650) 2022-01-24 21:43:37 +01:00
bcb7f5f522 Feature/add feature toggle for new calculation engine (#649)
* Add feature toggle for new calculation engine

* Update changelog
2022-01-24 21:38:59 +01:00
f15b33e950 Portfolio calculator rework (#632)
* Portfolio calculator rework

Co-authored-by: Reto Kaul <retokaul@sublimd.com>
2022-01-24 20:35:13 +01:00
ca64492e77 Bugfix/fix styling in activities table footer (#648)
* Fix styling

* Update changelog
2022-01-24 20:12:54 +01:00
761376d72d Release 1.106.0 (#647) 2022-01-23 17:41:54 +01:00
9c086edffe Feature/extend historical data view in admin control (#646)
* Extend market data view

* Update changelog
2022-01-23 17:02:12 +01:00
585f99e4df Feature/add summary row to activities table (#645)
* Add summary row to activities table

* Update changelog
2022-01-23 11:39:30 +01:00
9d907b5eb5 Bugfix/improve the redirection on logout (#642)
* Improve logout

* Update changelog
2022-01-22 09:38:01 +01:00
ba05f5ba30 Feature/upgrade prisma to version 3.8.1 (#640)
* Upgrade prisma to version 3.8.1

* Update changelog
2022-01-22 09:36:58 +01:00
3261e3ee59 Feature/upgrade stripe dependencies (#641)
* Upgrade stripe dependencies

* Update changelog
2022-01-21 20:30:41 +01:00
5607c6bb52 Update blog url (#639) 2022-01-21 20:07:56 +01:00
1c6050d3e3 Release 1.105.0 (#638) 2022-01-20 21:35:56 +01:00
38f2930ec6 Feature/improve data provider service (#637)
* Improve data provider service

* Update changelog
2022-01-20 21:34:23 +01:00
556be61fff Bugfix/fix unresolved account names in reports (#636)
* Fix unresolved account names

* Update changelog
2022-01-19 21:28:15 +01:00
651b4bcff7 Release 1.104.0 (#631) 2022-01-16 15:45:28 +01:00
0a8d159f78 Bugfix/fix missing symbol profile data connection in import (#630)
* Fix missing symbol profile data connection in import

* Update changelog
2022-01-16 15:31:56 +01:00
1a4109ebaa Bugfix/fix fallback to load currencies directly from data provider (#629)
* Fix fallback

* Update changelog
2022-01-16 13:46:00 +01:00
92e502e1c2 Release 1.103.0 (#628) 2022-01-13 20:33:31 +01:00
e344c43a5a Bugfix/fix currency of value in position detail dialog (#627)
* Fix currency

* Update changelog
2022-01-13 20:25:21 +01:00
d6b78f3457 Feature/add links to statistics section (#626)
* Add links and clean up style

* Update changelog
2022-01-13 19:07:23 +01:00
9bbb856f66 Release 1.102.0 (#625) 2022-01-11 19:53:23 +01:00
d3707bbb87 Bugfix/fix preselected default account in create activity dialog (#624)
* Fix preselected default account

* Update changelog
2022-01-11 19:50:22 +01:00
7df53896f3 Feature/start eliminating data source from order (#622)
* Start eliminating dataSource from order

* Update changelog
2022-01-11 19:49:45 +01:00
b2b3fde80e Bugfix/support multiple accounts with the same name (#623)
* Support multiple accounts with the same name

* Update changelog
2022-01-10 21:23:47 +01:00
a83441b3ba Release 1.101.0 (#621) 2022-01-08 18:21:33 +01:00
075431d868 Feature/add google sheets as data source (#620)
* Add google sheets as data source

* Update changelog
2022-01-08 18:19:25 +01:00
0168c1c4e8 Feature/exclude url pattern of shared portfolios in robots.txt (#619)
* Exclude shared portfolios

* Update changelog
2022-01-08 09:37:54 +01:00
07de8f87fc Set market prices explicitly (#618)
* Set market prices explicitly

* Set comments explicitly
2022-01-07 08:09:12 +01:00
3e16041c16 Release 1.100.0 (#617) 2022-01-05 20:24:34 +01:00
5882b7914d Feature/add first months in open source blog post (#616)
* Add blog post

* Update changelog
2022-01-05 20:22:59 +01:00
69c9e259b1 Bugfix/fix routing of create activity dialog (#615)
* Fix routing of create activity dialog

* Update changelog
2022-01-03 21:31:55 +01:00
aca37a27f9 Feature/add top performers to analysis page (#613)
* Add Top 3 / Bottom 3 performers

* Update changelog
2022-01-02 13:29:45 +01:00
126 changed files with 4732 additions and 1033 deletions

View File

@ -5,6 +5,151 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 1.111.0 - 03.02.2022
### Added
- Added support for deleting symbol profile data in the admin control panel
### Changed
- Used `dataSource` and `symbol` from `SymbolProfile` instead of the `order` object (in `ExportService` and `PortfolioService`)
### Fixed
- Fixed the symbol selection of the 7d data gathering
## 1.110.0 - 02.02.2022
### Fixed
- Fixed the data source of the _Fear & Greed Index_ (market mood)
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.109.0 - 01.02.2022
### Added
- Added support for the (optional) `accountId` in the import functionality for activities
- Added support for the (optional) `dataSource` in the import functionality for activities
- Added support for the data source transformation
- Added support for the cryptocurrency _Mina Protocol_ (`MINA-USD`)
### Changed
- Improved the usability of the form in the create or edit transaction dialog
- Improved the consistent use of `symbol` in combination with `dataSource`
- Removed the primary data source from the client
### Removed
- Removed the unused endpoint `GET api/order/:id`
## 1.108.0 - 27.01.2022
### Changed
- Improved the annualized performance in the new calculation engine
- Increased the historical data chart of the _Fear & Greed Index_ (market mood) to 90 days
## 1.107.0 - 24.01.2022
### Added
- Added a new calculation engine (experimental)
### Fixed
- Fixed the styling in the footer row of the activities table
## 1.106.0 - 23.01.2022
### Added
- Added the footer row with total fees and total value to the activities table
### Changed
- Extended the historical data view in the admin control panel
- Upgraded _Stripe_ dependencies
- Upgraded `prisma` from version `3.7.0` to `3.8.1`
### Fixed
- Improved the redirection on logout
## 1.105.0 - 20.01.2022
### Added
- Added support for fetching multiple symbols in the `GOOGLE_SHEETS` data provider
### Changed
- Improved the data provider with grouping by data source and thereby reducing the number of requests
### Fixed
- Fixed the unresolved account names in the _X-ray_ section
- Fixed the date conversion in the `GOOGLE_SHEETS` data provider
## 1.104.0 - 16.01.2022
### Fixed
- Fixed the fallback to load currencies directly from the data provider
- Fixed the missing symbol profile data connection in the import functionality for activities
## 1.103.0 - 13.01.2022
### Changed
- Added links to the statistics section on the about page
### Fixed
- Fixed the currency of the value in the position detail dialog
## 1.102.0 - 11.01.2022
### Changed
- Start eliminating `dataSource` from activity
### Fixed
- Fixed the support for multiple accounts with the same name
- Fixed the preselected default account of the create activity dialog
## 1.101.0 - 08.01.2022
### Added
- Added `GOOGLE_SHEETS` as a new data source type
### Changed
- Excluded the url pattern of shared portfolios in the `robots.txt` file
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.100.0 - 05.01.2022
### Added
- Added the _Top 3_ and _Bottom 3_ performers to the analysis page
- Added a blog post
### Fixed
- Fixed the routing of the create activity dialog
- Fixed the link color in the blog posts
## 1.99.0 - 01.01.2022
### Added
@ -98,7 +243,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added support for cryptocurrency _Solana_ (`SOL-USD`)
- Added support for the cryptocurrency _Solana_ (`SOL-USD`)
- Extended the documentation for self-hosting with the [official Ghostfolio Docker image](https://hub.docker.com/r/ghostfolio/ghostfolio)
### Fixed

View File

@ -12,7 +12,7 @@
<strong>Open Source Wealth Management Software made for Humans</strong>
</p>
<p>
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/en/blog/2021/07/hello-ghostfolio"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/blog"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
</p>
<p>
<a href="#contributing">
@ -187,6 +187,6 @@ Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Sl
## License
© 2021 [Ghostfolio](https://ghostfol.io)
© 2022 [Ghostfolio](https://ghostfol.io)
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).

View File

@ -1,4 +1,4 @@
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { PortfolioServiceStrategy } from '@ghostfolio/api/app/portfolio/portfolio-service.strategy';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import {
nullifyValuesInObject,
@ -35,7 +35,7 @@ export class AccountController {
public constructor(
private readonly accountService: AccountService,
private readonly impersonationService: ImpersonationService,
private readonly portfolioService: PortfolioService,
private readonly portfolioServiceStrategy: PortfolioServiceStrategy,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
) {}
@ -91,10 +91,9 @@ export class AccountController {
this.request.user.id
);
let accountsWithAggregations =
await this.portfolioService.getAccountsWithAggregations(
impersonationUserId || this.request.user.id
);
let accountsWithAggregations = await this.portfolioServiceStrategy
.get()
.getAccountsWithAggregations(impersonationUserId || this.request.user.id);
if (
impersonationUserId ||

View File

@ -11,6 +11,7 @@ import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Delete,
Get,
HttpException,
Inject,
@ -195,9 +196,10 @@ export class AdminController {
return this.adminService.getMarketData();
}
@Get('market-data/:symbol')
@Get('market-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
public async getMarketDataBySymbol(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<AdminMarketDataDetails> {
if (
@ -212,7 +214,7 @@ export class AdminController {
);
}
return this.adminService.getMarketDataBySymbol(symbol);
return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
}
@Put('market-data/:dataSource/:symbol/:dateString')
@ -248,6 +250,27 @@ export class AdminController {
});
}
@Delete('profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
public async deleteProfileData(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.adminService.deleteProfileData({ dataSource, symbol });
}
@Put('settings/:key')
@UseGuards(AuthGuard('jwt'))
public async updateProperty(

View File

@ -6,6 +6,7 @@ import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-d
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller';
@ -20,7 +21,8 @@ import { AdminService } from './admin.service';
MarketDataModule,
PrismaModule,
PropertyModule,
SubscriptionModule
SubscriptionModule,
SymbolProfileModule
],
controllers: [AdminController],
providers: [AdminService],

View File

@ -5,14 +5,16 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config';
import {
AdminData,
AdminMarketData,
AdminMarketDataDetails
AdminMarketDataDetails,
AdminMarketDataItem
} from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { Property } from '@prisma/client';
import { DataSource, Property } from '@prisma/client';
import { differenceInDays } from 'date-fns';
@Injectable()
@ -24,9 +26,21 @@ export class AdminService {
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService
private readonly subscriptionService: SubscriptionService,
private readonly symbolProfileService: SymbolProfileService
) {}
public async deleteProfileData({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
await this.marketDataService.deleteMany({ dataSource, symbol });
await this.symbolProfileService.delete({ dataSource, symbol });
}
public async get(): Promise<AdminData> {
return {
dataGatheringProgress:
@ -56,25 +70,85 @@ export class AdminService {
}
public async getMarketData(): Promise<AdminMarketData> {
return {
marketData: await (
await this.dataGatheringService.getSymbolsMax()
).map((symbol) => {
return symbol;
const marketData = await this.prismaService.marketData.groupBy({
_count: true,
by: ['dataSource', 'symbol']
});
const currencyPairsToGather: AdminMarketDataItem[] =
this.exchangeRateDataService
.getCurrencyPairs()
.map(({ dataSource, symbol }) => {
const marketDataItemCount =
marketData.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
return {
dataSource,
marketDataItemCount,
symbol
};
});
const symbolProfilesToGather: AdminMarketDataItem[] = (
await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }],
select: {
_count: {
select: { Order: true }
},
dataSource: true,
Order: {
orderBy: [{ date: 'asc' }],
select: { date: true },
take: 1
},
scraperConfiguration: true,
symbol: true
}
})
).map((symbolProfile) => {
const marketDataItemCount =
marketData.find((marketDataItem) => {
return (
marketDataItem.dataSource === symbolProfile.dataSource &&
marketDataItem.symbol === symbolProfile.symbol
);
})?._count ?? 0;
return {
marketDataItemCount,
activityCount: symbolProfile._count.Order,
dataSource: symbolProfile.dataSource,
date: symbolProfile.Order?.[0]?.date,
symbol: symbolProfile.symbol
};
});
return {
marketData: [...currencyPairsToGather, ...symbolProfilesToGather]
};
}
public async getMarketDataBySymbol(
aSymbol: string
): Promise<AdminMarketDataDetails> {
public async getMarketDataBySymbol({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}): Promise<AdminMarketDataDetails> {
return {
marketData: await this.marketDataService.marketDataItems({
orderBy: {
date: 'asc'
},
where: {
symbol: aSymbol
dataSource,
symbol
}
})
};

View File

@ -11,12 +11,13 @@ export class ExportService {
const orders = await this.prismaService.order.findMany({
orderBy: { date: 'desc' },
select: {
accountId: true,
currency: true,
dataSource: true,
date: true,
fee: true,
quantity: true,
symbol: true,
SymbolProfile: true,
type: true,
unitPrice: true
},
@ -25,7 +26,30 @@ export class ExportService {
return {
meta: { date: new Date().toISOString(), version: environment.version },
orders
orders: orders.map(
({
accountId,
currency,
date,
fee,
quantity,
SymbolProfile,
type,
unitPrice
}) => {
return {
accountId,
currency,
date,
fee,
quantity,
type,
unitPrice,
dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol
};
}
)
};
}
}

View File

@ -1,5 +1,5 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
@ -15,10 +15,11 @@ import { ImportService } from './import.service';
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
OrderModule,
PrismaModule,
RedisCacheModule
],
controllers: [ImportController],
providers: [CacheService, ImportService, OrderService]
providers: [CacheService, ImportService]
})
export class ImportModule {}

View File

@ -20,6 +20,11 @@ export class ImportService {
orders: Partial<Order>[];
userId: string;
}): Promise<void> {
for (const order of orders) {
order.dataSource =
order.dataSource ?? this.dataProviderService.getPrimaryDataSource();
}
await this.validateOrders({ orders, userId });
for (const {
@ -34,11 +39,7 @@ export class ImportService {
unitPrice
} of orders) {
await this.orderService.createOrder({
Account: {
connect: {
id_userId: { userId, id: accountId }
}
},
accountId,
currency,
dataSource,
fee,
@ -46,7 +47,22 @@ export class ImportService {
symbol,
type,
unitPrice,
userId,
date: parseISO(<string>(<unknown>date)),
SymbolProfile: {
connectOrCreate: {
create: {
dataSource,
symbol
},
where: {
dataSource_symbol: {
dataSource,
symbol
}
}
}
},
User: { connect: { id: userId } }
});
}

View File

@ -1,7 +1,6 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
@ -12,12 +11,14 @@ import {
PROPERTY_STRIPE_CONFIG,
PROPERTY_SYSTEM_MESSAGE
} from '@ghostfolio/common/config';
import { encodeDataSource } from '@ghostfolio/common/helper';
import { InfoItem } from '@ghostfolio/common/interfaces';
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
import { permissions } from '@ghostfolio/common/permissions';
import { Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { DataSource } from '@prisma/client';
import * as bent from 'bent';
import { subDays } from 'date-fns';
@ -27,7 +28,6 @@ export class InfoService {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly dataGatheringService: DataGatheringService,
private readonly jwtService: JwtService,
@ -51,6 +51,10 @@ export class InfoService {
globalPermissions.push(permissions.enableBlog);
}
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
info.fearAndGreedDataSource = encodeDataSource(DataSource.RAKUTEN);
}
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
globalPermissions.push(permissions.enableImport);
}
@ -92,7 +96,6 @@ export class InfoService {
currencies: this.exchangeRateDataService.getCurrencies(),
demoAuthToken: this.getDemoAuthToken(),
lastDataGathering: await this.getLastDataGathering(),
primaryDataSource: this.dataProviderService.getPrimaryDataSource(),
statistics: await this.getStatistics(),
subscriptions: await this.getSubscriptions()
};

View File

@ -1,14 +1,22 @@
import { DataSource, Type } from '@prisma/client';
import { IsEnum, IsISO8601, IsNumber, IsString } from 'class-validator';
import {
IsEnum,
IsISO8601,
IsNumber,
IsOptional,
IsString
} from 'class-validator';
export class CreateOrderDto {
@IsString()
@IsOptional()
accountId: string;
@IsString()
currency: string;
@IsEnum(DataSource, { each: true })
@IsOptional()
dataSource: DataSource;
@IsISO8601()

View File

@ -0,0 +1,10 @@
import { OrderWithAccount } from '@ghostfolio/common/types';
export interface Activities {
activities: Activity[];
}
export interface Activity extends OrderWithAccount {
feeInBaseCurrency: number;
valueInBaseCurrency: number;
}

View File

@ -1,5 +1,7 @@
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
@ -14,7 +16,8 @@ import {
Param,
Post,
Put,
UseGuards
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
@ -23,6 +26,7 @@ import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreateOrderDto } from './create-order.dto';
import { Activities } from './interfaces/activities.interface';
import { OrderService } from './order.service';
import { UpdateOrderDto } from './update-order.dto';
@ -57,16 +61,19 @@ export class OrderController {
@Get()
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAllOrders(
@Headers('impersonation-id') impersonationId
): Promise<OrderModel[]> {
): Promise<Activities> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
const userCurrency = this.request.user.Settings.currency;
let orders = await this.orderService.getOrders({
let activities = await this.orderService.getOrders({
userCurrency,
includeDrafts: true,
userId: impersonationUserId || this.request.user.id
});
@ -75,30 +82,22 @@ export class OrderController {
impersonationUserId ||
this.userService.isRestrictedView(this.request.user)
) {
orders = nullifyValuesInObjects(orders, [
activities = nullifyValuesInObjects(activities, [
'fee',
'feeInBaseCurrency',
'quantity',
'unitPrice',
'value'
'value',
'valueInBaseCurrency'
]);
}
return orders;
}
@Get(':id')
@UseGuards(AuthGuard('jwt'))
public async getOrderById(@Param('id') id: string): Promise<OrderModel> {
return this.orderService.order({
id_userId: {
id,
userId: this.request.user.id
}
});
return { activities };
}
@Post()
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> {
if (
!hasPermission(this.request.user.permissions, permissions.createOrder)
@ -109,39 +108,31 @@ export class OrderController {
);
}
const date = parseISO(data.date);
const accountId = data.accountId;
delete data.accountId;
return this.orderService.createOrder({
...data,
Account: {
connect: {
id_userId: { id: accountId, userId: this.request.user.id }
}
},
date,
date: parseISO(data.date),
SymbolProfile: {
connectOrCreate: {
create: {
dataSource: data.dataSource,
symbol: data.symbol
},
where: {
dataSource_symbol: {
dataSource: data.dataSource,
symbol: data.symbol
}
},
create: {
dataSource: data.dataSource,
symbol: data.symbol
}
}
},
User: { connect: { id: this.request.user.id } }
User: { connect: { id: this.request.user.id } },
userId: this.request.user.id
});
}
@Put(':id')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) {
if (
!hasPermission(this.request.user.permissions, permissions.updateOrder)

View File

@ -1,9 +1,11 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { Module } from '@nestjs/common';
@ -16,13 +18,14 @@ import { OrderService } from './order.service';
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
ImpersonationModule,
PrismaModule,
RedisCacheModule,
UserModule
],
controllers: [OrderController],
providers: [CacheService, OrderService],
providers: [AccountService, CacheService, OrderService],
exports: [OrderService]
})
export class OrderModule {}

View File

@ -1,5 +1,7 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
@ -7,10 +9,14 @@ import { DataSource, Order, Prisma, Type as TypeOfOrder } from '@prisma/client';
import Big from 'big.js';
import { endOfToday, isAfter } from 'date-fns';
import { Activity } from './interfaces/activities.interface';
@Injectable()
export class OrderService {
public constructor(
private readonly accountService: AccountService,
private readonly cacheService: CacheService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly dataGatheringService: DataGatheringService,
private readonly prismaService: PrismaService
) {}
@ -43,7 +49,24 @@ export class OrderService {
});
}
public async createOrder(data: Prisma.OrderCreateInput): Promise<Order> {
public async createOrder(
data: Prisma.OrderCreateInput & { accountId?: string; userId: string }
): Promise<Order> {
const defaultAccount = (
await this.accountService.getAccounts(data.userId)
).find((account) => {
return account.isDefault === true;
});
const Account = {
connect: {
id_userId: {
userId: data.userId,
id: data.accountId ?? defaultAccount?.id
}
}
};
const isDraft = isAfter(data.date as Date, endOfToday());
// Convert the symbol to uppercase to avoid case-sensitive duplicates
@ -66,9 +89,15 @@ export class OrderService {
await this.cacheService.flush();
delete data.accountId;
delete data.userId;
const orderData: Prisma.OrderCreateInput = data;
return this.prismaService.order.create({
data: {
...data,
...orderData,
Account,
isDraft,
symbol
}
@ -86,12 +115,14 @@ export class OrderService {
public async getOrders({
includeDrafts = false,
types,
userCurrency,
userId
}: {
includeDrafts?: boolean;
types?: TypeOfOrder[];
userCurrency: string;
userId: string;
}) {
}): Promise<Activity[]> {
const where: Prisma.OrderWhereInput = { userId };
if (includeDrafts === false) {
@ -124,12 +155,21 @@ export class OrderService {
orderBy: { date: 'asc' }
})
).map((order) => {
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
return {
...order,
value: new Big(order.quantity)
.mul(order.unitPrice)
.plus(order.fee)
.toNumber()
value,
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
order.fee,
order.currency,
userCurrency
),
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value,
order.currency,
userCurrency
)
};
});
}

View File

@ -85,19 +85,6 @@ describe('CurrentRateService', () => {
);
});
it('getValue', async () => {
expect(
await currentRateService.getValue({
currency: 'USD',
date: new Date(Date.UTC(2020, 0, 1, 0, 0, 0)),
symbol: 'AMZN',
userCurrency: 'CHF'
})
).toMatchObject({
marketPrice: 1847.839966
});
});
it('getValues', async () => {
expect(
await currentRateService.getValues({

View File

@ -7,7 +7,6 @@ import { isBefore, isToday } from 'date-fns';
import { flatten } from 'lodash';
import { GetValueObject } from './interfaces/get-value-object.interface';
import { GetValueParams } from './interfaces/get-value-params.interface';
import { GetValuesParams } from './interfaces/get-values-params.interface';
@Injectable()
@ -18,46 +17,6 @@ export class CurrentRateService {
private readonly marketDataService: MarketDataService
) {}
public async getValue({
currency,
date,
symbol,
userCurrency
}: GetValueParams): Promise<GetValueObject> {
if (isToday(date)) {
const dataProviderResult = await this.dataProviderService.get([
{
symbol,
dataSource: this.dataProviderService.getPrimaryDataSource()
}
]);
return {
symbol,
date: resetHours(date),
marketPrice: dataProviderResult?.[symbol]?.marketPrice ?? 0
};
}
const marketData = await this.marketDataService.get({
date,
symbol
});
if (marketData) {
return {
date: marketData.date,
marketPrice: this.exchangeRateDataService.toCurrency(
marketData.marketPrice,
currency,
userCurrency
),
symbol: marketData.symbol
};
}
throw new Error(`Value not found for ${symbol} at ${resetHours(date)}`);
}
public async getValues({
currencies,
dataGatheringItems,

View File

@ -6,7 +6,7 @@ export interface CurrentPositions {
positions: TimelinePosition[];
grossPerformance: Big;
grossPerformancePercentage: Big;
netAnnualizedPerformance: Big;
netAnnualizedPerformance?: Big;
netPerformance: Big;
netPerformancePercentage: Big;
currentValue: Big;

View File

@ -1,6 +0,0 @@
export interface GetValueParams {
currency: string;
date: Date;
symbol: string;
userCurrency: string;
}

View File

@ -0,0 +1,5 @@
import { PortfolioOrder } from './portfolio-order.interface';
export interface PortfolioOrderItem extends PortfolioOrder {
itemType?: '' | 'start' | 'end';
}

View File

@ -0,0 +1,73 @@
import Big from 'big.js';
import { CurrentRateService } from './current-rate.service';
import { PortfolioCalculatorNew } from './portfolio-calculator-new';
describe('PortfolioCalculatorNew', () => {
let currentRateService: CurrentRateService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null);
});
describe('annualized performance percentage', () => {
const portfolioCalculatorNew = new PortfolioCalculatorNew({
currentRateService,
currency: 'USD',
orders: []
});
it('Get annualized performance', async () => {
expect(
portfolioCalculatorNew
.getAnnualizedPerformancePercent({
daysInMarket: NaN, // differenceInDays of date-fns returns NaN for the same day
netPerformancePercent: new Big(0)
})
.toNumber()
).toEqual(0);
expect(
portfolioCalculatorNew
.getAnnualizedPerformancePercent({
daysInMarket: 0,
netPerformancePercent: new Big(0)
})
.toNumber()
).toEqual(0);
/**
* Source: https://www.readyratios.com/reference/analysis/annualized_rate.html
*/
expect(
portfolioCalculatorNew
.getAnnualizedPerformancePercent({
daysInMarket: 65, // < 1 year
netPerformancePercent: new Big(0.1025)
})
.toNumber()
).toBeCloseTo(0.729705);
expect(
portfolioCalculatorNew
.getAnnualizedPerformancePercent({
daysInMarket: 365, // 1 year
netPerformancePercent: new Big(0.05)
})
.toNumber()
).toBeCloseTo(0.05);
/**
* Source: https://www.investopedia.com/terms/a/annualized-total-return.asp#annualized-return-formula-and-calculation
*/
expect(
portfolioCalculatorNew
.getAnnualizedPerformancePercent({
daysInMarket: 575, // > 1 year
netPerformancePercent: new Big(0.2374)
})
.toNumber()
).toBeCloseTo(0.145);
});
});
});

View File

@ -0,0 +1,849 @@
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import { TimelinePosition } from '@ghostfolio/common/interfaces';
import { Logger } from '@nestjs/common';
import { Type as TypeOfOrder } from '@prisma/client';
import Big from 'big.js';
import {
addDays,
addMilliseconds,
addMonths,
addYears,
endOfDay,
format,
isAfter,
isBefore,
max,
min
} from 'date-fns';
import { first, flatten, isNumber, sortBy } from 'lodash';
import { CurrentRateService } from './current-rate.service';
import { CurrentPositions } from './interfaces/current-positions.interface';
import { GetValueObject } from './interfaces/get-value-object.interface';
import { PortfolioOrderItem } from './interfaces/portfolio-calculator.interface';
import { PortfolioOrder } from './interfaces/portfolio-order.interface';
import { TimelinePeriod } from './interfaces/timeline-period.interface';
import {
Accuracy,
TimelineSpecification
} from './interfaces/timeline-specification.interface';
import { TransactionPointSymbol } from './interfaces/transaction-point-symbol.interface';
import { TransactionPoint } from './interfaces/transaction-point.interface';
export class PortfolioCalculatorNew {
private currency: string;
private currentRateService: CurrentRateService;
private orders: PortfolioOrder[];
private transactionPoints: TransactionPoint[];
public constructor({
currency,
currentRateService,
orders
}: {
currency: string;
currentRateService: CurrentRateService;
orders: PortfolioOrder[];
}) {
this.currency = currency;
this.currentRateService = currentRateService;
this.orders = orders;
this.orders.sort((a, b) => a.date.localeCompare(b.date));
}
public computeTransactionPoints() {
this.transactionPoints = [];
const symbols: { [symbol: string]: TransactionPointSymbol } = {};
let lastDate: string = null;
let lastTransactionPoint: TransactionPoint = null;
for (const order of this.orders) {
const currentDate = order.date;
let currentTransactionPointItem: TransactionPointSymbol;
const oldAccumulatedSymbol = symbols[order.symbol];
const factor = this.getFactor(order.type);
const unitPrice = new Big(order.unitPrice);
if (oldAccumulatedSymbol) {
const newQuantity = order.quantity
.mul(factor)
.plus(oldAccumulatedSymbol.quantity);
currentTransactionPointItem = {
currency: order.currency,
dataSource: order.dataSource,
fee: order.fee.plus(oldAccumulatedSymbol.fee),
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
investment: newQuantity.eq(0)
? new Big(0)
: unitPrice
.mul(order.quantity)
.mul(factor)
.add(oldAccumulatedSymbol.investment),
quantity: newQuantity,
symbol: order.symbol,
transactionCount: oldAccumulatedSymbol.transactionCount + 1
};
} else {
currentTransactionPointItem = {
currency: order.currency,
dataSource: order.dataSource,
fee: order.fee,
firstBuyDate: order.date,
investment: unitPrice.mul(order.quantity).mul(factor),
quantity: order.quantity.mul(factor),
symbol: order.symbol,
transactionCount: 1
};
}
symbols[order.symbol] = currentTransactionPointItem;
const items = lastTransactionPoint?.items ?? [];
const newItems = items.filter(
(transactionPointItem) => transactionPointItem.symbol !== order.symbol
);
newItems.push(currentTransactionPointItem);
newItems.sort((a, b) => a.symbol.localeCompare(b.symbol));
if (lastDate !== currentDate || lastTransactionPoint === null) {
lastTransactionPoint = {
date: currentDate,
items: newItems
};
this.transactionPoints.push(lastTransactionPoint);
} else {
lastTransactionPoint.items = newItems;
}
lastDate = currentDate;
}
}
public getAnnualizedPerformancePercent({
daysInMarket,
netPerformancePercent
}: {
daysInMarket: number;
netPerformancePercent: Big;
}): Big {
if (isNumber(daysInMarket) && daysInMarket > 0) {
const exponent = new Big(365).div(daysInMarket).toNumber();
return new Big(
Math.pow(netPerformancePercent.plus(1).toNumber(), exponent)
).minus(1);
}
return new Big(0);
}
public getTransactionPoints(): TransactionPoint[] {
return this.transactionPoints;
}
public setTransactionPoints(transactionPoints: TransactionPoint[]) {
this.transactionPoints = transactionPoints;
}
public async getCurrentPositions(start: Date): Promise<CurrentPositions> {
if (!this.transactionPoints?.length) {
return {
currentValue: new Big(0),
hasErrors: false,
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
positions: [],
totalInvestment: new Big(0)
};
}
const lastTransactionPoint =
this.transactionPoints[this.transactionPoints.length - 1];
// use Date.now() to use the mock for today
const today = new Date(Date.now());
let firstTransactionPoint: TransactionPoint = null;
let firstIndex = this.transactionPoints.length;
const dates = [];
const dataGatheringItems: IDataGatheringItem[] = [];
const currencies: { [symbol: string]: string } = {};
dates.push(resetHours(start));
for (const item of this.transactionPoints[firstIndex - 1].items) {
dataGatheringItems.push({
dataSource: item.dataSource,
symbol: item.symbol
});
currencies[item.symbol] = item.currency;
}
for (let i = 0; i < this.transactionPoints.length; i++) {
if (
!isBefore(parseDate(this.transactionPoints[i].date), start) &&
firstTransactionPoint === null
) {
firstTransactionPoint = this.transactionPoints[i];
firstIndex = i;
}
if (firstTransactionPoint !== null) {
dates.push(resetHours(parseDate(this.transactionPoints[i].date)));
}
}
dates.push(resetHours(today));
const marketSymbols = await this.currentRateService.getValues({
currencies,
dataGatheringItems,
dateQuery: {
in: dates
},
userCurrency: this.currency
});
const marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
} = {};
for (const marketSymbol of marketSymbols) {
const date = format(marketSymbol.date, DATE_FORMAT);
if (!marketSymbolMap[date]) {
marketSymbolMap[date] = {};
}
if (marketSymbol.marketPrice) {
marketSymbolMap[date][marketSymbol.symbol] = new Big(
marketSymbol.marketPrice
);
}
}
const todayString = format(today, DATE_FORMAT);
if (firstIndex > 0) {
firstIndex--;
}
const initialValues: { [symbol: string]: Big } = {};
const positions: TimelinePosition[] = [];
let hasErrorsInSymbolMetrics = false;
for (const item of lastTransactionPoint.items) {
const marketValue = marketSymbolMap[todayString]?.[item.symbol];
const {
grossPerformance,
grossPerformancePercentage,
hasErrors,
initialValue,
netPerformance,
netPerformancePercentage
} = this.getSymbolMetrics({
marketSymbolMap,
start,
symbol: item.symbol
});
hasErrorsInSymbolMetrics = hasErrorsInSymbolMetrics || hasErrors;
initialValues[item.symbol] = initialValue;
positions.push({
averagePrice: item.quantity.eq(0)
? new Big(0)
: item.investment.div(item.quantity),
currency: item.currency,
dataSource: item.dataSource,
firstBuyDate: item.firstBuyDate,
grossPerformance: !hasErrors ? grossPerformance ?? null : null,
grossPerformancePercentage: !hasErrors
? grossPerformancePercentage ?? null
: null,
investment: item.investment,
marketPrice: marketValue?.toNumber() ?? null,
netPerformance: !hasErrors ? netPerformance ?? null : null,
netPerformancePercentage: !hasErrors
? netPerformancePercentage ?? null
: null,
quantity: item.quantity,
symbol: item.symbol,
transactionCount: item.transactionCount
});
}
const overall = this.calculateOverallPerformance(positions, initialValues);
return {
...overall,
positions,
hasErrors: hasErrorsInSymbolMetrics || overall.hasErrors
};
}
public getSymbolMetrics({
marketSymbolMap,
start,
symbol
}: {
marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
};
start: Date;
symbol: string;
}) {
let orders: PortfolioOrderItem[] = this.orders.filter((order) => {
return order.symbol === symbol;
});
if (orders.length <= 0) {
return {
hasErrors: false,
initialValue: new Big(0),
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0)
};
}
const dateOfFirstTransaction = new Date(first(orders).date);
const endDate = new Date(Date.now());
const unitPriceAtStartDate =
marketSymbolMap[format(start, DATE_FORMAT)]?.[symbol];
const unitPriceAtEndDate =
marketSymbolMap[format(endDate, DATE_FORMAT)]?.[symbol];
if (
!unitPriceAtEndDate ||
(!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start))
) {
return {
hasErrors: true,
initialValue: new Big(0),
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0)
};
}
let feesAtStartDate = new Big(0);
let fees = new Big(0);
let grossPerformance = new Big(0);
let grossPerformanceAtStartDate = new Big(0);
let grossPerformanceFromSells = new Big(0);
let initialValue: Big;
let lastAveragePrice = new Big(0);
let lastValueOfInvestment = new Big(0);
let lastNetValueOfInvestment = new Big(0);
let timeWeightedGrossPerformancePercentage = new Big(1);
let timeWeightedNetPerformancePercentage = new Big(1);
let totalInvestment = new Big(0);
let totalUnits = new Big(0);
// Add a synthetic order at the start and the end date
orders.push({
symbol,
currency: null,
date: format(start, DATE_FORMAT),
dataSource: null,
fee: new Big(0),
itemType: 'start',
name: '',
quantity: new Big(0),
type: TypeOfOrder.BUY,
unitPrice: unitPriceAtStartDate ?? new Big(0)
});
orders.push({
symbol,
currency: null,
date: format(endDate, DATE_FORMAT),
dataSource: null,
fee: new Big(0),
itemType: 'end',
name: '',
quantity: new Big(0),
type: TypeOfOrder.BUY,
unitPrice: unitPriceAtEndDate ?? new Big(0)
});
// Sort orders so that the start and end placeholder order are at the right
// position
orders = sortBy(orders, (order) => {
let sortIndex = new Date(order.date);
if (order.itemType === 'start') {
sortIndex = addMilliseconds(sortIndex, -1);
}
if (order.itemType === 'end') {
sortIndex = addMilliseconds(sortIndex, 1);
}
return sortIndex.getTime();
});
const indexOfStartOrder = orders.findIndex((order) => {
return order.itemType === 'start';
});
for (let i = 0; i < orders.length; i += 1) {
const order = orders[i];
const transactionInvestment = order.quantity.mul(order.unitPrice);
if (
!initialValue &&
order.itemType !== 'start' &&
order.itemType !== 'end'
) {
initialValue = transactionInvestment;
}
fees = fees.plus(order.fee);
totalUnits = totalUnits.plus(
order.quantity.mul(this.getFactor(order.type))
);
const valueOfInvestment = totalUnits.mul(order.unitPrice);
const netValueOfInvestment = totalUnits.mul(order.unitPrice).sub(fees);
const grossPerformanceFromSell =
order.type === TypeOfOrder.SELL
? order.unitPrice.minus(lastAveragePrice).mul(order.quantity)
: new Big(0);
grossPerformanceFromSells = grossPerformanceFromSells.plus(
grossPerformanceFromSell
);
totalInvestment = totalInvestment
.plus(transactionInvestment.mul(this.getFactor(order.type)))
.plus(grossPerformanceFromSell);
lastAveragePrice = totalUnits.eq(0)
? new Big(0)
: totalInvestment.div(totalUnits);
const newGrossPerformance = valueOfInvestment
.minus(totalInvestment)
.plus(grossPerformanceFromSells);
if (
i > indexOfStartOrder &&
!lastValueOfInvestment
.plus(transactionInvestment.mul(this.getFactor(order.type)))
.eq(0)
) {
timeWeightedGrossPerformancePercentage =
timeWeightedGrossPerformancePercentage.mul(
new Big(1).plus(
valueOfInvestment
.minus(
lastValueOfInvestment.plus(
transactionInvestment.mul(this.getFactor(order.type))
)
)
.div(
lastValueOfInvestment.plus(
transactionInvestment.mul(this.getFactor(order.type))
)
)
)
);
timeWeightedNetPerformancePercentage =
timeWeightedNetPerformancePercentage.mul(
new Big(1).plus(
netValueOfInvestment
.minus(
lastNetValueOfInvestment.plus(
transactionInvestment.mul(this.getFactor(order.type))
)
)
.div(
lastNetValueOfInvestment.plus(
transactionInvestment.mul(this.getFactor(order.type))
)
)
)
);
}
grossPerformance = newGrossPerformance;
lastNetValueOfInvestment = netValueOfInvestment;
lastValueOfInvestment = valueOfInvestment;
if (order.itemType === 'start') {
feesAtStartDate = fees;
grossPerformanceAtStartDate = grossPerformance;
}
}
timeWeightedGrossPerformancePercentage =
timeWeightedGrossPerformancePercentage.sub(1);
timeWeightedNetPerformancePercentage =
timeWeightedNetPerformancePercentage.sub(1);
const totalGrossPerformance = grossPerformance.minus(
grossPerformanceAtStartDate
);
const totalNetPerformance = grossPerformance
.minus(grossPerformanceAtStartDate)
.minus(fees.minus(feesAtStartDate));
return {
initialValue,
hasErrors: !initialValue || !unitPriceAtEndDate,
netPerformance: totalNetPerformance,
netPerformancePercentage: timeWeightedNetPerformancePercentage,
grossPerformance: totalGrossPerformance,
grossPerformancePercentage: timeWeightedGrossPerformancePercentage
};
}
public getInvestments(): { date: string; investment: Big }[] {
if (this.transactionPoints.length === 0) {
return [];
}
return this.transactionPoints.map((transactionPoint) => {
return {
date: transactionPoint.date,
investment: transactionPoint.items.reduce(
(investment, transactionPointSymbol) =>
investment.add(transactionPointSymbol.investment),
new Big(0)
)
};
});
}
public async calculateTimeline(
timelineSpecification: TimelineSpecification[],
endDate: string
): Promise<TimelineInfoInterface> {
if (timelineSpecification.length === 0) {
return {
maxNetPerformance: new Big(0),
minNetPerformance: new Big(0),
timelinePeriods: []
};
}
const startDate = timelineSpecification[0].start;
const start = parseDate(startDate);
const end = parseDate(endDate);
const timelinePeriodPromises: Promise<TimelineInfoInterface>[] = [];
let i = 0;
let j = -1;
for (
let currentDate = start;
!isAfter(currentDate, end);
currentDate = this.addToDate(
currentDate,
timelineSpecification[i].accuracy
)
) {
if (this.isNextItemActive(timelineSpecification, currentDate, i)) {
i++;
}
while (
j + 1 < this.transactionPoints.length &&
!isAfter(parseDate(this.transactionPoints[j + 1].date), currentDate)
) {
j++;
}
let periodEndDate = currentDate;
if (timelineSpecification[i].accuracy === 'day') {
let nextEndDate = end;
if (j + 1 < this.transactionPoints.length) {
nextEndDate = parseDate(this.transactionPoints[j + 1].date);
}
periodEndDate = min([
addMonths(currentDate, 3),
max([currentDate, nextEndDate])
]);
}
const timePeriodForDates = this.getTimePeriodForDate(
j,
currentDate,
endOfDay(periodEndDate)
);
currentDate = periodEndDate;
if (timePeriodForDates != null) {
timelinePeriodPromises.push(timePeriodForDates);
}
}
const timelineInfoInterfaces: TimelineInfoInterface[] = await Promise.all(
timelinePeriodPromises
);
const minNetPerformance = timelineInfoInterfaces
.map((timelineInfo) => timelineInfo.minNetPerformance)
.filter((performance) => performance !== null)
.reduce((minPerformance, current) => {
if (minPerformance.lt(current)) {
return minPerformance;
} else {
return current;
}
});
const maxNetPerformance = timelineInfoInterfaces
.map((timelineInfo) => timelineInfo.maxNetPerformance)
.filter((performance) => performance !== null)
.reduce((maxPerformance, current) => {
if (maxPerformance.gt(current)) {
return maxPerformance;
} else {
return current;
}
});
const timelinePeriods = timelineInfoInterfaces.map(
(timelineInfo) => timelineInfo.timelinePeriods
);
return {
maxNetPerformance,
minNetPerformance,
timelinePeriods: flatten(timelinePeriods)
};
}
private calculateOverallPerformance(
positions: TimelinePosition[],
initialValues: { [p: string]: Big }
) {
let hasErrors = false;
let currentValue = new Big(0);
let totalInvestment = new Big(0);
let grossPerformance = new Big(0);
let grossPerformancePercentage = new Big(0);
let netPerformance = new Big(0);
let netPerformancePercentage = new Big(0);
let completeInitialValue = new Big(0);
for (const currentPosition of positions) {
if (currentPosition.marketPrice) {
currentValue = currentValue.add(
new Big(currentPosition.marketPrice).mul(currentPosition.quantity)
);
} else {
hasErrors = true;
}
totalInvestment = totalInvestment.add(currentPosition.investment);
if (currentPosition.grossPerformance) {
grossPerformance = grossPerformance.plus(
currentPosition.grossPerformance
);
netPerformance = netPerformance.plus(currentPosition.netPerformance);
} else if (!currentPosition.quantity.eq(0)) {
hasErrors = true;
}
if (
currentPosition.grossPerformancePercentage &&
initialValues[currentPosition.symbol]
) {
const currentInitialValue = initialValues[currentPosition.symbol];
completeInitialValue = completeInitialValue.plus(currentInitialValue);
grossPerformancePercentage = grossPerformancePercentage.plus(
currentPosition.grossPerformancePercentage.mul(currentInitialValue)
);
netPerformancePercentage = netPerformancePercentage.plus(
currentPosition.netPerformancePercentage.mul(currentInitialValue)
);
} else if (!currentPosition.quantity.eq(0)) {
Logger.warn(
`Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`
);
hasErrors = true;
}
}
if (!completeInitialValue.eq(0)) {
grossPerformancePercentage =
grossPerformancePercentage.div(completeInitialValue);
netPerformancePercentage =
netPerformancePercentage.div(completeInitialValue);
}
return {
currentValue,
grossPerformance,
grossPerformancePercentage,
hasErrors,
netPerformance,
netPerformancePercentage,
totalInvestment
};
}
private async getTimePeriodForDate(
j: number,
startDate: Date,
endDate: Date
): Promise<TimelineInfoInterface> {
let investment: Big = new Big(0);
let fees: Big = new Big(0);
const marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
} = {};
if (j >= 0) {
const currencies: { [name: string]: string } = {};
const dataGatheringItems: IDataGatheringItem[] = [];
for (const item of this.transactionPoints[j].items) {
currencies[item.symbol] = item.currency;
dataGatheringItems.push({
dataSource: item.dataSource,
symbol: item.symbol
});
investment = investment.add(item.investment);
fees = fees.add(item.fee);
}
let marketSymbols: GetValueObject[] = [];
if (dataGatheringItems.length > 0) {
try {
marketSymbols = await this.currentRateService.getValues({
currencies,
dataGatheringItems,
dateQuery: {
gte: startDate,
lt: endOfDay(endDate)
},
userCurrency: this.currency
});
} catch (error) {
Logger.error(
`Failed to fetch info for date ${startDate} with exception`,
error
);
return null;
}
}
for (const marketSymbol of marketSymbols) {
const date = format(marketSymbol.date, DATE_FORMAT);
if (!marketSymbolMap[date]) {
marketSymbolMap[date] = {};
}
if (marketSymbol.marketPrice) {
marketSymbolMap[date][marketSymbol.symbol] = new Big(
marketSymbol.marketPrice
);
}
}
}
const results: TimelinePeriod[] = [];
let maxNetPerformance: Big = null;
let minNetPerformance: Big = null;
for (
let currentDate = startDate;
isBefore(currentDate, endDate);
currentDate = addDays(currentDate, 1)
) {
let value = new Big(0);
const currentDateAsString = format(currentDate, DATE_FORMAT);
let invalid = false;
if (j >= 0) {
for (const item of this.transactionPoints[j].items) {
if (
!marketSymbolMap[currentDateAsString]?.hasOwnProperty(item.symbol)
) {
invalid = true;
break;
}
value = value.add(
item.quantity.mul(marketSymbolMap[currentDateAsString][item.symbol])
);
}
}
if (!invalid) {
const grossPerformance = value.minus(investment);
const netPerformance = grossPerformance.minus(fees);
if (
minNetPerformance === null ||
minNetPerformance.gt(netPerformance)
) {
minNetPerformance = netPerformance;
}
if (
maxNetPerformance === null ||
maxNetPerformance.lt(netPerformance)
) {
maxNetPerformance = netPerformance;
}
const result = {
grossPerformance,
investment,
netPerformance,
value,
date: currentDateAsString
};
results.push(result);
}
}
return {
maxNetPerformance,
minNetPerformance,
timelinePeriods: results
};
}
private getFactor(type: TypeOfOrder) {
let factor: number;
switch (type) {
case 'BUY':
factor = 1;
break;
case 'SELL':
factor = -1;
break;
default:
factor = 0;
break;
}
return factor;
}
private addToDate(date: Date, accuracy: Accuracy): Date {
switch (accuracy) {
case 'day':
return addDays(date, 1);
case 'month':
return addMonths(date, 1);
case 'year':
return addYears(date, 1);
}
}
private isNextItemActive(
timelineSpecification: TimelineSpecification[],
currentDate: Date,
i: number
) {
return (
i + 1 < timelineSpecification.length &&
!isBefore(currentDate, parseDate(timelineSpecification[i + 1].start))
);
}
}

View File

@ -1,16 +1,9 @@
import { parseDate, resetHours } from '@ghostfolio/common/helper';
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import { DataSource } from '@prisma/client';
import Big from 'big.js';
import {
addDays,
differenceInCalendarDays,
endOfDay,
isBefore,
isSameDay
} from 'date-fns';
import { addDays, endOfDay, format, isBefore, isSameDay } from 'date-fns';
import { CurrentRateService } from './current-rate.service';
import { GetValueParams } from './interfaces/get-value-params.interface';
import { GetValuesParams } from './interfaces/get-values-params.interface';
import { PortfolioOrder } from './interfaces/portfolio-order.interface';
import { TimelinePeriod } from './interfaces/timeline-period.interface';
@ -67,15 +60,202 @@ function mockGetValue(symbol: string, date: Date) {
return { marketPrice: 0 };
case 'VTI':
return {
marketPrice: new Big('144.38')
.plus(
new Big('0.08').mul(
differenceInCalendarDays(date, parseDate('2019-02-01'))
)
)
.toNumber()
};
switch (format(date, DATE_FORMAT)) {
case '2019-01-01':
return { marketPrice: 144.38 };
case '2019-02-01':
return { marketPrice: 144.38 };
case '2019-03-01':
return { marketPrice: 146.62 };
case '2019-04-01':
return { marketPrice: 149.1 };
case '2019-05-01':
return { marketPrice: 151.5 };
case '2019-06-01':
return { marketPrice: 153.98 };
case '2019-07-01':
return { marketPrice: 156.38 };
case '2019-08-01':
return { marketPrice: 158.86 };
case '2019-08-03':
return { marketPrice: 159.02 };
case '2019-09-01':
return { marketPrice: 161.34 };
case '2019-10-01':
return { marketPrice: 163.74 };
case '2019-11-01':
return { marketPrice: 166.22 };
case '2019-12-01':
return { marketPrice: 168.62 };
case '2020-01-01':
return { marketPrice: 171.1 };
case '2020-02-01':
return { marketPrice: 173.58 };
case '2020-02-02':
return { marketPrice: 173.66 };
case '2020-03-01':
return { marketPrice: 175.9 };
case '2020-04-01':
return { marketPrice: 178.38 };
case '2020-05-01':
return { marketPrice: 180.78 };
case '2020-06-01':
return { marketPrice: 183.26 };
case '2020-07-01':
return { marketPrice: 185.66 };
case '2020-08-01':
return { marketPrice: 188.14 };
case '2020-08-02':
return { marketPrice: 188.22 };
case '2020-08-03':
return { marketPrice: 188.3 };
case '2020-09-01':
return { marketPrice: 190.62 };
case '2020-10-01':
return { marketPrice: 193.02 };
case '2020-11-01':
return { marketPrice: 195.5 };
case '2020-12-01':
return { marketPrice: 197.9 };
case '2021-01-01':
return { marketPrice: 200.38 };
case '2021-02-01':
return { marketPrice: 202.86 };
case '2021-03-01':
return { marketPrice: 205.1 };
case '2021-04-01':
return { marketPrice: 207.58 };
case '2021-05-01':
return { marketPrice: 209.98 };
case '2021-06-01':
return { marketPrice: 212.46 };
case '2021-06-02':
return { marketPrice: 212.54 };
case '2021-06-03':
return { marketPrice: 212.62 };
case '2021-06-04':
return { marketPrice: 212.7 };
case '2021-06-05':
return { marketPrice: 212.78 };
case '2021-06-06':
return { marketPrice: 212.86 };
case '2021-06-07':
return { marketPrice: 212.94 };
case '2021-06-08':
return { marketPrice: 213.02 };
case '2021-06-09':
return { marketPrice: 213.1 };
case '2021-06-10':
return { marketPrice: 213.18 };
case '2021-06-11':
return { marketPrice: 213.26 };
case '2021-06-12':
return { marketPrice: 213.34 };
case '2021-06-13':
return { marketPrice: 213.42 };
case '2021-06-14':
return { marketPrice: 213.5 };
case '2021-06-15':
return { marketPrice: 213.58 };
case '2021-06-16':
return { marketPrice: 213.66 };
case '2021-06-17':
return { marketPrice: 213.74 };
case '2021-06-18':
return { marketPrice: 213.82 };
case '2021-06-19':
return { marketPrice: 213.9 };
case '2021-06-20':
return { marketPrice: 213.98 };
case '2021-06-21':
return { marketPrice: 214.06 };
case '2021-06-22':
return { marketPrice: 214.14 };
case '2021-06-23':
return { marketPrice: 214.22 };
case '2021-06-24':
return { marketPrice: 214.3 };
case '2021-06-25':
return { marketPrice: 214.38 };
case '2021-06-26':
return { marketPrice: 214.46 };
case '2021-06-27':
return { marketPrice: 214.54 };
case '2021-06-28':
return { marketPrice: 214.62 };
case '2021-06-29':
return { marketPrice: 214.7 };
case '2021-06-30':
return { marketPrice: 214.78 };
case '2021-07-01':
return { marketPrice: 214.86 };
case '2021-07-02':
return { marketPrice: 214.94 };
case '2021-07-03':
return { marketPrice: 215.02 };
case '2021-07-04':
return { marketPrice: 215.1 };
case '2021-07-05':
return { marketPrice: 215.18 };
case '2021-07-06':
return { marketPrice: 215.26 };
case '2021-07-07':
return { marketPrice: 215.34 };
case '2021-07-08':
return { marketPrice: 215.42 };
case '2021-07-09':
return { marketPrice: 215.5 };
case '2021-07-10':
return { marketPrice: 215.58 };
case '2021-07-11':
return { marketPrice: 215.66 };
case '2021-07-12':
return { marketPrice: 215.74 };
case '2021-07-13':
return { marketPrice: 215.82 };
case '2021-07-14':
return { marketPrice: 215.9 };
case '2021-07-15':
return { marketPrice: 215.98 };
case '2021-07-16':
return { marketPrice: 216.06 };
case '2021-07-17':
return { marketPrice: 216.14 };
case '2021-07-18':
return { marketPrice: 216.22 };
case '2021-07-19':
return { marketPrice: 216.3 };
case '2021-07-20':
return { marketPrice: 216.38 };
case '2021-07-21':
return { marketPrice: 216.46 };
case '2021-07-22':
return { marketPrice: 216.54 };
case '2021-07-23':
return { marketPrice: 216.62 };
case '2021-07-24':
return { marketPrice: 216.7 };
case '2021-07-25':
return { marketPrice: 216.78 };
case '2021-07-26':
return { marketPrice: 216.86 };
case '2021-07-27':
return { marketPrice: 216.94 };
case '2021-07-28':
return { marketPrice: 217.02 };
case '2021-07-29':
return { marketPrice: 217.1 };
case '2021-07-30':
return { marketPrice: 217.18 };
case '2021-07-31':
return { marketPrice: 217.26 };
case '2021-08-01':
return { marketPrice: 217.34 };
case '2020-10-24':
return { marketPrice: 194.86 };
default:
return { marketPrice: 0 };
}
default:
return { marketPrice: 0 };
@ -87,9 +267,6 @@ jest.mock('./current-rate.service', () => {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return {
getValue: ({ date, symbol }: GetValueParams) => {
return Promise.resolve(mockGetValue(symbol, date));
},
getValues: ({ dataGatheringItems, dateQuery }: GetValuesParams) => {
const result = [];
if (dateQuery.lt) {
@ -1645,14 +1822,14 @@ describe('PortfolioCalculator', () => {
grossPerformance: new Big('498.3'),
netPerformance: new Big('498.3'),
investment: new Big('2923.7'),
value: new Big('3422') // 20 * (144.38 + days=335 * 0.08)
value: new Big('3422') // 20 * 171.1
},
{
date: '2021-01-01',
grossPerformance: new Big('349.35'),
netPerformance: new Big('349.35'),
investment: new Big('652.55'),
value: new Big('1001.9') // 5 * (144.38 + days=700 * 0.08)
value: new Big('1001.9') // 5 * 200.38
}
]);
});
@ -1765,14 +1942,14 @@ describe('PortfolioCalculator', () => {
grossPerformance: new Big('498.3'),
netPerformance: new Big('398.3'), // 100 fees
investment: new Big('2923.7'),
value: new Big('3422') // 20 * (144.38 + days=335 * 0.08)
value: new Big('3422') // 20 * 171.1
},
{
date: '2021-01-01',
grossPerformance: new Big('349.35'),
netPerformance: new Big('199.35'), // 150 fees
investment: new Big('652.55'),
value: new Big('1001.9') // 5 * (144.38 + days=700 * 0.08)
value: new Big('1001.9') // 5 * 200.38
}
]);
});
@ -1808,203 +1985,203 @@ describe('PortfolioCalculator', () => {
grossPerformance: new Big('0'),
netPerformance: new Big('0'),
investment: new Big('1443.8'),
value: new Big('1443.8') // 10 * (144.38 + days=0 * 0.08)
value: new Big('1443.8') // 10 * 144.38
},
{
date: '2019-03-01',
grossPerformance: new Big('22.4'),
netPerformance: new Big('22.4'),
investment: new Big('1443.8'),
value: new Big('1466.2') // 10 * (144.38 + days=28 * 0.08)
value: new Big('1466.2') // 10 * 146.62
},
{
date: '2019-04-01',
grossPerformance: new Big('47.2'),
netPerformance: new Big('47.2'),
investment: new Big('1443.8'),
value: new Big('1491') // 10 * (144.38 + days=59 * 0.08)
value: new Big('1491') // 10 * 149.1
},
{
date: '2019-05-01',
grossPerformance: new Big('71.2'),
netPerformance: new Big('71.2'),
investment: new Big('1443.8'),
value: new Big('1515') // 10 * (144.38 + days=89 * 0.08)
value: new Big('1515') // 10 * 151.5
},
{
date: '2019-06-01',
grossPerformance: new Big('96'),
netPerformance: new Big('96'),
investment: new Big('1443.8'),
value: new Big('1539.8') // 10 * (144.38 + days=120 * 0.08)
value: new Big('1539.8') // 10 * 153.98
},
{
date: '2019-07-01',
grossPerformance: new Big('120'),
netPerformance: new Big('120'),
investment: new Big('1443.8'),
value: new Big('1563.8') // 10 * (144.38 + days=150 * 0.08)
value: new Big('1563.8') // 10 * 156.38
},
{
date: '2019-08-01',
grossPerformance: new Big('144.8'),
netPerformance: new Big('144.8'),
investment: new Big('1443.8'),
value: new Big('1588.6') // 10 * (144.38 + days=181 * 0.08)
value: new Big('1588.6') // 10 * 158.86
},
{
date: '2019-09-01',
grossPerformance: new Big('303.1'),
netPerformance: new Big('303.1'),
investment: new Big('2923.7'),
value: new Big('3226.8') // 20 * (144.38 + days=212 * 0.08)
value: new Big('3226.8') // 20 * 161.34
},
{
date: '2019-10-01',
grossPerformance: new Big('351.1'),
netPerformance: new Big('351.1'),
investment: new Big('2923.7'),
value: new Big('3274.8') // 20 * (144.38 + days=242 * 0.08)
value: new Big('3274.8') // 20 * 163.74
},
{
date: '2019-11-01',
grossPerformance: new Big('400.7'),
netPerformance: new Big('400.7'),
investment: new Big('2923.7'),
value: new Big('3324.4') // 20 * (144.38 + days=273 * 0.08)
value: new Big('3324.4') // 20 * 166.22
},
{
date: '2019-12-01',
grossPerformance: new Big('448.7'),
netPerformance: new Big('448.7'),
investment: new Big('2923.7'),
value: new Big('3372.4') // 20 * (144.38 + days=303 * 0.08)
value: new Big('3372.4') // 20 * 168.62
},
{
date: '2020-01-01',
grossPerformance: new Big('498.3'),
netPerformance: new Big('498.3'),
investment: new Big('2923.7'),
value: new Big('3422') // 20 * (144.38 + days=335 * 0.08)
value: new Big('3422') // 20 * 171.1
},
{
date: '2020-02-01',
grossPerformance: new Big('547.9'),
netPerformance: new Big('547.9'),
investment: new Big('2923.7'),
value: new Big('3471.6') // 20 * (144.38 + days=365 * 0.08)
value: new Big('3471.6') // 20 * 173.58
},
{
date: '2020-03-01',
grossPerformance: new Big('226.95'),
netPerformance: new Big('226.95'),
investment: new Big('652.55'),
value: new Big('879.5') // 5 * (144.38 + days=394 * 0.08)
value: new Big('879.5') // 5 * 175.9
},
{
date: '2020-04-01',
grossPerformance: new Big('239.35'),
netPerformance: new Big('239.35'),
investment: new Big('652.55'),
value: new Big('891.9') // 5 * (144.38 + days=425 * 0.08)
value: new Big('891.9') // 5 * 178.38
},
{
date: '2020-05-01',
grossPerformance: new Big('251.35'),
netPerformance: new Big('251.35'),
investment: new Big('652.55'),
value: new Big('903.9') // 5 * (144.38 + days=455 * 0.08)
value: new Big('903.9') // 5 * 180.78
},
{
date: '2020-06-01',
grossPerformance: new Big('263.75'),
netPerformance: new Big('263.75'),
investment: new Big('652.55'),
value: new Big('916.3') // 5 * (144.38 + days=486 * 0.08)
value: new Big('916.3') // 5 * 183.26
},
{
date: '2020-07-01',
grossPerformance: new Big('275.75'),
netPerformance: new Big('275.75'),
investment: new Big('652.55'),
value: new Big('928.3') // 5 * (144.38 + days=516 * 0.08)
value: new Big('928.3') // 5 * 185.66
},
{
date: '2020-08-01',
grossPerformance: new Big('288.15'),
netPerformance: new Big('288.15'),
investment: new Big('652.55'),
value: new Big('940.7') // 5 * (144.38 + days=547 * 0.08)
value: new Big('940.7') // 5 * 188.14
},
{
date: '2020-09-01',
grossPerformance: new Big('300.55'),
netPerformance: new Big('300.55'),
investment: new Big('652.55'),
value: new Big('953.1') // 5 * (144.38 + days=578 * 0.08)
value: new Big('953.1') // 5 * 190.62
},
{
date: '2020-10-01',
grossPerformance: new Big('312.55'),
netPerformance: new Big('312.55'),
investment: new Big('652.55'),
value: new Big('965.1') // 5 * (144.38 + days=608 * 0.08)
value: new Big('965.1') // 5 * 193.02
},
{
date: '2020-11-01',
grossPerformance: new Big('324.95'),
netPerformance: new Big('324.95'),
investment: new Big('652.55'),
value: new Big('977.5') // 5 * (144.38 + days=639 * 0.08)
value: new Big('977.5') // 5 * 195.5
},
{
date: '2020-12-01',
grossPerformance: new Big('336.95'),
netPerformance: new Big('336.95'),
investment: new Big('652.55'),
value: new Big('989.5') // 5 * (144.38 + days=669 * 0.08)
value: new Big('989.5') // 5 * 197.9
},
{
date: '2021-01-01',
grossPerformance: new Big('349.35'),
netPerformance: new Big('349.35'),
investment: new Big('652.55'),
value: new Big('1001.9') // 5 * (144.38 + days=700 * 0.08)
value: new Big('1001.9') // 5 * 200.38
},
{
date: '2021-02-01',
grossPerformance: new Big('358.85'),
netPerformance: new Big('358.85'),
investment: new Big('2684.05'),
value: new Big('3042.9') // 15 * (144.38 + days=731 * 0.08)
value: new Big('3042.9') // 15 * 202.86
},
{
date: '2021-03-01',
grossPerformance: new Big('392.45'),
netPerformance: new Big('392.45'),
investment: new Big('2684.05'),
value: new Big('3076.5') // 15 * (144.38 + days=759 * 0.08)
value: new Big('3076.5') // 15 * 205.1
},
{
date: '2021-04-01',
grossPerformance: new Big('429.65'),
netPerformance: new Big('429.65'),
investment: new Big('2684.05'),
value: new Big('3113.7') // 15 * (144.38 + days=790 * 0.08)
value: new Big('3113.7') // 15 * 207.58
},
{
date: '2021-05-01',
grossPerformance: new Big('465.65'),
netPerformance: new Big('465.65'),
investment: new Big('2684.05'),
value: new Big('3149.7') // 15 * (144.38 + days=820 * 0.08)
value: new Big('3149.7') // 15 * 209.98
},
{
date: '2021-06-01',
grossPerformance: new Big('502.85'),
netPerformance: new Big('502.85'),
investment: new Big('2684.05'),
value: new Big('3186.9') // 15 * (144.38 + days=851 * 0.08)
value: new Big('3186.9') // 15 * 212.46
}
]);
@ -2047,49 +2224,49 @@ describe('PortfolioCalculator', () => {
grossPerformance: new Big('498.3'),
netPerformance: new Big('498.3'),
investment: new Big('2923.7'),
value: new Big('3422') // 20 * (144.38 + days=335 * 0.08)
value: new Big('3422') // 20 * 171.1
},
{
date: '2021-01-01',
grossPerformance: new Big('349.35'),
netPerformance: new Big('349.35'),
investment: new Big('652.55'),
value: new Big('1001.9') // 5 * (144.38 + days=700 * 0.08)
value: new Big('1001.9') // 5 * 200.38
},
{
date: '2021-02-01',
grossPerformance: new Big('358.85'),
netPerformance: new Big('358.85'),
investment: new Big('2684.05'),
value: new Big('3042.9') // 15 * (144.38 + days=731 * 0.08)
value: new Big('3042.9') // 15 * 202.86
},
{
date: '2021-03-01',
grossPerformance: new Big('392.45'),
netPerformance: new Big('392.45'),
investment: new Big('2684.05'),
value: new Big('3076.5') // 15 * (144.38 + days=759 * 0.08)
value: new Big('3076.5') // 15 * 205.1
},
{
date: '2021-04-01',
grossPerformance: new Big('429.65'),
netPerformance: new Big('429.65'),
investment: new Big('2684.05'),
value: new Big('3113.7') // 15 * (144.38 + days=790 * 0.08)
value: new Big('3113.7') // 15 * 207.58
},
{
date: '2021-05-01',
grossPerformance: new Big('465.65'),
netPerformance: new Big('465.65'),
investment: new Big('2684.05'),
value: new Big('3149.7') // 15 * (144.38 + days=820 * 0.08)
value: new Big('3149.7') // 15 * 209.98
},
{
date: '2021-06-01',
grossPerformance: new Big('502.85'),
netPerformance: new Big('502.85'),
investment: new Big('2684.05'),
value: new Big('3186.9') // 15 * (144.38 + days=851 * 0.08)
value: new Big('3186.9') // 15 * 212.46
}
]);
});
@ -2134,252 +2311,252 @@ describe('PortfolioCalculator', () => {
grossPerformance: new Big('498.3'),
netPerformance: new Big('498.3'),
investment: new Big('2923.7'),
value: new Big('3422') // 20 * (144.38 + days=335 * 0.08)
value: new Big('3422') // 20 * 171.1
},
{
date: '2021-01-01',
grossPerformance: new Big('349.35'),
netPerformance: new Big('349.35'),
investment: new Big('652.55'),
value: new Big('1001.9') // 5 * (144.38 + days=700 * 0.08)
value: new Big('1001.9') // 5 * 200.38
},
{
date: '2021-02-01',
grossPerformance: new Big('358.85'),
netPerformance: new Big('358.85'),
investment: new Big('2684.05'),
value: new Big('3042.9') // 15 * (144.38 + days=731 * 0.08)
value: new Big('3042.9') // 15 * 202.86
},
{
date: '2021-03-01',
grossPerformance: new Big('392.45'),
netPerformance: new Big('392.45'),
investment: new Big('2684.05'),
value: new Big('3076.5') // 15 * (144.38 + days=759 * 0.08)
value: new Big('3076.5') // 15 * 205.1
},
{
date: '2021-04-01',
grossPerformance: new Big('429.65'),
netPerformance: new Big('429.65'),
investment: new Big('2684.05'),
value: new Big('3113.7') // 15 * (144.38 + days=790 * 0.08)
value: new Big('3113.7') // 15 * 207.58
},
{
date: '2021-05-01',
grossPerformance: new Big('465.65'),
netPerformance: new Big('465.65'),
investment: new Big('2684.05'),
value: new Big('3149.7') // 15 * (144.38 + days=820 * 0.08)
value: new Big('3149.7') // 15 * 209.98
},
{
date: '2021-06-01',
grossPerformance: new Big('502.85'),
netPerformance: new Big('502.85'),
investment: new Big('2684.05'),
value: new Big('3186.9') // 15 * (144.38 + days=851 * 0.08)
value: new Big('3186.9') // 15 * 212.46
},
{
date: '2021-06-02',
grossPerformance: new Big('504.05'),
netPerformance: new Big('504.05'),
investment: new Big('2684.05'),
value: new Big('3188.1') // 15 * (144.38 + days=852 * 0.08) / +1.2
value: new Big('3188.1') // 15 * 212.54
},
{
date: '2021-06-03',
grossPerformance: new Big('505.25'),
netPerformance: new Big('505.25'),
investment: new Big('2684.05'),
value: new Big('3189.3') // +1.2
value: new Big('3189.3') // 15 * 212.62
},
{
date: '2021-06-04',
grossPerformance: new Big('506.45'),
netPerformance: new Big('506.45'),
investment: new Big('2684.05'),
value: new Big('3190.5') // +1.2
value: new Big('3190.5') // 15 * 212.7
},
{
date: '2021-06-05',
grossPerformance: new Big('507.65'),
netPerformance: new Big('507.65'),
investment: new Big('2684.05'),
value: new Big('3191.7') // +1.2
value: new Big('3191.7') // 15 * 212.78
},
{
date: '2021-06-06',
grossPerformance: new Big('508.85'),
netPerformance: new Big('508.85'),
investment: new Big('2684.05'),
value: new Big('3192.9') // +1.2
value: new Big('3192.9') // 15 * 212.86
},
{
date: '2021-06-07',
grossPerformance: new Big('510.05'),
netPerformance: new Big('510.05'),
investment: new Big('2684.05'),
value: new Big('3194.1') // +1.2
value: new Big('3194.1') // 15 * 212.94
},
{
date: '2021-06-08',
grossPerformance: new Big('511.25'),
netPerformance: new Big('511.25'),
investment: new Big('2684.05'),
value: new Big('3195.3') // +1.2
value: new Big('3195.3') // 15 * 213.02
},
{
date: '2021-06-09',
grossPerformance: new Big('512.45'),
netPerformance: new Big('512.45'),
investment: new Big('2684.05'),
value: new Big('3196.5') // +1.2
value: new Big('3196.5') // 15 * 213.1
},
{
date: '2021-06-10',
grossPerformance: new Big('513.65'),
netPerformance: new Big('513.65'),
investment: new Big('2684.05'),
value: new Big('3197.7') // +1.2
value: new Big('3197.7') // 15 * 213.18
},
{
date: '2021-06-11',
grossPerformance: new Big('514.85'),
netPerformance: new Big('514.85'),
investment: new Big('2684.05'),
value: new Big('3198.9') // +1.2
value: new Big('3198.9') // 15 * 213.26
},
{
date: '2021-06-12',
grossPerformance: new Big('516.05'),
netPerformance: new Big('516.05'),
investment: new Big('2684.05'),
value: new Big('3200.1') // +1.2
value: new Big('3200.1') // 15 * 213.34
},
{
date: '2021-06-13',
grossPerformance: new Big('517.25'),
netPerformance: new Big('517.25'),
investment: new Big('2684.05'),
value: new Big('3201.3') // +1.2
value: new Big('3201.3') // 15 * 213.42
},
{
date: '2021-06-14',
grossPerformance: new Big('518.45'),
netPerformance: new Big('518.45'),
investment: new Big('2684.05'),
value: new Big('3202.5') // +1.2
value: new Big('3202.5') // 15 * 213.5
},
{
date: '2021-06-15',
grossPerformance: new Big('519.65'),
netPerformance: new Big('519.65'),
investment: new Big('2684.05'),
value: new Big('3203.7') // +1.2
value: new Big('3203.7') // 15 * 213.58
},
{
date: '2021-06-16',
grossPerformance: new Big('520.85'),
netPerformance: new Big('520.85'),
investment: new Big('2684.05'),
value: new Big('3204.9') // +1.2
value: new Big('3204.9') // 15 * 213.66
},
{
date: '2021-06-17',
grossPerformance: new Big('522.05'),
netPerformance: new Big('522.05'),
investment: new Big('2684.05'),
value: new Big('3206.1') // +1.2
value: new Big('3206.1') // 15 * 213.74
},
{
date: '2021-06-18',
grossPerformance: new Big('523.25'),
netPerformance: new Big('523.25'),
investment: new Big('2684.05'),
value: new Big('3207.3') // +1.2
value: new Big('3207.3') // 15 * 213.82
},
{
date: '2021-06-19',
grossPerformance: new Big('524.45'),
netPerformance: new Big('524.45'),
investment: new Big('2684.05'),
value: new Big('3208.5') // +1.2
value: new Big('3208.5') // 15 * 213.9
},
{
date: '2021-06-20',
grossPerformance: new Big('525.65'),
netPerformance: new Big('525.65'),
investment: new Big('2684.05'),
value: new Big('3209.7') // +1.2
value: new Big('3209.7') // 15 * 213.98
},
{
date: '2021-06-21',
grossPerformance: new Big('526.85'),
netPerformance: new Big('526.85'),
investment: new Big('2684.05'),
value: new Big('3210.9') // +1.2
value: new Big('3210.9') // 15 * 214.06
},
{
date: '2021-06-22',
grossPerformance: new Big('528.05'),
netPerformance: new Big('528.05'),
investment: new Big('2684.05'),
value: new Big('3212.1') // +1.2
value: new Big('3212.1') // 15 * 214.14
},
{
date: '2021-06-23',
grossPerformance: new Big('529.25'),
netPerformance: new Big('529.25'),
investment: new Big('2684.05'),
value: new Big('3213.3') // +1.2
value: new Big('3213.3') // 15 * 214.22
},
{
date: '2021-06-24',
grossPerformance: new Big('530.45'),
netPerformance: new Big('530.45'),
investment: new Big('2684.05'),
value: new Big('3214.5') // +1.2
value: new Big('3214.5') // 15 * 214.3
},
{
date: '2021-06-25',
grossPerformance: new Big('531.65'),
netPerformance: new Big('531.65'),
investment: new Big('2684.05'),
value: new Big('3215.7') // +1.2
value: new Big('3215.7') // 15 * 214.38
},
{
date: '2021-06-26',
grossPerformance: new Big('532.85'),
netPerformance: new Big('532.85'),
investment: new Big('2684.05'),
value: new Big('3216.9') // +1.2
value: new Big('3216.9') // 15 * 214.46
},
{
date: '2021-06-27',
grossPerformance: new Big('534.05'),
netPerformance: new Big('534.05'),
investment: new Big('2684.05'),
value: new Big('3218.1') // +1.2
value: new Big('3218.1') // 15 * 214.54
},
{
date: '2021-06-28',
grossPerformance: new Big('535.25'),
netPerformance: new Big('535.25'),
investment: new Big('2684.05'),
value: new Big('3219.3') // +1.2
value: new Big('3219.3') // 15 * 214.62
},
{
date: '2021-06-29',
grossPerformance: new Big('536.45'),
netPerformance: new Big('536.45'),
investment: new Big('2684.05'),
value: new Big('3220.5') // +1.2
value: new Big('3220.5') // 15 * 214.7
},
{
date: '2021-06-30',
grossPerformance: new Big('537.65'),
netPerformance: new Big('537.65'),
investment: new Big('2684.05'),
value: new Big('3221.7') // +1.2
value: new Big('3221.7') // 15 * 214.78
}
])
);
@ -2442,7 +2619,7 @@ describe('PortfolioCalculator', () => {
grossPerformance: new Big('267.2'),
netPerformance: new Big('267.2'),
investment: new Big('11553.75'),
value: new Big('11820.95') // 10 * (144.38 + days=334 * 0.08) + 5 * 2021.99
value: new Big('11820.95') // 10 * 171.1 + 5 * 2021.99
}
]);
});

View File

@ -0,0 +1,25 @@
import type { RequestWithUser } from '@ghostfolio/common/types';
import { Inject, Injectable } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { PortfolioService } from './portfolio.service';
import { PortfolioServiceNew } from './portfolio.service-new';
@Injectable()
export class PortfolioServiceStrategy {
public constructor(
private readonly portfolioService: PortfolioService,
private readonly portfolioServiceNew: PortfolioServiceNew,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
public get() {
if (
this.request.user?.Settings?.settings?.['isNewCalculationEngine'] === true
) {
return this.portfolioServiceNew;
}
return this.portfolioService;
}
}

View File

@ -4,9 +4,12 @@ import {
hasNotDefinedValuesInObject,
nullifyValuesInObject
} from '@ghostfolio/api/helper/object.helper';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { baseCurrency } from '@ghostfolio/common/config';
import { parseDate } from '@ghostfolio/common/helper';
import {
PortfolioChart,
PortfolioDetails,
@ -25,17 +28,16 @@ import {
Inject,
Param,
Query,
Res,
UseGuards
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Response } from 'express';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
import { PortfolioPositions } from './interfaces/portfolio-positions.interface';
import { PortfolioService } from './portfolio.service';
import { PortfolioServiceStrategy } from './portfolio-service.strategy';
@Controller('portfolio')
export class PortfolioController {
@ -43,7 +45,7 @@ export class PortfolioController {
private readonly accessService: AccessService,
private readonly configurationService: ConfigurationService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly portfolioService: PortfolioService,
private readonly portfolioServiceStrategy: PortfolioServiceStrategy,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
) {}
@ -52,13 +54,11 @@ export class PortfolioController {
@UseGuards(AuthGuard('jwt'))
public async getChart(
@Headers('impersonation-id') impersonationId: string,
@Query('range') range,
@Res() res: Response
@Query('range') range
): Promise<PortfolioChart> {
const historicalDataContainer = await this.portfolioService.getChart(
impersonationId,
range
);
const historicalDataContainer = await this.portfolioServiceStrategy
.get()
.getChart(impersonationId, range);
let chartData = historicalDataContainer.items;
@ -90,37 +90,37 @@ export class PortfolioController {
});
}
return <any>res.json({
return {
hasError,
chart: chartData,
isAllTimeHigh: historicalDataContainer.isAllTimeHigh,
isAllTimeLow: historicalDataContainer.isAllTimeLow
});
};
}
@Get('details')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getDetails(
@Headers('impersonation-id') impersonationId: string,
@Query('range') range,
@Res() res: Response
): Promise<PortfolioDetails> {
@Query('range') range
): Promise<PortfolioDetails & { hasError: boolean }> {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
res.status(StatusCodes.FORBIDDEN);
return <any>res.json({ accounts: {}, holdings: {} });
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
let hasError = false;
const { accounts, holdings, hasErrors } =
await this.portfolioService.getDetails(
impersonationId,
this.request.user.id,
range
);
await this.portfolioServiceStrategy
.get()
.getDetails(impersonationId, this.request.user.id, range);
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
hasError = true;
@ -161,26 +161,27 @@ export class PortfolioController {
}
}
return <any>res.json({ accounts, hasError, holdings });
return { accounts, hasError, holdings };
}
@Get('investments')
@UseGuards(AuthGuard('jwt'))
public async getInvestments(
@Headers('impersonation-id') impersonationId: string,
@Res() res: Response
@Headers('impersonation-id') impersonationId: string
): Promise<PortfolioInvestments> {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
res.status(StatusCodes.FORBIDDEN);
return <any>res.json({});
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
let investments = await this.portfolioService.getInvestments(
impersonationId
);
let investments = await this.portfolioServiceStrategy
.get()
.getInvestments(impersonationId);
if (
impersonationId ||
@ -197,20 +198,18 @@ export class PortfolioController {
}));
}
return <any>res.json({ firstOrderDate: investments[0]?.date, investments });
return { firstOrderDate: parseDate(investments[0]?.date), investments };
}
@Get('performance')
@UseGuards(AuthGuard('jwt'))
public async getPerformance(
@Headers('impersonation-id') impersonationId: string,
@Query('range') range,
@Res() res: Response
@Query('range') range
): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> {
const performanceInformation = await this.portfolioService.getPerformance(
impersonationId,
range
);
const performanceInformation = await this.portfolioServiceStrategy
.get()
.getPerformance(impersonationId, range);
if (
impersonationId ||
@ -222,20 +221,19 @@ export class PortfolioController {
);
}
return <any>res.json(performanceInformation);
return performanceInformation;
}
@Get('positions')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPositions(
@Headers('impersonation-id') impersonationId: string,
@Query('range') range,
@Res() res: Response
@Query('range') range
): Promise<PortfolioPositions> {
const result = await this.portfolioService.getPositions(
impersonationId,
range
);
const result = await this.portfolioServiceStrategy
.get()
.getPositions(impersonationId, range);
if (
impersonationId ||
@ -251,13 +249,12 @@ export class PortfolioController {
});
}
return <any>res.json(result);
return result;
}
@Get('public/:accessId')
public async getPublic(
@Param('accessId') accessId,
@Res() res: Response
@Param('accessId') accessId
): Promise<PortfolioPublicDetails> {
const access = await this.accessService.access({ id: accessId });
const user = await this.userService.user({
@ -265,8 +262,10 @@ export class PortfolioController {
});
if (!access) {
res.status(StatusCodes.NOT_FOUND);
return <any>res.json({ accounts: {}, holdings: {} });
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
let hasDetails = true;
@ -274,10 +273,9 @@ export class PortfolioController {
hasDetails = user.subscription.type === 'Premium';
}
const { holdings } = await this.portfolioService.getDetails(
access.userId,
access.userId
);
const { holdings } = await this.portfolioServiceStrategy
.get()
.getDetails(access.userId, access.userId);
const portfolioPublicDetails: PortfolioPublicDetails = {
hasDetails,
@ -310,7 +308,7 @@ export class PortfolioController {
}
}
return <any>res.json(portfolioPublicDetails);
return portfolioPublicDetails;
}
@Get('summary')
@ -318,7 +316,9 @@ export class PortfolioController {
public async getSummary(
@Headers('impersonation-id') impersonationId
): Promise<PortfolioSummary> {
let summary = await this.portfolioService.getSummary(impersonationId);
let summary = await this.portfolioServiceStrategy
.get()
.getSummary(impersonationId);
if (
impersonationId ||
@ -341,16 +341,17 @@ export class PortfolioController {
return summary;
}
@Get('position/:symbol')
@Get('position/:dataSource/:symbol')
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseGuards(AuthGuard('jwt'))
public async getPosition(
@Headers('impersonation-id') impersonationId: string,
@Param('dataSource') dataSource,
@Param('symbol') symbol
): Promise<PortfolioPositionDetail> {
let position = await this.portfolioService.getPosition(
impersonationId,
symbol
);
let position = await this.portfolioServiceStrategy
.get()
.getPosition(dataSource, impersonationId, symbol);
if (position) {
if (
@ -379,19 +380,18 @@ export class PortfolioController {
@Get('report')
@UseGuards(AuthGuard('jwt'))
public async getReport(
@Headers('impersonation-id') impersonationId: string,
@Res() res: Response
@Headers('impersonation-id') impersonationId: string
): Promise<PortfolioReport> {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
res.status(StatusCodes.FORBIDDEN);
return <any>res.json({ rules: [] });
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return <any>(
res.json(await this.portfolioService.getReport(impersonationId))
);
return await this.portfolioServiceStrategy.get().getReport(impersonationId);
}
}

View File

@ -13,12 +13,14 @@ import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.mod
import { Module } from '@nestjs/common';
import { CurrentRateService } from './current-rate.service';
import { PortfolioServiceStrategy } from './portfolio-service.strategy';
import { PortfolioController } from './portfolio.controller';
import { PortfolioService } from './portfolio.service';
import { PortfolioServiceNew } from './portfolio.service-new';
import { RulesService } from './rules.service';
@Module({
exports: [PortfolioService],
exports: [PortfolioServiceStrategy],
imports: [
AccessModule,
ConfigurationModule,
@ -37,6 +39,8 @@ import { RulesService } from './rules.service';
AccountService,
CurrentRateService,
PortfolioService,
PortfolioServiceNew,
PortfolioServiceStrategy,
RulesService
]
})

File diff suppressed because it is too large Load Diff

View File

@ -107,7 +107,7 @@ export class PortfolioService {
account.currency,
userCurrency
),
value: details.accounts[account.name]?.current ?? 0
value: details.accounts[account.id]?.current ?? 0
};
delete result.Order;
@ -345,6 +345,7 @@ export class PortfolioService {
assetSubClass: symbolProfile.assetSubClass,
countries: symbolProfile.countries,
currency: item.currency,
dataSource: symbolProfile.dataSource,
exchange: dataProviderResponse.exchange,
grossPerformance: item.grossPerformance?.toNumber() ?? 0,
grossPerformancePercent:
@ -385,14 +386,21 @@ export class PortfolioService {
}
public async getPosition(
aDataSource: DataSource,
aImpersonationId: string,
aSymbol: string
): Promise<PortfolioPositionDetail> {
const userCurrency = this.request.user.Settings.currency;
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const orders = (await this.orderService.getOrders({ userId })).filter(
(order) => order.symbol === aSymbol
);
const orders = (
await this.orderService.getOrders({ userCurrency, userId })
).filter(({ SymbolProfile }) => {
return (
SymbolProfile.dataSource === aDataSource &&
SymbolProfile.symbol === aSymbol
);
});
if (orders.length <= 0) {
return {
@ -428,7 +436,7 @@ export class PortfolioService {
})
.map((order) => ({
currency: order.currency,
dataSource: order.dataSource,
dataSource: order.SymbolProfile?.dataSource ?? order.dataSource,
date: format(order.date, DATE_FORMAT),
fee: new Big(order.fee),
name: order.SymbolProfile?.name,
@ -466,7 +474,6 @@ export class PortfolioService {
} = position;
// Convert investment, gross and net performance to currency of user
const userCurrency = this.request.user.Settings.currency;
const investment = this.exchangeRateDataService.toCurrency(
position.investment?.toNumber(),
currency,
@ -846,24 +853,25 @@ export class PortfolioService {
}
public async getSummary(aImpersonationId: string): Promise<PortfolioSummary> {
const currency = this.request.user.Settings.currency;
const userCurrency = this.request.user.Settings.currency;
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const performanceInformation = await this.getPerformance(aImpersonationId);
const { balance } = await this.accountService.getCashDetails(
userId,
currency
userCurrency
);
const orders = await this.orderService.getOrders({
userCurrency,
userId
});
const dividend = this.getDividend(orders).toNumber();
const fees = this.getFees(orders).toNumber();
const firstOrderDate = orders[0]?.date;
const totalBuy = this.getTotalByType(orders, currency, 'BUY');
const totalSell = this.getTotalByType(orders, currency, 'SELL');
const totalBuy = this.getTotalByType(orders, userCurrency, 'BUY');
const totalSell = this.getTotalByType(orders, userCurrency, 'SELL');
const committedFunds = new Big(totalBuy).sub(totalSell);
@ -879,6 +887,8 @@ export class PortfolioService {
netWorth,
totalBuy,
totalSell,
annualizedPerformancePercent:
performanceInformation.performance.annualizedPerformancePercent,
cash: balance,
committedFunds: committedFunds.toNumber(),
ordersCount: orders.filter((order) => {
@ -895,8 +905,8 @@ export class PortfolioService {
}: {
cashDetails: CashDetails;
investment: Big;
value: Big;
userCurrency: string;
value: Big;
}) {
const cashPositions = {};
@ -1025,8 +1035,11 @@ export class PortfolioService {
transactionPoints: TransactionPoint[];
orders: OrderWithAccount[];
}> {
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
const orders = await this.orderService.getOrders({
includeDrafts,
userCurrency,
userId,
types: ['BUY', 'SELL']
});
@ -1035,10 +1048,9 @@ export class PortfolioService {
return { transactionPoints: [], orders: [] };
}
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
currency: order.currency,
dataSource: order.dataSource,
dataSource: order.SymbolProfile?.dataSource ?? order.dataSource,
date: format(order.date, DATE_FORMAT),
fee: new Big(
this.exchangeRateDataService.toCurrency(
@ -1091,10 +1103,11 @@ export class PortfolioService {
account.currency,
userCurrency
);
accounts[account.name] = {
accounts[account.id] = {
balance: convertedBalance,
currency: account.currency,
current: convertedBalance,
name: account.name,
original: convertedBalance
};
@ -1108,16 +1121,17 @@ export class PortfolioService {
originalValueOfSymbol *= -1;
}
if (accounts[order.Account?.name || UNKNOWN_KEY]?.current) {
accounts[order.Account?.name || UNKNOWN_KEY].current +=
if (accounts[order.Account?.id || UNKNOWN_KEY]?.current) {
accounts[order.Account?.id || UNKNOWN_KEY].current +=
currentValueOfSymbol;
accounts[order.Account?.name || UNKNOWN_KEY].original +=
accounts[order.Account?.id || UNKNOWN_KEY].original +=
originalValueOfSymbol;
} else {
accounts[order.Account?.name || UNKNOWN_KEY] = {
accounts[order.Account?.id || UNKNOWN_KEY] = {
balance: 0,
currency: order.Account?.currency,
current: currentValueOfSymbol,
name: account.name,
original: originalValueOfSymbol
};
}

View File

@ -7,6 +7,7 @@ import {
Body,
Controller,
Get,
HttpCode,
HttpException,
Inject,
Logger,
@ -17,7 +18,6 @@ import {
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Response } from 'express';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { SubscriptionService } from './subscription.service';
@ -32,11 +32,9 @@ export class SubscriptionController {
) {}
@Post('redeem-coupon')
@HttpCode(StatusCodes.OK)
@UseGuards(AuthGuard('jwt'))
public async redeemCoupon(
@Body() { couponCode }: { couponCode: string },
@Res() res: Response
) {
public async redeemCoupon(@Body() { couponCode }: { couponCode: string }) {
if (!this.request.user) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
@ -74,12 +72,10 @@ export class SubscriptionController {
`Subscription for user '${this.request.user.id}' has been created with coupon`
);
res.status(StatusCodes.OK);
return <any>res.json({
return {
message: getReasonPhrase(StatusCodes.OK),
statusCode: StatusCodes.OK
});
};
}
@Get('stripe/callback')

View File

@ -1,19 +1,17 @@
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
DefaultValuePipe,
Get,
HttpException,
Inject,
Param,
ParseBoolPipe,
Query,
UseGuards
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { DataSource, MarketData } from '@prisma/client';
import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { isDate, isEmpty } from 'lodash';
@ -23,22 +21,19 @@ import { SymbolService } from './symbol.service';
@Controller('symbol')
export class SymbolController {
public constructor(
private readonly symbolService: SymbolService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
public constructor(private readonly symbolService: SymbolService) {}
/**
* Must be before /:symbol
*/
@Get('lookup')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async lookupSymbol(
@Query() { query = '' }
): Promise<{ items: LookupItem[] }> {
try {
const encodedQuery = encodeURIComponent(query.toLowerCase());
return this.symbolService.lookup(encodedQuery);
return this.symbolService.lookup(query.toLowerCase());
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
@ -52,11 +47,12 @@ export class SymbolController {
*/
@Get(':dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getSymbolData(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string,
@Query('includeHistoricalData', new DefaultValuePipe(false), ParseBoolPipe)
includeHistoricalData: boolean
@Query('includeHistoricalData') includeHistoricalData?: number
): Promise<SymbolItem> {
if (!DataSource[dataSource]) {
throw new HttpException(

View File

@ -5,7 +5,6 @@ import {
IDataProviderHistoricalResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client';
@ -18,25 +17,24 @@ import { SymbolItem } from './interfaces/symbol-item.interface';
export class SymbolService {
public constructor(
private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService
private readonly marketDataService: MarketDataService
) {}
public async get({
dataGatheringItem,
includeHistoricalData = false
includeHistoricalData
}: {
dataGatheringItem: IDataGatheringItem;
includeHistoricalData?: boolean;
includeHistoricalData?: number;
}): Promise<SymbolItem> {
const response = await this.dataProviderService.get([dataGatheringItem]);
const { currency, marketPrice } = response[dataGatheringItem.symbol] ?? {};
if (dataGatheringItem.dataSource && marketPrice) {
let historicalData: HistoricalDataItem[];
let historicalData: HistoricalDataItem[] = [];
if (includeHistoricalData) {
const days = 30;
if (includeHistoricalData > 0) {
const days = includeHistoricalData;
const marketData = await this.marketDataService.getRange({
dateQuery: { gte: subDays(new Date(), days) },
@ -93,32 +91,6 @@ export class SymbolService {
try {
const { items } = await this.dataProviderService.search(aQuery);
results.items = items;
// Add custom symbols
const ghostfolioSymbolProfiles =
await this.prismaService.symbolProfile.findMany({
select: {
currency: true,
dataSource: true,
name: true,
symbol: true
},
where: {
AND: [
{
dataSource: DataSource.GHOSTFOLIO,
name: {
startsWith: aQuery
}
}
]
}
});
for (const ghostfolioSymbolProfile of ghostfolioSymbolProfiles) {
results.items.push(ghostfolioSymbolProfile);
}
return results;
} catch (error) {
Logger.error(error);

View File

@ -1,6 +1,11 @@
import { IsBoolean } from 'class-validator';
import { IsBoolean, IsOptional } from 'class-validator';
export class UpdateUserSettingDto {
@IsBoolean()
@IsOptional()
isNewCalculationEngine?: boolean;
@IsBoolean()
@IsOptional()
isRestrictedView?: boolean;
}

View File

@ -23,7 +23,7 @@ import {
import { REQUEST } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import { AuthGuard } from '@nestjs/passport';
import { Provider, Role } from '@prisma/client';
import { Provider } from '@prisma/client';
import { User as UserModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -115,6 +115,12 @@ export class UserController {
...data
};
for (const key in userSettings) {
if (userSettings[key] === false) {
delete userSettings[key];
}
}
return await this.userService.updateUserSetting({
userSettings,
userId: this.request.user.id

View File

@ -0,0 +1,39 @@
import { decodeDataSource } from '@ghostfolio/common/helper';
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { ConfigurationService } from '../services/configuration.service';
@Injectable()
export class TransformDataSourceInRequestInterceptor<T>
implements NestInterceptor<T, any>
{
public constructor(
private readonly configurationService: ConfigurationService
) {}
public intercept(
context: ExecutionContext,
next: CallHandler<T>
): Observable<any> {
const http = context.switchToHttp();
const request = http.getRequest();
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true) {
if (request.body.dataSource) {
request.body.dataSource = decodeDataSource(request.body.dataSource);
}
if (request.params.dataSource) {
request.params.dataSource = decodeDataSource(request.params.dataSource);
}
}
return next.handle();
}
}

View File

@ -0,0 +1,73 @@
import { encodeDataSource } from '@ghostfolio/common/helper';
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { ConfigurationService } from '../services/configuration.service';
@Injectable()
export class TransformDataSourceInResponseInterceptor<T>
implements NestInterceptor<T, any>
{
public constructor(
private readonly configurationService: ConfigurationService
) {}
public intercept(
context: ExecutionContext,
next: CallHandler<T>
): Observable<any> {
return next.handle().pipe(
map((data: any) => {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true
) {
if (data.activities) {
data.activities.map((activity) => {
activity.SymbolProfile.dataSource = encodeDataSource(
activity.SymbolProfile.dataSource
);
activity.dataSource = encodeDataSource(activity.dataSource);
return activity;
});
}
if (data.dataSource) {
data.dataSource = encodeDataSource(data.dataSource);
}
if (data.holdings) {
for (const symbol of Object.keys(data.holdings)) {
if (data.holdings[symbol].dataSource) {
data.holdings[symbol].dataSource = encodeDataSource(
data.holdings[symbol].dataSource
);
}
}
}
if (data.items) {
data.items.map((item) => {
item.dataSource = encodeDataSource(item.dataSource);
return item;
});
}
if (data.positions) {
data.positions.map((position) => {
position.dataSource = encodeDataSource(position.dataSource);
return position;
});
}
}
return data;
})
);
}
}

View File

@ -25,17 +25,17 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
};
} = {};
for (const account of Object.keys(this.accounts)) {
accounts[account] = {
name: account,
investment: this.accounts[account].current
for (const [accountId, account] of Object.entries(this.accounts)) {
accounts[accountId] = {
name: account.name,
investment: account.current
};
}
let maxItem;
let totalInvestment = 0;
Object.values(accounts).forEach((account) => {
for (const account of Object.values(accounts)) {
if (!maxItem) {
maxItem = account;
}
@ -47,7 +47,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
if (account.investment > maxItem?.investment) {
maxItem = account;
}
});
}
const maxInvestmentRatio = maxItem.investment / totalInvestment;

View File

@ -19,35 +19,35 @@ export class AccountClusterRiskInitialInvestment extends Rule<Settings> {
}
public evaluate(ruleSettings?: Settings) {
const platforms: {
const accounts: {
[symbol: string]: Pick<PortfolioPosition, 'name'> & {
investment: number;
};
} = {};
for (const account of Object.keys(this.accounts)) {
platforms[account] = {
name: account,
investment: this.accounts[account].original
for (const [accountId, account] of Object.entries(this.accounts)) {
accounts[accountId] = {
name: account.name,
investment: account.original
};
}
let maxItem;
let totalInvestment = 0;
Object.values(platforms).forEach((platform) => {
for (const account of Object.values(accounts)) {
if (!maxItem) {
maxItem = platform;
maxItem = account;
}
// Calculate total investment
totalInvestment += platform.investment;
totalInvestment += account.investment;
// Find maximum
if (platform.investment > maxItem?.investment) {
maxItem = platform;
if (account.investment > maxItem?.investment) {
maxItem = account;
}
});
}
const maxInvestmentRatio = maxItem.investment / totalInvestment;

View File

@ -13,6 +13,7 @@ export class ConfigurationService {
ACCESS_TOKEN_SALT: str(),
ALPHA_VANTAGE_API_KEY: str({ default: '' }),
CACHE_TTL: num({ default: 1 }),
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }),
ENABLE_FEATURE_BLOG: bool({ default: false }),
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
@ -25,6 +26,9 @@ export class ConfigurationService {
ENABLE_FEATURE_SYSTEM_MESSAGE: bool({ default: false }),
GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }),
GOOGLE_SECRET: str({ default: 'dummySecret' }),
GOOGLE_SHEETS_ACCOUNT: str({ default: '' }),
GOOGLE_SHEETS_ID: str({ default: '' }),
GOOGLE_SHEETS_PRIVATE_KEY: str({ default: '' }),
JWT_SECRET_KEY: str({}),
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
MAX_ORDERS_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),

View File

@ -5,6 +5,7 @@
"AVAX": "Avalanche",
"DOT": "Polkadot",
"MATIC": "Polygon",
"MINA": "Mina Protocol",
"SHIB": "Shiba Inu",
"SOL": "Solana",
"UNI3": "Uniswap"

View File

@ -473,9 +473,18 @@ export class DataGatheringService {
private async getSymbols7D(): Promise<IDataGatheringItem[]> {
const startDate = subDays(resetHours(new Date()), 7);
const symbolProfiles = await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }],
select: {
dataSource: true,
scraperConfiguration: true,
symbol: true
}
});
// Only consider symbols with incomplete market data for the last
// 7 days
const symbolsToGather = (
const symbolsNotToGather = (
await this.prismaService.marketData.groupBy({
_count: true,
by: ['symbol'],
@ -485,24 +494,15 @@ export class DataGatheringService {
})
)
.filter((group) => {
return group._count < 6;
return group._count >= 6;
})
.map((group) => {
return group.symbol;
});
const symbolProfilesToGather = (
await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }],
select: {
dataSource: true,
scraperConfiguration: true,
symbol: true
}
})
)
const symbolProfilesToGather = symbolProfiles
.filter(({ symbol }) => {
return symbolsToGather.includes(symbol);
return !symbolsNotToGather.includes(symbol);
})
.map((symbolProfile) => {
return {
@ -514,7 +514,7 @@ export class DataGatheringService {
const currencyPairsToGather = this.exchangeRateDataService
.getCurrencyPairs()
.filter(({ symbol }) => {
return symbolsToGather.includes(symbol);
return !symbolsNotToGather.includes(symbol);
})
.map(({ dataSource, symbol }) => {
return {

View File

@ -88,13 +88,13 @@ export class AlphaVantageService implements DataProviderInterface {
return DataSource.ALPHA_VANTAGE;
}
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
const result = await this.alphaVantage.data.search(aSymbol);
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const result = await this.alphaVantage.data.search(aQuery);
return {
items: result?.bestMatches?.map((bestMatch) => {
return {
dataSource: DataSource.ALPHA_VANTAGE,
dataSource: this.getName(),
name: bestMatch['2. name'],
symbol: bestMatch['1. symbol']
};

View File

@ -1,6 +1,7 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
@ -21,12 +22,14 @@ import { DataProviderService } from './data-provider.service';
AlphaVantageService,
DataProviderService,
GhostfolioScraperApiService,
GoogleSheetsService,
RakutenRapidApiService,
YahooFinanceService,
{
inject: [
AlphaVantageService,
GhostfolioScraperApiService,
GoogleSheetsService,
RakutenRapidApiService,
YahooFinanceService
],
@ -34,11 +37,13 @@ import { DataProviderService } from './data-provider.service';
useFactory: (
alphaVantageService,
ghostfolioScraperApiService,
googleSheetsService,
rakutenRapidApiService,
yahooFinanceService
) => [
alphaVantageService,
ghostfolioScraperApiService,
googleSheetsService,
rakutenRapidApiService,
yahooFinanceService
]

View File

@ -12,7 +12,7 @@ import { Granularity } from '@ghostfolio/common/types';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource, MarketData } from '@prisma/client';
import { format, isValid } from 'date-fns';
import { isEmpty } from 'lodash';
import { groupBy, isEmpty } from 'lodash';
@Injectable()
export class DataProviderService {
@ -30,18 +30,27 @@ export class DataProviderService {
[symbol: string]: IDataProviderResponse;
} = {};
for (const item of items) {
const dataProvider = this.getDataProvider(item.dataSource);
response[item.symbol] = (await dataProvider.get([item.symbol]))[
item.symbol
];
}
const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource);
const promises = [];
for (const symbol of Object.keys(response)) {
const promise = Promise.resolve(response[symbol]);
for (const [dataSource, dataGatheringItems] of Object.entries(
itemsGroupedByDataSource
)) {
const symbols = dataGatheringItems.map((dataGatheringItem) => {
return dataGatheringItem.symbol;
});
const promise = Promise.resolve(
this.getDataProvider(DataSource[dataSource]).get(symbols)
);
promises.push(
promise.then((currentResponse) => (response[symbol] = currentResponse))
promise.then((result) => {
for (const [symbol, dataProviderResponse] of Object.entries(result)) {
response[symbol] = dataProviderResponse;
}
})
);
}
@ -149,13 +158,13 @@ export class DataProviderService {
return result;
}
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const promises: Promise<{ items: LookupItem[] }>[] = [];
let lookupItems: LookupItem[] = [];
for (const dataSource of this.configurationService.get('DATA_SOURCES')) {
promises.push(
this.getDataProvider(DataSource[dataSource]).search(aSymbol)
this.getDataProvider(DataSource[dataSource]).search(aQuery)
);
}
@ -176,7 +185,7 @@ export class DataProviderService {
}
public getPrimaryDataSource(): DataSource {
return DataSource[this.configurationService.get('DATA_SOURCES')[0]];
return DataSource[this.configurationService.get('DATA_SOURCE_PRIMARY')];
}
private getDataProvider(providerName: DataSource) {

View File

@ -1,4 +1,10 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse,
MarketState
} from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import {
@ -13,13 +19,6 @@ import * as bent from 'bent';
import * as cheerio from 'cheerio';
import { format } from 'date-fns';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse,
MarketState
} from '../../interfaces/interfaces';
import { DataProviderInterface } from '../interfaces/data-provider.interface';
@Injectable()
export class GhostfolioScraperApiService implements DataProviderInterface {
private static NUMERIC_REGEXP = /[-]{0,1}[\d]*[.,]{0,1}[\d]+/g;
@ -59,7 +58,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
[symbol]: {
marketPrice,
currency: symbolProfile?.currency,
dataSource: DataSource.GHOSTFOLIO,
dataSource: this.getName(),
marketState: MarketState.delayed
}
};
@ -116,8 +115,35 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
return DataSource.GHOSTFOLIO;
}
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
return { items: [] };
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const items = await this.prismaService.symbolProfile.findMany({
select: {
currency: true,
dataSource: true,
name: true,
symbol: true
},
where: {
OR: [
{
dataSource: this.getName(),
name: {
mode: 'insensitive',
startsWith: aQuery
}
},
{
dataSource: this.getName(),
symbol: {
mode: 'insensitive',
startsWith: aQuery
}
}
]
}
});
return { items };
}
private extractNumberFromString(aString: string): number {

View File

@ -0,0 +1,181 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse,
MarketState
} from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { format } from 'date-fns';
import { GoogleSpreadsheet } from 'google-spreadsheet';
@Injectable()
export class GoogleSheetsService implements DataProviderInterface {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService
) {}
public canHandle(symbol: string) {
return true;
}
public async get(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
if (aSymbols.length <= 0) {
return {};
}
try {
const response: { [symbol: string]: IDataProviderResponse } = {};
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
aSymbols
);
const sheet = await this.getSheet({
sheetId: this.configurationService.get('GOOGLE_SHEETS_ID'),
symbol: 'Overview'
});
const rows = await sheet.getRows();
for (const row of rows) {
const marketPrice = parseFloat(row['marketPrice']);
const symbol = row['symbol'];
if (aSymbols.includes(symbol)) {
response[symbol] = {
marketPrice,
currency: symbolProfiles.find((symbolProfile) => {
return symbolProfile.symbol === symbol;
})?.currency,
dataSource: this.getName(),
marketState: MarketState.delayed
};
}
}
return response;
} catch (error) {
Logger.error(error);
}
return {};
}
public async getHistorical(
aSymbols: string[],
aGranularity: Granularity = 'day',
from: Date,
to: Date
): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> {
if (aSymbols.length <= 0) {
return {};
}
try {
const [symbol] = aSymbols;
const sheet = await this.getSheet({
symbol,
sheetId: this.configurationService.get('GOOGLE_SHEETS_ID')
});
const rows = await sheet.getRows();
const historicalData: {
[date: string]: IDataProviderHistoricalResponse;
} = {};
rows
.filter((row, index) => {
return index >= 1;
})
.forEach((row) => {
const date = parseDate(row._rawData[0]);
const close = parseFloat(row._rawData[1]);
historicalData[format(date, DATE_FORMAT)] = { marketPrice: close };
});
return {
[symbol]: historicalData
};
} catch (error) {
Logger.error(error);
}
return {};
}
public getName(): DataSource {
return DataSource.GOOGLE_SHEETS;
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const items = await this.prismaService.symbolProfile.findMany({
select: {
currency: true,
dataSource: true,
name: true,
symbol: true
},
where: {
OR: [
{
dataSource: this.getName(),
name: {
mode: 'insensitive',
startsWith: aQuery
}
},
{
dataSource: this.getName(),
symbol: {
mode: 'insensitive',
startsWith: aQuery
}
}
]
}
});
return { items };
}
private async getSheet({
sheetId,
symbol
}: {
sheetId: string;
symbol: string;
}) {
const doc = new GoogleSpreadsheet(sheetId);
await doc.useServiceAccountAuth({
client_email: this.configurationService.get('GOOGLE_SHEETS_ACCOUNT'),
private_key: this.configurationService
.get('GOOGLE_SHEETS_PRIVATE_KEY')
.replace(/\\n/g, '\n')
});
await doc.loadInfo();
const sheet = doc.sheetsByTitle[symbol];
await sheet.loadCells();
return sheet;
}
}

View File

@ -1,11 +1,10 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { Granularity } from '@ghostfolio/common/types';
import { DataSource } from '@prisma/client';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '../../interfaces/interfaces';
} from '@ghostfolio/api/services/interfaces/interfaces';
import { Granularity } from '@ghostfolio/common/types';
import { DataSource } from '@prisma/client';
export interface DataProviderInterface {
canHandle(symbol: string): boolean;
@ -23,5 +22,5 @@ export interface DataProviderInterface {
getName(): DataSource;
search(aSymbol: string): Promise<{ items: LookupItem[] }>;
search(aQuery: string): Promise<{ items: LookupItem[] }>;
}

View File

@ -45,7 +45,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
return {
[ghostfolioFearAndGreedIndexSymbol]: {
currency: undefined,
dataSource: DataSource.RAKUTEN,
dataSource: this.getName(),
marketPrice: fgi.now.value,
marketState: MarketState.open,
name: RakutenRapidApiService.FEAR_AND_GREED_INDEX_NAME
@ -85,7 +85,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
await this.prismaService.marketData.create({
data: {
symbol,
dataSource: DataSource.RAKUTEN,
dataSource: this.getName(),
date: subWeeks(getToday(), 1),
marketPrice: fgi.oneWeekAgo.value
}
@ -94,7 +94,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
await this.prismaService.marketData.create({
data: {
symbol,
dataSource: DataSource.RAKUTEN,
dataSource: this.getName(),
date: subMonths(getToday(), 1),
marketPrice: fgi.oneMonthAgo.value
}
@ -103,7 +103,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
await this.prismaService.marketData.create({
data: {
symbol,
dataSource: DataSource.RAKUTEN,
dataSource: this.getName(),
date: subYears(getToday(), 1),
marketPrice: fgi.oneYearAgo.value
}
@ -129,7 +129,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
return DataSource.RAKUTEN;
}
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
return { items: [] };
}

View File

@ -103,7 +103,7 @@ export class YahooFinanceService implements DataProviderInterface {
assetClass,
assetSubClass,
currency: value.price?.currency,
dataSource: DataSource.YAHOO,
dataSource: this.getName(),
exchange: this.parseExchange(value.price?.exchangeName),
marketState:
value.price?.marketState === 'REGULAR' ||
@ -221,12 +221,14 @@ export class YahooFinanceService implements DataProviderInterface {
return DataSource.YAHOO;
}
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const items: LookupItem[] = [];
try {
const get = bent(
`${this.yahooFinanceHostname}/v1/finance/search?q=${aSymbol}&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`,
`${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
@ -268,7 +270,7 @@ export class YahooFinanceService implements DataProviderInterface {
items.push({
symbol,
currency: value.currency,
dataSource: DataSource.YAHOO,
dataSource: this.getName(),
name: value.name
});
}

View File

@ -58,9 +58,9 @@ export class ExchangeRateDataService {
getYesterday()
);
if (isEmpty(result)) {
if (Object.keys(result).length !== this.currencyPairs.length) {
// Load currencies directly from data provider as a fallback
// if historical data is not yet available
// if historical data is not fully available
const historicalData = await this.dataProviderService.get(
this.currencyPairs.map(({ dataSource, symbol }) => {
return { dataSource, symbol };

View File

@ -4,6 +4,7 @@ export interface Environment extends CleanedEnvAccessors {
ACCESS_TOKEN_SALT: string;
ALPHA_VANTAGE_API_KEY: string;
CACHE_TTL: number;
DATA_SOURCE_PRIMARY: string;
DATA_SOURCES: string | string[]; // string is not correct, error in envalid?
ENABLE_FEATURE_BLOG: boolean;
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
@ -16,6 +17,9 @@ export interface Environment extends CleanedEnvAccessors {
ENABLE_FEATURE_SYSTEM_MESSAGE: boolean;
GOOGLE_CLIENT_ID: string;
GOOGLE_SECRET: string;
GOOGLE_SHEETS_ACCOUNT: string;
GOOGLE_SHEETS_ID: string;
GOOGLE_SHEETS_PRIVATE_KEY: string;
JWT_SECRET_KEY: string;
MAX_ITEM_IN_CACHE: number;
MAX_ORDERS_TO_IMPORT: number;

View File

@ -9,6 +9,21 @@ import { DataSource, MarketData, Prisma } from '@prisma/client';
export class MarketDataService {
public constructor(private readonly prismaService: PrismaService) {}
public async deleteMany({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
return this.prismaService.marketData.deleteMany({
where: {
dataSource,
symbol
}
});
}
public async get({
date,
symbol

View File

@ -4,14 +4,26 @@ import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { Injectable } from '@nestjs/common';
import { Prisma, SymbolProfile } from '@prisma/client';
import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
import { continents, countries } from 'countries-list';
import { ScraperConfiguration } from './data-provider/ghostfolio-scraper-api/interfaces/scraper-configuration.interface';
@Injectable()
export class SymbolProfileService {
constructor(private readonly prismaService: PrismaService) {}
public constructor(private readonly prismaService: PrismaService) {}
public async delete({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
return this.prismaService.symbolProfile.delete({
where: { dataSource_symbol: { dataSource, symbol } }
});
}
public async getSymbolProfiles(
symbols: string[]

View File

@ -59,6 +59,13 @@ const routes: Routes = [
'./pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.module'
).then((m) => m.HelloGhostfolioPageModule)
},
{
path: 'en/blog/2022/01/ghostfolio-first-months-in-open-source',
loadChildren: () =>
import(
'./pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.module'
).then((m) => m.FirstMonthsInOpenSourcePageModule)
},
{
path: 'home',
loadChildren: () =>

View File

@ -89,7 +89,7 @@ export class AppComponent implements OnDestroy, OnInit {
this.tokenStorageService.signOut();
this.userService.remove();
this.router.navigate(['/']);
document.location.href = '/';
}
public ngOnDestroy() {

View File

@ -15,7 +15,7 @@
>(Default)</span
>
</td>
<td *matFooterCellDef class="px-1" mat-footer-cell>Total</td>
<td *matFooterCellDef class="px-1" mat-footer-cell i18n>Total</td>
</ng-container>
<ng-container matColumnDef="currency">

View File

@ -20,6 +20,7 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './admin-market-data.html'
})
export class AdminMarketDataComponent implements OnDestroy, OnInit {
public currentDataSource: DataSource;
public currentSymbol: string;
public defaultDateFormat = DEFAULT_DATE_FORMAT;
public marketData: AdminMarketDataItem[] = [];
@ -43,6 +44,19 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
this.fetchAdminMarketData();
}
public onDeleteProfileData({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
this.adminService
.deleteProfileData({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {});
}
public onGatherProfileDataBySymbol({
dataSource,
symbol
@ -69,22 +83,33 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
.subscribe(() => {});
}
public setCurrentSymbol(aSymbol: string) {
this.marketDataDetails = [];
if (this.currentSymbol === aSymbol) {
this.currentSymbol = '';
} else {
this.currentSymbol = aSymbol;
this.fetchAdminMarketDataBySymbol(this.currentSymbol);
}
}
public onMarketDataChanged(withRefresh: boolean = false) {
if (withRefresh) {
this.fetchAdminMarketData();
this.fetchAdminMarketDataBySymbol(this.currentSymbol);
this.fetchAdminMarketDataBySymbol({
dataSource: this.currentDataSource,
symbol: this.currentSymbol
});
}
}
public setCurrentProfile({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
this.marketDataDetails = [];
if (this.currentSymbol === symbol) {
this.currentDataSource = undefined;
this.currentSymbol = '';
} else {
this.currentDataSource = dataSource;
this.currentSymbol = symbol;
this.fetchAdminMarketDataBySymbol({ dataSource, symbol });
}
}
@ -104,9 +129,15 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
});
}
private fetchAdminMarketDataBySymbol(aSymbol: string) {
this.dataService
.fetchAdminMarketDataBySymbol(aSymbol)
private fetchAdminMarketDataBySymbol({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
this.adminService
.fetchAdminMarketDataBySymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ marketData }) => {
this.marketDataDetails = marketData;

View File

@ -6,7 +6,9 @@
<tr class="mat-header-row">
<th class="mat-header-cell px-1 py-2" i18n>Symbol</th>
<th class="mat-header-cell px-1 py-2" i18n>Data Source</th>
<th class="mat-header-cell px-1 py-2" i18n>First Transaction</th>
<th class="mat-header-cell px-1 py-2" i18n>First Activity</th>
<th class="mat-header-cell px-1 py-2" i18n>Activity Count</th>
<th class="mat-header-cell px-1 py-2" i18n>Historical Data</th>
<th class="mat-header-cell px-1 py-2"></th>
</tr>
</thead>
@ -14,13 +16,15 @@
<ng-container *ngFor="let item of marketData; let i = index">
<tr
class="cursor-pointer mat-row"
(click)="setCurrentSymbol(item.symbol)"
(click)="setCurrentProfile({ dataSource: item.dataSource, symbol: item.symbol })"
>
<td class="mat-cell px-1 py-2">{{ item.symbol }}</td>
<td class="mat-cell px-1 py-2">{{ item.dataSource}}</td>
<td class="mat-cell px-1 py-2">{{ item.symbol }}</td>
<td class="mat-cell px-1 py-2">{{ item.dataSource }}</td>
<td class="mat-cell px-1 py-2">
{{ (item.date | date: defaultDateFormat) ?? '' }}
</td>
<td class="mat-cell px-1 py-2">{{ item.activityCount }}</td>
<td class="mat-cell px-1 py-2">{{ item.marketDataItemCount }}</td>
<td class="mat-cell px-1 py-2">
<button
class="mx-1 no-min-width px-2"
@ -45,11 +49,19 @@
>
Gather Profile Data
</button>
<button
i18n
mat-menu-item
[disabled]="item.activityCount !== 0"
(click)="onDeleteProfileData({dataSource: item.dataSource, symbol: item.symbol})"
>
Delete Profile Data
</button>
</mat-menu>
</td>
</tr>
<tr *ngIf="currentSymbol === item.symbol" class="mat-row">
<td class="p-1" colspan="4">
<td class="p-1" colspan="6">
<gf-admin-market-data-detail
[dataSource]="item.dataSource"
[marketData]="marketDataDetails"

View File

@ -12,6 +12,7 @@ import { defaultDateRangeOptions } from '@ghostfolio/common/config';
import { Position, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types';
import { DataSource } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -47,8 +48,15 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (params['positionDetailDialog'] && params['symbol']) {
this.openPositionDialog({ symbol: params['symbol'] });
if (
params['dataSource'] &&
params['positionDetailDialog'] &&
params['symbol']
) {
this.openPositionDialog({
dataSource: params['dataSource'],
symbol: params['symbol']
});
}
});
@ -91,7 +99,13 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.complete();
}
private openPositionDialog({ symbol }: { symbol: string }) {
private openPositionDialog({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
@ -101,6 +115,7 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false,
data: {
dataSource,
symbol,
baseCurrency: this.user?.settings?.baseCurrency,
deviceType: this.deviceType,

View File

@ -14,6 +14,7 @@
<gf-positions
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
[locale]="user?.settings?.locale"
[positions]="positions"
[range]="dateRange"

View File

@ -4,9 +4,8 @@ import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
import { resetHours } from '@ghostfolio/common/helper';
import { User } from '@ghostfolio/common/interfaces';
import { InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DataSource } from '@prisma/client';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -19,7 +18,9 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
public fearAndGreedIndex: number;
public hasPermissionToAccessFearAndGreedIndex: boolean;
public historicalData: HistoricalDataItem[];
public info: InfoItem;
public isLoading = true;
public readonly numberOfDays = 90;
public user: User;
private unsubscribeSubject = new Subject<void>();
@ -32,6 +33,7 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
private dataService: DataService,
private userService: UserService
) {
this.info = this.dataService.fetchInfo();
this.isLoading = true;
this.userService.stateChanged
@ -48,8 +50,8 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
if (this.hasPermissionToAccessFearAndGreedIndex) {
this.dataService
.fetchSymbolItem({
dataSource: DataSource.RAKUTEN,
includeHistoricalData: true,
dataSource: this.info.fearAndGreedDataSource,
includeHistoricalData: this.numberOfDays,
symbol: ghostfolioFearAndGreedIndexSymbol
})
.pipe(takeUntil(this.unsubscribeSubject))

View File

@ -1,18 +1,10 @@
<div
class="
align-items-center
container
d-flex
flex-grow-1
h-100
justify-content-center
w-100
"
class="align-items-center container d-flex flex-grow-1 h-100 justify-content-center w-100"
>
<div class="no-gutters row w-100">
<div class="col-xs-12 col-md-8 offset-md-2">
<div class="mb-2 text-center text-muted">
<small i18n>Last 30 Days</small>
<small i18n>Last {{ numberOfDays }} Days</small>
</div>
<gf-line-chart
class="mb-5"

View File

@ -8,6 +8,7 @@ import {
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { defaultDateRangeOptions } from '@ghostfolio/common/config';
import { PortfolioPerformance, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types';
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
import { DeviceDetectorService } from 'ngx-device-detector';
@ -25,6 +26,7 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
public deviceType: string;
public hasError: boolean;
public hasImpersonationId: boolean;
public hasPermissionToCreateOrder: boolean;
public historicalDataItems: LineChartItem[];
public isAllTimeHigh: boolean;
public isAllTimeLow: boolean;
@ -51,6 +53,11 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
if (state?.user) {
this.user = state.user;
this.hasPermissionToCreateOrder = hasPermission(
this.user.permissions,
permissions.createOrder
);
this.changeDetectorRef.markForCheck();
}
});

View File

@ -13,7 +13,7 @@
[showYAxis]="false"
></gf-line-chart>
<div
*ngIf="historicalDataItems?.length === 0"
*ngIf="hasPermissionToCreateOrder && historicalDataItems?.length === 0"
class="align-items-center d-flex h-100 justify-content-center w-100"
>
<div class="d-flex justify-content-center">

View File

@ -1,6 +0,0 @@
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
export interface PositionDetailDialogParams {
deviceType: string;
historicalDataItems: LineChartItem[];
}

View File

@ -1,12 +0,0 @@
:host {
display: block;
.mat-dialog-content {
max-height: unset;
gf-line-chart {
aspect-ratio: 16 / 9;
margin: 0 -1rem;
}
}
}

View File

@ -1,94 +0,0 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Inject
} from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { DataService } from '@ghostfolio/client/services/data.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
import { isToday, parse } from 'date-fns';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { PositionDetailDialogParams } from './interfaces/interfaces';
@Component({
selector: 'gf-performance-chart-dialog',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: 'performance-chart-dialog.html',
styleUrls: ['./performance-chart-dialog.component.scss']
})
export class PerformanceChartDialog {
public benchmarkDataItems: LineChartItem[];
public benchmarkSymbol = 'VOO';
public currency: string;
public firstBuyDate: string;
public marketPrice: number;
public historicalDataItems: LineChartItem[];
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
public dialogRef: MatDialogRef<PerformanceChartDialog>,
@Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams
) {
this.dataService
.fetchPositionDetail(this.benchmarkSymbol)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ currency, firstBuyDate, historicalData, marketPrice }) => {
this.benchmarkDataItems = [];
this.currency = currency;
this.firstBuyDate = firstBuyDate;
this.historicalDataItems = [];
this.marketPrice = marketPrice;
let coefficient = 1;
this.historicalDataItems = this.data.historicalDataItems;
this.historicalDataItems?.forEach((historicalDataItem) => {
const benchmarkItem = historicalData.find((item) => {
return item.date === historicalDataItem.date;
});
if (benchmarkItem) {
if (coefficient === 1) {
coefficient = historicalDataItem.value / benchmarkItem.value || 1;
}
this.benchmarkDataItems.push({
date: historicalDataItem.date,
value: benchmarkItem.value * coefficient
});
} else if (
isToday(parse(historicalDataItem.date, DATE_FORMAT, new Date()))
) {
this.benchmarkDataItems.push({
date: historicalDataItem.date,
value: marketPrice * coefficient
});
} else {
this.benchmarkDataItems.push({
date: historicalDataItem.date,
value: undefined
});
}
});
this.changeDetectorRef.markForCheck();
});
}
public onClose(): void {
this.dialogRef.close();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -1,27 +0,0 @@
<gf-dialog-header
mat-dialog-title
title="Performance"
[deviceType]="data.deviceType"
(closeButtonClicked)="onClose()"
></gf-dialog-header>
<div mat-dialog-content>
<div class="container p-0">
<gf-line-chart
class="mb-4"
symbol="Performance"
[benchmarkDataItems]="benchmarkDataItems"
[historicalDataItems]="historicalDataItems"
[showGradient]="true"
[showLegend]="true"
[showXAxis]="true"
[showYAxis]="false"
></gf-line-chart>
</div>
</div>
<gf-dialog-footer
mat-dialog-actions
[deviceType]="data.deviceType"
(closeButtonClicked)="onClose()"
></gf-dialog-footer>

View File

@ -1,28 +0,0 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfDialogFooterModule } from '../dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '../dialog-header/dialog-header.module';
import { PerformanceChartDialog } from './performance-chart-dialog.component';
@NgModule({
declarations: [PerformanceChartDialog],
exports: [],
imports: [
CommonModule,
GfDialogFooterModule,
GfDialogHeaderModule,
GfLineChartModule,
GfValueModule,
MatButtonModule,
MatDialogModule,
NgxSkeletonLoaderModule
],
providers: []
})
export class GfPerformanceChartDialogModule {}

View File

@ -1,5 +1,8 @@
import { DataSource } from '@prisma/client';
export interface PositionDetailDialogParams {
baseCurrency: string;
dataSource: DataSource;
deviceType: string;
locale: string;
symbol: string;

View File

@ -59,7 +59,10 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
public ngOnInit(): void {
this.dataService
.fetchPositionDetail(this.data.symbol)
.fetchPositionDetail({
dataSource: this.data.dataSource,
symbol: this.data.symbol
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(
({

View File

@ -12,7 +12,7 @@
<div class="col-12 d-flex justify-content-center mb-3">
<gf-value
size="large"
[currency]="currency"
[currency]="data.baseCurrency"
[locale]="data.locale"
[value]="value"
></gf-value>

View File

@ -3,7 +3,11 @@
<a
class="d-flex p-3 w-100"
[routerLink]="[]"
[queryParams]="{ positionDetailDialog: true, symbol: position?.symbol }"
[queryParams]="{
dataSource: position?.dataSource,
positionDetailDialog: true,
symbol: position?.symbol
}"
>
<div class="d-flex mr-2">
<gf-trend-indicator

View File

@ -108,7 +108,7 @@
}"
(click)="
!ignoreAssetSubClasses.includes(row.assetSubClass) &&
onOpenPositionDialog({ symbol: row.symbol })
onOpenPositionDialog({ dataSource: row.dataSource, symbol: row.symbol })
"
></tr>
</table>
@ -123,7 +123,12 @@
}"
></ngx-skeleton-loader>
<div *ngIf="dataSource.data.length === 0 && !isLoading" class="p-3 text-center">
<div
*ngIf="
dataSource.data.length === 0 && hasPermissionToCreateOrder && !isLoading
"
class="p-3 text-center"
>
<gf-no-transactions-info-indicator
[hasBorder]="false"
></gf-no-transactions-info-indicator>

View File

@ -14,7 +14,7 @@ import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { Router } from '@angular/router';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { AssetClass, Order as OrderModel } from '@prisma/client';
import { AssetClass, DataSource, Order as OrderModel } from '@prisma/client';
import { Subject, Subscription } from 'rxjs';
@Component({
@ -26,6 +26,7 @@ import { Subject, Subscription } from 'rxjs';
export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
@Input() baseCurrency: string;
@Input() deviceType: string;
@Input() hasPermissionToCreateOrder: boolean;
@Input() locale: string;
@Input() positions: PortfolioPosition[];
@ -74,9 +75,15 @@ export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
this.dataSource.filter = filterValue.trim().toLowerCase();
}*/
public onOpenPositionDialog({ symbol }: { symbol: string }): void {
public onOpenPositionDialog({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}): void {
this.router.navigate([], {
queryParams: { positionDetailDialog: true, symbol }
queryParams: { dataSource, symbol, positionDetailDialog: true }
});
}

View File

@ -23,7 +23,10 @@
[range]="range"
></gf-position>
</ng-container>
<div *ngIf="!hasPositions" class="p-3 text-center">
<div
*ngIf="hasPermissionToCreateOrder && !hasPositions"
class="p-3 text-center"
>
<gf-no-transactions-info-indicator
[hasBorder]="false"
></gf-no-transactions-info-indicator>

View File

@ -17,6 +17,7 @@ import { Position } from '@ghostfolio/common/interfaces';
export class PositionsComponent implements OnChanges, OnInit {
@Input() baseCurrency: string;
@Input() deviceType: string;
@Input() hasPermissionToCreateOrder: boolean;
@Input() locale: string;
@Input() positions: Position[];
@Input() range: string;

View File

@ -1,7 +1,10 @@
<div class="container p-0">
<div class="row no-gutters">
<div class="col">
<mat-card *ngIf="rules === null" class="my-2 text-center">
<mat-card
*ngIf="hasPermissionToCreateOrder && rules === null"
class="my-2 text-center"
>
<gf-no-transactions-info-indicator
[hasBorder]="false"
></gf-no-transactions-info-indicator>

View File

@ -8,6 +8,7 @@ import { PortfolioReportRule } from '@ghostfolio/common/interfaces';
styleUrls: ['./rules.component.scss']
})
export class RulesComponent {
@Input() hasPermissionToCreateOrder: boolean;
@Input() rules: PortfolioReportRule;
public constructor() {}

View File

@ -132,16 +132,35 @@
</div>
</div>
<div class="col-xs-12 col-md-4 my-2">
<h3 class="mb-0">{{ statistics?.slackCommunityUsers ?? '-' }}</h3>
<div class="h6 mb-0" i18n>Users in Slack community</div>
<a
class="d-block"
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
>
<h3 class="mb-0">
{{ statistics?.slackCommunityUsers ?? '-' }}
</h3>
<div class="h6 mb-0" i18n>Users in Slack community</div>
</a>
</div>
<div class="col-xs-12 col-md-4 my-2">
<h3 class="mb-0">{{ statistics?.gitHubContributors ?? '-' }}</h3>
<div class="h6 mb-0" i18n>Contributors on GitHub</div>
<a
class="d-block"
href="https://github.com/ghostfolio/ghostfolio/graphs/contributors"
>
<h3 class="mb-0">
{{ statistics?.gitHubContributors ?? '-' }}
</h3>
<div class="h6 mb-0" i18n>Contributors on GitHub</div>
</a>
</div>
<div class="col-xs-12 col-md-4 my-2">
<h3 class="mb-0">{{ statistics?.gitHubStargazers ?? '-' }}</h3>
<div class="h6 mb-0" i18n>Stars on GitHub</div>
<a
class="d-block"
href="https://github.com/ghostfolio/ghostfolio/stargazers"
>
<h3 class="mb-0">{{ statistics?.gitHubStargazers ?? '-' }}</h3>
<div class="h6 mb-0" i18n>Stars on GitHub</div>
</a>
</div>
</div>
</mat-card-content>
@ -150,22 +169,28 @@
</div>
<div class="row">
<div *ngIf="hasPermissionForBlog" class="col-md-6 col-xs-12 my-2">
<a class="py-2 w-100" i18n mat-stroked-button [routerLink]="['/blog']"
>Blog</a
>
</div>
<div
class="col-md-6 col-xs-12 my-2"
[ngClass]="{ 'offset-md-3': !hasPermissionForBlog }"
>
<a
class="py-2 w-100"
color="primary"
i18n
mat-stroked-button
[routerLink]="['/about', 'changelog']"
>Changelog & License</a
>
</div>
<div *ngIf="hasPermissionForBlog" class="col-md-6 col-xs-12 my-2">
<a
class="py-2 w-100"
color="primary"
i18n
mat-flat-button
[routerLink]="['/blog']"
>Blog</a
>
</div>
</div>
</div>

View File

@ -2,13 +2,8 @@
color: rgb(var(--dark-primary-text));
display: block;
a {
color: rgb(var(--dark-primary-text));
}
.mat-card {
&.about-container,
&.changelog {
&.about-container {
a {
color: rgba(var(--palette-primary-500), 1);
font-weight: 500;
@ -19,29 +14,6 @@
}
}
&.changelog {
::ng-deep {
markdown {
h1,
p {
display: none;
}
h2 {
font-size: 18px;
&:not(:first-of-type) {
margin-top: 2rem;
}
}
h3 {
font-size: 15px;
}
}
}
}
.independent-and-bootstrapped-logo {
background-image: url('/assets/bootstrapped-dark.svg');
background-position: center;
@ -57,10 +29,6 @@
:host-context(.is-dark-theme) {
color: rgb(var(--light-primary-text));
a {
color: rgb(var(--light-primary-text));
}
.mat-card {
.independent-and-bootstrapped-logo {
background-image: url('/assets/bootstrapped-light.svg');

View File

@ -192,6 +192,24 @@ export class AccountPageComponent implements OnDestroy, OnInit {
});
}
public onNewCalculationChange(aEvent: MatSlideToggleChange) {
this.dataService
.putUserSetting({ isNewCalculationEngine: aEvent.checked })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService.remove();
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
this.changeDetectorRef.markForCheck();
});
});
}
public onRedeemCoupon() {
let couponCode = prompt('Please enter your coupon code:');
couponCode = couponCode?.trim();

View File

@ -135,6 +135,23 @@
></mat-slide-toggle>
</div>
</div>
<div
*ngIf="user?.subscription"
class="align-items-center d-flex mt-4 py-1"
>
<div class="pr-1 w-50">
<div i18n>New Calculation Engine</div>
<div class="hint-text text-muted" i18n>Experimental</div>
</div>
<div class="pl-1 w-50">
<mat-slide-toggle
color="primary"
[checked]="user.settings.isNewCalculationEngine"
[disabled]="!hasPermissionToUpdateUserSettings"
(change)="onNewCalculationChange($event)"
></mat-slide-toggle>
</div>
</div>
</mat-card-content>
</mat-card>
</div>

View File

@ -1,6 +1,6 @@
<div class="blog container">
<div class="row">
<div class="col">
<div class="col-md-8 offset-md-2">
<article>
<div class="mb-4 text-center">
<h1 class="mb-1" i18n>Hallo Ghostfolio 👋</h1>
@ -141,58 +141,59 @@
</section>
<section class="mb-4">
<ul class="list-inline">
<li class="h5">
<span class="badge badge-light font-weight-normal mr-2"
>Aktie</span
>
<span class="badge badge-light font-weight-normal mr-2"
>Altersvorsorge</span
>
<span class="badge badge-light font-weight-normal mr-2"
>Anlage</span
>
<span class="badge badge-light font-weight-normal mr-2">App</span>
<span class="badge badge-light font-weight-normal mr-2"
>Cryptocurrency</span
>
<span class="badge badge-light font-weight-normal mr-2">ETF</span>
<span class="badge badge-light font-weight-normal mr-2"
>Feedback</span
>
<span class="badge badge-light font-weight-normal mr-2"
>Fintech</span
>
<span class="badge badge-light font-weight-normal mr-2"
>Ghostfolio</span
>
<span class="badge badge-light font-weight-normal mr-2"
>Investition</span
>
<span class="badge badge-light font-weight-normal mr-2"
>Open Source</span
>
<span class="badge badge-light font-weight-normal mr-2">OSS</span>
<span class="badge badge-light font-weight-normal mr-2"
>Portfolio</span
>
<span class="badge badge-light font-weight-normal mr-2"
>Software</span
>
<span class="badge badge-light font-weight-normal mr-2"
>Strategie</span
>
<span class="badge badge-light font-weight-normal mr-2"
>Trading</span
>
<span class="badge badge-light font-weight-normal mr-2"
>TypeScript</span
>
<span class="badge badge-light font-weight-normal mr-2"
>Vermögen</span
>
<span class="badge badge-light font-weight-normal mr-2"
>Wealth Management</span
>
<li class="list-inline-item">
<span class="badge badge-light">Aktie</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Altersvorsorge</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Anlage</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">App</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Cryptocurrency</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Feedback</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Fintech</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Ghostfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Investition</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Open Source</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">OSS</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Portfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Software</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Strategie</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Trading</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">TypeScript</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Vermögen</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Wealth Management</span>
</li>
</ul>
</section>

View File

@ -7,9 +7,7 @@ import { HalloGhostfolioPageComponent } from './hallo-ghostfolio-page.component'
@NgModule({
declarations: [HalloGhostfolioPageComponent],
exports: [],
imports: [CommonModule, HalloGhostfolioPageRoutingModule, RouterModule],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class HalloGhostfolioPageModule {}

View File

@ -1,6 +1,6 @@
<div class="blog container">
<div class="row">
<div class="col">
<div class="col-md-8 offset-md-2">
<article>
<div class="mb-4 text-center">
<h1 class="mb-1" i18n>Hello Ghostfolio 👋</h1>
@ -136,42 +136,44 @@
</section>
<section class="mb-4">
<ul class="list-inline">
<li class="h5">
<span class="badge badge-light font-weight-normal mr-2"
>Cryptocurrency</span
>
<span class="badge badge-light font-weight-normal mr-2">ETF</span>
<span class="badge badge-light font-weight-normal mr-2"
>Fintech</span
>
<span class="badge badge-light font-weight-normal mr-2"
>Ghostfolio</span
>
<span class="badge badge-light font-weight-normal mr-2"
>Investment</span
>
<span class="badge badge-light font-weight-normal mr-2"
>Open Source</span
>
<span class="badge badge-light font-weight-normal mr-2">OSS</span>
<span class="badge badge-light font-weight-normal mr-2"
>Portfolio</span
>
<span class="badge badge-light font-weight-normal mr-2"
>Software</span
>
<span class="badge badge-light font-weight-normal mr-2"
>Stock</span
>
<span class="badge badge-light font-weight-normal mr-2"
>Strategy</span
>
<span class="badge badge-light font-weight-normal mr-2"
>Wealth</span
>
<span class="badge badge-light font-weight-normal mr-2"
>Wealth Management</span
>
<li class="list-inline-item">
<span class="badge badge-light">Cryptocurrency</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">ETF</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Fintech</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Ghostfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Investment</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Open Source</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">OSS</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Portfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Software</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Stock</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Strategy</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Wealth</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Wealth Management</span>
</li>
</ul>
</section>

View File

@ -7,9 +7,7 @@ import { HelloGhostfolioPageComponent } from './hello-ghostfolio-page.component'
@NgModule({
declarations: [HelloGhostfolioPageComponent],
exports: [],
imports: [CommonModule, HelloGhostfolioPageRoutingModule, RouterModule],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class HelloGhostfolioPageModule {}

View File

@ -0,0 +1,19 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { FirstMonthsInOpenSourcePageComponent } from './first-months-in-open-source-page.component';
const routes: Routes = [
{
path: '',
component: FirstMonthsInOpenSourcePageComponent,
canActivate: [AuthGuard]
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class FirstMonthsInOpenSourceRoutingModule {}

View File

@ -0,0 +1,9 @@
import { Component } from '@angular/core';
@Component({
host: { class: 'mb-5' },
selector: 'gf-first-months-in-open-source-page',
styleUrls: ['./first-months-in-open-source-page.scss'],
templateUrl: './first-months-in-open-source-page.html'
})
export class FirstMonthsInOpenSourcePageComponent {}

View File

@ -0,0 +1,185 @@
<div class="blog container">
<div class="row">
<div class="col-md-8 offset-md-2">
<article>
<div class="mb-4 text-center">
<h1 class="mb-1" i18n>
👻 Ghostfolio
<span class="text-nowrap">First months in Open Source</span>
</h1>
<div class="text-muted"><small>05.01.2022</small></div>
</div>
<section class="mb-4">
<p>
In this article I would like to recap the first months running the
open source project <a href="https://ghostfol.io">Ghostfolio</a>, a
web-based personal finance management software.
</p>
</section>
<section class="mb-4">
<h2 class="h4">From 1* to 100 stars on GitHub</h2>
<p>
When I decided to
<a [routerLink]="['/en', 'blog', '2021', '07', 'hello-ghostfolio']"
>publish</a
>
the project as
<a href="https://github.com/ghostfolio/ghostfolio"
>open source software</a
>
(OSS), I did not know what exactly to expect. In the worst case,
nobody would care. And in the best case, the repository would be
overrun with contributions. The truth is probably somewhere in
between.
</p>
<p>
In the beginning, it felt quite weird to develop in public where
anyone can observe the progress. Stupid mistakes remain visible
forever. But this feeling, fortunately, quickly settled. I believe
the benefits like all the learning clearly outweigh the drawbacks
when you just do it.
</p>
<p>
At the end of 2021, Ghostfolio reached an important milestone:
<a href="https://twitter.com/ghostfolio_/status/1470075774640218121"
>100 stars</a
>
on GitHub. This is really exciting with almost no marketing. I am a
technical founder, so I prefer writing code over anything else. But
there is so much more to make this project happen: writing
documentation, maintaining bug reports and feature requests,
supporting users and managing the community, keeping the SaaS
running, etc.
</p>
<p>
Reaching 100 stars will not only attract very early adopters, but
also the early adopters. At the same time, the demands and
expectations are also increasing.
</p>
</section>
<section class="mb-4">
<h2 class="h4">What is new?</h2>
<p>
During the last months, Ghostfolio has transformed from a one man
project into a prospering wealth management application with 9
contributors and counting. User feedback has directly shaped the
direction of the product development.
</p>
<p>These are some selected key features:</p>
<ul>
<li>
Simplified self-hosting with an
<a href="https://hub.docker.com/r/ghostfolio/ghostfolio"
>official Ghostfolio docker image</a
>
on Docker Hub
</li>
<li>Improved import for activities (transactions and dividend)</li>
<li>Enriched market data for ETFs (region and industries)</li>
</ul>
</section>
<section class="mb-4">
<h2 class="h4">What is coming?</h2>
<p>Here is a brief overview of what I am planning in 2022.</p>
<p>
The goal remains to offer a simple and solid software to manage
personal finances. Thus, the main focus is on the core
functionality.
</p>
<p>
My personal goal is to reach break-even with the Saas offering (<a
[routerLink]="['/pricing']"
>Ghostfolio Premium</a
>) and regularly report about the progress and my learnings on this
exciting journey.
</p>
<p>
I have already started to build a
<a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
>community</a
>
of users. In the future, I would like to involve more contributors
to further extend the functionality of Ghostfolio (e.g. with new
reports). Get in touch with me by email at
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a> if you
are interested, Im happy to discuss ideas.
</p>
<p>
I would like to say thank you for all your feedback and support
during the last months.
</p>
<p>
Have a great start into the new year and happy investing<br />
Thomas from Ghostfolio
</p>
</section>
<section class="mb-4">
<p>* Pro Tip: add the first star to your own open source project</p>
</section>
<section class="mb-4">
<ul class="list-inline">
<li class="list-inline-item">
<span class="badge badge-light">BuildInPublic</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Community</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Cryptocurrency</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Docker</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">ETF</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Fintech</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Ghostfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Image</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Investment</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Open Source</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">OSS</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Portfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Progress</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">SaaS</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Software</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Stock</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Strategy</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Wealth</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Wealth Management</span>
</li>
</ul>
</section>
</article>
</div>
</div>
</div>

View File

@ -0,0 +1,13 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { FirstMonthsInOpenSourceRoutingModule } from './first-months-in-open-source-page-routing.module';
import { FirstMonthsInOpenSourcePageComponent } from './first-months-in-open-source-page.component';
@NgModule({
declarations: [FirstMonthsInOpenSourcePageComponent],
imports: [CommonModule, FirstMonthsInOpenSourceRoutingModule, RouterModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class FirstMonthsInOpenSourcePageModule {}

View File

@ -5,6 +5,26 @@
<mat-card class="blog-container">
<mat-card-content>
<div class="container p-0">
<div class="flex-nowrap mb-3 no-gutters row">
<a
class="d-flex w-100"
[routerLink]="['/en', 'blog', '2022', '01', 'ghostfolio-first-months-in-open-source']"
>
<div class="flex-grow-1">
<div class="h6 m-0 text-truncate">
First months in Open Source
</div>
<div class="d-flex text-muted">05.01.2021</div>
</div>
<div class="align-items-center d-flex">
<ion-icon
class="chevron text-muted"
name="chevron-forward-outline"
size="small"
></ion-icon>
</div>
</a>
</div>
<div class="flex-nowrap mb-3 no-gutters row">
<a
class="d-flex w-100"

View File

@ -12,8 +12,9 @@ import {
PortfolioPosition,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { ToggleOption } from '@ghostfolio/common/types';
import { AssetClass } from '@prisma/client';
import { AssetClass, DataSource } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -36,6 +37,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
};
public deviceType: string;
public hasImpersonationId: boolean;
public hasPermissionToCreateOrder: boolean;
public period = 'current';
public periodOptions: ToggleOption[] = [
{ label: 'Initial', value: 'original' },
@ -82,8 +84,13 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
this.routeQueryParams = route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (params['positionDetailDialog'] && params['symbol']) {
if (
params['dataSource'] &&
params['positionDetailDialog'] &&
params['symbol']
) {
this.openPositionDialog({
dataSource: params['dataSource'],
symbol: params['symbol']
});
}
@ -120,6 +127,11 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
if (state?.user) {
this.user = state.user;
this.hasPermissionToCreateOrder = hasPermission(
this.user.permissions,
permissions.createOrder
);
this.changeDetectorRef.markForCheck();
}
});
@ -155,10 +167,10 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
}
};
for (const [name, { current, original }] of Object.entries(
for (const [id, { current, name, original }] of Object.entries(
this.portfolioDetails.accounts
)) {
this.accounts[name] = {
this.accounts[id] = {
name,
value: aPeriod === 'original' ? original : current
};
@ -284,7 +296,13 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.complete();
}
private openPositionDialog({ symbol }: { symbol: string }) {
private openPositionDialog({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
@ -294,6 +312,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false,
data: {
dataSource,
symbol,
baseCurrency: this.user?.settings?.baseCurrency,
deviceType: this.deviceType,

View File

@ -197,6 +197,7 @@
<gf-positions-table
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
[locale]="user?.settings?.locale"
[positions]="positionsArray"
></gf-positions-table>

View File

@ -2,9 +2,10 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { User } from '@ghostfolio/common/interfaces';
import { Position, User } from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { differenceInDays } from 'date-fns';
import { sortBy } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -16,10 +17,12 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './analysis-page.html'
})
export class AnalysisPageComponent implements OnDestroy, OnInit {
public bottom3: Position[];
public daysInMarket: number;
public deviceType: string;
public hasImpersonationId: boolean;
public investments: InvestmentItem[];
public top3: Position[];
public user: User;
private unsubscribeSubject = new Subject<void>();
@ -58,6 +61,26 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
this.changeDetectorRef.markForCheck();
});
this.dataService
.fetchPositions({ range: 'max' })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ positions }) => {
const positionsSorted = sortBy(
positions,
'netPerformancePercentage'
).reverse();
this.top3 = positionsSorted.slice(0, 3);
if (positions?.length > 3) {
this.bottom3 = positionsSorted.slice(-3).reverse();
} else {
this.bottom3 = [];
}
this.changeDetectorRef.markForCheck();
});
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {

View File

@ -5,7 +5,7 @@
<mat-card class="mb-3">
<mat-card-header>
<mat-card-title class="align-items-center d-flex" i18n
>Timeline</mat-card-title
>Investment Timeline</mat-card-title
>
</mat-card-header>
<mat-card-content>
@ -19,4 +19,82 @@
</mat-card>
</div>
</div>
<div class="row">
<div class="col-md-6">
<mat-card class="mb-3">
<mat-card-header>
<mat-card-title class="align-items-center d-flex" i18n
>Top 3</mat-card-title
>
</mat-card-header>
<mat-card-content>
<div *ngFor="let position of top3; let i = index" class="d-flex py-1">
<div class="flex-grow-1 mr-2 text-truncate">
{{ i + 1 }}. {{ position.name }}
</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
position="end"
[colorizeSign]="true"
[isPercent]="true"
[locale]="user?.settings?.locale"
[value]="position.netPerformancePercentage"
></gf-value>
</div>
</div>
<div>
<ngx-skeleton-loader
*ngIf="!top3"
animation="pulse"
[theme]="{
height: '1.5rem',
width: '100%'
}"
></ngx-skeleton-loader>
</div>
</mat-card-content>
</mat-card>
</div>
<div class="col-md-6">
<mat-card class="mb-3">
<mat-card-header>
<mat-card-title class="align-items-center d-flex" i18n
>Bottom 3</mat-card-title
>
</mat-card-header>
<mat-card-content>
<div
*ngFor="let position of bottom3; let i = index"
class="d-flex py-1"
>
<div class="flex-grow-1 mr-2 text-truncate">
{{ i + 1 }}. {{ position.name }}
</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
position="end"
[colorizeSign]="true"
[isPercent]="true"
[locale]="user?.settings?.locale"
[value]="position.netPerformancePercentage"
></gf-value>
</div>
</div>
<div>
<ngx-skeleton-loader
*ngIf="!bottom3"
animation="pulse"
[theme]="{
height: '1.5rem',
width: '100%'
}"
></ngx-skeleton-loader>
</div>
</mat-card-content>
</mat-card>
</div>
</div>
</div>

View File

@ -2,6 +2,8 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.module';
import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { AnalysisPageRoutingModule } from './analysis-page-routing.module';
import { AnalysisPageComponent } from './analysis-page.component';
@ -13,7 +15,9 @@ import { AnalysisPageComponent } from './analysis-page.component';
AnalysisPageRoutingModule,
CommonModule,
GfInvestmentChartModule,
MatCardModule
GfValueModule,
MatCardModule,
NgxSkeletonLoaderModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]

View File

@ -1,6 +1,8 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import { PortfolioReportRule } from '@ghostfolio/common/interfaces';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { PortfolioReportRule, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -14,6 +16,8 @@ export class ReportPageComponent implements OnDestroy, OnInit {
public accountClusterRiskRules: PortfolioReportRule[];
public currencyClusterRiskRules: PortfolioReportRule[];
public feeRules: PortfolioReportRule[];
public hasPermissionToCreateOrder: boolean;
public user: User;
private unsubscribeSubject = new Subject<void>();
@ -22,7 +26,8 @@ export class ReportPageComponent implements OnDestroy, OnInit {
*/
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService
private dataService: DataService,
private userService: UserService
) {}
/**
@ -41,6 +46,21 @@ export class ReportPageComponent implements OnDestroy, OnInit {
this.changeDetectorRef.markForCheck();
});
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.hasPermissionToCreateOrder = hasPermission(
this.user.permissions,
permissions.createOrder
);
this.changeDetectorRef.markForCheck();
}
});
}
public ngOnDestroy() {

View File

@ -15,15 +15,24 @@
</p>
<div class="mb-4">
<h4 class="m-0" i18n>Currency Cluster Risks</h4>
<gf-rules [rules]="currencyClusterRiskRules"></gf-rules>
<gf-rules
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
[rules]="currencyClusterRiskRules"
></gf-rules>
</div>
<div class="mb-4">
<h4 class="m-0" i18n>Account Cluster Risks</h4>
<gf-rules [rules]="accountClusterRiskRules"></gf-rules>
<gf-rules
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
[rules]="accountClusterRiskRules"
></gf-rules>
</div>
<div>
<h4 class="m-0" i18n>Fees</h4>
<gf-rules [rules]="feeRules"></gf-rules>
<gf-rules
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
[rules]="feeRules"
></gf-rules>
</div>
</div>
</div>

View File

@ -107,18 +107,6 @@
<mat-datepicker #date disabled="false"></mat-datepicker>
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Fee</mat-label>
<input
matInput
name="fee"
required
type="number"
[(ngModel)]="data.transaction.fee"
/>
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Quantity</mat-label>
@ -141,6 +129,7 @@
type="number"
[(ngModel)]="data.transaction.unitPrice"
/>
<span class="ml-2" matSuffix>{{ data.transaction.currency }}</span>
<button
*ngIf="currentMarketPrice && (data.transaction.type === 'BUY' || data.transaction.type === 'SELL')"
mat-icon-button
@ -152,6 +141,19 @@
</button>
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Fee</mat-label>
<input
matInput
name="fee"
required
type="number"
[(ngModel)]="data.transaction.fee"
/>
<span class="ml-2" matSuffix>{{ data.transaction.currency }}</span>
</mat-form-field>
</div>
</div>
<div class="d-flex" mat-dialog-actions>
<gf-value

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