Browse Source

Merge branch 'main' into feature/fix-issue-with-recent-transactions

pull/750/head
gizmodus 3 years ago
committed by GitHub
parent
commit
bd412e5517
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 97
      CHANGELOG.md
  2. 4
      angular.json
  3. 8
      apps/api/src/app/account/account.controller.ts
  4. 1
      apps/api/src/app/account/account.module.ts
  5. 16
      apps/api/src/app/account/account.service.ts
  6. 2
      apps/api/src/app/account/interfaces/cash-details.interface.ts
  7. 16
      apps/api/src/app/admin/admin.service.ts
  8. 2
      apps/api/src/app/auth/google.strategy.ts
  9. 4
      apps/api/src/app/auth/web-auth.service.ts
  10. 5
      apps/api/src/app/export/export.service.ts
  11. 3
      apps/api/src/app/import/import-data.dto.ts
  12. 2
      apps/api/src/app/import/import.controller.ts
  13. 2
      apps/api/src/app/import/import.module.ts
  14. 35
      apps/api/src/app/import/import.service.ts
  15. 4
      apps/api/src/app/info/info.service.ts
  16. 9
      apps/api/src/app/order/order.controller.ts
  17. 67
      apps/api/src/app/order/order.service.ts
  18. 2
      apps/api/src/app/portfolio/current-rate.service.ts
  19. 5
      apps/api/src/app/portfolio/interfaces/current-positions.interface.ts
  20. 1
      apps/api/src/app/portfolio/portfolio-calculator-new-baln-buy-and-sell.spec.ts
  21. 1
      apps/api/src/app/portfolio/portfolio-calculator-new-baln-buy.spec.ts
  22. 20
      apps/api/src/app/portfolio/portfolio-calculator-new.ts
  23. 14
      apps/api/src/app/portfolio/portfolio-calculator.ts
  24. 7
      apps/api/src/app/portfolio/portfolio.controller.ts
  25. 102
      apps/api/src/app/portfolio/portfolio.service-new.ts
  26. 106
      apps/api/src/app/portfolio/portfolio.service.ts
  27. 25
      apps/api/src/app/subscription/subscription.controller.ts
  28. 21
      apps/api/src/app/subscription/subscription.service.ts
  29. 8
      apps/api/src/app/symbol/symbol.service.ts
  30. 16
      apps/api/src/interceptors/transform-data-source-in-response.interceptor.ts
  31. 130
      apps/api/src/services/data-gathering.service.ts
  32. 38
      apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts
  33. 18
      apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts
  34. 122
      apps/api/src/services/data-provider/data-provider.service.ts
  35. 95
      apps/api/src/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service.ts
  36. 104
      apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts
  37. 6
      apps/api/src/services/data-provider/interfaces/data-enhancer.interface.ts
  38. 12
      apps/api/src/services/data-provider/interfaces/data-provider.interface.ts
  39. 20
      apps/api/src/services/data-provider/manual/manual.service.ts
  40. 84
      apps/api/src/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service.ts
  41. 32
      apps/api/src/services/data-provider/yahoo-finance/interfaces/interfaces.ts
  42. 260
      apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts
  43. 20
      apps/api/src/services/exchange-rate-data.service.ts
  44. 9
      apps/api/src/services/interfaces/interfaces.ts
  45. 9
      apps/api/src/services/market-data.service.ts
  46. 11
      apps/api/src/services/twitter-bot/twitter-bot.service.ts
  47. 6
      apps/client/src/app/components/accounts-table/accounts-table.component.html
  48. 4
      apps/client/src/app/components/accounts-table/accounts-table.component.ts
  49. 6
      apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.html
  50. 5
      apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.scss
  51. 55
      apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.ts
  52. 41
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  53. 1
      apps/client/src/app/components/admin-market-data/admin-market-data.html
  54. 11
      apps/client/src/app/components/admin-overview/admin-overview.component.ts
  55. 28
      apps/client/src/app/components/admin-overview/admin-overview.html
  56. 7
      apps/client/src/app/components/admin-overview/admin-overview.module.ts
  57. 6
      apps/client/src/app/components/admin-overview/admin-overview.scss
  58. 4
      apps/client/src/app/components/admin-users/admin-users.html
  59. 4
      apps/client/src/app/components/home-holdings/home-holdings.component.ts
  60. 4
      apps/client/src/app/components/home-holdings/home-holdings.html
  61. 18
      apps/client/src/app/components/home-overview/home-overview.component.ts
  62. 5
      apps/client/src/app/components/home-overview/home-overview.html
  63. 1
      apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html
  64. 14
      apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts
  65. 6
      apps/client/src/app/components/position/position.component.html
  66. 2
      apps/client/src/app/components/position/position.component.scss
  67. 12
      apps/client/src/app/components/positions-table/positions-table.component.ts
  68. 2
      apps/client/src/app/pages/account/account-page.component.ts
  69. 18
      apps/client/src/app/pages/account/account-page.html
  70. 19
      apps/client/src/app/pages/accounts/accounts-page.component.ts
  71. 4
      apps/client/src/app/pages/accounts/accounts-page.html
  72. 16
      apps/client/src/app/pages/features/features-page.component.ts
  73. 8
      apps/client/src/app/pages/features/features-page.html
  74. 25
      apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts
  75. 4
      apps/client/src/app/pages/portfolio/allocations/allocations-page.html
  76. 53
      apps/client/src/app/pages/portfolio/portfolio-page.html
  77. 14
      apps/client/src/app/pages/portfolio/transactions/create-or-update-transaction-dialog/create-or-update-transaction-dialog.component.ts
  78. 25
      apps/client/src/app/services/admin.service.ts
  79. 16
      apps/client/src/app/services/data.service.ts
  80. 4
      apps/client/src/styles.scss
  81. 4
      docker/docker-compose.dev.yml
  82. 4
      libs/common/src/lib/helper.ts
  83. 4
      libs/common/src/lib/interfaces/accounts.interface.ts
  84. 2
      libs/common/src/lib/interfaces/admin-data.interface.ts
  85. 3
      libs/common/src/lib/interfaces/coupon.interface.ts
  86. 6
      libs/common/src/lib/interfaces/index.ts
  87. 6
      libs/common/src/lib/interfaces/responses/errors.interface.ts
  88. 6
      libs/common/src/lib/interfaces/responses/portfolio-performance-response.interface.ts
  89. 6
      libs/common/src/lib/interfaces/unique-asset.interface.ts
  90. 3
      libs/common/src/lib/types/account-with-value.type.ts
  91. 4
      libs/ui/src/lib/activities-table/activities-table.component.html
  92. 9
      libs/ui/src/lib/activities-table/activities-table.component.ts
  93. 96
      libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts
  94. 33
      package.json
  95. 2
      prisma/migrations/20220227092214_added_mutualfund_to_asset_sub_class/migration.sql
  96. 2
      prisma/migrations/20220227093650_added_url_to_symbol_profile/migration.sql
  97. 2
      prisma/migrations/20220302184222_removed_data_source_from_order/migration.sql
  98. 2
      prisma/migrations/20220302191841_removed_currency_from_order/migration.sql
  99. 2
      prisma/migrations/20220302193633_removed_symbol_from_order/migration.sql
  100. 5
      prisma/migrations/20220302200727_changed_currency_to_required_in_symbol_profile/migration.sql

97
CHANGELOG.md

@ -7,11 +7,108 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
### Added
- Added the contexts to the logger commands
### Changed
- Upgraded `Nx` from version `13.8.1` to `13.8.5`
## 1.124.0 - 06.03.2022
### Added
- Added support for setting a duration in the coupon system
### Changed
- Upgraded `ngx-skeleton-loader` from version `2.9.1` to `5.0.0`
- Upgraded `prisma` from version `3.9.1` to `3.10.0`
- Upgraded `yahoo-finance2` from version `2.1.9` to `2.2.0`
## 1.123.0 - 05.03.2022
### Added
- Included data provider errors in the API response
### Changed
- Removed the redundant attributes (`currency`, `dataSource`, `symbol`) of the activity model
- Removed the prefix for symbols with the data source `GHOSTFOLIO`
### Fixed
- Improved the account calculations
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.122.0 - 01.03.2022
### Added
- Added support for click in the portfolio proportion chart component
### Fixed
- Fixed an issue with undefined currencies after creating an activity
## 1.121.0 - 27.02.2022
### Added
- Added support for mutual funds
- Added the url to the symbol profile model
### Changed
- Migrated from `yahoo-finance` to `yahoo-finance2`
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.120.0 - 25.02.2022
### Changed
- Distinguished the labels _Other_ and _Unknown_ in the portfolio proportion chart component
- Improved the portfolio entry page
### Fixed
- Fixed the _Zen Mode_
## 1.119.0 - 21.02.2022
### Added
- Added a trial for the subscription
## 1.118.0 - 20.02.2022
### Changed
- Improved the calculation of the overall performance percentage in the new calculation engine
- Displayed features in features overview page based on permissions
- Extended the data points of historical data in the admin control panel
## 1.117.0 - 19.02.2022
### Changed
- Moved the countries and sectors charts in the position detail dialog
- Distinguished today's data point of historical data in the admin control panel
- Restructured the server modules
### Fixed
- Fixed the allocations by account for non-unique account names
- Added a fallback to the default account if the `accountId` is invalid in the import functionality for activities
## 1.116.0 - 16.02.2022
### Added

4
angular.json

@ -9,7 +9,7 @@
"schematics": {},
"architect": {
"build": {
"builder": "@nrwl/node:build",
"builder": "@nrwl/node:webpack",
"options": {
"outputPath": "dist/apps/api",
"main": "apps/api/src/main.ts",
@ -33,7 +33,7 @@
"outputs": ["{options.outputPath}"]
},
"serve": {
"builder": "@nrwl/node:execute",
"builder": "@nrwl/node:node",
"options": {
"buildTarget": "api:build"
}

8
apps/api/src/app/account/account.controller.ts

@ -101,16 +101,18 @@ export class AccountController {
) {
accountsWithAggregations = {
...nullifyValuesInObject(accountsWithAggregations, [
'totalBalance',
'totalValue'
'totalBalanceInBaseCurrency',
'totalValueInBaseCurrency'
]),
accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [
'balance',
'balanceInBaseCurrency',
'convertedBalance',
'fee',
'quantity',
'unitPrice',
'value'
'value',
'valueInBaseCurrency'
])
};
}

1
apps/api/src/app/account/account.module.ts

@ -13,6 +13,7 @@ import { AccountService } from './account.service';
@Module({
controllers: [AccountController],
exports: [AccountService],
imports: [
ConfigurationModule,
DataProviderModule,

16
apps/api/src/app/account/account.service.ts

@ -2,6 +2,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable } from '@nestjs/common';
import { Account, Order, Platform, Prisma } from '@prisma/client';
import Big from 'big.js';
import { CashDetails } from './interfaces/cash-details.interface';
@ -105,21 +106,26 @@ export class AccountService {
aUserId: string,
aCurrency: string
): Promise<CashDetails> {
let totalCashBalance = 0;
let totalCashBalanceInBaseCurrency = new Big(0);
const accounts = await this.accounts({
where: { userId: aUserId }
});
accounts.forEach((account) => {
totalCashBalance += this.exchangeRateDataService.toCurrency(
for (const account of accounts) {
totalCashBalanceInBaseCurrency = totalCashBalanceInBaseCurrency.plus(
this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
aCurrency
)
);
});
}
return { accounts, balance: totalCashBalance };
return {
accounts,
balanceInBaseCurrency: totalCashBalanceInBaseCurrency.toNumber()
};
}
public async updateAccount(

2
apps/api/src/app/account/interfaces/cash-details.interface.ts

@ -2,5 +2,5 @@ import { Account } from '@prisma/client';
export interface CashDetails {
accounts: Account[];
balance: number;
balanceInBaseCurrency: number;
}

16
apps/api/src/app/admin/admin.service.ts

@ -11,7 +11,8 @@ import {
AdminData,
AdminMarketData,
AdminMarketDataDetails,
AdminMarketDataItem
AdminMarketDataItem,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { DataSource, Property } from '@prisma/client';
@ -30,13 +31,7 @@ export class AdminService {
private readonly symbolProfileService: SymbolProfileService
) {}
public async deleteProfileData({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
public async deleteProfileData({ dataSource, symbol }: UniqueAsset) {
await this.marketDataService.deleteMany({ dataSource, symbol });
await this.symbolProfileService.delete({ dataSource, symbol });
}
@ -137,10 +132,7 @@ export class AdminService {
public async getMarketDataBySymbol({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}): Promise<AdminMarketDataDetails> {
}: UniqueAsset): Promise<AdminMarketDataDetails> {
return {
marketData: await this.marketDataService.marketDataItems({
orderBy: {

2
apps/api/src/app/auth/google.strategy.ts

@ -42,7 +42,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
done(null, user);
} catch (error) {
Logger.error(error);
Logger.error(error, 'GoogleStrategy');
done(error, false);
}
}

4
apps/api/src/app/auth/web-auth.service.ts

@ -95,7 +95,7 @@ export class WebAuthService {
};
verification = await verifyRegistrationResponse(opts);
} catch (error) {
Logger.error(error);
Logger.error(error, 'WebAuthService');
throw new InternalServerErrorException(error.message);
}
@ -193,7 +193,7 @@ export class WebAuthService {
};
verification = verifyAuthenticationResponse(opts);
} catch (error) {
Logger.error(error);
Logger.error(error, 'WebAuthService');
throw new InternalServerErrorException({ error: error.message });
}

5
apps/api/src/app/export/export.service.ts

@ -18,8 +18,6 @@ export class ExportService {
orderBy: { date: 'desc' },
select: {
accountId: true,
currency: true,
dataSource: true,
date: true,
fee: true,
id: true,
@ -42,7 +40,6 @@ export class ExportService {
orders: orders.map(
({
accountId,
currency,
date,
fee,
quantity,
@ -52,12 +49,12 @@ export class ExportService {
}) => {
return {
accountId,
currency,
date,
fee,
quantity,
type,
unitPrice,
currency: SymbolProfile.currency,
dataSource: SymbolProfile.dataSource,
symbol: type === 'ITEM' ? SymbolProfile.name : SymbolProfile.symbol
};

3
apps/api/src/app/import/import-data.dto.ts

@ -1,5 +1,4 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Order } from '@prisma/client';
import { Type } from 'class-transformer';
import { IsArray, ValidateNested } from 'class-validator';
@ -7,5 +6,5 @@ export class ImportDataDto {
@IsArray()
@Type(() => CreateOrderDto)
@ValidateNested({ each: true })
orders: Order[];
orders: CreateOrderDto[];
}

2
apps/api/src/app/import/import.controller.ts

@ -40,7 +40,7 @@ export class ImportController {
userId: this.request.user.id
});
} catch (error) {
Logger.error(error);
Logger.error(error, ImportController);
throw new HttpException(
{

2
apps/api/src/app/import/import.module.ts

@ -1,3 +1,4 @@
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
@ -13,6 +14,7 @@ import { ImportService } from './import.service';
@Module({
controllers: [ImportController],
imports: [
AccountModule,
CacheModule,
ConfigurationModule,
DataGatheringModule,

35
apps/api/src/app/import/import.service.ts

@ -1,13 +1,15 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { Injectable } from '@nestjs/common';
import { Order } from '@prisma/client';
import { isSameDay, parseISO } from 'date-fns';
@Injectable()
export class ImportService {
public constructor(
private readonly accountService: AccountService,
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService,
private readonly orderService: OrderService
@ -17,7 +19,7 @@ export class ImportService {
orders,
userId
}: {
orders: Partial<Order>[];
orders: Partial<CreateOrderDto>[];
userId: string;
}): Promise<void> {
for (const order of orders) {
@ -32,6 +34,12 @@ export class ImportService {
await this.validateOrders({ orders, userId });
const accountIds = (await this.accountService.getAccounts(userId)).map(
(account) => {
return account.id;
}
);
for (const {
accountId,
currency,
@ -44,19 +52,17 @@ export class ImportService {
unitPrice
} of orders) {
await this.orderService.createOrder({
accountId,
currency,
dataSource,
fee,
quantity,
symbol,
type,
unitPrice,
userId,
accountId: accountIds.includes(accountId) ? accountId : undefined,
date: parseISO(<string>(<unknown>date)),
SymbolProfile: {
connectOrCreate: {
create: {
currency,
dataSource,
symbol
},
@ -77,7 +83,7 @@ export class ImportService {
orders,
userId
}: {
orders: Partial<Order>[];
orders: Partial<CreateOrderDto>[];
userId: string;
}) {
if (
@ -91,6 +97,7 @@ export class ImportService {
}
const existingOrders = await this.orderService.orders({
include: { SymbolProfile: true },
orderBy: { date: 'desc' },
where: { userId }
});
@ -101,12 +108,12 @@ export class ImportService {
] of orders.entries()) {
const duplicateOrder = existingOrders.find((order) => {
return (
order.currency === currency &&
order.dataSource === dataSource &&
order.SymbolProfile.currency === currency &&
order.SymbolProfile.dataSource === dataSource &&
isSameDay(order.date, parseISO(<string>(<unknown>date))) &&
order.fee === fee &&
order.quantity === quantity &&
order.symbol === symbol &&
order.SymbolProfile.symbol === symbol &&
order.type === type &&
order.unitPrice === unitPrice
);
@ -117,19 +124,19 @@ export class ImportService {
}
if (dataSource !== 'MANUAL') {
const result = await this.dataProviderService.get([
const quotes = await this.dataProviderService.getQuotes([
{ dataSource, symbol }
]);
if (result[symbol] === undefined) {
if (quotes[symbol] === undefined) {
throw new Error(
`orders.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
);
}
if (result[symbol].currency !== currency) {
if (quotes[symbol].currency !== currency) {
throw new Error(
`orders.${index}.currency ("${currency}") does not match with "${result[symbol].currency}"`
`orders.${index}.currency ("${currency}") does not match with "${quotes[symbol].currency}"`
);
}
}

4
apps/api/src/app/info/info.service.ts

@ -144,7 +144,7 @@ export class InfoService {
const contributors = await get();
return contributors?.length;
} catch (error) {
Logger.error(error);
Logger.error(error, 'InfoService');
return undefined;
}
@ -165,7 +165,7 @@ export class InfoService {
const { stargazers_count } = await get();
return stargazers_count;
} catch (error) {
Logger.error(error);
Logger.error(error, 'InfoService');
return undefined;
}

9
apps/api/src/app/order/order.controller.ts

@ -114,6 +114,7 @@ export class OrderController {
SymbolProfile: {
connectOrCreate: {
create: {
currency: data.currency,
dataSource: data.dataSource,
symbol: data.symbol
},
@ -171,6 +172,14 @@ export class OrderController {
id_userId: { id: accountId, userId: this.request.user.id }
}
},
SymbolProfile: {
connect: {
dataSource_symbol: {
dataSource: data.dataSource,
symbol: data.symbol
}
}
},
User: { connect: { id: this.request.user.id } }
},
where: {

67
apps/api/src/app/order/order.service.ts

@ -53,7 +53,13 @@ export class OrderService {
}
public async createOrder(
data: Prisma.OrderCreateInput & { accountId?: string; userId: string }
data: Prisma.OrderCreateInput & {
accountId?: string;
currency?: string;
dataSource?: DataSource;
symbol?: string;
userId: string;
}
): Promise<Order> {
const defaultAccount = (
await this.accountService.getAccounts(data.userId)
@ -71,15 +77,13 @@ export class OrderService {
};
if (data.type === 'ITEM') {
const currency = data.currency;
const currency = data.SymbolProfile.connectOrCreate.create.currency;
const dataSource: DataSource = 'MANUAL';
const id = uuidv4();
const name = data.SymbolProfile.connectOrCreate.create.symbol;
Account = undefined;
data.dataSource = dataSource;
data.id = id;
data.symbol = null;
data.SymbolProfile.connectOrCreate.create.currency = currency;
data.SymbolProfile.connectOrCreate.create.dataSource = dataSource;
data.SymbolProfile.connectOrCreate.create.name = name;
@ -93,29 +97,32 @@ export class OrderService {
data.SymbolProfile.connectOrCreate.create.symbol.toUpperCase();
}
await this.dataGatheringService.gatherProfileData([
{
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
symbol: data.SymbolProfile.connectOrCreate.create.symbol
}
]);
const isDraft = isAfter(data.date as Date, endOfToday());
if (!isDraft) {
// Gather symbol data of order in the background, if not draft
this.dataGatheringService.gatherSymbols([
{
dataSource: data.dataSource,
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
date: <Date>data.date,
symbol: data.SymbolProfile.connectOrCreate.create.symbol
}
]);
}
this.dataGatheringService.gatherProfileData([
{
dataSource: data.dataSource,
symbol: data.SymbolProfile.connectOrCreate.create.symbol
}
]);
await this.cacheService.flush();
delete data.accountId;
delete data.currency;
delete data.dataSource;
delete data.symbol;
delete data.userId;
const orderData: Prisma.OrderCreateInput = data;
@ -193,50 +200,60 @@ export class OrderService {
value,
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
order.fee,
order.currency,
order.SymbolProfile.currency,
userCurrency
),
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value,
order.currency,
order.SymbolProfile.currency,
userCurrency
)
};
});
}
public async updateOrder(params: {
public async updateOrder({
data,
where
}: {
data: Prisma.OrderUpdateInput & {
currency?: string;
dataSource?: DataSource;
symbol?: string;
};
where: Prisma.OrderWhereUniqueInput;
data: Prisma.OrderUpdateInput;
}): Promise<Order> {
const { data, where } = params;
if (data.Account.connect.id_userId.id === null) {
delete data.Account;
}
let isDraft = false;
if (data.type === 'ITEM') {
const name = data.symbol;
const name = data.SymbolProfile.connect.dataSource_symbol.symbol;
data.symbol = null;
data.SymbolProfile = { update: { name } };
}
const isDraft = isAfter(data.date as Date, endOfToday());
} else {
isDraft = isAfter(data.date as Date, endOfToday());
if (!isDraft) {
// Gather symbol data of order in the background, if not draft
this.dataGatheringService.gatherSymbols([
{
dataSource: <DataSource>data.dataSource,
dataSource: data.SymbolProfile.connect.dataSource_symbol.dataSource,
date: <Date>data.date,
symbol: <string>data.symbol
symbol: data.SymbolProfile.connect.dataSource_symbol.symbol
}
]);
}
}
await this.cacheService.flush();
delete data.currency;
delete data.dataSource;
delete data.symbol;
return this.prismaService.order.update({
data: {
...data,

2
apps/api/src/app/portfolio/current-rate.service.ts

@ -40,7 +40,7 @@ export class CurrentRateService {
const today = resetHours(new Date());
promises.push(
this.dataProviderService
.get(dataGatheringItems)
.getQuotes(dataGatheringItems)
.then((dataResultProvider) => {
const result = [];
for (const dataGatheringItem of dataGatheringItems) {

5
apps/api/src/app/portfolio/interfaces/current-positions.interface.ts

@ -1,8 +1,7 @@
import { TimelinePosition } from '@ghostfolio/common/interfaces';
import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
import Big from 'big.js';
export interface CurrentPositions {
hasErrors: boolean;
export interface CurrentPositions extends ResponseError {
positions: TimelinePosition[];
grossPerformance: Big;
grossPerformancePercentage: Big;

1
apps/api/src/app/portfolio/portfolio-calculator-new-baln-buy-and-sell.spec.ts

@ -66,6 +66,7 @@ describe('PortfolioCalculatorNew', () => {
expect(currentPositions).toEqual({
currentValue: new Big('0'),
errors: [],
grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.0440867739678096571'),
hasErrors: false,

1
apps/api/src/app/portfolio/portfolio-calculator-new-baln-buy.spec.ts

@ -55,6 +55,7 @@ describe('PortfolioCalculatorNew', () => {
expect(currentPositions).toEqual({
currentValue: new Big('297.8'),
errors: [],
grossPerformance: new Big('24.6'),
grossPerformancePercentage: new Big('0.09004392386530014641'),
hasErrors: false,

20
apps/api/src/app/portfolio/portfolio-calculator-new.ts

@ -1,7 +1,11 @@
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 {
ResponseError,
TimelinePosition,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { Logger } from '@nestjs/common';
import { Type as TypeOfOrder } from '@prisma/client';
import Big from 'big.js';
@ -36,7 +40,7 @@ export class PortfolioCalculatorNew {
private static readonly CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT =
true;
private static readonly ENABLE_LOGGING = true;
private static readonly ENABLE_LOGGING = false;
private currency: string;
private currentRateService: CurrentRateService;
@ -234,6 +238,7 @@ export class PortfolioCalculatorNew {
const positions: TimelinePosition[] = [];
let hasAnySymbolMetricsErrors = false;
const errors: ResponseError['errors'] = [];
for (const item of lastTransactionPoint.items) {
const marketValue = marketSymbolMap[todayString]?.[item.symbol];
@ -275,12 +280,17 @@ export class PortfolioCalculatorNew {
symbol: item.symbol,
transactionCount: item.transactionCount
});
if (hasErrors) {
errors.push({ dataSource: item.dataSource, symbol: item.symbol });
}
}
const overall = this.calculateOverallPerformance(positions, initialValues);
return {
...overall,
errors,
positions,
hasErrors: hasAnySymbolMetricsErrors || overall.hasErrors
};
@ -450,7 +460,8 @@ export class PortfolioCalculatorNew {
);
} else if (!currentPosition.quantity.eq(0)) {
Logger.warn(
`Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`
`Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`,
'PortfolioCalculatorNew'
);
hasErrors = true;
}
@ -515,7 +526,8 @@ export class PortfolioCalculatorNew {
} catch (error) {
Logger.error(
`Failed to fetch info for date ${startDate} with exception`,
error
error,
'PortfolioCalculatorNew'
);
return null;
}

14
apps/api/src/app/portfolio/portfolio-calculator.ts

@ -238,7 +238,10 @@ export class PortfolioCalculator {
if (!marketSymbolMap[nextDate]?.[item.symbol]) {
invalidSymbols.push(item.symbol);
hasErrors = true;
Logger.warn(`Missing value for symbol ${item.symbol} at ${nextDate}`);
Logger.warn(
`Missing value for symbol ${item.symbol} at ${nextDate}`,
'PortfolioCalculator'
);
continue;
}
let lastInvestment: Big = new Big(0);
@ -270,7 +273,8 @@ export class PortfolioCalculator {
invalidSymbols.push(item.symbol);
hasErrors = true;
Logger.warn(
`Missing value for symbol ${item.symbol} at ${currentDate}`
`Missing value for symbol ${item.symbol} at ${currentDate}`,
'PortfolioCalculator'
);
continue;
}
@ -514,7 +518,8 @@ export class PortfolioCalculator {
);
} else if (!currentPosition.quantity.eq(0)) {
Logger.warn(
`Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`
`Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`,
'PortfolioCalculator'
);
hasErrors = true;
}
@ -581,7 +586,8 @@ export class PortfolioCalculator {
} catch (error) {
Logger.error(
`Failed to fetch info for date ${startDate} with exception`,
error
error,
'PortfolioCalculator'
);
return null;
}

7
apps/api/src/app/portfolio/portfolio.controller.ts

@ -14,7 +14,7 @@ import {
PortfolioChart,
PortfolioDetails,
PortfolioInvestments,
PortfolioPerformance,
PortfolioPerformanceResponse,
PortfolioPublicDetails,
PortfolioReport,
PortfolioSummary
@ -33,6 +33,7 @@ import {
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { ViewMode } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
@ -203,16 +204,18 @@ export class PortfolioController {
@Get('performance')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPerformance(
@Headers('impersonation-id') impersonationId: string,
@Query('range') range
): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> {
): Promise<PortfolioPerformanceResponse> {
const performanceInformation = await this.portfolioServiceStrategy
.get()
.getPerformance(impersonationId, range);
if (
impersonationId ||
this.request.user.Settings.viewMode === ViewMode.ZEN ||
this.userService.isRestrictedView(this.request.user)
) {
performanceInformation.performance = nullifyValuesInObject(

102
apps/api/src/app/portfolio/portfolio.service-new.ts

@ -24,7 +24,7 @@ import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import {
Accounts,
PortfolioDetails,
PortfolioPerformance,
PortfolioPerformanceResponse,
PortfolioReport,
PortfolioSummary,
Position,
@ -100,15 +100,22 @@ export class PortfolioServiceNew {
}
}
const value = details.accounts[account.id]?.current ?? 0;
const result = {
...account,
transactionCount,
convertedBalance: this.exchangeRateDataService.toCurrency(
value,
balanceInBaseCurrency: this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
userCurrency
),
value: details.accounts[account.id]?.current ?? 0
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value,
account.currency,
userCurrency
)
};
delete result.Order;
@ -119,17 +126,26 @@ export class PortfolioServiceNew {
public async getAccountsWithAggregations(aUserId: string): Promise<Accounts> {
const accounts = await this.getAccounts(aUserId);
let totalBalance = 0;
let totalValue = 0;
let totalBalanceInBaseCurrency = new Big(0);
let totalValueInBaseCurrency = new Big(0);
let transactionCount = 0;
for (const account of accounts) {
totalBalance += account.convertedBalance;
totalValue += account.value;
totalBalanceInBaseCurrency = totalBalanceInBaseCurrency.plus(
account.balanceInBaseCurrency
);
totalValueInBaseCurrency = totalValueInBaseCurrency.plus(
account.valueInBaseCurrency
);
transactionCount += account.transactionCount;
}
return { accounts, totalBalance, totalValue, transactionCount };
return {
accounts,
transactionCount,
totalBalanceInBaseCurrency: totalBalanceInBaseCurrency.toNumber(),
totalValueInBaseCurrency: totalValueInBaseCurrency.toNumber()
};
}
public async getInvestments(
@ -293,13 +309,11 @@ export class PortfolioServiceNew {
orders: portfolioOrders
});
if (transactionPoints?.length <= 0) {
return { accounts: {}, holdings: {}, hasErrors: false };
}
portfolioCalculator.setTransactionPoints(transactionPoints);
const portfolioStart = parseDate(transactionPoints[0].date);
const portfolioStart = parseDate(
transactionPoints[0]?.date ?? format(new Date(), DATE_FORMAT)
);
const startDate = this.getStartDate(aDateRange, portfolioStart);
const currentPositions = await portfolioCalculator.getCurrentPositions(
startDate
@ -312,9 +326,11 @@ export class PortfolioServiceNew {
const holdings: PortfolioDetails['holdings'] = {};
const totalInvestment = currentPositions.totalInvestment.plus(
cashDetails.balance
cashDetails.balanceInBaseCurrency
);
const totalValue = currentPositions.currentValue.plus(
cashDetails.balanceInBaseCurrency
);
const totalValue = currentPositions.currentValue.plus(cashDetails.balance);
const dataGatheringItems = currentPositions.positions.map((position) => {
return {
@ -327,7 +343,7 @@ export class PortfolioServiceNew {
);
const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.get(dataGatheringItems),
this.dataProviderService.getQuotes(dataGatheringItems),
this.symbolProfileService.getSymbolProfiles(symbols)
]);
@ -358,7 +374,6 @@ export class PortfolioServiceNew {
countries: symbolProfile.countries,
currency: item.currency,
dataSource: symbolProfile.dataSource,
exchange: dataProviderResponse.exchange,
grossPerformance: item.grossPerformance?.toNumber() ?? 0,
grossPerformancePercent:
item.grossPerformancePercentage?.toNumber() ?? 0,
@ -435,7 +450,7 @@ export class PortfolioServiceNew {
};
}
const positionCurrency = orders[0].currency;
const positionCurrency = orders[0].SymbolProfile.currency;
const [SymbolProfile] = await this.symbolProfileService.getSymbolProfiles([
aSymbol
]);
@ -445,13 +460,13 @@ export class PortfolioServiceNew {
return order.type === 'BUY' || order.type === 'SELL';
})
.map((order) => ({
currency: order.currency,
dataSource: order.SymbolProfile?.dataSource ?? order.dataSource,
currency: order.SymbolProfile.currency,
dataSource: order.SymbolProfile.dataSource,
date: format(order.date, DATE_FORMAT),
fee: new Big(order.fee),
name: order.SymbolProfile?.name,
quantity: new Big(order.quantity),
symbol: order.symbol,
symbol: order.SymbolProfile.symbol,
type: order.type,
unitPrice: new Big(order.unitPrice)
}));
@ -578,7 +593,7 @@ export class PortfolioServiceNew {
)
};
} else {
const currentData = await this.dataProviderService.get([
const currentData = await this.dataProviderService.getQuotes([
{ dataSource: DataSource.YAHOO, symbol: aSymbol }
]);
const marketPrice = currentData[aSymbol]?.marketPrice;
@ -679,7 +694,7 @@ export class PortfolioServiceNew {
const symbols = positions.map((position) => position.symbol);
const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.get(dataGatheringItem),
this.dataProviderService.getQuotes(dataGatheringItem),
this.symbolProfileService.getSymbolProfiles(symbols)
]);
@ -715,7 +730,7 @@ export class PortfolioServiceNew {
public async getPerformance(
aImpersonationId: string,
aDateRange: DateRange = 'max'
): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> {
): Promise<PortfolioPerformanceResponse> {
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const { portfolioOrders, transactionPoints } =
@ -761,6 +776,7 @@ export class PortfolioServiceNew {
currentPositions.netPerformancePercentage.toNumber();
return {
errors: currentPositions.errors,
hasErrors: currentPositions.hasErrors || hasErrors,
performance: {
currentGrossPerformance,
@ -870,7 +886,7 @@ export class PortfolioServiceNew {
const performanceInformation = await this.getPerformance(aImpersonationId);
const { balance } = await this.accountService.getCashDetails(
const { balanceInBaseCurrency } = await this.accountService.getCashDetails(
userId,
userCurrency
);
@ -888,7 +904,7 @@ export class PortfolioServiceNew {
const committedFunds = new Big(totalBuy).minus(totalSell);
const netWorth = new Big(balance)
const netWorth = new Big(balanceInBaseCurrency)
.plus(performanceInformation.performance.currentValue)
.plus(items)
.toNumber();
@ -918,7 +934,7 @@ export class PortfolioServiceNew {
netWorth,
totalBuy,
totalSell,
cash: balance,
cash: balanceInBaseCurrency,
committedFunds: committedFunds.toNumber(),
ordersCount: orders.filter((order) => {
return order.type === 'BUY' || order.type === 'SELL';
@ -1007,7 +1023,7 @@ export class PortfolioServiceNew {
.map((order) => {
return this.exchangeRateDataService.toCurrency(
new Big(order.quantity).mul(order.unitPrice).toNumber(),
order.currency,
order.SymbolProfile.currency,
this.request.user.Settings.currency
);
})
@ -1026,7 +1042,7 @@ export class PortfolioServiceNew {
.map((order) => {
return this.exchangeRateDataService.toCurrency(
order.fee,
order.currency,
order.SymbolProfile.currency,
this.request.user.Settings.currency
);
})
@ -1048,7 +1064,7 @@ export class PortfolioServiceNew {
.map((order) => {
return this.exchangeRateDataService.toCurrency(
new Big(order.quantity).mul(order.unitPrice).toNumber(),
order.currency,
order.SymbolProfile.currency,
this.request.user.Settings.currency
);
})
@ -1101,24 +1117,24 @@ export class PortfolioServiceNew {
}
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
currency: order.currency,
dataSource: order.SymbolProfile?.dataSource ?? order.dataSource,
currency: order.SymbolProfile.currency,
dataSource: order.SymbolProfile.dataSource,
date: format(order.date, DATE_FORMAT),
fee: new Big(
this.exchangeRateDataService.toCurrency(
order.fee,
order.currency,
order.SymbolProfile.currency,
userCurrency
)
),
name: order.SymbolProfile?.name,
quantity: new Big(order.quantity),
symbol: order.symbol,
symbol: order.SymbolProfile.symbol,
type: order.type,
unitPrice: new Big(
this.exchangeRateDataService.toCurrency(
order.unitPrice,
order.currency,
order.SymbolProfile.currency,
userCurrency
)
)
@ -1154,22 +1170,18 @@ export class PortfolioServiceNew {
return accountId === account.id;
});
const convertedBalance = this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
userCurrency
);
accounts[account.id] = {
balance: convertedBalance,
balance: account.balance,
currency: account.currency,
current: convertedBalance,
current: account.balance,
name: account.name,
original: convertedBalance
original: account.balance
};
for (const order of ordersByAccount) {
let currentValueOfSymbol =
order.quantity * portfolioItemsNow[order.symbol].marketPrice;
order.quantity *
portfolioItemsNow[order.SymbolProfile.symbol].marketPrice;
let originalValueOfSymbol = order.quantity * order.unitPrice;
if (order.type === 'SELL') {
@ -1219,7 +1231,7 @@ export class PortfolioServiceNew {
.map((order) => {
return this.exchangeRateDataService.toCurrency(
order.quantity * order.unitPrice,
order.currency,
order.SymbolProfile.currency,
currency
);
})

106
apps/api/src/app/portfolio/portfolio.service.ts

@ -25,7 +25,7 @@ import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import {
Accounts,
PortfolioDetails,
PortfolioPerformance,
PortfolioPerformanceResponse,
PortfolioReport,
PortfolioSummary,
Position,
@ -99,15 +99,22 @@ export class PortfolioService {
}
}
const value = details.accounts[account.id]?.current ?? 0;
const result = {
...account,
transactionCount,
convertedBalance: this.exchangeRateDataService.toCurrency(
value,
balanceInBaseCurrency: this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
userCurrency
),
value: details.accounts[account.id]?.current ?? 0
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value,
account.currency,
userCurrency
)
};
delete result.Order;
@ -118,17 +125,26 @@ export class PortfolioService {
public async getAccountsWithAggregations(aUserId: string): Promise<Accounts> {
const accounts = await this.getAccounts(aUserId);
let totalBalance = 0;
let totalValue = 0;
let totalBalanceInBaseCurrency = new Big(0);
let totalValueInBaseCurrency = new Big(0);
let transactionCount = 0;
for (const account of accounts) {
totalBalance += account.convertedBalance;
totalValue += account.value;
totalBalanceInBaseCurrency = totalBalanceInBaseCurrency.plus(
account.balanceInBaseCurrency
);
totalValueInBaseCurrency = totalValueInBaseCurrency.plus(
account.valueInBaseCurrency
);
transactionCount += account.transactionCount;
}
return { accounts, totalBalance, totalValue, transactionCount };
return {
accounts,
transactionCount,
totalBalanceInBaseCurrency: totalBalanceInBaseCurrency.toNumber(),
totalValueInBaseCurrency: totalValueInBaseCurrency.toNumber()
};
}
public async getInvestments(
@ -281,13 +297,11 @@ export class PortfolioService {
userId
});
if (transactionPoints?.length <= 0) {
return { accounts: {}, holdings: {}, hasErrors: false };
}
portfolioCalculator.setTransactionPoints(transactionPoints);
const portfolioStart = parseDate(transactionPoints[0].date);
const portfolioStart = parseDate(
transactionPoints[0]?.date ?? format(new Date(), DATE_FORMAT)
);
const startDate = this.getStartDate(aDateRange, portfolioStart);
const currentPositions = await portfolioCalculator.getCurrentPositions(
startDate
@ -300,9 +314,11 @@ export class PortfolioService {
const holdings: PortfolioDetails['holdings'] = {};
const totalInvestment = currentPositions.totalInvestment.plus(
cashDetails.balance
cashDetails.balanceInBaseCurrency
);
const totalValue = currentPositions.currentValue.plus(
cashDetails.balanceInBaseCurrency
);
const totalValue = currentPositions.currentValue.plus(cashDetails.balance);
const dataGatheringItems = currentPositions.positions.map((position) => {
return {
@ -315,7 +331,7 @@ export class PortfolioService {
);
const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.get(dataGatheringItems),
this.dataProviderService.getQuotes(dataGatheringItems),
this.symbolProfileService.getSymbolProfiles(symbols)
]);
@ -346,7 +362,6 @@ export class PortfolioService {
countries: symbolProfile.countries,
currency: item.currency,
dataSource: symbolProfile.dataSource,
exchange: dataProviderResponse.exchange,
grossPerformance: item.grossPerformance?.toNumber() ?? 0,
grossPerformancePercent:
item.grossPerformancePercentage?.toNumber() ?? 0,
@ -423,7 +438,7 @@ export class PortfolioService {
};
}
const positionCurrency = orders[0].currency;
const positionCurrency = orders[0].SymbolProfile.currency;
const [SymbolProfile] = await this.symbolProfileService.getSymbolProfiles([
aSymbol
]);
@ -433,13 +448,13 @@ export class PortfolioService {
return order.type === 'BUY' || order.type === 'SELL';
})
.map((order) => ({
currency: order.currency,
dataSource: order.SymbolProfile?.dataSource ?? order.dataSource,
currency: order.SymbolProfile.currency,
dataSource: order.SymbolProfile.dataSource,
date: format(order.date, DATE_FORMAT),
fee: new Big(order.fee),
name: order.SymbolProfile?.name,
quantity: new Big(order.quantity),
symbol: order.symbol,
symbol: order.SymbolProfile.symbol,
type: order.type,
unitPrice: new Big(order.unitPrice)
}));
@ -552,9 +567,10 @@ export class PortfolioService {
SymbolProfile,
transactionCount,
averagePrice: averagePrice.toNumber(),
grossPerformancePercent: position.grossPerformancePercentage.toNumber(),
grossPerformancePercent:
position.grossPerformancePercentage?.toNumber(),
historicalData: historicalDataArray,
netPerformancePercent: position.netPerformancePercentage.toNumber(),
netPerformancePercent: position.netPerformancePercentage?.toNumber(),
quantity: quantity.toNumber(),
value: this.exchangeRateDataService.toCurrency(
quantity.mul(marketPrice).toNumber(),
@ -563,7 +579,7 @@ export class PortfolioService {
)
};
} else {
const currentData = await this.dataProviderService.get([
const currentData = await this.dataProviderService.getQuotes([
{ dataSource: DataSource.YAHOO, symbol: aSymbol }
]);
const marketPrice = currentData[aSymbol]?.marketPrice;
@ -660,7 +676,7 @@ export class PortfolioService {
const symbols = positions.map((position) => position.symbol);
const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.get(dataGatheringItem),
this.dataProviderService.getQuotes(dataGatheringItem),
this.symbolProfileService.getSymbolProfiles(symbols)
]);
@ -696,7 +712,7 @@ export class PortfolioService {
public async getPerformance(
aImpersonationId: string,
aDateRange: DateRange = 'max'
): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> {
): Promise<PortfolioPerformanceResponse> {
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const portfolioCalculator = new PortfolioCalculator(
@ -848,7 +864,7 @@ export class PortfolioService {
const performanceInformation = await this.getPerformance(aImpersonationId);
const { balance } = await this.accountService.getCashDetails(
const { balanceInBaseCurrency } = await this.accountService.getCashDetails(
userId,
userCurrency
);
@ -866,7 +882,7 @@ export class PortfolioService {
const committedFunds = new Big(totalBuy).minus(totalSell);
const netWorth = new Big(balance)
const netWorth = new Big(balanceInBaseCurrency)
.plus(performanceInformation.performance.currentValue)
.plus(items)
.toNumber();
@ -882,7 +898,7 @@ export class PortfolioService {
totalSell,
annualizedPerformancePercent:
performanceInformation.performance.annualizedPerformancePercent,
cash: balance,
cash: balanceInBaseCurrency,
committedFunds: committedFunds.toNumber(),
ordersCount: orders.filter((order) => {
return order.type === 'BUY' || order.type === 'SELL';
@ -971,7 +987,7 @@ export class PortfolioService {
.map((order) => {
return this.exchangeRateDataService.toCurrency(
new Big(order.quantity).mul(order.unitPrice).toNumber(),
order.currency,
order.SymbolProfile.currency,
this.request.user.Settings.currency
);
})
@ -990,7 +1006,7 @@ export class PortfolioService {
.map((order) => {
return this.exchangeRateDataService.toCurrency(
order.fee,
order.currency,
order.SymbolProfile.currency,
this.request.user.Settings.currency
);
})
@ -1012,7 +1028,7 @@ export class PortfolioService {
.map((order) => {
return this.exchangeRateDataService.toCurrency(
new Big(order.quantity).mul(order.unitPrice).toNumber(),
order.currency,
order.SymbolProfile.currency,
this.request.user.Settings.currency
);
})
@ -1064,24 +1080,24 @@ export class PortfolioService {
}
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
currency: order.currency,
dataSource: order.SymbolProfile?.dataSource ?? order.dataSource,
currency: order.SymbolProfile.currency,
dataSource: order.SymbolProfile.dataSource,
date: format(order.date, DATE_FORMAT),
fee: new Big(
this.exchangeRateDataService.toCurrency(
order.fee,
order.currency,
order.SymbolProfile.currency,
userCurrency
)
),
name: order.SymbolProfile?.name,
quantity: new Big(order.quantity),
symbol: order.symbol,
symbol: order.SymbolProfile.symbol,
type: order.type,
unitPrice: new Big(
this.exchangeRateDataService.toCurrency(
order.unitPrice,
order.currency,
order.SymbolProfile.currency,
userCurrency
)
)
@ -1113,22 +1129,18 @@ export class PortfolioService {
return accountId === account.id;
});
const convertedBalance = this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
userCurrency
);
accounts[account.id] = {
balance: convertedBalance,
balance: account.balance,
currency: account.currency,
current: convertedBalance,
current: account.balance,
name: account.name,
original: convertedBalance
original: account.balance
};
for (const order of ordersByAccount) {
let currentValueOfSymbol =
order.quantity * portfolioItemsNow[order.symbol].marketPrice;
order.quantity *
portfolioItemsNow[order.SymbolProfile.symbol].marketPrice;
let originalValueOfSymbol = order.quantity * order.unitPrice;
if (order.type === 'SELL') {
@ -1178,7 +1190,7 @@ export class PortfolioService {
.map((order) => {
return this.exchangeRateDataService.toCurrency(
order.quantity * order.unitPrice,
order.currency,
order.SymbolProfile.currency,
currency
);
})

25
apps/api/src/app/subscription/subscription.controller.ts

@ -46,22 +46,25 @@ export class SubscriptionController {
((await this.propertyService.getByKey(PROPERTY_COUPONS)) as Coupon[]) ??
[];
const isValid = coupons.some((coupon) => {
return coupon.code === couponCode;
const coupon = coupons.find((currentCoupon) => {
return currentCoupon.code === couponCode;
});
if (!isValid) {
if (coupon === undefined) {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
await this.subscriptionService.createSubscription(this.request.user.id);
await this.subscriptionService.createSubscription({
duration: coupon.duration,
userId: this.request.user.id
});
// Destroy coupon
coupons = coupons.filter((coupon) => {
return coupon.code !== couponCode;
coupons = coupons.filter((currentCoupon) => {
return currentCoupon.code !== couponCode;
});
await this.propertyService.put({
key: PROPERTY_COUPONS,
@ -69,7 +72,8 @@ export class SubscriptionController {
});
Logger.log(
`Subscription for user '${this.request.user.id}' has been created with coupon`
`Subscription for user '${this.request.user.id}' has been created with a coupon for ${coupon.duration}`,
'SubscriptionController'
);
return {
@ -84,7 +88,10 @@ export class SubscriptionController {
req.query.checkoutSessionId
);
Logger.log(`Subscription for user '${userId}' has been created via Stripe`);
Logger.log(
`Subscription for user '${userId}' has been created via Stripe`,
'SubscriptionController'
);
res.redirect(`${this.configurationService.get('ROOT_URL')}/account`);
}
@ -101,7 +108,7 @@ export class SubscriptionController {
userId: this.request.user.id
});
} catch (error) {
Logger.error(error);
Logger.error(error, 'SubscriptionController');
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),

21
apps/api/src/app/subscription/subscription.service.ts

@ -2,8 +2,9 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import { Injectable, Logger } from '@nestjs/common';
import { Subscription, User } from '@prisma/client';
import { addDays, isBefore } from 'date-fns';
import { Subscription } from '@prisma/client';
import { addMilliseconds, isBefore } from 'date-fns';
import ms, { StringValue } from 'ms';
import Stripe from 'stripe';
@Injectable()
@ -64,13 +65,19 @@ export class SubscriptionService {
};
}
public async createSubscription(aUserId: string) {
public async createSubscription({
duration = '1 year',
userId
}: {
duration?: StringValue;
userId: string;
}) {
await this.prismaService.subscription.create({
data: {
expiresAt: addDays(new Date(), 365),
expiresAt: addMilliseconds(new Date(), ms(duration)),
User: {
connect: {
id: aUserId
id: userId
}
}
}
@ -83,7 +90,7 @@ export class SubscriptionService {
aCheckoutSessionId
);
await this.createSubscription(session.client_reference_id);
await this.createSubscription({ userId: session.client_reference_id });
await this.stripe.customers.update(session.customer as string, {
description: session.client_reference_id
@ -91,7 +98,7 @@ export class SubscriptionService {
return session.client_reference_id;
} catch (error) {
Logger.error(error);
Logger.error(error, 'SubscriptionService');
}
}

8
apps/api/src/app/symbol/symbol.service.ts

@ -27,8 +27,10 @@ export class SymbolService {
dataGatheringItem: IDataGatheringItem;
includeHistoricalData?: number;
}): Promise<SymbolItem> {
const response = await this.dataProviderService.get([dataGatheringItem]);
const { currency, marketPrice } = response[dataGatheringItem.symbol] ?? {};
const quotes = await this.dataProviderService.getQuotes([
dataGatheringItem
]);
const { currency, marketPrice } = quotes[dataGatheringItem.symbol] ?? {};
if (dataGatheringItem.dataSource && marketPrice) {
let historicalData: HistoricalDataItem[] = [];
@ -93,7 +95,7 @@ export class SymbolService {
results.items = items;
return results;
} catch (error) {
Logger.error(error);
Logger.error(error, 'SymbolService');
throw error;
}

16
apps/api/src/interceptors/transform-data-source-in-response.interceptor.ts

@ -32,7 +32,6 @@ export class TransformDataSourceInResponseInterceptor<T>
activity.SymbolProfile.dataSource = encodeDataSource(
activity.SymbolProfile.dataSource
);
activity.dataSource = encodeDataSource(activity.dataSource);
return activity;
});
}
@ -41,6 +40,14 @@ export class TransformDataSourceInResponseInterceptor<T>
data.dataSource = encodeDataSource(data.dataSource);
}
if (data.errors) {
for (const error of data.errors) {
if (error.dataSource) {
error.dataSource = encodeDataSource(error.dataSource);
}
}
}
if (data.holdings) {
for (const symbol of Object.keys(data.holdings)) {
if (data.holdings[symbol].dataSource) {
@ -58,13 +65,6 @@ export class TransformDataSourceInResponseInterceptor<T>
});
}
if (data.orders) {
data.orders.map((order) => {
order.dataSource = encodeDataSource(order.dataSource);
return order;
});
}
if (data.positions) {
data.positions.map((position) => {
position.dataSource = encodeDataSource(position.dataSource);

130
apps/api/src/services/data-gathering.service.ts

@ -4,6 +4,7 @@ import {
PROPERTY_LOCKED_DATA_GATHERING
} from '@ghostfolio/common/config';
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import {
@ -39,7 +40,7 @@ export class DataGatheringService {
const isDataGatheringNeeded = await this.isDataGatheringNeeded();
if (isDataGatheringNeeded) {
Logger.log('7d data gathering has been started.');
Logger.log('7d data gathering has been started.', 'DataGatheringService');
console.time('data-gathering-7d');
await this.prismaService.property.create({
@ -63,7 +64,7 @@ export class DataGatheringService {
where: { key: PROPERTY_LAST_DATA_GATHERING }
});
} catch (error) {
Logger.error(error);
Logger.error(error, 'DataGatheringService');
}
await this.prismaService.property.delete({
@ -72,7 +73,10 @@ export class DataGatheringService {
}
});
Logger.log('7d data gathering has been completed.');
Logger.log(
'7d data gathering has been completed.',
'DataGatheringService'
);
console.timeEnd('data-gathering-7d');
}
}
@ -83,7 +87,10 @@ export class DataGatheringService {
});
if (!isDataGatheringLocked) {
Logger.log('Max data gathering has been started.');
Logger.log(
'Max data gathering has been started.',
'DataGatheringService'
);
console.time('data-gathering-max');
await this.prismaService.property.create({
@ -107,7 +114,7 @@ export class DataGatheringService {
where: { key: PROPERTY_LAST_DATA_GATHERING }
});
} catch (error) {
Logger.error(error);
Logger.error(error, 'DataGatheringService');
}
await this.prismaService.property.delete({
@ -116,24 +123,24 @@ export class DataGatheringService {
}
});
Logger.log('Max data gathering has been completed.');
Logger.log(
'Max data gathering has been completed.',
'DataGatheringService'
);
console.timeEnd('data-gathering-max');
}
}
public async gatherSymbol({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
public async gatherSymbol({ dataSource, symbol }: UniqueAsset) {
const isDataGatheringLocked = await this.prismaService.property.findUnique({
where: { key: PROPERTY_LOCKED_DATA_GATHERING }
});
if (!isDataGatheringLocked) {
Logger.log(`Symbol data gathering for ${symbol} has been started.`);
Logger.log(
`Symbol data gathering for ${symbol} has been started.`,
'DataGatheringService'
);
console.time('data-gathering-symbol');
await this.prismaService.property.create({
@ -164,7 +171,7 @@ export class DataGatheringService {
where: { key: PROPERTY_LAST_DATA_GATHERING }
});
} catch (error) {
Logger.error(error);
Logger.error(error, 'DataGatheringService');
}
await this.prismaService.property.delete({
@ -173,7 +180,10 @@ export class DataGatheringService {
}
});
Logger.log(`Symbol data gathering for ${symbol} has been completed.`);
Logger.log(
`Symbol data gathering for ${symbol} has been completed.`,
'DataGatheringService'
);
console.timeEnd('data-gathering-symbol');
}
}
@ -210,42 +220,55 @@ export class DataGatheringService {
});
}
} catch (error) {
Logger.error(error);
Logger.error(error, 'DataGatheringService');
} finally {
return undefined;
}
}
public async gatherProfileData(aDataGatheringItems?: IDataGatheringItem[]) {
Logger.log('Profile data gathering has been started.');
Logger.log(
'Profile data gathering has been started.',
'DataGatheringService'
);
console.time('data-gathering-profile');
let dataGatheringItems = aDataGatheringItems;
let dataGatheringItems = aDataGatheringItems?.filter(
(dataGatheringItem) => {
return dataGatheringItem.dataSource !== 'MANUAL';
}
);
if (!dataGatheringItems) {
dataGatheringItems = await this.getSymbolsProfileData();
}
const currentData = await this.dataProviderService.get(dataGatheringItems);
const assetProfiles = await this.dataProviderService.getAssetProfiles(
dataGatheringItems
);
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
dataGatheringItems.map(({ symbol }) => {
return symbol;
})
);
for (const [symbol, response] of Object.entries(currentData)) {
for (const [symbol, assetProfile] of Object.entries(assetProfiles)) {
const symbolMapping = symbolProfiles.find((symbolProfile) => {
return symbolProfile.symbol === symbol;
})?.symbolMapping;
for (const dataEnhancer of this.dataEnhancers) {
try {
currentData[symbol] = await dataEnhancer.enhance({
response,
assetProfiles[symbol] = await dataEnhancer.enhance({
response: assetProfile,
symbol: symbolMapping?.[dataEnhancer.getName()] ?? symbol
});
} catch (error) {
Logger.error(`Failed to enhance data for symbol ${symbol}`, error);
Logger.error(
`Failed to enhance data for symbol ${symbol} by ${dataEnhancer.getName()}`,
error,
'DataGatheringService'
);
}
}
@ -256,8 +279,9 @@ export class DataGatheringService {
currency,
dataSource,
name,
sectors
} = currentData[symbol];
sectors,
url
} = assetProfiles[symbol];
try {
await this.prismaService.symbolProfile.upsert({
@ -269,7 +293,8 @@ export class DataGatheringService {
dataSource,
name,
sectors,
symbol
symbol,
url
},
update: {
assetClass,
@ -277,7 +302,8 @@ export class DataGatheringService {
countries,
currency,
name,
sectors
sectors,
url
},
where: {
dataSource_symbol: {
@ -287,11 +313,18 @@ export class DataGatheringService {
}
});
} catch (error) {
Logger.error(`${symbol}: ${error?.meta?.cause}`);
Logger.error(
`${symbol}: ${error?.meta?.cause}`,
error,
'DataGatheringService'
);
}
}
Logger.log('Profile data gathering has been completed.');
Logger.log(
'Profile data gathering has been completed.',
'DataGatheringService'
);
console.timeEnd('data-gathering-profile');
}
@ -300,6 +333,10 @@ export class DataGatheringService {
let symbolCounter = 0;
for (const { dataSource, date, symbol } of aSymbolsWithStartDate) {
if (dataSource === 'MANUAL') {
continue;
}
this.dataGatheringProgress = symbolCounter / aSymbolsWithStartDate.length;
try {
@ -347,10 +384,11 @@ export class DataGatheringService {
} catch {}
} else {
Logger.warn(
`Failed to gather data for symbol ${symbol} at ${format(
`Failed to gather data for symbol ${symbol} from ${dataSource} at ${format(
currentDate,
DATE_FORMAT
)}.`
)}.`,
'DataGatheringService'
);
}
@ -366,14 +404,15 @@ export class DataGatheringService {
}
} catch (error) {
hasError = true;
Logger.error(error);
Logger.error(error, 'DataGatheringService');
}
if (symbolCounter > 0 && symbolCounter % 100 === 0) {
Logger.log(
`Data gathering progress: ${(
this.dataGatheringProgress * 100
).toFixed(2)}%`
).toFixed(2)}%`,
'DataGatheringService'
);
}
@ -463,7 +502,7 @@ export class DataGatheringService {
}
public async reset() {
Logger.log('Data gathering has been reset.');
Logger.log('Data gathering has been reset.', 'DataGatheringService');
await this.prismaService.property.deleteMany({
where: {
@ -538,18 +577,23 @@ export class DataGatheringService {
}
private async getSymbolsProfileData(): Promise<IDataGatheringItem[]> {
const distinctOrders = await this.prismaService.order.findMany({
distinct: ['symbol'],
orderBy: [{ symbol: 'asc' }],
select: { dataSource: true, symbol: true }
const symbolProfiles = await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }]
});
return distinctOrders.filter((distinctOrder) => {
return symbolProfiles
.filter((symbolProfile) => {
return (
distinctOrder.dataSource !== DataSource.GHOSTFOLIO &&
distinctOrder.dataSource !== DataSource.MANUAL &&
distinctOrder.dataSource !== DataSource.RAKUTEN
symbolProfile.dataSource !== DataSource.GHOSTFOLIO &&
symbolProfile.dataSource !== DataSource.MANUAL &&
symbolProfile.dataSource !== DataSource.RAKUTEN
);
})
.map((symbolProfile) => {
return {
dataSource: symbolProfile.dataSource,
symbol: symbolProfile.symbol
};
});
}

38
apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts

@ -1,16 +1,16 @@
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
} from '@ghostfolio/api/services/interfaces/interfaces';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { DataSource, SymbolProfile } from '@prisma/client';
import { isAfter, isBefore, parse } from 'date-fns';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '../../interfaces/interfaces';
import { DataProviderInterface } from '../interfaces/data-provider.interface';
import { IAlphaVantageHistoricalResponse } from './interfaces/interfaces';
@Injectable()
@ -29,25 +29,23 @@ export class AlphaVantageService implements DataProviderInterface {
return !!this.configurationService.get('ALPHA_VANTAGE_API_KEY');
}
public async get(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
return {};
public async getAssetProfile(
aSymbol: string
): Promise<Partial<SymbolProfile>> {
return {
dataSource: this.getName()
};
}
public async getHistorical(
aSymbols: string[],
aSymbol: string,
aGranularity: Granularity = 'day',
from: Date,
to: Date
): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> {
if (aSymbols.length <= 0) {
return {};
}
const symbol = aSymbols[0];
const symbol = aSymbol;
try {
const historicalData: {
@ -78,7 +76,7 @@ export class AlphaVantageService implements DataProviderInterface {
return response;
} catch (error) {
Logger.error(error, symbol);
Logger.error(error, 'AlphaVantageService');
return {};
}
@ -88,6 +86,12 @@ export class AlphaVantageService implements DataProviderInterface {
return DataSource.ALPHA_VANTAGE;
}
public async getQuotes(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
return {};
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const result = await this.alphaVantage.data.search(aQuery);

18
apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts

@ -1,5 +1,7 @@
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { SymbolProfile } from '@prisma/client';
import bent from 'bent';
const getJSON = bent('json');
@ -21,9 +23,9 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
response,
symbol
}: {
response: IDataProviderResponse;
response: Partial<SymbolProfile>;
symbol: string;
}): Promise<IDataProviderResponse> {
}): Promise<Partial<SymbolProfile>> {
if (
!(response.assetClass === 'EQUITY' && response.assetSubClass === 'ETF')
) {
@ -40,7 +42,10 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
);
});
if (!response.countries || response.countries.length === 0) {
if (
!response.countries ||
(response.countries as unknown as Country[]).length === 0
) {
response.countries = [];
for (const [name, value] of Object.entries<any>(holdings.countries)) {
let countryCode: string;
@ -65,7 +70,10 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
}
}
if (!response.sectors || response.sectors.length === 0) {
if (
!response.sectors ||
(response.sectors as unknown as Sector[]).length === 0
) {
response.sectors = [];
for (const [name, value] of Object.entries<any>(holdings.sectors)) {
response.sectors.push({

122
apps/api/src/services/data-provider/data-provider.service.ts

@ -10,7 +10,7 @@ import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource, MarketData } from '@prisma/client';
import { DataSource, MarketData, SymbolProfile } from '@prisma/client';
import { format, isValid } from 'date-fns';
import { groupBy, isEmpty } from 'lodash';
@ -23,42 +23,6 @@ export class DataProviderService {
private readonly prismaService: PrismaService
) {}
public async get(items: IDataGatheringItem[]): Promise<{
[symbol: string]: IDataProviderResponse;
}> {
const response: {
[symbol: string]: IDataProviderResponse;
} = {};
const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource);
const promises = [];
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((result) => {
for (const [symbol, dataProviderResponse] of Object.entries(result)) {
response[symbol] = dataProviderResponse;
}
})
);
}
await Promise.all(promises);
return response;
}
public async getHistorical(
aItems: IDataGatheringItem[],
aGranularity: Granularity = 'month',
@ -118,7 +82,7 @@ export class DataProviderService {
return r;
}, {});
} catch (error) {
Logger.error(error);
Logger.error(error, 'DataProviderService');
} finally {
return response;
}
@ -144,7 +108,7 @@ export class DataProviderService {
if (dataProvider.canHandle(symbol)) {
promises.push(
dataProvider
.getHistorical([symbol], undefined, from, to)
.getHistorical(symbol, undefined, from, to)
.then((data) => ({ data: data?.[symbol], symbol }))
);
}
@ -158,6 +122,82 @@ export class DataProviderService {
return result;
}
public getPrimaryDataSource(): DataSource {
return DataSource[this.configurationService.get('DATA_SOURCE_PRIMARY')];
}
public async getAssetProfiles(items: IDataGatheringItem[]): Promise<{
[symbol: string]: Partial<SymbolProfile>;
}> {
const response: {
[symbol: string]: Partial<SymbolProfile>;
} = {};
const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource);
const promises = [];
for (const [dataSource, dataGatheringItems] of Object.entries(
itemsGroupedByDataSource
)) {
const symbols = dataGatheringItems.map((dataGatheringItem) => {
return dataGatheringItem.symbol;
});
for (const symbol of symbols) {
const promise = Promise.resolve(
this.getDataProvider(DataSource[dataSource]).getAssetProfile(symbol)
);
promises.push(
promise.then((symbolProfile) => {
response[symbol] = symbolProfile;
})
);
}
}
await Promise.all(promises);
return response;
}
public async getQuotes(items: IDataGatheringItem[]): Promise<{
[symbol: string]: IDataProviderResponse;
}> {
const response: {
[symbol: string]: IDataProviderResponse;
} = {};
const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource);
const promises = [];
for (const [dataSource, dataGatheringItems] of Object.entries(
itemsGroupedByDataSource
)) {
const symbols = dataGatheringItems.map((dataGatheringItem) => {
return dataGatheringItem.symbol;
});
const promise = Promise.resolve(
this.getDataProvider(DataSource[dataSource]).getQuotes(symbols)
);
promises.push(
promise.then((result) => {
for (const [symbol, dataProviderResponse] of Object.entries(result)) {
response[symbol] = dataProviderResponse;
}
})
);
}
await Promise.all(promises);
return response;
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const promises: Promise<{ items: LookupItem[] }>[] = [];
let lookupItems: LookupItem[] = [];
@ -184,10 +224,6 @@ export class DataProviderService {
};
}
public getPrimaryDataSource(): DataSource {
return DataSource[this.configurationService.get('DATA_SOURCE_PRIMARY')];
}
private getDataProvider(providerName: DataSource) {
for (const dataProviderInterface of this.dataProviderInterfaces) {
if (dataProviderInterface.getName() === providerName) {

95
apps/api/src/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service.ts

@ -7,14 +7,10 @@ import {
} 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,
getYesterday,
isGhostfolioScraperApiSymbol
} from '@ghostfolio/common/helper';
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { DataSource, SymbolProfile } from '@prisma/client';
import * as bent from 'bent';
import * as cheerio from 'cheerio';
import { format } from 'date-fns';
@ -29,60 +25,28 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
) {}
public canHandle(symbol: string) {
return isGhostfolioScraperApiSymbol(symbol);
}
public async get(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
if (aSymbols.length <= 0) {
return {};
return true;
}
try {
const [symbol] = aSymbols;
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
[symbol]
);
const { marketPrice } = await this.prismaService.marketData.findFirst({
orderBy: {
date: 'desc'
},
where: {
symbol
}
});
public async getAssetProfile(
aSymbol: string
): Promise<Partial<SymbolProfile>> {
return {
[symbol]: {
marketPrice,
currency: symbolProfile?.currency,
dataSource: this.getName(),
marketState: MarketState.delayed
}
dataSource: this.getName()
};
} catch (error) {
Logger.error(error);
}
return {};
}
public async getHistorical(
aSymbols: string[],
aSymbol: string,
aGranularity: Granularity = 'day',
from: Date,
to: Date
): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> {
if (aSymbols.length <= 0) {
return {};
}
try {
const [symbol] = aSymbols;
const symbol = aSymbol;
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
[symbol]
);
@ -105,7 +69,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
}
};
} catch (error) {
Logger.error(error);
Logger.error(error, 'GhostfolioScraperApiService');
}
return {};
@ -115,6 +79,43 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
return DataSource.GHOSTFOLIO;
}
public async getQuotes(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
if (aSymbols.length <= 0) {
return {};
}
try {
const [symbol] = aSymbols;
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
[symbol]
);
const { marketPrice } = await this.prismaService.marketData.findFirst({
orderBy: {
date: 'desc'
},
where: {
symbol
}
});
return {
[symbol]: {
marketPrice,
currency: symbolProfile?.currency,
dataSource: this.getName(),
marketState: MarketState.delayed
}
};
} catch (error) {
Logger.error(error, 'GhostfolioScraperApiService');
}
return {};
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const items = await this.prismaService.symbolProfile.findMany({
select: {

104
apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts

@ -11,7 +11,7 @@ import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.se
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 { DataSource, SymbolProfile } from '@prisma/client';
import { format } from 'date-fns';
import { GoogleSpreadsheet } from 'google-spreadsheet';
@ -27,65 +27,24 @@ export class GoogleSheetsService implements DataProviderInterface {
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
public async getAssetProfile(
aSymbol: string
): Promise<Partial<SymbolProfile>> {
return {
dataSource: this.getName()
};
}
}
return response;
} catch (error) {
Logger.error(error);
}
return {};
}
public async getHistorical(
aSymbols: string[],
aSymbol: string,
aGranularity: Granularity = 'day',
from: Date,
to: Date
): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> {
if (aSymbols.length <= 0) {
return {};
}
try {
const [symbol] = aSymbols;
const symbol = aSymbol;
const sheet = await this.getSheet({
symbol,
@ -113,7 +72,7 @@ export class GoogleSheetsService implements DataProviderInterface {
[symbol]: historicalData
};
} catch (error) {
Logger.error(error);
Logger.error(error, 'GoogleSheetsService');
}
return {};
@ -123,6 +82,51 @@ export class GoogleSheetsService implements DataProviderInterface {
return DataSource.GOOGLE_SHEETS;
}
public async getQuotes(
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, 'GoogleSheetsService');
}
return {};
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const items = await this.prismaService.symbolProfile.findMany({
select: {

6
apps/api/src/services/data-provider/interfaces/data-enhancer.interface.ts

@ -1,13 +1,13 @@
import { IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import { SymbolProfile } from '@prisma/client';
export interface DataEnhancerInterface {
enhance({
response,
symbol
}: {
response: IDataProviderResponse;
response: Partial<SymbolProfile>;
symbol: string;
}): Promise<IDataProviderResponse>;
}): Promise<Partial<SymbolProfile>>;
getName(): string;
}

12
apps/api/src/services/data-provider/interfaces/data-provider.interface.ts

@ -4,23 +4,27 @@ import {
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { Granularity } from '@ghostfolio/common/types';
import { DataSource } from '@prisma/client';
import { DataSource, SymbolProfile } from '@prisma/client';
export interface DataProviderInterface {
canHandle(symbol: string): boolean;
get(aSymbols: string[]): Promise<{ [symbol: string]: IDataProviderResponse }>;
getAssetProfile(aSymbol: string): Promise<Partial<SymbolProfile>>;
getHistorical(
aSymbols: string[],
aSymbol: string,
aGranularity: Granularity,
from: Date,
to: Date
): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}>;
}>; // TODO: Return only one symbol
getName(): DataSource;
getQuotes(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }>;
search(aQuery: string): Promise<{ items: LookupItem[] }>;
}

20
apps/api/src/services/data-provider/manual/manual.service.ts

@ -6,7 +6,7 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { DataSource, SymbolProfile } from '@prisma/client';
@Injectable()
export class ManualService implements DataProviderInterface {
@ -16,14 +16,16 @@ export class ManualService implements DataProviderInterface {
return false;
}
public async get(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
return {};
public async getAssetProfile(
aSymbol: string
): Promise<Partial<SymbolProfile>> {
return {
dataSource: this.getName()
};
}
public async getHistorical(
aSymbols: string[],
aSymbol: string,
aGranularity: Granularity = 'day',
from: Date,
to: Date
@ -37,6 +39,12 @@ export class ManualService implements DataProviderInterface {
return DataSource.MANUAL;
}
public async getQuotes(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
return {};
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
return { items: [] };
}

84
apps/api/src/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service.ts

@ -1,21 +1,20 @@
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 { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
import { DATE_FORMAT, getToday, getYesterday } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { DataSource, SymbolProfile } from '@prisma/client';
import * as bent from 'bent';
import { format, subMonths, subWeeks, subYears } from 'date-fns';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse,
MarketState
} from '../../interfaces/interfaces';
import { DataProviderInterface } from '../interfaces/data-provider.interface';
@Injectable()
export class RakutenRapidApiService implements DataProviderInterface {
public static FEAR_AND_GREED_INDEX_NAME = 'Fear & Greed Index';
@ -29,50 +28,24 @@ export class RakutenRapidApiService implements DataProviderInterface {
return !!this.configurationService.get('RAKUTEN_RAPID_API_KEY');
}
public async get(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
if (aSymbols.length <= 0) {
return {};
}
try {
const symbol = aSymbols[0];
if (symbol === ghostfolioFearAndGreedIndexSymbol) {
const fgi = await this.getFearAndGreedIndex();
public async getAssetProfile(
aSymbol: string
): Promise<Partial<SymbolProfile>> {
return {
[ghostfolioFearAndGreedIndexSymbol]: {
currency: undefined,
dataSource: this.getName(),
marketPrice: fgi.now.value,
marketState: MarketState.open,
name: RakutenRapidApiService.FEAR_AND_GREED_INDEX_NAME
}
dataSource: this.getName()
};
}
} catch (error) {
Logger.error(error);
}
return {};
}
public async getHistorical(
aSymbols: string[],
aSymbol: string,
aGranularity: Granularity = 'day',
from: Date,
to: Date
): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> {
if (aSymbols.length <= 0) {
return {};
}
try {
const symbol = aSymbols[0];
const symbol = aSymbol;
if (symbol === ghostfolioFearAndGreedIndexSymbol) {
const fgi = await this.getFearAndGreedIndex();
@ -129,6 +102,35 @@ export class RakutenRapidApiService implements DataProviderInterface {
return DataSource.RAKUTEN;
}
public async getQuotes(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
if (aSymbols.length <= 0) {
return {};
}
try {
const symbol = aSymbols[0];
if (symbol === ghostfolioFearAndGreedIndexSymbol) {
const fgi = await this.getFearAndGreedIndex();
return {
[ghostfolioFearAndGreedIndexSymbol]: {
currency: undefined,
dataSource: this.getName(),
marketPrice: fgi.now.value,
marketState: MarketState.open
}
};
}
} catch (error) {
Logger.error(error, 'RakutenRapidApiService');
}
return {};
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
return { items: [] };
}
@ -158,7 +160,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
const { fgi } = await get();
return fgi;
} catch (error) {
Logger.error(error);
Logger.error(error, 'RakutenRapidApiService');
return undefined;
}

32
apps/api/src/services/data-provider/yahoo-finance/interfaces/interfaces.ts

@ -1,32 +0,0 @@
export interface IYahooFinanceHistoricalResponse {
adjClose: number;
close: number;
date: Date;
high: number;
low: number;
open: number;
symbol: string;
volume: number;
}
export interface IYahooFinanceQuoteResponse {
price: IYahooFinancePrice;
summaryProfile: IYahooFinanceSummaryProfile;
}
export interface IYahooFinancePrice {
currency: string;
exchangeName: string;
longName: string;
marketState: string;
quoteType: string;
regularMarketPrice: number;
shortName: string;
}
export interface IYahooFinanceSummaryProfile {
country?: string;
industry?: string;
sector?: string;
website?: string;
}

260
apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts

@ -1,31 +1,30 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { UNKNOWN_KEY, baseCurrency } from '@ghostfolio/common/config';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse,
MarketState
} from '@ghostfolio/api/services/interfaces/interfaces';
import { baseCurrency } from '@ghostfolio/common/config';
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
import {
AssetClass,
AssetSubClass,
DataSource,
SymbolProfile
} from '@prisma/client';
import * as bent from 'bent';
import Big from 'big.js';
import { countries } from 'countries-list';
import { addDays, format, isSameDay } from 'date-fns';
import * as yahooFinance from 'yahoo-finance';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse,
MarketState
} from '../../interfaces/interfaces';
import { DataProviderInterface } from '../interfaces/data-provider.interface';
import {
IYahooFinanceHistoricalResponse,
IYahooFinancePrice,
IYahooFinanceQuoteResponse
} from './interfaces/interfaces';
import yahooFinance from 'yahoo-finance2';
@Injectable()
export class YahooFinanceService implements DataProviderInterface {
private yahooFinanceHostname = 'https://query1.finance.yahoo.com';
private readonly yahooFinanceHostname = 'https://query1.finance.yahoo.com';
public constructor(
private readonly cryptocurrencyService: CryptocurrencyService
@ -73,145 +72,114 @@ export class YahooFinanceService implements DataProviderInterface {
return aSymbol;
}
public async get(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
if (aSymbols.length <= 0) {
return {};
}
const yahooFinanceSymbols = aSymbols.map((symbol) =>
this.convertToYahooFinanceSymbol(symbol)
);
public async getAssetProfile(
aSymbol: string
): Promise<Partial<SymbolProfile>> {
const response: Partial<SymbolProfile> = {};
try {
const response: { [symbol: string]: IDataProviderResponse } = {};
const data: {
[symbol: string]: IYahooFinanceQuoteResponse;
} = await yahooFinance.quote({
modules: ['price', 'summaryProfile'],
symbols: yahooFinanceSymbols
const symbol = this.convertToYahooFinanceSymbol(aSymbol);
const assetProfile = await yahooFinance.quoteSummary(symbol, {
modules: ['price', 'summaryProfile']
});
for (const [yahooFinanceSymbol, value] of Object.entries(data)) {
// Convert symbols back
const symbol = this.convertFromYahooFinanceSymbol(yahooFinanceSymbol);
const { assetClass, assetSubClass } = this.parseAssetClass(value.price);
response[symbol] = {
assetClass,
assetSubClass,
currency: value.price?.currency,
dataSource: this.getName(),
exchange: this.parseExchange(value.price?.exchangeName),
marketState:
value.price?.marketState === 'REGULAR' ||
this.cryptocurrencyService.isCryptocurrency(symbol)
? MarketState.open
: MarketState.closed,
marketPrice: value.price?.regularMarketPrice || 0,
name: value.price?.longName || value.price?.shortName || symbol
};
const { assetClass, assetSubClass } = this.parseAssetClass(
assetProfile.price
);
if (value.price?.currency === 'GBp') {
// Convert GBp (pence) to GBP
response[symbol].currency = 'GBP';
response[symbol].marketPrice = new Big(
value.price?.regularMarketPrice ?? 0
)
.div(100)
.toNumber();
}
response.assetClass = assetClass;
response.assetSubClass = assetSubClass;
response.currency = assetProfile.price.currency;
response.dataSource = this.getName();
response.name =
assetProfile.price.longName || assetProfile.price.shortName || symbol;
response.symbol = aSymbol;
// Add country if stock and available
if (
assetSubClass === AssetSubClass.STOCK &&
value.summaryProfile?.country
assetProfile.summaryProfile?.country
) {
// Add country if asset is stock and country available
try {
const [code] = Object.entries(countries).find(([, country]) => {
return country.name === value.summaryProfile?.country;
return country.name === assetProfile.summaryProfile?.country;
});
if (code) {
response[symbol].countries = [{ code, weight: 1 }];
response.countries = [{ code, weight: 1 }];
}
} catch {}
if (value.summaryProfile?.sector) {
response[symbol].sectors = [
{ name: value.summaryProfile?.sector, weight: 1 }
if (assetProfile.summaryProfile?.sector) {
response.sectors = [
{ name: assetProfile.summaryProfile?.sector, weight: 1 }
];
}
}
// Add url if available
const url = value.summaryProfile?.website;
const url = assetProfile.summaryProfile?.website;
if (url) {
response[symbol].url = url;
}
response.url = url;
}
} catch {}
return response;
} catch (error) {
Logger.error(error);
return {};
}
}
public async getHistorical(
aSymbols: string[],
aSymbol: string,
aGranularity: Granularity = 'day',
from: Date,
to: Date
): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> {
if (aSymbols.length <= 0) {
return {};
}
if (isSameDay(from, to)) {
to = addDays(to, 1);
}
const yahooFinanceSymbols = aSymbols.map((symbol) => {
return this.convertToYahooFinanceSymbol(symbol);
});
const yahooFinanceSymbol = this.convertToYahooFinanceSymbol(aSymbol);
try {
const historicalData: {
[symbol: string]: IYahooFinanceHistoricalResponse[];
} = await yahooFinance.historical({
symbols: yahooFinanceSymbols,
from: format(from, DATE_FORMAT),
to: format(to, DATE_FORMAT)
});
const historicalResult = await yahooFinance.historical(
yahooFinanceSymbol,
{
interval: '1d',
period1: format(from, DATE_FORMAT),
period2: format(to, DATE_FORMAT)
}
);
const response: {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
} = {};
for (const [yahooFinanceSymbol, timeSeries] of Object.entries(
historicalData
)) {
// Convert symbols back
// Convert symbol back
const symbol = this.convertFromYahooFinanceSymbol(yahooFinanceSymbol);
response[symbol] = {};
timeSeries.forEach((timeSerie) => {
response[symbol][format(timeSerie.date, DATE_FORMAT)] = {
marketPrice: timeSerie.close,
performance: timeSerie.open - timeSerie.close
for (const historicalItem of historicalResult) {
let marketPrice = historicalItem.close;
if (symbol === 'USDGBp') {
// Convert GPB to GBp (pence)
marketPrice = new Big(marketPrice).mul(100).toNumber();
}
response[symbol][format(historicalItem.date, DATE_FORMAT)] = {
marketPrice,
performance: historicalItem.open - historicalItem.close
};
});
}
return response;
} catch (error) {
Logger.error(error);
Logger.warn(
`Skipping yahooFinance2.getHistorical("${aSymbol}"): [${error.name}] ${error.message}`,
'YahooFinanceService'
);
return {};
}
@ -221,6 +189,56 @@ export class YahooFinanceService implements DataProviderInterface {
return DataSource.YAHOO;
}
public async getQuotes(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
if (aSymbols.length <= 0) {
return {};
}
const yahooFinanceSymbols = aSymbols.map((symbol) =>
this.convertToYahooFinanceSymbol(symbol)
);
try {
const response: { [symbol: string]: IDataProviderResponse } = {};
const quotes = await yahooFinance.quote(yahooFinanceSymbols);
for (const quote of quotes) {
// Convert symbols back
const symbol = this.convertFromYahooFinanceSymbol(quote.symbol);
response[symbol] = {
currency: quote.currency,
dataSource: this.getName(),
marketState:
quote.marketState === 'REGULAR' ||
this.cryptocurrencyService.isCryptocurrency(symbol)
? MarketState.open
: MarketState.closed,
marketPrice: quote.regularMarketPrice || 0
};
if (symbol === 'USDGBP' && yahooFinanceSymbols.includes('USDGBp=X')) {
// Convert GPB to GBp (pence)
response['USDGBp'] = {
...response[symbol],
currency: 'GBp',
marketPrice: new Big(response[symbol].marketPrice)
.mul(100)
.toNumber()
};
}
}
return response;
} catch (error) {
Logger.error(error, 'YahooFinanceService');
return {};
}
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const items: LookupItem[] = [];
@ -236,7 +254,7 @@ export class YahooFinanceService implements DataProviderInterface {
const searchResult = await get();
const symbols: string[] = searchResult.quotes
const quotes = searchResult.quotes
.filter((quote) => {
// filter out undefined symbols
return quote.symbol;
@ -247,8 +265,7 @@ export class YahooFinanceService implements DataProviderInterface {
this.cryptocurrencyService.isCryptocurrency(
symbol.replace(new RegExp(`-${baseCurrency}$`), baseCurrency)
)) ||
quoteType === 'EQUITY' ||
quoteType === 'ETF'
['EQUITY', 'ETF', 'MUTUALFUND'].includes(quoteType)
);
})
.filter(({ quoteType, symbol }) => {
@ -259,27 +276,34 @@ export class YahooFinanceService implements DataProviderInterface {
}
return true;
})
.map(({ symbol }) => {
return symbol;
});
const marketData = await this.get(symbols);
const marketData = await this.getQuotes(
quotes.map(({ symbol }) => {
return symbol;
})
);
for (const [symbol, value] of Object.entries(marketData)) {
const quote = quotes.find((currentQuote: any) => {
return currentQuote.symbol === symbol;
});
items.push({
symbol,
currency: value.currency,
dataSource: this.getName(),
name: value.name
name: quote?.longname || quote?.shortname || symbol
});
}
} catch {}
} catch (error) {
Logger.error(error, 'YahooFinanceService');
}
return { items };
}
private parseAssetClass(aPrice: IYahooFinancePrice): {
private parseAssetClass(aPrice: any): {
assetClass: AssetClass;
assetSubClass: AssetSubClass;
} {
@ -299,16 +323,12 @@ export class YahooFinanceService implements DataProviderInterface {
assetClass = AssetClass.EQUITY;
assetSubClass = AssetSubClass.ETF;
break;
case 'mutualfund':
assetClass = AssetClass.EQUITY;
assetSubClass = AssetSubClass.MUTUALFUND;
break;
}
return { assetClass, assetSubClass };
}
private parseExchange(aString: string): string {
if (aString?.toLowerCase() === 'ccc') {
return UNKNOWN_KEY;
}
return aString;
}
}

20
apps/api/src/services/exchange-rate-data.service.ts

@ -2,7 +2,7 @@ import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config';
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common';
import { format } from 'date-fns';
import { isEmpty, isNumber, uniq } from 'lodash';
import { isNumber, uniq } from 'lodash';
import { DataProviderService } from './data-provider/data-provider.service';
import { IDataGatheringItem } from './interfaces/interfaces';
@ -61,7 +61,7 @@ export class ExchangeRateDataService {
if (Object.keys(result).length !== this.currencyPairs.length) {
// Load currencies directly from data provider as a fallback
// if historical data is not fully available
const historicalData = await this.dataProviderService.get(
const historicalData = await this.dataProviderService.getQuotes(
this.currencyPairs.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
})
@ -114,6 +114,10 @@ export class ExchangeRateDataService {
aFromCurrency: string,
aToCurrency: string
) {
if (aValue === 0) {
return 0;
}
const hasNaN = Object.values(this.exchangeRates).some((exchangeRate) => {
return isNaN(exchangeRate);
});
@ -145,7 +149,8 @@ export class ExchangeRateDataService {
// Fallback with error, if currencies are not available
Logger.error(
`No exchange rate has been found for ${aFromCurrency}${aToCurrency}`
`No exchange rate has been found for ${aFromCurrency}${aToCurrency}`,
'ExchangeRateDataService'
);
return aValue;
}
@ -187,12 +192,7 @@ export class ExchangeRateDataService {
await this.prismaService.symbolProfile.findMany({
distinct: ['currency'],
orderBy: [{ currency: 'asc' }],
select: { currency: true },
where: {
currency: {
not: null
}
}
select: { currency: true }
})
).forEach((symbolProfile) => {
currencies.push(symbolProfile.currency);
@ -206,7 +206,7 @@ export class ExchangeRateDataService {
currencies = currencies.concat(customCurrencies);
}
return uniq(currencies).sort();
return uniq(currencies).filter(Boolean).sort();
}
private prepareCurrencyPairs(aCurrencies: string[]) {

9
apps/api/src/services/interfaces/interfaces.ts

@ -33,19 +33,10 @@ export interface IDataProviderHistoricalResponse {
}
export interface IDataProviderResponse {
assetClass?: AssetClass;
assetSubClass?: AssetSubClass;
countries?: { code: string; weight: number }[];
currency: string;
dataSource: DataSource;
exchange?: string;
marketChange?: number;
marketChangePercent?: number;
marketPrice: number;
marketState: MarketState;
name?: string;
sectors?: { name: string; weight: number }[];
url?: string;
}
export interface IDataGatheringItem {

9
apps/api/src/services/market-data.service.ts

@ -2,6 +2,7 @@ import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-dat
import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.interface';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { resetHours } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { DataSource, MarketData, Prisma } from '@prisma/client';
@ -9,13 +10,7 @@ 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;
}) {
public async deleteMany({ dataSource, symbol }: UniqueAsset) {
return this.prismaService.marketData.deleteMany({
where: {
dataSource,

11
apps/api/src/services/twitter-bot/twitter-bot.service.ts

@ -6,6 +6,7 @@ import {
} from '@ghostfolio/common/config';
import { resolveFearAndGreedIndex } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common';
import { isSunday } from 'date-fns';
import { TwitterApi, TwitterApiReadWrite } from 'twitter-api-v2';
@Injectable()
@ -27,7 +28,10 @@ export class TwitterBotService {
}
public async tweetFearAndGreedIndex() {
if (!this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
if (
!this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX') ||
isSunday(new Date())
) {
return;
}
@ -50,11 +54,12 @@ export class TwitterBotService {
);
Logger.log(
`Fear & Greed Index has been tweeted: https://twitter.com/ghostfolio_/status/${createdTweet.id}`
`Fear & Greed Index has been tweeted: https://twitter.com/ghostfolio_/status/${createdTweet.id}`,
'TwitterBotService'
);
}
} catch (error) {
Logger.error(error);
Logger.error(error, 'TwitterBotService');
}
}
}

6
apps/client/src/app/components/accounts-table/accounts-table.component.html

@ -86,7 +86,7 @@
class="d-inline-block justify-content-end"
[isCurrency]="true"
[locale]="locale"
[value]="element.convertedBalance"
[value]="element.balance"
></gf-value>
</td>
<td *matFooterCellDef class="px-1 text-right" mat-footer-cell>
@ -94,7 +94,7 @@
class="d-inline-block justify-content-end"
[isCurrency]="true"
[locale]="locale"
[value]="totalBalance"
[value]="totalBalanceInBaseCurrency"
></gf-value>
</td>
</ng-container>
@ -116,7 +116,7 @@
class="d-inline-block justify-content-end"
[isCurrency]="true"
[locale]="locale"
[value]="totalValue"
[value]="totalValueInBaseCurrency"
></gf-value>
</td>
</ng-container>

4
apps/client/src/app/components/accounts-table/accounts-table.component.ts

@ -24,8 +24,8 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
@Input() deviceType: string;
@Input() locale: string;
@Input() showActions: boolean;
@Input() totalBalance: number;
@Input() totalValue: number;
@Input() totalBalanceInBaseCurrency: number;
@Input() totalValueInBaseCurrency: number;
@Input() transactionCount: number;
@Output() accountDeleted = new EventEmitter<string>();

6
apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.html

@ -18,8 +18,10 @@
available:
marketDataByMonth[itemByMonth.key][
i + 1 < 10 ? '0' + (i + 1) : i + 1
]?.day ===
i + 1
]?.marketPrice,
today: isToday(
itemByMonth.key + '-' + (i + 1 < 10 ? '0' + (i + 1) : i + 1)
)
}"
[title]="
(itemByMonth.key + '-' + (i + 1 < 10 ? '0' + (i + 1) : i + 1)

5
apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.scss

@ -25,5 +25,10 @@
&.available {
background-color: var(--success);
}
&.today {
background-color: rgba(var(--palette-accent-500), 1);
cursor: default;
}
}
}

55
apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.ts

@ -12,7 +12,15 @@ import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
import { DataSource, MarketData } from '@prisma/client';
import { format, isBefore, isValid, parse } from 'date-fns';
import {
addDays,
format,
isBefore,
isSameDay,
isValid,
parse,
parseISO
} from 'date-fns';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, takeUntil } from 'rxjs';
@ -26,6 +34,7 @@ import { MarketDataDetailDialog } from './market-data-detail-dialog/market-data-
})
export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
@Input() dataSource: DataSource;
@Input() dateOfFirstActivity: string;
@Input() marketData: MarketData[];
@Input() symbol: string;
@ -36,7 +45,9 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
public deviceType: string;
public historicalDataItems: LineChartItem[];
public marketDataByMonth: {
[yearMonth: string]: { [day: string]: MarketData & { day: number } };
[yearMonth: string]: {
[day: string]: Pick<MarketData, 'date' | 'marketPrice'> & { day: number };
};
} = {};
private unsubscribeSubject = new Subject<void>();
@ -57,9 +68,30 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
value: marketDataItem.marketPrice
};
});
let date = parseISO(this.dateOfFirstActivity);
const missingMarketData: Partial<MarketData>[] = [];
if (this.historicalDataItems?.[0]?.date) {
while (
isBefore(
date,
parse(this.historicalDataItems[0].date, DATE_FORMAT, new Date())
)
) {
missingMarketData.push({
date,
marketPrice: undefined
});
date = addDays(date, 1);
}
}
this.marketDataByMonth = {};
for (const marketDataItem of this.marketData) {
for (const marketDataItem of [...missingMarketData, ...this.marketData]) {
const currentDay = parseInt(format(marketDataItem.date, 'd'), 10);
const key = format(marketDataItem.date, 'yyyy-MM');
@ -70,8 +102,9 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
this.marketDataByMonth[key][
currentDay < 10 ? `0${currentDay}` : currentDay
] = {
...marketDataItem,
day: currentDay
date: marketDataItem.date,
day: currentDay,
marketPrice: marketDataItem.marketPrice
};
}
}
@ -82,6 +115,11 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
return isValid(date) && isBefore(date, new Date());
}
public isToday(aDateString: string) {
const date = parse(aDateString, DATE_FORMAT, new Date());
return isValid(date) && isSameDay(date, new Date());
}
public onOpenMarketDataDetail({
day,
yearMonth
@ -89,13 +127,18 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
day: string;
yearMonth: string;
}) {
const date = new Date(`${yearMonth}-${day}`);
const marketPrice = this.marketDataByMonth[yearMonth]?.[day]?.marketPrice;
if (isSameDay(date, new Date())) {
return;
}
const dialogRef = this.dialog.open(MarketDataDetailDialog, {
data: {
date,
marketPrice,
dataSource: this.dataSource,
date: new Date(`${yearMonth}-${day}`),
symbol: this.symbol
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',

41
apps/client/src/app/components/admin-market-data/admin-market-data.component.ts

@ -8,6 +8,7 @@ import {
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
import { DataSource, MarketData } from '@prisma/client';
import { Subject } from 'rxjs';
@ -44,39 +45,21 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
this.fetchAdminMarketData();
}
public onDeleteProfileData({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) {
this.adminService
.deleteProfileData({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {});
}
public onGatherProfileDataBySymbol({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
public onGatherProfileDataBySymbol({ dataSource, symbol }: UniqueAsset) {
this.adminService
.gatherProfileDataBySymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {});
}
public onGatherSymbol({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
public onGatherSymbol({ dataSource, symbol }: UniqueAsset) {
this.adminService
.gatherSymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
@ -93,13 +76,7 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
}
}
public setCurrentProfile({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
public setCurrentProfile({ dataSource, symbol }: UniqueAsset) {
this.marketDataDetails = [];
if (this.currentSymbol === symbol) {
@ -129,13 +106,7 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
});
}
private fetchAdminMarketDataBySymbol({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
private fetchAdminMarketDataBySymbol({ dataSource, symbol }: UniqueAsset) {
this.adminService
.fetchAdminMarketDataBySymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))

1
apps/client/src/app/components/admin-market-data/admin-market-data.html

@ -64,6 +64,7 @@
<td class="p-1" colspan="6">
<gf-admin-market-data-detail
[dataSource]="item.dataSource"
[dateOfFirstActivity]="item.date"
[marketData]="marketDataDetails"
[symbol]="item.symbol"
(marketDataChanged)="onMarketDataChanged($event)"

11
apps/client/src/app/components/admin-overview/admin-overview.component.ts

@ -20,6 +20,7 @@ import {
parseISO
} from 'date-fns';
import { uniq } from 'lodash';
import { StringValue } from 'ms';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -29,6 +30,7 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './admin-overview.html'
})
export class AdminOverviewComponent implements OnDestroy, OnInit {
public couponDuration: StringValue = '30 days';
public coupons: Coupon[];
public customCurrencies: string[];
public dataGatheringInProgress: boolean;
@ -105,7 +107,10 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
}
public onAddCoupon() {
const coupons = [...this.coupons, { code: this.generateCouponCode(16) }];
const coupons = [
...this.coupons,
{ code: this.generateCouponCode(16), duration: this.couponDuration }
];
this.putCoupons(coupons);
}
@ -118,6 +123,10 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
}
}
public onChangeCouponDuration(aCouponDuration: StringValue) {
this.couponDuration = aCouponDuration;
}
public onDeleteCoupon(aCouponCode: string) {
const confirmation = confirm('Do you really want to delete this coupon?');

28
apps/client/src/app/components/admin-overview/admin-overview.html

@ -156,11 +156,14 @@
></mat-slide-toggle>
</div>
</div>
<div *ngIf="hasPermissionForSubscription" class="d-flex my-3">
<div
*ngIf="hasPermissionForSubscription"
class="d-flex my-3 subscription"
>
<div class="w-50" i18n>Coupons</div>
<div class="w-50">
<div *ngFor="let coupon of coupons">
<span>{{ coupon.code }}</span>
<span>{{ coupon.code }} ({{ coupon.duration }})</span>
<button
class="mini-icon mx-1 no-min-width px-2"
mat-button
@ -170,10 +173,25 @@
</button>
</div>
<div class="mt-2">
<button color="primary" mat-flat-button (click)="onAddCoupon()">
<ion-icon class="mr-1" name="add-outline"></ion-icon>
<span i18n>Add Coupon</span>
<form #couponForm="ngForm">
<mat-form-field appearance="outline" class="mr-2">
<mat-select
name="duration"
[value]="couponDuration"
(selectionChange)="onChangeCouponDuration($event.value)"
>
<mat-option value="30 days">30 Days</mat-option>
<mat-option value="1 year">1 Year</mat-option>
</mat-select>
</mat-form-field>
<button
color="primary"
mat-flat-button
(click)="onAddCoupon()"
>
<span i18n>Add</span>
</button>
</form>
</div>
</div>
</div>

7
apps/client/src/app/components/admin-overview/admin-overview.module.ts

@ -1,7 +1,9 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatSelectModule } from '@angular/material/select';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { CacheService } from '@ghostfolio/client/services/cache.service';
import { GfValueModule } from '@ghostfolio/ui/value';
@ -12,11 +14,14 @@ import { AdminOverviewComponent } from './admin-overview.component';
declarations: [AdminOverviewComponent],
exports: [],
imports: [
FormsModule,
CommonModule,
GfValueModule,
MatButtonModule,
MatCardModule,
MatSlideToggleModule
MatSelectModule,
MatSlideToggleModule,
ReactiveFormsModule
],
providers: [CacheService],
schemas: [CUSTOM_ELEMENTS_SCHEMA]

6
apps/client/src/app/components/admin-overview/admin-overview.scss

@ -20,4 +20,10 @@
}
}
}
.subscription {
.mat-form-field {
max-width: 100%;
}
}
}

4
apps/client/src/app/components/admin-users/admin-users.html

@ -14,12 +14,12 @@
Accounts
</th>
<th class="mat-header-cell px-1 py-2 text-right" i18n>
Transactions
Activities
</th>
<th class="mat-header-cell px-1 py-2 text-right" i18n>
Engagement per Day
</th>
<th class="mat-header-cell px-1 py-2" i18n>Last Activitiy</th>
<th class="mat-header-cell px-1 py-2" i18n>Last Request</th>
<th class="mat-header-cell px-1 py-2"></th>
</tr>
</thead>

4
apps/client/src/app/components/home-holdings/home-holdings.component.ts

@ -93,7 +93,9 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
});
this.dateRange =
<DateRange>this.settingsStorageService.getSetting(RANGE) || 'max';
this.user.settings.viewMode === 'ZEN'
? 'max'
: <DateRange>this.settingsStorageService.getSetting(RANGE) ?? 'max';
this.update();
}

4
apps/client/src/app/components/home-holdings/home-holdings.html

@ -1,5 +1,5 @@
<div class="container justify-content-center p-3">
<div class="mb-3 text-center">
<div *ngIf="user.settings.viewMode !== 'ZEN'" class="mb-3 text-center">
<gf-toggle
[defaultValue]="dateRange"
[isLoading]="positions === undefined"
@ -25,7 +25,7 @@
<a
class="mt-3"
i18n
mat-button
mat-stroked-button
[routerLink]="['/portfolio', 'activities']"
>Manage Activities</a
>

18
apps/client/src/app/components/home-overview/home-overview.component.ts

@ -7,7 +7,11 @@ import {
} from '@ghostfolio/client/services/settings-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { defaultDateRangeOptions } from '@ghostfolio/common/config';
import { PortfolioPerformance, User } from '@ghostfolio/common/interfaces';
import {
PortfolioPerformance,
UniqueAsset,
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';
@ -24,6 +28,7 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
public dateRange: DateRange;
public dateRangeOptions = defaultDateRangeOptions;
public deviceType: string;
public errors: UniqueAsset[];
public hasError: boolean;
public hasImpersonationId: boolean;
public hasPermissionToCreateOrder: boolean;
@ -32,6 +37,7 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
public isAllTimeLow: boolean;
public isLoadingPerformance = true;
public performance: PortfolioPerformance;
public showDetails = false;
public user: User;
private unsubscribeSubject = new Subject<void>();
@ -79,7 +85,14 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
});
this.dateRange =
<DateRange>this.settingsStorageService.getSetting(RANGE) || 'max';
this.user.settings.viewMode === 'ZEN'
? 'max'
: <DateRange>this.settingsStorageService.getSetting(RANGE) ?? 'max';
this.showDetails =
!this.hasImpersonationId &&
!this.user.settings.isRestrictedView &&
this.user.settings.viewMode !== 'ZEN';
this.update();
}
@ -118,6 +131,7 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
.fetchPortfolioPerformance({ range: this.dateRange })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => {
this.errors = response.errors;
this.hasError = response.hasErrors;
this.performance = response.performance;
this.isLoadingPerformance = false;

5
apps/client/src/app/components/home-overview/home-overview.html

@ -28,15 +28,16 @@
class="pb-4"
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[errors]="errors"
[hasError]="hasError"
[isAllTimeHigh]="isAllTimeHigh"
[isAllTimeLow]="isAllTimeLow"
[isLoading]="isLoadingPerformance"
[locale]="user?.settings?.locale"
[performance]="performance"
[showDetails]="!hasImpersonationId && !user.settings.isRestrictedView"
[showDetails]="showDetails"
></gf-portfolio-performance>
<div class="text-center">
<div *ngIf="showDetails" class="text-center">
<gf-toggle
[defaultValue]="dateRange"
[isLoading]="isLoadingPerformance"

1
apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html

@ -7,6 +7,7 @@
? 'Sorry! Our data provider partner is experiencing the hiccups.'
: ''
"
(click)="errors?.length > 0 && onShowErrors()"
>
<ion-icon
*ngIf="hasError && !isLoading"

14
apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts

@ -7,7 +7,10 @@ import {
OnInit,
ViewChild
} from '@angular/core';
import { PortfolioPerformance } from '@ghostfolio/common/interfaces';
import {
PortfolioPerformance,
ResponseError
} from '@ghostfolio/common/interfaces';
import { CountUp } from 'countup.js';
import { isNumber } from 'lodash';
@ -20,6 +23,7 @@ import { isNumber } from 'lodash';
export class PortfolioPerformanceComponent implements OnChanges, OnInit {
@Input() baseCurrency: string;
@Input() deviceType: string;
@Input() errors: ResponseError['errors'];
@Input() hasError: boolean;
@Input() isAllTimeHigh: boolean;
@Input() isAllTimeLow: boolean;
@ -69,4 +73,12 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
}
}
}
public onShowErrors() {
const errorMessageParts = this.errors.map((error) => {
return `${error.symbol} (${error.dataSource})`;
});
alert(errorMessageParts.join('\n'));
}
}

6
apps/client/src/app/components/position/position.component.html

@ -2,6 +2,7 @@
<div class="flex-nowrap no-gutters row">
<a
class="d-flex p-3 w-100"
[ngClass]="{ 'cursor-default': isLoading }"
[routerLink]="[]"
[queryParams]="{
dataSource: position?.dataSource,
@ -39,11 +40,6 @@
<div class="h6 m-0 text-truncate">{{ position?.name }}</div>
<div class="d-flex">
<span>{{ position?.symbol | gfSymbol }}</span>
<span
*ngIf="position?.exchange && position?.exchange !== unknownKey"
class="ml-2 text-muted"
>({{ position.exchange }})</span
>
</div>
<div class="d-flex mt-1">
<gf-value

2
apps/client/src/app/components/position/position.component.scss

@ -2,8 +2,6 @@
display: block;
.container {
cursor: pointer;
gf-trend-indicator {
padding-top: 0.15rem;
}

12
apps/client/src/app/components/positions-table/positions-table.component.ts

@ -13,8 +13,8 @@ import { MatPaginator } from '@angular/material/paginator';
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, DataSource, Order as OrderModel } from '@prisma/client';
import { PortfolioPosition, UniqueAsset } from '@ghostfolio/common/interfaces';
import { AssetClass, Order as OrderModel } from '@prisma/client';
import { Subject, Subscription } from 'rxjs';
@Component({
@ -75,13 +75,7 @@ export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
this.dataSource.filter = filterValue.trim().toLowerCase();
}*/
public onOpenPositionDialog({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}): void {
public onOpenPositionDialog({ dataSource, symbol }: UniqueAsset): void {
this.router.navigate([], {
queryParams: { dataSource, symbol, positionDetailDialog: true }
});

2
apps/client/src/app/pages/account/account-page.component.ts

@ -55,6 +55,8 @@ export class AccountPageComponent implements OnDestroy, OnInit {
public price: number;
public priceId: string;
public snackBarRef: MatSnackBarRef<TextOnlySnackBar>;
public trySubscriptionMail =
'mailto:hi@ghostfol.io?Subject=Ghostfolio Premium Trial&body=Hello%0D%0DI am interested in Ghostfolio Premium. Can you please send me a coupon code to try it for some time?%0D%0DKind regards';
public user: User;
private unsubscribeSubject = new Subject<void>();

18
apps/client/src/app/pages/account/account-page.html

@ -23,12 +23,12 @@
name="diamond-outline"
></ion-icon>
</div>
<div *ngIf="user?.subscription?.expiresAt">
<div *ngIf="user?.subscription?.type === 'Premium'">
Valid until {{ user?.subscription?.expiresAt | date:
defaultDateFormat }}
</div>
<div
*ngIf="hasPermissionForSubscription && !user?.subscription?.expiresAt"
*ngIf="hasPermissionForSubscription && user?.subscription?.type === 'Basic'"
>
<button
color="primary"
@ -48,8 +48,20 @@
<span i18n> per year</span>
</div>
<a
class="cursor-pointer d-block mt-2"
*ngIf="!user?.subscription?.expiresAt"
class="mr-2 my-2"
mat-stroked-button
[href]="trySubscriptionMail"
><span i18n>Try Premium</span
><ion-icon
class="ml-1 text-muted"
name="diamond-outline"
></ion-icon
></a>
<a
class="mr-2 my-2"
i18n
mat-stroked-button
[routerLink]=""
(click)="onRedeemCoupon()"
>Redeem Coupon</a

19
apps/client/src/app/pages/accounts/accounts-page.component.ts

@ -28,8 +28,8 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
public hasPermissionToCreateAccount: boolean;
public hasPermissionToDeleteAccount: boolean;
public routeQueryParams: Subscription;
public totalBalance = 0;
public totalValue = 0;
public totalBalanceInBaseCurrency = 0;
public totalValueInBaseCurrency = 0;
public transactionCount = 0;
public user: User;
@ -106,10 +106,16 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
this.dataService
.fetchAccounts()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ accounts, totalBalance, totalValue, transactionCount }) => {
.subscribe(
({
accounts,
totalBalanceInBaseCurrency,
totalValueInBaseCurrency,
transactionCount
}) => {
this.accounts = accounts;
this.totalBalance = totalBalance;
this.totalValue = totalValue;
this.totalBalanceInBaseCurrency = totalBalanceInBaseCurrency;
this.totalValueInBaseCurrency = totalValueInBaseCurrency;
this.transactionCount = transactionCount;
if (this.accounts?.length <= 0) {
@ -117,7 +123,8 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
}
this.changeDetectorRef.markForCheck();
});
}
);
}
public onDeleteAccount(aId: string) {

4
apps/client/src/app/pages/accounts/accounts-page.html

@ -9,8 +9,8 @@
[deviceType]="deviceType"
[locale]="user?.settings?.locale"
[showActions]="!hasImpersonationId && hasPermissionToDeleteAccount && !user.settings.isRestrictedView"
[totalBalance]="totalBalance"
[totalValue]="totalValue"
[totalBalanceInBaseCurrency]="totalBalanceInBaseCurrency"
[totalValueInBaseCurrency]="totalValueInBaseCurrency"
[transactionCount]="transactionCount"
(accountDeleted)="onDeleteAccount($event)"
(accountToUpdate)="onUpdateAccount($event)"

16
apps/client/src/app/pages/features/features-page.component.ts

@ -1,6 +1,8 @@
import { ChangeDetectorRef, Component, OnDestroy } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { User } from '@ghostfolio/common/interfaces';
import { InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Subject, takeUntil } from 'rxjs';
@Component({
@ -10,6 +12,8 @@ import { Subject, takeUntil } from 'rxjs';
templateUrl: './features-page.html'
})
export class FeaturesPageComponent implements OnDestroy {
public hasPermissionForSubscription: boolean;
public info: InfoItem;
public user: User;
private unsubscribeSubject = new Subject<void>();
@ -19,8 +23,11 @@ export class FeaturesPageComponent implements OnDestroy {
*/
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private userService: UserService
) {}
) {
this.info = this.dataService.fetchInfo();
}
/**
* Initializes the controller
@ -35,6 +42,11 @@ export class FeaturesPageComponent implements OnDestroy {
this.changeDetectorRef.markForCheck();
}
});
this.hasPermissionForSubscription = hasPermission(
this.info?.globalPermissions,
permissions.enableSubscription
);
}
public ngOnDestroy() {

8
apps/client/src/app/pages/features/features-page.html

@ -89,6 +89,7 @@
<h4 class="align-items-center d-flex">
<span i18n>Portfolio Calculations</span>
<ion-icon
*ngIf="hasPermissionForSubscription"
class="ml-1 text-muted"
name="diamond-outline"
></ion-icon>
@ -107,6 +108,7 @@
<h4 class="align-items-center d-flex">
<span i18n>Portfolio Allocations</span>
<ion-icon
*ngIf="hasPermissionForSubscription"
class="ml-1 text-muted"
name="diamond-outline"
></ion-icon>
@ -139,7 +141,10 @@
</div>
</mat-card>
</div>
<div class="col-xs-12 col-md-4 mb-3">
<div
*ngIf="hasPermissionForSubscription"
class="col-xs-12 col-md-4 mb-3"
>
<mat-card class="d-flex flex-column h-100">
<div class="flex-grow-1">
<h4 class="align-items-center d-flex">
@ -163,6 +168,7 @@
<h4 class="align-items-center d-flex">
<span i18n>Static Analysis</span>
<ion-icon
*ngIf="hasPermissionForSubscription"
class="ml-1 text-muted"
name="diamond-outline"
></ion-icon>

25
apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts

@ -10,11 +10,12 @@ import { prettifySymbol } from '@ghostfolio/common/helper';
import {
PortfolioDetails,
PortfolioPosition,
UniqueAsset,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { ToggleOption } from '@ghostfolio/common/types';
import { AssetClass, DataSource } from '@prisma/client';
import { Account, AssetClass, DataSource } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -27,7 +28,10 @@ import { takeUntil } from 'rxjs/operators';
})
export class AllocationsPageComponent implements OnDestroy, OnInit {
public accounts: {
[symbol: string]: Pick<PortfolioPosition, 'name'> & { value: number };
[id: string]: Pick<Account, 'name'> & {
id: string;
value: number;
};
};
public continents: {
[code: string]: { name: string; value: number };
@ -61,7 +65,12 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
[name: string]: { name: string; value: number };
};
public symbols: {
[name: string]: { name: string; symbol: string; value: number };
[name: string]: {
dataSource?: DataSource;
name: string;
symbol: string;
value: number;
};
};
public user: User;
@ -171,6 +180,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
this.portfolioDetails.accounts
)) {
this.accounts[id] = {
id,
name,
value: aPeriod === 'original' ? original : current
};
@ -277,6 +287,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
if (position.assetClass === AssetClass.EQUITY) {
this.symbols[prettifySymbol(symbol)] = {
dataSource: position.dataSource,
name: position.name,
symbol: prettifySymbol(symbol),
value: aPeriod === 'original' ? position.investment : position.value
@ -291,6 +302,14 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
this.initializeAnalysisData(this.period);
}
public onProportionChartClicked({ dataSource, symbol }: UniqueAsset) {
if (dataSource && symbol) {
this.router.navigate([], {
queryParams: { dataSource, symbol, positionDetailDialog: true }
});
}
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();

4
apps/client/src/app/pages/portfolio/allocations/allocations-page.html

@ -20,7 +20,7 @@
<gf-portfolio-proportion-chart
[baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[keys]="['name']"
[keys]="['id']"
[locale]="user?.settings?.locale"
[positions]="accounts"
></gf-portfolio-proportion-chart>
@ -89,12 +89,14 @@
<mat-card-content>
<gf-portfolio-proportion-chart
class="mx-auto"
cursor="pointer"
[baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[keys]="['symbol']"
[locale]="user?.settings?.locale"
[positions]="symbols"
[showLabels]="deviceType !== 'mobile'"
(proportionChartClicked)="onProportionChartClicked($event)"
></gf-portfolio-proportion-chart>
</mat-card-content>
</mat-card>

53
apps/client/src/app/pages/portfolio/portfolio-page.html

@ -1,11 +1,14 @@
<div class="container">
<h3 class="d-flex justify-content-center mb-3" i18n>Portfolio</h3>
<div class="row">
<div class="col-xs-12 col-md-6">
<mat-card class="mb-3">
<div class="col-xs-12 col-md-6 mb-3">
<mat-card class="d-flex flex-column h-100">
<h4 i18n>Activities</h4>
<p class="mb-0">Manage your activities.</p>
<p class="text-right">
<div class="flex-grow-1">
Manage your activities: stocks, ETFs, cryptocurrencies, dividend, and
valuables.
</div>
<div class="mt-2 text-right">
<a
color="primary"
mat-button
@ -14,11 +17,11 @@
<span i18n>Open Activities</span>
<ion-icon class="ml-1" name="arrow-forward-outline"></ion-icon>
</a>
</p>
</div>
</mat-card>
</div>
<div class="col-xs-12 col-md-6">
<mat-card class="mb-3">
<div class="col-xs-12 col-md-6 mb-3">
<mat-card class="d-flex flex-column h-100">
<h4 class="align-items-center d-flex">
<span i18n>Allocations</span>
<ion-icon
@ -27,8 +30,11 @@
name="diamond-outline"
></ion-icon>
</h4>
<p class="mb-0">Check the allocations of your portfolio.</p>
<p class="text-right">
<div class="flex-grow-1">
Check the allocations of your portfolio by account, asset class,
currency, sector and region.
</div>
<div class="mt-2 text-right">
<a
color="primary"
mat-button
@ -38,13 +44,11 @@
<span i18n>Open Allocations</span>
<ion-icon class="ml-1" name="arrow-forward-outline"></ion-icon>
</a>
</p>
</mat-card>
</div>
</mat-card>
</div>
<div class="row">
<div class="col-xs-12 col-md-6">
<mat-card class="mb-3">
<div class="col-xs-12 col-md-6 mb-3">
<mat-card class="d-flex flex-column h-100">
<h4 class="align-items-center d-flex">
<span i18n>Analysis</span>
<ion-icon
@ -53,8 +57,11 @@
name="diamond-outline"
></ion-icon>
</h4>
<p class="mb-0">Ghostfolio Analysis visualizes your portfolio.</p>
<p class="text-right">
<div class="flex-grow-1">
Ghostfolio Analysis visualizes your portfolio and shows your top and
bottom performers.
</div>
<div class="mt-2 text-right">
<a
color="primary"
mat-button
@ -64,11 +71,11 @@
<span i18n>Open Analysis</span>
<ion-icon class="ml-1" name="arrow-forward-outline"></ion-icon>
</a>
</p>
</div>
</mat-card>
</div>
<div class="col-xs-12 col-md-6">
<mat-card class="mb-3">
<div class="col-xs-12 col-md-6 mb-3">
<mat-card class="d-flex flex-column h-100">
<h4 class="align-items-center d-flex">
<span i18n>X-ray</span>
<ion-icon
@ -77,11 +84,11 @@
name="diamond-outline"
></ion-icon>
</h4>
<p class="mb-0">
<div class="flex-grow-1">
Ghostfolio X-ray uses static analysis to identify potential issues and
risks in your portfolio.
</p>
<p class="text-right">
</div>
<div class="mt-2 text-right">
<a
color="primary"
mat-button
@ -91,7 +98,7 @@
<span i18n>Open X-ray</span>
<ion-icon class="ml-1" name="arrow-forward-outline"></ion-icon>
</a>
</p>
</div>
</mat-card>
</div>
</div>

14
apps/client/src/app/pages/portfolio/transactions/create-or-update-transaction-dialog/create-or-update-transaction-dialog.component.ts

@ -158,11 +158,11 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
this.activityForm.controls['type'].disable();
}
if (this.data.activity?.symbol) {
if (this.data.activity?.SymbolProfile?.symbol) {
this.dataService
.fetchSymbolItem({
dataSource: this.data.activity?.dataSource,
symbol: this.data.activity?.symbol
dataSource: this.data.activity?.SymbolProfile?.dataSource,
symbol: this.data.activity?.SymbolProfile?.symbol
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ marketPrice }) => {
@ -196,9 +196,7 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
} else {
this.activityForm.controls['searchSymbol'].setErrors({ incorrect: true });
this.data.activity.currency = null;
this.data.activity.dataSource = null;
this.data.activity.symbol = null;
this.data.activity.SymbolProfile = null;
}
this.changeDetectorRef.markForCheck();
@ -259,9 +257,7 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
})
.pipe(
catchError(() => {
this.data.activity.currency = null;
this.data.activity.dataSource = null;
this.data.activity.unitPrice = null;
this.data.activity.SymbolProfile = null;
this.isLoading = false;

25
apps/client/src/app/services/admin.service.ts

@ -3,7 +3,10 @@ import { Injectable } from '@angular/core';
import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto';
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { AdminMarketDataDetails } from '@ghostfolio/common/interfaces';
import {
AdminMarketDataDetails,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { DataSource, MarketData } from '@prisma/client';
import { format, parseISO } from 'date-fns';
import { Observable, map } from 'rxjs';
@ -14,13 +17,7 @@ import { Observable, map } from 'rxjs';
export class AdminService {
public constructor(private http: HttpClient) {}
public deleteProfileData({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
public deleteProfileData({ dataSource, symbol }: UniqueAsset) {
return this.http.delete<void>(
`/api/admin/profile-data/${dataSource}/${symbol}`
);
@ -53,13 +50,7 @@ export class AdminService {
return this.http.post<void>(`/api/admin/gather/profile-data`, {});
}
public gatherProfileDataBySymbol({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
public gatherProfileDataBySymbol({ dataSource, symbol }: UniqueAsset) {
return this.http.post<void>(
`/api/admin/gather/profile-data/${dataSource}/${symbol}`,
{}
@ -70,10 +61,8 @@ export class AdminService {
dataSource,
date,
symbol
}: {
dataSource: DataSource;
}: UniqueAsset & {
date?: Date;
symbol: string;
}) {
let url = `/api/admin/gather/${dataSource}/${symbol}`;

16
apps/client/src/app/services/data.service.ts

@ -24,9 +24,11 @@ import {
PortfolioDetails,
PortfolioInvestments,
PortfolioPerformance,
PortfolioPerformanceResponse,
PortfolioPublicDetails,
PortfolioReport,
PortfolioSummary,
UniqueAsset,
User
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
@ -188,13 +190,13 @@ export class DataService {
});
}
public fetchPortfolioPerformance(aParams: { [param: string]: any }) {
return this.http.get<{
hasErrors: boolean;
performance: PortfolioPerformance;
}>('/api/portfolio/performance', {
params: aParams
});
public fetchPortfolioPerformance(params: { [param: string]: any }) {
return this.http.get<PortfolioPerformanceResponse>(
'/api/portfolio/performance',
{
params
}
);
}
public fetchPortfolioPublic(aId: string) {

4
apps/client/src/styles.scss

@ -134,6 +134,10 @@ ngx-skeleton-loader {
}
}
.cursor-default {
cursor: default;
}
.cursor-pointer {
cursor: pointer;
}

4
docker/docker-compose.dev.yml

@ -4,10 +4,10 @@ services:
image: postgres:12
container_name: postgres
restart: unless-stopped
ports:
- 5432:5432
env_file:
- ../.env
ports:
- 5432:5432
volumes:
- postgres:/var/lib/postgresql/data

4
libs/common/src/lib/helper.ts

@ -102,10 +102,6 @@ export function isCurrency(aSymbol = '') {
return currencies[aSymbol];
}
export function isGhostfolioScraperApiSymbol(aSymbol = '') {
return aSymbol.startsWith(ghostfolioScraperApiSymbolPrefix);
}
export function resetHours(aDate: Date) {
const year = getYear(aDate);
const month = getMonth(aDate);

4
libs/common/src/lib/interfaces/accounts.interface.ts

@ -2,7 +2,7 @@ import { AccountWithValue } from '@ghostfolio/common/types';
export interface Accounts {
accounts: AccountWithValue[];
totalBalance: number;
totalValue: number;
totalBalanceInBaseCurrency: number;
totalValueInBaseCurrency: number;
transactionCount: number;
}

2
libs/common/src/lib/interfaces/admin-data.interface.ts

@ -1,5 +1,3 @@
import { Property } from '@prisma/client';
export interface AdminData {
dataGatheringProgress?: number;
exchangeRates: { label1: string; label2: string; value: number }[];

3
libs/common/src/lib/interfaces/coupon.interface.ts

@ -1,3 +1,6 @@
import { StringValue } from 'ms';
export interface Coupon {
code: string;
duration?: StringValue;
}

6
libs/common/src/lib/interfaces/index.ts

@ -21,7 +21,10 @@ import { PortfolioReportRule } from './portfolio-report-rule.interface';
import { PortfolioReport } from './portfolio-report.interface';
import { PortfolioSummary } from './portfolio-summary.interface';
import { Position } from './position.interface';
import { ResponseError } from './responses/errors.interface';
import { PortfolioPerformanceResponse } from './responses/portfolio-performance-response.interface';
import { TimelinePosition } from './timeline-position.interface';
import { UniqueAsset } from './unique-asset.interface';
import { UserSettings } from './user-settings.interface';
import { UserWithSettings } from './user-with-settings';
import { User } from './user.interface';
@ -42,13 +45,16 @@ export {
PortfolioItem,
PortfolioOverview,
PortfolioPerformance,
PortfolioPerformanceResponse,
PortfolioPosition,
PortfolioPublicDetails,
PortfolioReport,
PortfolioReportRule,
PortfolioSummary,
Position,
ResponseError,
TimelinePosition,
UniqueAsset,
User,
UserSettings,
UserWithSettings

6
libs/common/src/lib/interfaces/responses/errors.interface.ts

@ -0,0 +1,6 @@
import { UniqueAsset } from '../unique-asset.interface';
export interface ResponseError {
errors?: UniqueAsset[];
hasErrors: boolean;
}

6
libs/common/src/lib/interfaces/responses/portfolio-performance-response.interface.ts

@ -0,0 +1,6 @@
import { PortfolioPerformance } from '../portfolio-performance.interface';
import { ResponseError } from './errors.interface';
export interface PortfolioPerformanceResponse extends ResponseError {
performance: PortfolioPerformance;
}

6
libs/common/src/lib/interfaces/unique-asset.interface.ts

@ -0,0 +1,6 @@
import { DataSource } from '@prisma/client';
export interface UniqueAsset {
dataSource: DataSource;
symbol: string;
}

3
libs/common/src/lib/types/account-with-value.type.ts

@ -1,7 +1,8 @@
import { Account as AccountModel } from '@prisma/client';
export type AccountWithValue = AccountModel & {
convertedBalance: number;
balanceInBaseCurrency: number;
transactionCount: number;
value: number;
valueInBaseCurrency: number;
};

4
libs/ui/src/lib/activities-table/activities-table.component.html

@ -144,7 +144,7 @@
class="d-none d-lg-table-cell px-1"
mat-cell
>
{{ element.currency }}
{{ element.SymbolProfile.currency }}
</td>
<td *matFooterCellDef class="d-none d-lg-table-cell px-1" mat-footer-cell>
{{ baseCurrency }}
@ -362,7 +362,7 @@
!row.isDraft &&
row.type !== 'ITEM' &&
onOpenPositionDialog({
dataSource: row.dataSource,
dataSource: row.SymbolProfile.dataSource,
symbol: row.SymbolProfile.symbol
})
"

9
libs/ui/src/lib/activities-table/activities-table.component.ts

@ -21,6 +21,7 @@ import { MatTableDataSource } from '@angular/material/table';
import { Router } from '@angular/router';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { DataSource } from '@prisma/client';
import Big from 'big.js';
@ -199,13 +200,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
this.import.emit();
}
public onOpenPositionDialog({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}): void {
public onOpenPositionDialog({ dataSource, symbol }: UniqueAsset): void {
this.router.navigate([], {
queryParams: { dataSource, symbol, positionDetailDialog: true }
});

96
libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts

@ -3,14 +3,18 @@ import {
ChangeDetectionStrategy,
Component,
ElementRef,
EventEmitter,
Input,
OnChanges,
OnDestroy,
Output,
ViewChild
} from '@angular/core';
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { getTextColor } from '@ghostfolio/common/helper';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { PortfolioPosition, UniqueAsset } from '@ghostfolio/common/interfaces';
import { DataSource } from '@prisma/client';
import Big from 'big.js';
import { Tooltip } from 'chart.js';
import { LinearScale } from 'chart.js';
import { ArcElement } from 'chart.js';
@ -29,6 +33,7 @@ export class PortfolioProportionChartComponent
implements AfterViewInit, OnChanges, OnDestroy
{
@Input() baseCurrency: string;
@Input() cursor: string;
@Input() isInPercent = false;
@Input() keys: string[] = [];
@Input() locale = '';
@ -36,19 +41,25 @@ export class PortfolioProportionChartComponent
@Input() showLabels = false;
@Input() positions: {
[symbol: string]: Pick<PortfolioPosition, 'type'> & {
dataSource?: DataSource;
name: string;
value: number;
};
} = {};
@Output() proportionChartClicked = new EventEmitter<UniqueAsset>();
@ViewChild('chartCanvas') chartCanvas: ElementRef<HTMLCanvasElement>;
public chart: Chart;
public isLoading = true;
private readonly OTHER_KEY = 'OTHER';
private colorMap: {
[symbol: string]: string;
} = {
[this.OTHER_KEY]: `rgba(${getTextColor()}, 0.24)`,
[UNKNOWN_KEY]: `rgba(${getTextColor()}, 0.12)`
};
@ -78,16 +89,17 @@ export class PortfolioProportionChartComponent
[symbol: string]: {
color?: string;
name: string;
subCategory: { [symbol: string]: { value: number } };
value: number;
subCategory: { [symbol: string]: { value: Big } };
value: Big;
};
} = {};
Object.keys(this.positions).forEach((symbol) => {
if (this.positions[symbol][this.keys[0]]) {
if (chartData[this.positions[symbol][this.keys[0]]]) {
chartData[this.positions[symbol][this.keys[0]]].value +=
this.positions[symbol].value;
chartData[this.positions[symbol][this.keys[0]]].value = chartData[
this.positions[symbol][this.keys[0]]
].value.plus(this.positions[symbol].value);
if (
chartData[this.positions[symbol][this.keys[0]]].subCategory[
@ -96,37 +108,43 @@ export class PortfolioProportionChartComponent
) {
chartData[this.positions[symbol][this.keys[0]]].subCategory[
this.positions[symbol][this.keys[1]]
].value += this.positions[symbol].value;
].value = chartData[
this.positions[symbol][this.keys[0]]
].subCategory[this.positions[symbol][this.keys[1]]].value.plus(
this.positions[symbol].value
);
} else {
chartData[this.positions[symbol][this.keys[0]]].subCategory[
this.positions[symbol][this.keys[1]] ?? UNKNOWN_KEY
] = { value: this.positions[symbol].value };
] = { value: new Big(this.positions[symbol].value) };
}
} else {
chartData[this.positions[symbol][this.keys[0]]] = {
name: this.positions[symbol].name,
subCategory: {},
value: this.positions[symbol].value
value: new Big(this.positions[symbol].value)
};
if (this.positions[symbol][this.keys[1]]) {
chartData[this.positions[symbol][this.keys[0]]].subCategory = {
[this.positions[symbol][this.keys[1]]]: {
value: this.positions[symbol].value
value: new Big(this.positions[symbol].value)
}
};
}
}
} else {
if (chartData[UNKNOWN_KEY]) {
chartData[UNKNOWN_KEY].value += this.positions[symbol].value;
chartData[UNKNOWN_KEY].value = chartData[UNKNOWN_KEY].value.plus(
this.positions[symbol].value
);
} else {
chartData[UNKNOWN_KEY] = {
name: this.positions[symbol].name,
subCategory: this.keys[1]
? { [this.keys[1]]: { value: 0 } }
? { [this.keys[1]]: { value: new Big(0) } }
: undefined,
value: this.positions[symbol].value
value: new Big(this.positions[symbol].value)
};
}
}
@ -134,35 +152,29 @@ export class PortfolioProportionChartComponent
let chartDataSorted = Object.entries(chartData)
.sort((a, b) => {
return a[1].value - b[1].value;
return a[1].value.minus(b[1].value).toNumber();
})
.reverse();
if (this.maxItems && chartDataSorted.length > this.maxItems) {
// Add surplus items to unknown group
// Add surplus items to OTHER group
const rest = chartDataSorted.splice(
this.maxItems,
chartDataSorted.length - 1
);
let unknownItem = chartDataSorted.find((charDataItem) => {
return charDataItem[0] === UNKNOWN_KEY;
});
if (!unknownItem) {
chartDataSorted.push([
UNKNOWN_KEY,
{ name: UNKNOWN_KEY, subCategory: {}, value: 0 }
this.OTHER_KEY,
{ name: this.OTHER_KEY, subCategory: {}, value: new Big(0) }
]);
unknownItem = chartDataSorted[chartDataSorted.length - 1];
}
const otherItem = chartDataSorted[chartDataSorted.length - 1];
rest.forEach((restItem) => {
if (unknownItem?.[1]) {
unknownItem[1] = {
name: UNKNOWN_KEY,
if (otherItem?.[1]) {
otherItem[1] = {
name: this.OTHER_KEY,
subCategory: {},
value: unknownItem[1].value + restItem[1].value
value: otherItem[1].value.plus(restItem[1].value)
};
}
});
@ -170,7 +182,7 @@ export class PortfolioProportionChartComponent
// Sort data again
chartDataSorted = chartDataSorted
.sort((a, b) => {
return a[1].value - b[1].value;
return a[1].value.minus(b[1].value).toNumber();
})
.reverse();
}
@ -201,7 +213,7 @@ export class PortfolioProportionChartComponent
backgroundColorSubCategory.push(
Color(item.color).lighten(lightnessRatio).hex()
);
dataSubCategory.push(item.subCategory[subCategory].value);
dataSubCategory.push(item.subCategory[subCategory].value.toNumber());
labelSubCategory.push(subCategory);
lightnessRatio += 0.1;
@ -215,7 +227,7 @@ export class PortfolioProportionChartComponent
}),
borderWidth: 0,
data: chartDataSorted.map(([, item]) => {
return item.value;
return item.value.toNumber();
})
}
];
@ -251,6 +263,21 @@ export class PortfolioProportionChartComponent
layout: {
padding: this.showLabels === true ? 100 : 0
},
onClick: (event, activeElements) => {
const dataIndex = activeElements[0].index;
const symbol: string = event.chart.data.labels[dataIndex];
const dataSource = this.positions[symbol]?.dataSource;
this.proportionChartClicked.emit({ dataSource, symbol });
},
onHover: (event, chartElement) => {
if (this.cursor) {
event.native.target.style.cursor = chartElement[0]
? this.cursor
: 'default';
}
},
plugins: {
datalabels: {
color: (context) => {
@ -279,8 +306,13 @@ export class PortfolioProportionChartComponent
const labelIndex =
(data.datasets[context.datasetIndex - 1]?.data?.length ??
0) + context.dataIndex;
const symbol =
context.chart.data.labels?.[labelIndex] ?? '';
let symbol = context.chart.data.labels?.[labelIndex] ?? '';
if (symbol === this.OTHER_KEY) {
symbol = 'Other';
} else if (symbol === UNKNOWN_KEY) {
symbol = 'Unknown';
}
const name = this.positions[<string>symbol]?.name;

33
package.json

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "1.116.0",
"version": "1.124.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"scripts": {
@ -70,8 +70,8 @@
"@nestjs/platform-express": "8.2.3",
"@nestjs/schedule": "1.0.2",
"@nestjs/serve-static": "2.2.2",
"@nrwl/angular": "13.8.1",
"@prisma/client": "3.9.1",
"@nrwl/angular": "13.8.5",
"@prisma/client": "3.10.0",
"@simplewebauthn/browser": "4.1.0",
"@simplewebauthn/server": "4.1.0",
"@simplewebauthn/typescript-types": "4.0.0",
@ -100,15 +100,16 @@
"http-status-codes": "2.2.0",
"ionicons": "5.5.1",
"lodash": "4.17.21",
"ms": "3.0.0-canary.1",
"ngx-device-detector": "3.0.0",
"ngx-markdown": "13.0.0",
"ngx-skeleton-loader": "2.9.1",
"ngx-skeleton-loader": "5.0.0",
"ngx-stripe": "13.0.0",
"papaparse": "5.3.1",
"passport": "0.4.1",
"passport-google-oauth20": "2.0.0",
"passport-jwt": "4.0.0",
"prisma": "3.9.1",
"prisma": "3.10.0",
"reflect-metadata": "0.1.13",
"round-to": "5.0.0",
"rxjs": "7.4.0",
@ -117,7 +118,7 @@
"tslib": "2.0.0",
"twitter-api-v2": "1.10.3",
"uuid": "8.3.2",
"yahoo-finance": "0.3.6",
"yahoo-finance2": "2.2.0",
"zone.js": "0.11.4"
},
"devDependencies": {
@ -131,15 +132,15 @@
"@angular/localize": "13.2.2",
"@nestjs/schematics": "8.0.5",
"@nestjs/testing": "8.2.3",
"@nrwl/cli": "13.8.1",
"@nrwl/cypress": "13.8.1",
"@nrwl/eslint-plugin-nx": "13.8.1",
"@nrwl/jest": "13.8.1",
"@nrwl/nest": "13.8.1",
"@nrwl/node": "13.8.1",
"@nrwl/storybook": "13.8.1",
"@nrwl/tao": "13.8.1",
"@nrwl/workspace": "13.8.1",
"@nrwl/cli": "13.8.5",
"@nrwl/cypress": "13.8.5",
"@nrwl/eslint-plugin-nx": "13.8.5",
"@nrwl/jest": "13.8.5",
"@nrwl/nest": "13.8.5",
"@nrwl/node": "13.8.5",
"@nrwl/storybook": "13.8.5",
"@nrwl/tao": "13.8.5",
"@nrwl/workspace": "13.8.5",
"@storybook/addon-essentials": "6.4.18",
"@storybook/angular": "6.4.18",
"@storybook/builder-webpack5": "6.4.18",
@ -165,7 +166,7 @@
"import-sort-parser-typescript": "6.0.0",
"import-sort-style-module": "6.0.0",
"jest": "27.2.3",
"jest-preset-angular": "11.0.0",
"jest-preset-angular": "11.1.1",
"prettier": "2.5.1",
"replace-in-file": "6.2.0",
"rimraf": "3.0.2",

2
prisma/migrations/20220227092214_added_mutualfund_to_asset_sub_class/migration.sql

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "AssetSubClass" ADD VALUE 'MUTUALFUND';

2
prisma/migrations/20220227093650_added_url_to_symbol_profile/migration.sql

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "SymbolProfile" ADD COLUMN "url" TEXT;

2
prisma/migrations/20220302184222_removed_data_source_from_order/migration.sql

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Order" DROP COLUMN "dataSource";

2
prisma/migrations/20220302191841_removed_currency_from_order/migration.sql

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Order" DROP COLUMN "currency";

2
prisma/migrations/20220302193633_removed_symbol_from_order/migration.sql

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Order" DROP COLUMN "symbol";

5
prisma/migrations/20220302200727_changed_currency_to_required_in_symbol_profile/migration.sql

@ -0,0 +1,5 @@
-- Set default value
UPDATE "SymbolProfile" SET "currency" = 'USD' WHERE "currency" IS NULL;
-- AlterTable
ALTER TABLE "SymbolProfile" ALTER COLUMN "currency" SET NOT NULL;

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

Loading…
Cancel
Save