Compare commits
19 Commits
Author | SHA1 | Date | |
---|---|---|---|
e3cd99f5d2 | |||
6dea9093ba | |||
43104f81d0 | |||
6d2e3b6e40 | |||
1d9a31dbb8 | |||
ea0e92220c | |||
b57301ef50 | |||
67dbc6b014 | |||
2e5176bacf | |||
060846023f | |||
f06a0fbbee | |||
4ab6a1a071 | |||
93dcbeb6c7 | |||
b9f0a57522 | |||
174c1d1a62 | |||
f308ae7a13 | |||
a7a6b0608b | |||
15a61b7a20 | |||
d1eedf9726 |
@ -1 +1,2 @@
|
|||||||
/dist
|
/dist
|
||||||
|
/test/import
|
||||||
|
64
CHANGELOG.md
64
CHANGELOG.md
@ -5,6 +5,70 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## 1.63.0 - 19.10.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a public page to share your portfolio
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the skeleton loader size of the portfolio proportion chart component
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn prisma migrate deploy`)
|
||||||
|
|
||||||
|
## 1.62.0 - 17.10.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the validation message of the import functionality for transactions
|
||||||
|
|
||||||
|
## 1.61.0 - 15.10.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the import functionality for transactions by `csv` files
|
||||||
|
- Introduced the primary data source
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Restricted the file selector of the import functionality for transactions to `csv` and `json`
|
||||||
|
|
||||||
|
## 1.60.0 - 13.10.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the validation of the import functionality for transactions
|
||||||
|
- Valid data types
|
||||||
|
- Maximum number of orders
|
||||||
|
- No duplicate orders
|
||||||
|
- Data provider service returns data for the `dataSource` / `symbol` pair
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Harmonized the page layouts
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the broken line charts showing value labels
|
||||||
|
|
||||||
|
## 1.59.0 - 11.10.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a data enhancer for symbol profile data (countries and sectors) via _Trackinsight_
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Changed the values of the global heat map to fixed-point notation
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the links of cryptocurrency assets in the positions table
|
||||||
|
- Fixed various values in the impersonation mode which have not been nullified
|
||||||
|
|
||||||
## 1.58.1 - 03.10.2021
|
## 1.58.1 - 03.10.2021
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
@ -34,7 +34,7 @@
|
|||||||
|
|
||||||
Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
|
Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
|
||||||
|
|
||||||
If you prefer to run Ghostfolio on your own infrastructure, please find the source code and further instructions here on _GitHub_.
|
If you prefer to run Ghostfolio on your own infrastructure, please find the source code and further instructions here on _GitHub_ or use the [setup](https://github.com/psychowood/ghostfolio-docker) by [psychowood](https://github.com/psychowood).
|
||||||
|
|
||||||
## Why Ghostfolio?
|
## Why Ghostfolio?
|
||||||
|
|
||||||
|
@ -24,8 +24,18 @@ export class AccessController {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return accessesWithGranteeUser.map((access) => {
|
return accessesWithGranteeUser.map((access) => {
|
||||||
|
if (access.GranteeUser) {
|
||||||
|
return {
|
||||||
|
granteeAlias: access.GranteeUser?.alias,
|
||||||
|
id: access.id,
|
||||||
|
type: 'RESTRICTED_VIEW'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
granteeAlias: access.GranteeUser.alias
|
granteeAlias: 'Public',
|
||||||
|
id: access.id,
|
||||||
|
type: 'PUBLIC'
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -5,8 +5,9 @@ import { AccessController } from './access.controller';
|
|||||||
import { AccessService } from './access.service';
|
import { AccessService } from './access.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [],
|
|
||||||
controllers: [AccessController],
|
controllers: [AccessController],
|
||||||
|
exports: [AccessService],
|
||||||
|
imports: [],
|
||||||
providers: [AccessService, PrismaService]
|
providers: [AccessService, PrismaService]
|
||||||
})
|
})
|
||||||
export class AccessModule {}
|
export class AccessModule {}
|
||||||
|
@ -7,6 +7,17 @@ import { Prisma } from '@prisma/client';
|
|||||||
export class AccessService {
|
export class AccessService {
|
||||||
public constructor(private readonly prismaService: PrismaService) {}
|
public constructor(private readonly prismaService: PrismaService) {}
|
||||||
|
|
||||||
|
public async access(
|
||||||
|
accessWhereInput: Prisma.AccessWhereInput
|
||||||
|
): Promise<AccessWithGranteeUser | null> {
|
||||||
|
return this.prismaService.access.findFirst({
|
||||||
|
include: {
|
||||||
|
GranteeUser: true
|
||||||
|
},
|
||||||
|
where: accessWhereInput
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public async accesses(params: {
|
public async accesses(params: {
|
||||||
include?: Prisma.AccessInclude;
|
include?: Prisma.AccessInclude;
|
||||||
skip?: number;
|
skip?: number;
|
||||||
|
15
apps/api/src/app/cache/cache.module.ts
vendored
15
apps/api/src/app/cache/cache.module.ts
vendored
@ -2,11 +2,7 @@ import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
|||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||||
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
|
||||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
|
||||||
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
|
||||||
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
@ -14,18 +10,13 @@ import { Module } from '@nestjs/common';
|
|||||||
import { CacheController } from './cache.controller';
|
import { CacheController } from './cache.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ExchangeRateDataModule, RedisCacheModule],
|
imports: [DataProviderModule, ExchangeRateDataModule, RedisCacheModule],
|
||||||
controllers: [CacheController],
|
controllers: [CacheController],
|
||||||
providers: [
|
providers: [
|
||||||
AlphaVantageService,
|
|
||||||
CacheService,
|
CacheService,
|
||||||
ConfigurationService,
|
ConfigurationService,
|
||||||
DataGatheringService,
|
DataGatheringService,
|
||||||
DataProviderService,
|
PrismaService
|
||||||
GhostfolioScraperApiService,
|
|
||||||
PrismaService,
|
|
||||||
RakutenRapidApiService,
|
|
||||||
YahooFinanceService
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class CacheModule {}
|
export class CacheModule {}
|
||||||
|
@ -1,7 +1,11 @@
|
|||||||
|
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||||
import { Order } from '@prisma/client';
|
import { Order } from '@prisma/client';
|
||||||
import { IsArray } from 'class-validator';
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsArray, ValidateNested } from 'class-validator';
|
||||||
|
|
||||||
export class ImportDataDto {
|
export class ImportDataDto {
|
||||||
@IsArray()
|
@IsArray()
|
||||||
orders: Partial<Order>[];
|
@Type(() => CreateOrderDto)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
orders: Order[];
|
||||||
}
|
}
|
||||||
|
@ -42,7 +42,10 @@ export class ImportController {
|
|||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
{
|
||||||
|
error: getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||||
|
message: [error.message]
|
||||||
|
},
|
||||||
StatusCodes.BAD_REQUEST
|
StatusCodes.BAD_REQUEST
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,17 @@
|
|||||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||||
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Order } from '@prisma/client';
|
import { Order } from '@prisma/client';
|
||||||
import { parseISO } from 'date-fns';
|
import { isSameDay, parseISO } from 'date-fns';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ImportService {
|
export class ImportService {
|
||||||
public constructor(private readonly orderService: OrderService) {}
|
private static MAX_ORDERS_TO_IMPORT = 20;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly dataProviderService: DataProviderService,
|
||||||
|
private readonly orderService: OrderService
|
||||||
|
) {}
|
||||||
|
|
||||||
public async import({
|
public async import({
|
||||||
orders,
|
orders,
|
||||||
@ -14,7 +20,10 @@ export class ImportService {
|
|||||||
orders: Partial<Order>[];
|
orders: Partial<Order>[];
|
||||||
userId: string;
|
userId: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
|
await this.validateOrders({ orders, userId });
|
||||||
|
|
||||||
for (const {
|
for (const {
|
||||||
|
accountId,
|
||||||
currency,
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
date,
|
date,
|
||||||
@ -25,6 +34,11 @@ export class ImportService {
|
|||||||
unitPrice
|
unitPrice
|
||||||
} of orders) {
|
} of orders) {
|
||||||
await this.orderService.createOrder({
|
await this.orderService.createOrder({
|
||||||
|
Account: {
|
||||||
|
connect: {
|
||||||
|
id_userId: { userId, id: accountId }
|
||||||
|
}
|
||||||
|
},
|
||||||
currency,
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
fee,
|
fee,
|
||||||
@ -37,4 +51,53 @@ export class ImportService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async validateOrders({
|
||||||
|
orders,
|
||||||
|
userId
|
||||||
|
}: {
|
||||||
|
orders: Partial<Order>[];
|
||||||
|
userId: string;
|
||||||
|
}) {
|
||||||
|
if (orders?.length > ImportService.MAX_ORDERS_TO_IMPORT) {
|
||||||
|
throw new Error('Too many transactions');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingOrders = await this.orderService.orders({
|
||||||
|
orderBy: { date: 'desc' },
|
||||||
|
where: { userId }
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [
|
||||||
|
index,
|
||||||
|
{ currency, dataSource, date, fee, quantity, symbol, type, unitPrice }
|
||||||
|
] of orders.entries()) {
|
||||||
|
const duplicateOrder = existingOrders.find((order) => {
|
||||||
|
return (
|
||||||
|
order.currency === currency &&
|
||||||
|
order.dataSource === dataSource &&
|
||||||
|
isSameDay(order.date, parseISO(<string>(<unknown>date))) &&
|
||||||
|
order.fee === fee &&
|
||||||
|
order.quantity === quantity &&
|
||||||
|
order.symbol === symbol &&
|
||||||
|
order.type === type &&
|
||||||
|
order.unitPrice === unitPrice
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (duplicateOrder) {
|
||||||
|
throw new Error(`orders.${index} is a duplicate transaction`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.dataProviderService.get([
|
||||||
|
{ dataSource, symbol }
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (result[symbol] === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
`orders.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,6 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||||
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
|
||||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
|
||||||
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
|
||||||
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
@ -15,6 +11,7 @@ import { InfoService } from './info.service';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
DataProviderModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
JwtModule.register({
|
JwtModule.register({
|
||||||
secret: process.env.JWT_SECRET_KEY,
|
secret: process.env.JWT_SECRET_KEY,
|
||||||
@ -23,15 +20,10 @@ import { InfoService } from './info.service';
|
|||||||
],
|
],
|
||||||
controllers: [InfoController],
|
controllers: [InfoController],
|
||||||
providers: [
|
providers: [
|
||||||
AlphaVantageService,
|
|
||||||
ConfigurationService,
|
ConfigurationService,
|
||||||
DataGatheringService,
|
DataGatheringService,
|
||||||
DataProviderService,
|
|
||||||
GhostfolioScraperApiService,
|
|
||||||
InfoService,
|
InfoService,
|
||||||
PrismaService,
|
PrismaService
|
||||||
RakutenRapidApiService,
|
|
||||||
YahooFinanceService
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class InfoModule {}
|
export class InfoModule {}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||||
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { InfoItem } from '@ghostfolio/common/interfaces';
|
import { InfoItem } from '@ghostfolio/common/interfaces';
|
||||||
@ -16,6 +17,7 @@ export class InfoService {
|
|||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
@ -60,6 +62,7 @@ export class InfoService {
|
|||||||
currencies: this.exchangeRateDataService.getCurrencies(),
|
currencies: this.exchangeRateDataService.getCurrencies(),
|
||||||
demoAuthToken: this.getDemoAuthToken(),
|
demoAuthToken: this.getDemoAuthToken(),
|
||||||
lastDataGathering: await this.getLastDataGathering(),
|
lastDataGathering: await this.getLastDataGathering(),
|
||||||
|
primaryDataSource: this.dataProviderService.getPrimaryDataSource(),
|
||||||
statistics: await this.getStatistics(),
|
statistics: await this.getStatistics(),
|
||||||
subscriptions: await this.getSubscriptions()
|
subscriptions: await this.getSubscriptions()
|
||||||
};
|
};
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { DataSource, Type } from '@prisma/client';
|
import { DataSource, Type } from '@prisma/client';
|
||||||
import { IsISO8601, IsNumber, IsString } from 'class-validator';
|
import { IsEnum, IsISO8601, IsNumber, IsString } from 'class-validator';
|
||||||
|
|
||||||
export class CreateOrderDto {
|
export class CreateOrderDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@ -8,7 +8,7 @@ export class CreateOrderDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
@IsString()
|
@IsEnum(DataSource, { each: true })
|
||||||
dataSource: DataSource;
|
dataSource: DataSource;
|
||||||
|
|
||||||
@IsISO8601()
|
@IsISO8601()
|
||||||
@ -23,7 +23,7 @@ export class CreateOrderDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
symbol: string;
|
symbol: string;
|
||||||
|
|
||||||
@IsString()
|
@IsEnum(Type, { each: true })
|
||||||
type: Type;
|
type: Type;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
|
@ -75,6 +75,7 @@ describe('CurrentRateService', () => {
|
|||||||
dataProviderService = new DataProviderService(
|
dataProviderService = new DataProviderService(
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
|
[],
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
|
@ -2,7 +2,6 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
|
|||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { resetHours } from '@ghostfolio/common/helper';
|
import { resetHours } from '@ghostfolio/common/helper';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
|
||||||
import { isBefore, isToday } from 'date-fns';
|
import { isBefore, isToday } from 'date-fns';
|
||||||
import { flatten } from 'lodash';
|
import { flatten } from 'lodash';
|
||||||
|
|
||||||
@ -27,12 +26,15 @@ export class CurrentRateService {
|
|||||||
}: GetValueParams): Promise<GetValueObject> {
|
}: GetValueParams): Promise<GetValueObject> {
|
||||||
if (isToday(date)) {
|
if (isToday(date)) {
|
||||||
const dataProviderResult = await this.dataProviderService.get([
|
const dataProviderResult = await this.dataProviderService.get([
|
||||||
{ symbol, dataSource: DataSource.YAHOO }
|
{
|
||||||
|
symbol,
|
||||||
|
dataSource: this.dataProviderService.getPrimaryDataSource()
|
||||||
|
}
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
|
symbol,
|
||||||
date: resetHours(date),
|
date: resetHours(date),
|
||||||
marketPrice: dataProviderResult?.[symbol]?.marketPrice ?? 0,
|
marketPrice: dataProviderResult?.[symbol]?.marketPrice ?? 0
|
||||||
symbol: symbol
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { AccessService } from '@ghostfolio/api/app/access/access.service';
|
||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
import {
|
import {
|
||||||
hasNotDefinedValuesInObject,
|
hasNotDefinedValuesInObject,
|
||||||
@ -5,9 +6,11 @@ import {
|
|||||||
} from '@ghostfolio/api/helper/object.helper';
|
} from '@ghostfolio/api/helper/object.helper';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
|
import { baseCurrency } from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
PortfolioPerformance,
|
PortfolioPerformance,
|
||||||
|
PortfolioPublicDetails,
|
||||||
PortfolioReport,
|
PortfolioReport,
|
||||||
PortfolioSummary
|
PortfolioSummary
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
@ -39,6 +42,7 @@ import { PortfolioService } from './portfolio.service';
|
|||||||
@Controller('portfolio')
|
@Controller('portfolio')
|
||||||
export class PortfolioController {
|
export class PortfolioController {
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly accessService: AccessService,
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly portfolioService: PortfolioService,
|
private readonly portfolioService: PortfolioService,
|
||||||
@ -145,7 +149,11 @@ export class PortfolioController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { accounts, holdings, hasErrors } =
|
const { accounts, holdings, hasErrors } =
|
||||||
await this.portfolioService.getDetails(impersonationId, range);
|
await this.portfolioService.getDetails(
|
||||||
|
impersonationId,
|
||||||
|
this.request.user.id,
|
||||||
|
range
|
||||||
|
);
|
||||||
|
|
||||||
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
||||||
res.status(StatusCodes.ACCEPTED);
|
res.status(StatusCodes.ACCEPTED);
|
||||||
@ -175,8 +183,9 @@ export class PortfolioController {
|
|||||||
portfolioPosition.grossPerformance = null;
|
portfolioPosition.grossPerformance = null;
|
||||||
portfolioPosition.investment =
|
portfolioPosition.investment =
|
||||||
portfolioPosition.investment / totalInvestment;
|
portfolioPosition.investment / totalInvestment;
|
||||||
|
portfolioPosition.netPerformance = null;
|
||||||
portfolioPosition.quantity = null;
|
portfolioPosition.quantity = null;
|
||||||
|
portfolioPosition.value = portfolioPosition.value / totalValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [name, { current, original }] of Object.entries(accounts)) {
|
for (const [name, { current, original }] of Object.entries(accounts)) {
|
||||||
@ -251,6 +260,59 @@ export class PortfolioController {
|
|||||||
return <any>res.json(result);
|
return <any>res.json(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('public/:accessId')
|
||||||
|
public async getPublic(
|
||||||
|
@Param('accessId') accessId,
|
||||||
|
@Res() res: Response
|
||||||
|
): Promise<PortfolioPublicDetails> {
|
||||||
|
const access = await this.accessService.access({ id: accessId });
|
||||||
|
|
||||||
|
if (!access) {
|
||||||
|
res.status(StatusCodes.NOT_FOUND);
|
||||||
|
return <any>res.json({ accounts: {}, holdings: {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { hasErrors, holdings } = await this.portfolioService.getDetails(
|
||||||
|
access.userId,
|
||||||
|
access.userId
|
||||||
|
);
|
||||||
|
|
||||||
|
const portfolioPublicDetails: PortfolioPublicDetails = {
|
||||||
|
holdings: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
||||||
|
res.status(StatusCodes.ACCEPTED);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalValue = Object.values(holdings)
|
||||||
|
.filter((holding) => {
|
||||||
|
return holding.assetClass === 'EQUITY';
|
||||||
|
})
|
||||||
|
.map((portfolioPosition) => {
|
||||||
|
return this.exchangeRateDataService.toCurrency(
|
||||||
|
portfolioPosition.quantity * portfolioPosition.marketPrice,
|
||||||
|
portfolioPosition.currency,
|
||||||
|
this.request.user?.Settings?.currency ?? baseCurrency
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||||
|
if (portfolioPosition.assetClass === 'EQUITY') {
|
||||||
|
portfolioPublicDetails.holdings[symbol] = {
|
||||||
|
allocationCurrent: portfolioPosition.allocationCurrent,
|
||||||
|
countries: [],
|
||||||
|
name: portfolioPosition.name,
|
||||||
|
sectors: [],
|
||||||
|
value: portfolioPosition.value / totalValue
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <any>res.json(portfolioPublicDetails);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('summary')
|
@Get('summary')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getSummary(
|
public async getSummary(
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { AccessModule } from '@ghostfolio/api/app/access/access.module';
|
||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
@ -18,6 +19,7 @@ import { RulesService } from './rules.service';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
AccessModule,
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
// TODO ///////////
|
||||||
|
|
||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
|
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
|
||||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||||
@ -21,7 +23,11 @@ import { ImpersonationService } from '@ghostfolio/api/services/impersonation.ser
|
|||||||
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
|
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
|
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
import { UNKNOWN_KEY, ghostfolioCashSymbol } from '@ghostfolio/common/config';
|
import {
|
||||||
|
UNKNOWN_KEY,
|
||||||
|
baseCurrency,
|
||||||
|
ghostfolioCashSymbol
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
@ -78,7 +84,7 @@ export class PortfolioService {
|
|||||||
public async getInvestments(
|
public async getInvestments(
|
||||||
aImpersonationId: string
|
aImpersonationId: string
|
||||||
): Promise<InvestmentItem[]> {
|
): Promise<InvestmentItem[]> {
|
||||||
const userId = await this.getUserId(aImpersonationId);
|
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||||
|
|
||||||
const portfolioCalculator = new PortfolioCalculator(
|
const portfolioCalculator = new PortfolioCalculator(
|
||||||
this.currentRateService,
|
this.currentRateService,
|
||||||
@ -106,7 +112,7 @@ export class PortfolioService {
|
|||||||
aImpersonationId: string,
|
aImpersonationId: string,
|
||||||
aDateRange: DateRange = 'max'
|
aDateRange: DateRange = 'max'
|
||||||
): Promise<HistoricalDataItem[]> {
|
): Promise<HistoricalDataItem[]> {
|
||||||
const userId = await this.getUserId(aImpersonationId);
|
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||||
|
|
||||||
const portfolioCalculator = new PortfolioCalculator(
|
const portfolioCalculator = new PortfolioCalculator(
|
||||||
this.currentRateService,
|
this.currentRateService,
|
||||||
@ -148,11 +154,12 @@ export class PortfolioService {
|
|||||||
|
|
||||||
public async getDetails(
|
public async getDetails(
|
||||||
aImpersonationId: string,
|
aImpersonationId: string,
|
||||||
|
aUserId: string,
|
||||||
aDateRange: DateRange = 'max'
|
aDateRange: DateRange = 'max'
|
||||||
): Promise<PortfolioDetails & { hasErrors: boolean }> {
|
): Promise<PortfolioDetails & { hasErrors: boolean }> {
|
||||||
const userId = await this.getUserId(aImpersonationId);
|
const userId = await this.getUserId(aImpersonationId, aUserId);
|
||||||
|
|
||||||
const userCurrency = this.request.user.Settings.currency;
|
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
|
||||||
const portfolioCalculator = new PortfolioCalculator(
|
const portfolioCalculator = new PortfolioCalculator(
|
||||||
this.currentRateService,
|
this.currentRateService,
|
||||||
userCurrency
|
userCurrency
|
||||||
@ -265,7 +272,7 @@ export class PortfolioService {
|
|||||||
aImpersonationId: string,
|
aImpersonationId: string,
|
||||||
aSymbol: string
|
aSymbol: string
|
||||||
): Promise<PortfolioPositionDetail> {
|
): Promise<PortfolioPositionDetail> {
|
||||||
const userId = await this.getUserId(aImpersonationId);
|
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||||
|
|
||||||
const orders = (await this.orderService.getOrders({ userId })).filter(
|
const orders = (await this.orderService.getOrders({ userId })).filter(
|
||||||
(order) => order.symbol === aSymbol
|
(order) => order.symbol === aSymbol
|
||||||
@ -484,7 +491,7 @@ export class PortfolioService {
|
|||||||
aImpersonationId: string,
|
aImpersonationId: string,
|
||||||
aDateRange: DateRange = 'max'
|
aDateRange: DateRange = 'max'
|
||||||
): Promise<{ hasErrors: boolean; positions: Position[] }> {
|
): Promise<{ hasErrors: boolean; positions: Position[] }> {
|
||||||
const userId = await this.getUserId(aImpersonationId);
|
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||||
|
|
||||||
const portfolioCalculator = new PortfolioCalculator(
|
const portfolioCalculator = new PortfolioCalculator(
|
||||||
this.currentRateService,
|
this.currentRateService,
|
||||||
@ -555,7 +562,7 @@ export class PortfolioService {
|
|||||||
aImpersonationId: string,
|
aImpersonationId: string,
|
||||||
aDateRange: DateRange = 'max'
|
aDateRange: DateRange = 'max'
|
||||||
): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> {
|
): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> {
|
||||||
const userId = await this.getUserId(aImpersonationId);
|
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||||
|
|
||||||
const portfolioCalculator = new PortfolioCalculator(
|
const portfolioCalculator = new PortfolioCalculator(
|
||||||
this.currentRateService,
|
this.currentRateService,
|
||||||
@ -628,8 +635,8 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getReport(impersonationId: string): Promise<PortfolioReport> {
|
public async getReport(impersonationId: string): Promise<PortfolioReport> {
|
||||||
const userId = await this.getUserId(impersonationId);
|
const currency = this.request.user.Settings.currency;
|
||||||
const baseCurrency = this.request.user.Settings.currency;
|
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||||
|
|
||||||
const { orders, transactionPoints } = await this.getTransactionPoints({
|
const { orders, transactionPoints } = await this.getTransactionPoints({
|
||||||
userId
|
userId
|
||||||
@ -643,7 +650,7 @@ export class PortfolioService {
|
|||||||
|
|
||||||
const portfolioCalculator = new PortfolioCalculator(
|
const portfolioCalculator = new PortfolioCalculator(
|
||||||
this.currentRateService,
|
this.currentRateService,
|
||||||
this.request.user.Settings.currency
|
currency
|
||||||
);
|
);
|
||||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||||
|
|
||||||
@ -659,7 +666,7 @@ export class PortfolioService {
|
|||||||
const accounts = await this.getAccounts(
|
const accounts = await this.getAccounts(
|
||||||
orders,
|
orders,
|
||||||
portfolioItemsNow,
|
portfolioItemsNow,
|
||||||
baseCurrency,
|
currency,
|
||||||
userId
|
userId
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@ -679,7 +686,7 @@ export class PortfolioService {
|
|||||||
accounts
|
accounts
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
{ baseCurrency }
|
{ baseCurrency: currency }
|
||||||
),
|
),
|
||||||
currencyClusterRisk: await this.rulesService.evaluate(
|
currencyClusterRisk: await this.rulesService.evaluate(
|
||||||
[
|
[
|
||||||
@ -700,7 +707,7 @@ export class PortfolioService {
|
|||||||
currentPositions
|
currentPositions
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
{ baseCurrency }
|
{ baseCurrency: currency }
|
||||||
),
|
),
|
||||||
fees: await this.rulesService.evaluate(
|
fees: await this.rulesService.evaluate(
|
||||||
[
|
[
|
||||||
@ -710,7 +717,7 @@ export class PortfolioService {
|
|||||||
this.getFees(orders)
|
this.getFees(orders)
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
{ baseCurrency }
|
{ baseCurrency: currency }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -718,7 +725,7 @@ export class PortfolioService {
|
|||||||
|
|
||||||
public async getSummary(aImpersonationId: string): Promise<PortfolioSummary> {
|
public async getSummary(aImpersonationId: string): Promise<PortfolioSummary> {
|
||||||
const currency = this.request.user.Settings.currency;
|
const currency = this.request.user.Settings.currency;
|
||||||
const userId = await this.getUserId(aImpersonationId);
|
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||||
|
|
||||||
const performanceInformation = await this.getPerformance(aImpersonationId);
|
const performanceInformation = await this.getPerformance(aImpersonationId);
|
||||||
|
|
||||||
@ -820,7 +827,7 @@ export class PortfolioService {
|
|||||||
return { transactionPoints: [], orders: [] };
|
return { transactionPoints: [], orders: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const userCurrency = this.request.user.Settings.currency;
|
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
|
||||||
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
|
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
|
||||||
currency: order.currency,
|
currency: order.currency,
|
||||||
dataSource: order.dataSource,
|
dataSource: order.dataSource,
|
||||||
@ -920,14 +927,14 @@ export class PortfolioService {
|
|||||||
return accounts;
|
return accounts;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getUserId(aImpersonationId: string) {
|
private async getUserId(aImpersonationId: string, aUserId: string) {
|
||||||
const impersonationUserId =
|
const impersonationUserId =
|
||||||
await this.impersonationService.validateImpersonationId(
|
await this.impersonationService.validateImpersonationId(
|
||||||
aImpersonationId,
|
aImpersonationId,
|
||||||
this.request.user.id
|
aUserId
|
||||||
);
|
);
|
||||||
|
|
||||||
return impersonationUserId || this.request.user.id;
|
return impersonationUserId || aUserId;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getTotalByType(
|
private getTotalByType(
|
||||||
|
@ -132,7 +132,15 @@ export class DataGatheringService {
|
|||||||
|
|
||||||
for (const [
|
for (const [
|
||||||
symbol,
|
symbol,
|
||||||
{ assetClass, assetSubClass, countries, currency, dataSource, name }
|
{
|
||||||
|
assetClass,
|
||||||
|
assetSubClass,
|
||||||
|
countries,
|
||||||
|
currency,
|
||||||
|
dataSource,
|
||||||
|
name,
|
||||||
|
sectors
|
||||||
|
}
|
||||||
] of Object.entries(currentData)) {
|
] of Object.entries(currentData)) {
|
||||||
try {
|
try {
|
||||||
await this.prismaService.symbolProfile.upsert({
|
await this.prismaService.symbolProfile.upsert({
|
||||||
@ -143,6 +151,7 @@ export class DataGatheringService {
|
|||||||
currency,
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
name,
|
name,
|
||||||
|
sectors,
|
||||||
symbol
|
symbol
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
@ -150,7 +159,8 @@ export class DataGatheringService {
|
|||||||
assetSubClass,
|
assetSubClass,
|
||||||
countries,
|
countries,
|
||||||
currency,
|
currency,
|
||||||
name
|
name,
|
||||||
|
sectors
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
dataSource_symbol: {
|
dataSource_symbol: {
|
||||||
|
@ -6,11 +6,11 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
import { isAfter, isBefore, parse } from 'date-fns';
|
import { isAfter, isBefore, parse } from 'date-fns';
|
||||||
|
|
||||||
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
|
|
||||||
import {
|
import {
|
||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
IDataProviderResponse
|
IDataProviderResponse
|
||||||
} from '../../interfaces/interfaces';
|
} from '../../interfaces/interfaces';
|
||||||
|
import { DataProviderInterface } from '../interfaces/data-provider.interface';
|
||||||
import { IAlphaVantageHistoricalResponse } from './interfaces/interfaces';
|
import { IAlphaVantageHistoricalResponse } from './interfaces/interfaces';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
@ -0,0 +1,73 @@
|
|||||||
|
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
|
||||||
|
import { IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
|
import bent from 'bent';
|
||||||
|
|
||||||
|
const getJSON = bent('json');
|
||||||
|
|
||||||
|
export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
||||||
|
private static baseUrl = 'https://data.trackinsight.com/holdings';
|
||||||
|
private static countries = require('countries-list/dist/countries.json');
|
||||||
|
private static sectorsMapping = {
|
||||||
|
'Consumer Discretionary': 'Consumer Cyclical',
|
||||||
|
'Consumer Defensive': 'Consumer Staples',
|
||||||
|
'Health Care': 'Healthcare',
|
||||||
|
'Information Technology': 'Technology'
|
||||||
|
};
|
||||||
|
|
||||||
|
public async enhance({
|
||||||
|
response,
|
||||||
|
symbol
|
||||||
|
}: {
|
||||||
|
response: IDataProviderResponse;
|
||||||
|
symbol: string;
|
||||||
|
}): Promise<IDataProviderResponse> {
|
||||||
|
if (
|
||||||
|
!(response.assetClass === 'EQUITY' && response.assetSubClass === 'ETF')
|
||||||
|
) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const holdings = await getJSON(
|
||||||
|
`${TrackinsightDataEnhancerService.baseUrl}/${symbol}.json`
|
||||||
|
).catch(() => {
|
||||||
|
return getJSON(
|
||||||
|
`${TrackinsightDataEnhancerService.baseUrl}/${
|
||||||
|
symbol.split('.')[0]
|
||||||
|
}.json`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.countries || response.countries.length === 0) {
|
||||||
|
response.countries = [];
|
||||||
|
for (const [name, value] of Object.entries<any>(holdings.countries)) {
|
||||||
|
let countryCode: string;
|
||||||
|
|
||||||
|
for (const [key, country] of Object.entries<any>(
|
||||||
|
TrackinsightDataEnhancerService.countries
|
||||||
|
)) {
|
||||||
|
if (country.name === name) {
|
||||||
|
countryCode = key;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response.countries.push({
|
||||||
|
code: countryCode,
|
||||||
|
weight: value.weight
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.sectors || response.sectors.length === 0) {
|
||||||
|
response.sectors = [];
|
||||||
|
for (const [name, value] of Object.entries<any>(holdings.sectors)) {
|
||||||
|
response.sectors.push({
|
||||||
|
name: TrackinsightDataEnhancerService.sectorsMapping[name] ?? name,
|
||||||
|
weight: value.weight
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(response);
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,5 @@
|
|||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
|
import { TrackinsightDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/trackinsight/trackinsight.service';
|
||||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||||
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
||||||
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
||||||
@ -15,7 +16,13 @@ import { DataProviderService } from './data-provider.service';
|
|||||||
DataProviderService,
|
DataProviderService,
|
||||||
GhostfolioScraperApiService,
|
GhostfolioScraperApiService,
|
||||||
RakutenRapidApiService,
|
RakutenRapidApiService,
|
||||||
YahooFinanceService
|
TrackinsightDataEnhancerService,
|
||||||
|
YahooFinanceService,
|
||||||
|
{
|
||||||
|
inject: [TrackinsightDataEnhancerService],
|
||||||
|
provide: 'DataEnhancers',
|
||||||
|
useFactory: (trackinsight) => [trackinsight]
|
||||||
|
}
|
||||||
],
|
],
|
||||||
exports: [DataProviderService, GhostfolioScraperApiService]
|
exports: [DataProviderService, GhostfolioScraperApiService]
|
||||||
})
|
})
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
|
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
|
||||||
import {
|
import {
|
||||||
IDataGatheringItem,
|
IDataGatheringItem,
|
||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
@ -8,7 +9,7 @@ import {
|
|||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { DataSource, MarketData } from '@prisma/client';
|
import { DataSource, MarketData } from '@prisma/client';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { isEmpty } from 'lodash';
|
import { isEmpty } from 'lodash';
|
||||||
@ -16,16 +17,15 @@ import { isEmpty } from 'lodash';
|
|||||||
import { AlphaVantageService } from './alpha-vantage/alpha-vantage.service';
|
import { AlphaVantageService } from './alpha-vantage/alpha-vantage.service';
|
||||||
import { GhostfolioScraperApiService } from './ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
import { GhostfolioScraperApiService } from './ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||||
import { RakutenRapidApiService } from './rakuten-rapid-api/rakuten-rapid-api.service';
|
import { RakutenRapidApiService } from './rakuten-rapid-api/rakuten-rapid-api.service';
|
||||||
import {
|
import { YahooFinanceService } from './yahoo-finance/yahoo-finance.service';
|
||||||
YahooFinanceService,
|
|
||||||
convertToYahooFinanceSymbol
|
|
||||||
} from './yahoo-finance/yahoo-finance.service';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DataProviderService {
|
export class DataProviderService {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly alphaVantageService: AlphaVantageService,
|
private readonly alphaVantageService: AlphaVantageService,
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
|
@Inject('DataEnhancers')
|
||||||
|
private readonly dataEnhancers: DataEnhancerInterface[],
|
||||||
private readonly ghostfolioScraperApiService: GhostfolioScraperApiService,
|
private readonly ghostfolioScraperApiService: GhostfolioScraperApiService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly rakutenRapidApiService: RakutenRapidApiService,
|
private readonly rakutenRapidApiService: RakutenRapidApiService,
|
||||||
@ -42,27 +42,35 @@ export class DataProviderService {
|
|||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.dataSource === DataSource.ALPHA_VANTAGE) {
|
const dataProvider = this.getDataProvider(item.dataSource);
|
||||||
response[item.symbol] = (
|
response[item.symbol] = (await dataProvider.get([item.symbol]))[
|
||||||
await this.alphaVantageService.get([item.symbol])
|
item.symbol
|
||||||
)[item.symbol];
|
];
|
||||||
} else if (item.dataSource === DataSource.GHOSTFOLIO) {
|
|
||||||
response[item.symbol] = (
|
|
||||||
await this.ghostfolioScraperApiService.get([item.symbol])
|
|
||||||
)[item.symbol];
|
|
||||||
} else if (item.dataSource === DataSource.RAKUTEN) {
|
|
||||||
response[item.symbol] = (
|
|
||||||
await this.rakutenRapidApiService.get([item.symbol])
|
|
||||||
)[item.symbol];
|
|
||||||
} else if (item.dataSource === DataSource.YAHOO) {
|
|
||||||
response[item.symbol] = (
|
|
||||||
await this.yahooFinanceService.get([
|
|
||||||
convertToYahooFinanceSymbol(item.symbol)
|
|
||||||
])
|
|
||||||
)[item.symbol];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const promises = [];
|
||||||
|
for (const symbol of Object.keys(response)) {
|
||||||
|
let promise = Promise.resolve(response[symbol]);
|
||||||
|
for (const dataEnhancer of this.dataEnhancers) {
|
||||||
|
promise = promise.then((currentResponse) =>
|
||||||
|
dataEnhancer
|
||||||
|
.enhance({ symbol, response: currentResponse })
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(
|
||||||
|
`Failed to enhance data for symbol ${symbol}`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
return currentResponse;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
promises.push(
|
||||||
|
promise.then((currentResponse) => (response[symbol] = currentResponse))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -103,11 +111,13 @@ export class DataProviderService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const queryRaw = `SELECT * FROM "MarketData" WHERE "dataSource" IN ('${dataSources.join(
|
const queryRaw = `SELECT *
|
||||||
`','`
|
FROM "MarketData"
|
||||||
)}') AND "symbol" IN ('${symbols.join(
|
WHERE "dataSource" IN ('${dataSources.join(`','`)}')
|
||||||
`','`
|
AND "symbol" IN ('${symbols.join(
|
||||||
)}') ${granularityQuery} ${rangeQuery} ORDER BY date;`;
|
`','`
|
||||||
|
)}') ${granularityQuery} ${rangeQuery}
|
||||||
|
ORDER BY date;`;
|
||||||
|
|
||||||
const marketDataByGranularity: MarketData[] =
|
const marketDataByGranularity: MarketData[] =
|
||||||
await this.prismaService.$queryRaw(queryRaw);
|
await this.prismaService.$queryRaw(queryRaw);
|
||||||
@ -189,6 +199,10 @@ export class DataProviderService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getPrimaryDataSource(): DataSource {
|
||||||
|
return DataSource[this.configurationService.get('DATA_SOURCES')[0]];
|
||||||
|
}
|
||||||
|
|
||||||
private getDataProvider(providerName: DataSource) {
|
private getDataProvider(providerName: DataSource) {
|
||||||
switch (providerName) {
|
switch (providerName) {
|
||||||
case DataSource.ALPHA_VANTAGE:
|
case DataSource.ALPHA_VANTAGE:
|
||||||
|
@ -12,13 +12,13 @@ import * as bent from 'bent';
|
|||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
|
|
||||||
import {
|
import {
|
||||||
IDataGatheringItem,
|
IDataGatheringItem,
|
||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
IDataProviderResponse,
|
IDataProviderResponse,
|
||||||
MarketState
|
MarketState
|
||||||
} from '../../interfaces/interfaces';
|
} from '../../interfaces/interfaces';
|
||||||
|
import { DataProviderInterface } from '../interfaces/data-provider.interface';
|
||||||
import { ScraperConfig } from './interfaces/scraper-config.interface';
|
import { ScraperConfig } from './interfaces/scraper-config.interface';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
@ -0,0 +1,11 @@
|
|||||||
|
import { IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
|
|
||||||
|
export interface DataEnhancerInterface {
|
||||||
|
enhance({
|
||||||
|
response,
|
||||||
|
symbol
|
||||||
|
}: {
|
||||||
|
response: IDataProviderResponse;
|
||||||
|
symbol: string;
|
||||||
|
}): Promise<IDataProviderResponse>;
|
||||||
|
}
|
@ -4,7 +4,7 @@ import { Granularity } from '@ghostfolio/common/types';
|
|||||||
import {
|
import {
|
||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
IDataProviderResponse
|
IDataProviderResponse
|
||||||
} from './interfaces';
|
} from '../../interfaces/interfaces';
|
||||||
|
|
||||||
export interface DataProviderInterface {
|
export interface DataProviderInterface {
|
||||||
canHandle(symbol: string): boolean;
|
canHandle(symbol: string): boolean;
|
@ -14,12 +14,12 @@ import { DataSource } from '@prisma/client';
|
|||||||
import * as bent from 'bent';
|
import * as bent from 'bent';
|
||||||
import { format, subMonths, subWeeks, subYears } from 'date-fns';
|
import { format, subMonths, subWeeks, subYears } from 'date-fns';
|
||||||
|
|
||||||
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
|
|
||||||
import {
|
import {
|
||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
IDataProviderResponse,
|
IDataProviderResponse,
|
||||||
MarketState
|
MarketState
|
||||||
} from '../../interfaces/interfaces';
|
} from '../../interfaces/interfaces';
|
||||||
|
import { DataProviderInterface } from '../interfaces/data-provider.interface';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RakutenRapidApiService implements DataProviderInterface {
|
export class RakutenRapidApiService implements DataProviderInterface {
|
||||||
|
@ -10,12 +10,12 @@ import { countries } from 'countries-list';
|
|||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import * as yahooFinance from 'yahoo-finance';
|
import * as yahooFinance from 'yahoo-finance';
|
||||||
|
|
||||||
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
|
|
||||||
import {
|
import {
|
||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
IDataProviderResponse,
|
IDataProviderResponse,
|
||||||
MarketState
|
MarketState
|
||||||
} from '../../interfaces/interfaces';
|
} from '../../interfaces/interfaces';
|
||||||
|
import { DataProviderInterface } from '../interfaces/data-provider.interface';
|
||||||
import {
|
import {
|
||||||
IYahooFinanceHistoricalResponse,
|
IYahooFinanceHistoricalResponse,
|
||||||
IYahooFinancePrice,
|
IYahooFinancePrice,
|
||||||
@ -33,11 +33,14 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async get(
|
public async get(
|
||||||
aYahooFinanceSymbols: string[]
|
aSymbols: string[]
|
||||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||||
if (aYahooFinanceSymbols.length <= 0) {
|
if (aSymbols.length <= 0) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
const yahooFinanceSymbols = aSymbols.map((symbol) =>
|
||||||
|
this.convertToYahooFinanceSymbol(symbol)
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response: { [symbol: string]: IDataProviderResponse } = {};
|
const response: { [symbol: string]: IDataProviderResponse } = {};
|
||||||
@ -46,12 +49,12 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
[symbol: string]: IYahooFinanceQuoteResponse;
|
[symbol: string]: IYahooFinanceQuoteResponse;
|
||||||
} = await yahooFinance.quote({
|
} = await yahooFinance.quote({
|
||||||
modules: ['price', 'summaryProfile'],
|
modules: ['price', 'summaryProfile'],
|
||||||
symbols: aYahooFinanceSymbols
|
symbols: yahooFinanceSymbols
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const [yahooFinanceSymbol, value] of Object.entries(data)) {
|
for (const [yahooFinanceSymbol, value] of Object.entries(data)) {
|
||||||
// Convert symbols back
|
// Convert symbols back
|
||||||
const symbol = convertFromYahooFinanceSymbol(yahooFinanceSymbol);
|
const symbol = this.convertFromYahooFinanceSymbol(yahooFinanceSymbol);
|
||||||
|
|
||||||
const { assetClass, assetSubClass } = this.parseAssetClass(value.price);
|
const { assetClass, assetSubClass } = this.parseAssetClass(value.price);
|
||||||
|
|
||||||
@ -93,6 +96,12 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
response[symbol].countries = [{ code, weight: 1 }];
|
response[symbol].countries = [{ code, weight: 1 }];
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
|
if (value.summaryProfile?.sector) {
|
||||||
|
response[symbol].sectors = [
|
||||||
|
{ name: value.summaryProfile?.sector, weight: 1 }
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add url if available
|
// Add url if available
|
||||||
@ -123,7 +132,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const yahooFinanceSymbols = aSymbols.map((symbol) => {
|
const yahooFinanceSymbols = aSymbols.map((symbol) => {
|
||||||
return convertToYahooFinanceSymbol(symbol);
|
return this.convertToYahooFinanceSymbol(symbol);
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -143,7 +152,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
historicalData
|
historicalData
|
||||||
)) {
|
)) {
|
||||||
// Convert symbols back
|
// Convert symbols back
|
||||||
const symbol = convertFromYahooFinanceSymbol(yahooFinanceSymbol);
|
const symbol = this.convertFromYahooFinanceSymbol(yahooFinanceSymbol);
|
||||||
response[symbol] = {};
|
response[symbol] = {};
|
||||||
|
|
||||||
timeSeries.forEach((timeSerie) => {
|
timeSeries.forEach((timeSerie) => {
|
||||||
@ -214,6 +223,40 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
return { items };
|
return { items };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) {
|
||||||
|
const symbol = aYahooFinanceSymbol.replace('-', '');
|
||||||
|
return symbol.replace('=X', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a symbol to a Yahoo Finance symbol
|
||||||
|
*
|
||||||
|
* Currency: USDCHF -> USDCHF=X
|
||||||
|
* Cryptocurrency: BTCUSD -> BTC-USD
|
||||||
|
* DOGEUSD -> DOGE-USD
|
||||||
|
* SOL1USD -> SOL1-USD
|
||||||
|
*/
|
||||||
|
private convertToYahooFinanceSymbol(aSymbol: string) {
|
||||||
|
if (
|
||||||
|
(aSymbol.includes('CHF') ||
|
||||||
|
aSymbol.includes('EUR') ||
|
||||||
|
aSymbol.includes('USD')) &&
|
||||||
|
aSymbol.length >= 6
|
||||||
|
) {
|
||||||
|
if (isCurrency(aSymbol.substring(0, aSymbol.length - 3))) {
|
||||||
|
return `${aSymbol}=X`;
|
||||||
|
} else if (isCrypto(aSymbol) || isCrypto(aSymbol.replace('1', ''))) {
|
||||||
|
// Add a dash before the last three characters
|
||||||
|
// BTCUSD -> BTC-USD
|
||||||
|
// DOGEUSD -> DOGE-USD
|
||||||
|
// SOL1USD -> SOL1-USD
|
||||||
|
return aSymbol.replace('USD', '-USD');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return aSymbol;
|
||||||
|
}
|
||||||
|
|
||||||
private parseAssetClass(aPrice: IYahooFinancePrice): {
|
private parseAssetClass(aPrice: IYahooFinancePrice): {
|
||||||
assetClass: AssetClass;
|
assetClass: AssetClass;
|
||||||
assetSubClass: AssetSubClass;
|
assetSubClass: AssetSubClass;
|
||||||
@ -247,37 +290,3 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
return aString;
|
return aString;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const convertFromYahooFinanceSymbol = (aYahooFinanceSymbol: string) => {
|
|
||||||
const symbol = aYahooFinanceSymbol.replace('-', '');
|
|
||||||
return symbol.replace('=X', '');
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts a symbol to a Yahoo Finance symbol
|
|
||||||
*
|
|
||||||
* Currency: USDCHF -> USDCHF=X
|
|
||||||
* Cryptocurrency: BTCUSD -> BTC-USD
|
|
||||||
* DOGEUSD -> DOGE-USD
|
|
||||||
* SOL1USD -> SOL1-USD
|
|
||||||
*/
|
|
||||||
export const convertToYahooFinanceSymbol = (aSymbol: string) => {
|
|
||||||
if (
|
|
||||||
(aSymbol.includes('CHF') ||
|
|
||||||
aSymbol.includes('EUR') ||
|
|
||||||
aSymbol.includes('USD')) &&
|
|
||||||
aSymbol.length >= 6
|
|
||||||
) {
|
|
||||||
if (isCurrency(aSymbol.substring(0, aSymbol.length - 3))) {
|
|
||||||
return `${aSymbol}=X`;
|
|
||||||
} else if (isCrypto(aSymbol) || isCrypto(aSymbol.replace('1', ''))) {
|
|
||||||
// Add a dash before the last three characters
|
|
||||||
// BTCUSD -> BTC-USD
|
|
||||||
// DOGEUSD -> DOGE-USD
|
|
||||||
// SOL1USD -> SOL1-USD
|
|
||||||
return aSymbol.replace('USD', '-USD');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return aSymbol;
|
|
||||||
};
|
|
||||||
|
@ -210,7 +210,7 @@ export class ExchangeRateDataService {
|
|||||||
return {
|
return {
|
||||||
currency1: baseCurrency,
|
currency1: baseCurrency,
|
||||||
currency2: currency,
|
currency2: currency,
|
||||||
dataSource: DataSource.YAHOO,
|
dataSource: this.dataProviderService.getPrimaryDataSource(),
|
||||||
symbol: `${baseCurrency}${currency}`
|
symbol: `${baseCurrency}${currency}`
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
@ -45,6 +45,7 @@ export interface IDataProviderResponse {
|
|||||||
marketPrice: number;
|
marketPrice: number;
|
||||||
marketState: MarketState;
|
marketState: MarketState;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
sectors?: { name: string; weight: number }[];
|
||||||
url?: string;
|
url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -52,6 +52,13 @@ const routes: Routes = [
|
|||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('./pages/home/home-page.module').then((m) => m.HomePageModule)
|
import('./pages/home/home-page.module').then((m) => m.HomePageModule)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'p',
|
||||||
|
loadChildren: () =>
|
||||||
|
import('./pages/public/public-page.module').then(
|
||||||
|
(m) => m.PublicPageModule
|
||||||
|
)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'portfolio',
|
path: 'portfolio',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
|
@ -28,7 +28,10 @@
|
|||||||
<router-outlet></router-outlet>
|
<router-outlet></router-outlet>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer *ngIf="!user" class="footer d-flex justify-content-center w-100">
|
<footer
|
||||||
|
*ngIf="currentRoute === 'start'"
|
||||||
|
class="footer d-flex justify-content-center w-100"
|
||||||
|
>
|
||||||
<div class="container text-center">
|
<div class="container text-center">
|
||||||
<div>
|
<div>
|
||||||
© {{ currentYear }} <a href="https://ghostfol.io">Ghostfolio</a>
|
© {{ currentYear }} <a href="https://ghostfol.io">Ghostfolio</a>
|
||||||
|
@ -9,8 +9,14 @@
|
|||||||
<ng-container matColumnDef="type">
|
<ng-container matColumnDef="type">
|
||||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Type</th>
|
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Type</th>
|
||||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
<ion-icon class="mr-1" name="lock-closed-outline"></ion-icon>
|
<ng-container *ngIf="element.type === 'PUBLIC'">
|
||||||
Restricted View
|
<ion-icon class="mr-1" name="link-outline"></ion-icon>
|
||||||
|
{{ baseUrl }}/p/{{ element.id }}
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="element.type === 'RESTRICTED_VIEW'">
|
||||||
|
<ion-icon class="mr-1" name="lock-closed-outline"></ion-icon>
|
||||||
|
Restricted View
|
||||||
|
</ng-container>
|
||||||
</td></ng-container
|
</td></ng-container
|
||||||
>
|
>
|
||||||
|
|
||||||
|
@ -17,6 +17,7 @@ import { Access } from '@ghostfolio/common/interfaces';
|
|||||||
export class AccessTableComponent implements OnChanges, OnInit {
|
export class AccessTableComponent implements OnChanges, OnInit {
|
||||||
@Input() accesses: Access[];
|
@Input() accesses: Access[];
|
||||||
|
|
||||||
|
public baseUrl = window.location.origin;
|
||||||
public dataSource: MatTableDataSource<Access>;
|
public dataSource: MatTableDataSource<Access>;
|
||||||
public displayedColumns = ['granteeAlias', 'type'];
|
public displayedColumns = ['granteeAlias', 'type'];
|
||||||
|
|
||||||
|
@ -51,7 +51,7 @@
|
|||||||
|
|
||||||
<ng-container matColumnDef="balance">
|
<ng-container matColumnDef="balance">
|
||||||
<th *matHeaderCellDef class="px-1 text-right" i18n mat-header-cell>
|
<th *matHeaderCellDef class="px-1 text-right" i18n mat-header-cell>
|
||||||
Balance
|
Cash Balance
|
||||||
</th>
|
</th>
|
||||||
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||||
<gf-value
|
<gf-value
|
||||||
|
@ -83,10 +83,10 @@
|
|||||||
*matRowDef="let row; columns: displayedColumns"
|
*matRowDef="let row; columns: displayedColumns"
|
||||||
mat-row
|
mat-row
|
||||||
[ngClass]="{
|
[ngClass]="{
|
||||||
'cursor-pointer': !ignoreAssetClasses.includes(row.assetClass)
|
'cursor-pointer': !ignoreAssetSubClasses.includes(row.assetSubClass)
|
||||||
}"
|
}"
|
||||||
(click)="
|
(click)="
|
||||||
!ignoreAssetClasses.includes(row.assetClass) &&
|
!ignoreAssetSubClasses.includes(row.assetSubClass) &&
|
||||||
onOpenPositionDialog({ symbol: row.symbol })
|
onOpenPositionDialog({ symbol: row.symbol })
|
||||||
"
|
"
|
||||||
></tr>
|
></tr>
|
||||||
|
@ -42,7 +42,7 @@ export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
public dataSource: MatTableDataSource<PortfolioPosition> =
|
public dataSource: MatTableDataSource<PortfolioPosition> =
|
||||||
new MatTableDataSource();
|
new MatTableDataSource();
|
||||||
public displayedColumns = [];
|
public displayedColumns = [];
|
||||||
public ignoreAssetClasses = [AssetClass.CASH.toString()];
|
public ignoreAssetSubClasses = [AssetClass.CASH.toString()];
|
||||||
public isLoading = true;
|
public isLoading = true;
|
||||||
public pageSize = 7;
|
public pageSize = 7;
|
||||||
public routeQueryParams: Subscription;
|
public routeQueryParams: Subscription;
|
||||||
|
@ -54,6 +54,13 @@ export class WorldMapChartComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
((this.countries[country].value * 100) / sum).toFixed(2)
|
((this.countries[country].value * 100) / sum).toFixed(2)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// Convert value to fixed-point notation
|
||||||
|
Object.keys(this.countries).map((country) => {
|
||||||
|
this.countries[country].value = Number(
|
||||||
|
this.countries[country].value.toFixed(2)
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.svgMapElement = new svgMap({
|
this.svgMapElement = new svgMap({
|
||||||
|
@ -18,6 +18,7 @@ export class AuthGuard implements CanActivate {
|
|||||||
'/about',
|
'/about',
|
||||||
'/de/blog',
|
'/de/blog',
|
||||||
'/en/blog',
|
'/en/blog',
|
||||||
|
'/p',
|
||||||
'/pricing',
|
'/pricing',
|
||||||
'/register',
|
'/register',
|
||||||
'/resources'
|
'/resources'
|
||||||
|
@ -101,7 +101,7 @@ export class HttpResponseInterceptor implements HttpInterceptor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return throwError('');
|
return throwError(error);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -11,9 +11,10 @@ import { takeUntil } from 'rxjs/operators';
|
|||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
host: { class: 'mb-5' },
|
||||||
selector: 'gf-about-page',
|
selector: 'gf-about-page',
|
||||||
templateUrl: './about-page.html',
|
styleUrls: ['./about-page.scss'],
|
||||||
styleUrls: ['./about-page.scss']
|
templateUrl: './about-page.html'
|
||||||
})
|
})
|
||||||
export class AboutPageComponent implements OnDestroy, OnInit {
|
export class AboutPageComponent implements OnDestroy, OnInit {
|
||||||
public baseCurrency = baseCurrency;
|
public baseCurrency = baseCurrency;
|
||||||
|
@ -20,9 +20,10 @@ import { EMPTY, Subject } from 'rxjs';
|
|||||||
import { catchError, switchMap, takeUntil } from 'rxjs/operators';
|
import { catchError, switchMap, takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
host: { class: 'mb-5' },
|
||||||
selector: 'gf-account-page',
|
selector: 'gf-account-page',
|
||||||
templateUrl: './account-page.html',
|
styleUrls: ['./account-page.scss'],
|
||||||
styleUrls: ['./account-page.scss']
|
templateUrl: './account-page.html'
|
||||||
})
|
})
|
||||||
export class AccountPageComponent implements OnDestroy, OnInit {
|
export class AccountPageComponent implements OnDestroy, OnInit {
|
||||||
@ViewChild('toggleSignInWithFingerprintEnabledElement')
|
@ViewChild('toggleSignInWithFingerprintEnabledElement')
|
||||||
|
@ -16,9 +16,10 @@ import { takeUntil } from 'rxjs/operators';
|
|||||||
import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog/create-or-update-account-dialog.component';
|
import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog/create-or-update-account-dialog.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
host: { class: 'mb-5' },
|
||||||
selector: 'gf-accounts-page',
|
selector: 'gf-accounts-page',
|
||||||
templateUrl: './accounts-page.html',
|
styleUrls: ['./accounts-page.scss'],
|
||||||
styleUrls: ['./accounts-page.scss']
|
templateUrl: './accounts-page.html'
|
||||||
})
|
})
|
||||||
export class AccountsPageComponent implements OnDestroy, OnInit {
|
export class AccountsPageComponent implements OnDestroy, OnInit {
|
||||||
public accounts: AccountModel[];
|
public accounts: AccountModel[];
|
||||||
|
@ -15,9 +15,10 @@ import { Subject } from 'rxjs';
|
|||||||
import { takeUntil } from 'rxjs/operators';
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
host: { class: 'mb-5' },
|
||||||
selector: 'gf-admin-page',
|
selector: 'gf-admin-page',
|
||||||
templateUrl: './admin-page.html',
|
styleUrls: ['./admin-page.scss'],
|
||||||
styleUrls: ['./admin-page.scss']
|
templateUrl: './admin-page.html'
|
||||||
})
|
})
|
||||||
export class AdminPageComponent implements OnDestroy, OnInit {
|
export class AdminPageComponent implements OnDestroy, OnInit {
|
||||||
public dataGatheringInProgress: boolean;
|
public dataGatheringInProgress: boolean;
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
host: { class: 'mb-5' },
|
||||||
selector: 'gf-hallo-ghostfolio-page',
|
selector: 'gf-hallo-ghostfolio-page',
|
||||||
|
styleUrls: ['./hallo-ghostfolio-page.scss'],
|
||||||
templateUrl: './hallo-ghostfolio-page.html'
|
templateUrl: './hallo-ghostfolio-page.html'
|
||||||
})
|
})
|
||||||
export class HalloGhostfolioPageComponent {}
|
export class HalloGhostfolioPageComponent {}
|
||||||
|
@ -139,7 +139,7 @@
|
|||||||
Thomas von Ghostfolio
|
Thomas von Ghostfolio
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
<section class="my-5">
|
<section class="mb-4">
|
||||||
<ul class="list-inline">
|
<ul class="list-inline">
|
||||||
<li class="h5">
|
<li class="h5">
|
||||||
<span class="badge badge-light font-weight-normal mr-2"
|
<span class="badge badge-light font-weight-normal mr-2"
|
||||||
|
@ -0,0 +1,3 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
@ -1,7 +1,9 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
host: { class: 'mb-5' },
|
||||||
selector: 'gf-hello-ghostfolio-page',
|
selector: 'gf-hello-ghostfolio-page',
|
||||||
|
styleUrls: ['./hello-ghostfolio-page.scss'],
|
||||||
templateUrl: './hello-ghostfolio-page.html'
|
templateUrl: './hello-ghostfolio-page.html'
|
||||||
})
|
})
|
||||||
export class HelloGhostfolioPageComponent {}
|
export class HelloGhostfolioPageComponent {}
|
||||||
|
@ -134,7 +134,7 @@
|
|||||||
Thomas from Ghostfolio
|
Thomas from Ghostfolio
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
<section class="my-5">
|
<section class="mb-4">
|
||||||
<ul class="list-inline">
|
<ul class="list-inline">
|
||||||
<li class="h5">
|
<li class="h5">
|
||||||
<span class="badge badge-light font-weight-normal mr-2"
|
<span class="badge badge-light font-weight-normal mr-2"
|
||||||
|
@ -0,0 +1,3 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
@ -36,8 +36,8 @@ import { takeUntil } from 'rxjs/operators';
|
|||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'gf-home-page',
|
selector: 'gf-home-page',
|
||||||
templateUrl: './home-page.html',
|
styleUrls: ['./home-page.scss'],
|
||||||
styleUrls: ['./home-page.scss']
|
templateUrl: './home-page.html'
|
||||||
})
|
})
|
||||||
export class HomePageComponent implements OnDestroy, OnInit {
|
export class HomePageComponent implements OnDestroy, OnInit {
|
||||||
@HostBinding('class.with-create-account-container') get isDemo() {
|
@HostBinding('class.with-create-account-container') get isDemo() {
|
||||||
|
@ -7,9 +7,10 @@ import { format } from 'date-fns';
|
|||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
host: { class: 'mb-5' },
|
||||||
selector: 'gf-landing-page',
|
selector: 'gf-landing-page',
|
||||||
templateUrl: './landing-page.html',
|
styleUrls: ['./landing-page.scss'],
|
||||||
styleUrls: ['./landing-page.scss']
|
templateUrl: './landing-page.html'
|
||||||
})
|
})
|
||||||
export class LandingPageComponent implements OnDestroy, OnInit {
|
export class LandingPageComponent implements OnDestroy, OnInit {
|
||||||
public currentYear = format(new Date(), 'yyyy');
|
public currentYear = format(new Date(), 'yyyy');
|
||||||
|
@ -15,9 +15,10 @@ import { Subject } from 'rxjs';
|
|||||||
import { takeUntil } from 'rxjs/operators';
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
host: { class: 'mb-5' },
|
||||||
selector: 'gf-allocations-page',
|
selector: 'gf-allocations-page',
|
||||||
templateUrl: './allocations-page.html',
|
styleUrls: ['./allocations-page.scss'],
|
||||||
styleUrls: ['./allocations-page.scss']
|
templateUrl: './allocations-page.html'
|
||||||
})
|
})
|
||||||
export class AllocationsPageComponent implements OnDestroy, OnInit {
|
export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||||
public accounts: {
|
public accounts: {
|
||||||
|
@ -10,9 +10,10 @@ import { Subject } from 'rxjs';
|
|||||||
import { takeUntil } from 'rxjs/operators';
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
host: { class: 'mb-5' },
|
||||||
selector: 'gf-analysis-page',
|
selector: 'gf-analysis-page',
|
||||||
templateUrl: './analysis-page.html',
|
styleUrls: ['./analysis-page.scss'],
|
||||||
styleUrls: ['./analysis-page.scss']
|
templateUrl: './analysis-page.html'
|
||||||
})
|
})
|
||||||
export class AnalysisPageComponent implements OnDestroy, OnInit {
|
export class AnalysisPageComponent implements OnDestroy, OnInit {
|
||||||
public accounts: {
|
public accounts: {
|
||||||
|
@ -7,9 +7,10 @@ import { Subject } from 'rxjs';
|
|||||||
import { takeUntil } from 'rxjs/operators';
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
host: { class: 'mb-5' },
|
||||||
selector: 'gf-portfolio-page',
|
selector: 'gf-portfolio-page',
|
||||||
templateUrl: './portfolio-page.html',
|
styleUrls: ['./portfolio-page.scss'],
|
||||||
styleUrls: ['./portfolio-page.scss']
|
templateUrl: './portfolio-page.html'
|
||||||
})
|
})
|
||||||
export class PortfolioPageComponent implements OnDestroy, OnInit {
|
export class PortfolioPageComponent implements OnDestroy, OnInit {
|
||||||
public hasPermissionForSubscription: boolean;
|
public hasPermissionForSubscription: boolean;
|
||||||
|
@ -5,9 +5,10 @@ import { Subject } from 'rxjs';
|
|||||||
import { takeUntil } from 'rxjs/operators';
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
host: { class: 'mb-5' },
|
||||||
selector: 'gf-report-page',
|
selector: 'gf-report-page',
|
||||||
templateUrl: './report-page.html',
|
styleUrls: ['./report-page.scss'],
|
||||||
styleUrls: ['./report-page.scss']
|
templateUrl: './report-page.html'
|
||||||
})
|
})
|
||||||
export class ReportPageComponent implements OnDestroy, OnInit {
|
export class ReportPageComponent implements OnDestroy, OnInit {
|
||||||
public accountClusterRiskRules: PortfolioReportRule[];
|
public accountClusterRiskRules: PortfolioReportRule[];
|
||||||
|
@ -35,4 +35,4 @@ import { CreateOrUpdateTransactionDialog } from './create-or-update-transaction-
|
|||||||
providers: [],
|
providers: [],
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
export class CreateOrUpdateTransactionDialogModule {}
|
export class GfCreateOrUpdateTransactionDialogModule {}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { User } from '@ghostfolio/common/interfaces';
|
import { User } from '@ghostfolio/common/interfaces';
|
||||||
import { Account, Order } from '@prisma/client';
|
import { Order } from '@prisma/client';
|
||||||
|
|
||||||
export interface CreateOrUpdateTransactionDialogParams {
|
export interface CreateOrUpdateTransactionDialogParams {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
|
@ -0,0 +1,50 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
Inject,
|
||||||
|
OnDestroy
|
||||||
|
} from '@angular/core';
|
||||||
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
|
||||||
|
import { ImportTransactionDialogParams } from './interfaces/interfaces';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
selector: 'gf-import-transaction-dialog',
|
||||||
|
styleUrls: ['./import-transaction-dialog.scss'],
|
||||||
|
templateUrl: 'import-transaction-dialog.html'
|
||||||
|
})
|
||||||
|
export class ImportTransactionDialog implements OnDestroy {
|
||||||
|
public details: any[] = [];
|
||||||
|
|
||||||
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
@Inject(MAT_DIALOG_DATA) public data: ImportTransactionDialogParams,
|
||||||
|
public dialogRef: MatDialogRef<ImportTransactionDialog>
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public ngOnInit() {
|
||||||
|
for (const message of this.data.messages) {
|
||||||
|
if (message.includes('orders.')) {
|
||||||
|
let [index] = message.split(' ');
|
||||||
|
index = index.replace('orders.', '');
|
||||||
|
[index] = index.split('.');
|
||||||
|
|
||||||
|
this.details.push(this.data.orders[index]);
|
||||||
|
} else {
|
||||||
|
this.details.push('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public onCancel(): void {
|
||||||
|
this.dialogRef.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.unsubscribeSubject.next();
|
||||||
|
this.unsubscribeSubject.complete();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,36 @@
|
|||||||
|
<gf-dialog-header
|
||||||
|
mat-dialog-title
|
||||||
|
title="Import Transactions Error"
|
||||||
|
[deviceType]="data.deviceType"
|
||||||
|
(closeButtonClicked)="onCancel()"
|
||||||
|
></gf-dialog-header>
|
||||||
|
|
||||||
|
<div class="flex-grow-1" mat-dialog-content>
|
||||||
|
<mat-accordion displayMode="flat">
|
||||||
|
<mat-expansion-panel
|
||||||
|
*ngFor="let message of data.messages; let i = index"
|
||||||
|
[disabled]="!details[i]"
|
||||||
|
>
|
||||||
|
<mat-expansion-panel-header class="pl-1">
|
||||||
|
<mat-panel-title>
|
||||||
|
<div class="d-flex">
|
||||||
|
<div class="align-items-center d-flex mr-2">
|
||||||
|
<ion-icon name="warning-outline"></ion-icon>
|
||||||
|
</div>
|
||||||
|
<div>{{ message }}</div>
|
||||||
|
</div>
|
||||||
|
</mat-panel-title>
|
||||||
|
</mat-expansion-panel-header>
|
||||||
|
<pre
|
||||||
|
*ngIf="details[i]"
|
||||||
|
class="m-0"
|
||||||
|
><code>{{ details[i] | json }}</code></pre>
|
||||||
|
</mat-expansion-panel>
|
||||||
|
</mat-accordion>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<gf-dialog-footer
|
||||||
|
mat-dialog-actions
|
||||||
|
[deviceType]="data.deviceType"
|
||||||
|
(closeButtonClicked)="onCancel()"
|
||||||
|
></gf-dialog-footer>
|
@ -0,0 +1,25 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatDialogModule } from '@angular/material/dialog';
|
||||||
|
import { MatExpansionModule } from '@angular/material/expansion';
|
||||||
|
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
|
||||||
|
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
|
||||||
|
|
||||||
|
import { ImportTransactionDialog } from './import-transaction-dialog.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [ImportTransactionDialog],
|
||||||
|
exports: [],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
GfDialogFooterModule,
|
||||||
|
GfDialogHeaderModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatDialogModule,
|
||||||
|
MatExpansionModule
|
||||||
|
],
|
||||||
|
providers: [],
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
|
})
|
||||||
|
export class GfImportTransactionDialogModule {}
|
@ -0,0 +1,16 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
.mat-expansion-panel {
|
||||||
|
background: none;
|
||||||
|
box-shadow: none;
|
||||||
|
|
||||||
|
.mat-expansion-panel-header {
|
||||||
|
color: inherit;
|
||||||
|
|
||||||
|
&[aria-disabled='true'] {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
export interface ImportTransactionDialogParams {
|
||||||
|
deviceType: string;
|
||||||
|
messages: string[];
|
||||||
|
orders: any[];
|
||||||
|
}
|
@ -6,23 +6,27 @@ import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
|||||||
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
|
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||||
|
import { ImportTransactionsService } from '@ghostfolio/client/services/import-transactions.service';
|
||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
import { User } from '@ghostfolio/common/interfaces';
|
import { User } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import { Order as OrderModel } from '@prisma/client';
|
import { DataSource, Order as OrderModel } from '@prisma/client';
|
||||||
import { format, parseISO } from 'date-fns';
|
import { format, parseISO } from 'date-fns';
|
||||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||||
import { EMPTY, Subject, Subscription } from 'rxjs';
|
import { Subject, Subscription } from 'rxjs';
|
||||||
import { catchError, takeUntil } from 'rxjs/operators';
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
import { CreateOrUpdateTransactionDialog } from './create-or-update-transaction-dialog/create-or-update-transaction-dialog.component';
|
import { CreateOrUpdateTransactionDialog } from './create-or-update-transaction-dialog/create-or-update-transaction-dialog.component';
|
||||||
|
import { ImportTransactionDialog } from './import-transaction-dialog/import-transaction-dialog.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
host: { class: 'mb-5' },
|
||||||
selector: 'gf-transactions-page',
|
selector: 'gf-transactions-page',
|
||||||
templateUrl: './transactions-page.html',
|
styleUrls: ['./transactions-page.scss'],
|
||||||
styleUrls: ['./transactions-page.scss']
|
templateUrl: './transactions-page.html'
|
||||||
})
|
})
|
||||||
export class TransactionsPageComponent implements OnDestroy, OnInit {
|
export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||||
|
public defaultAccountId: string;
|
||||||
public deviceType: string;
|
public deviceType: string;
|
||||||
public hasImpersonationId: boolean;
|
public hasImpersonationId: boolean;
|
||||||
public hasPermissionToCreateOrder: boolean;
|
public hasPermissionToCreateOrder: boolean;
|
||||||
@ -32,6 +36,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
|||||||
public transactions: OrderModel[];
|
public transactions: OrderModel[];
|
||||||
public user: User;
|
public user: User;
|
||||||
|
|
||||||
|
private primaryDataSource: DataSource;
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -43,11 +48,15 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
|||||||
private deviceService: DeviceDetectorService,
|
private deviceService: DeviceDetectorService,
|
||||||
private dialog: MatDialog,
|
private dialog: MatDialog,
|
||||||
private impersonationStorageService: ImpersonationStorageService,
|
private impersonationStorageService: ImpersonationStorageService,
|
||||||
|
private importTransactionsService: ImportTransactionsService,
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private snackBar: MatSnackBar,
|
private snackBar: MatSnackBar,
|
||||||
private userService: UserService
|
private userService: UserService
|
||||||
) {
|
) {
|
||||||
|
const { primaryDataSource } = this.dataService.fetchInfo();
|
||||||
|
this.primaryDataSource = primaryDataSource;
|
||||||
|
|
||||||
this.routeQueryParams = route.queryParams
|
this.routeQueryParams = route.queryParams
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((params) => {
|
.subscribe((params) => {
|
||||||
@ -55,8 +64,8 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
|||||||
this.openCreateTransactionDialog();
|
this.openCreateTransactionDialog();
|
||||||
} else if (params['editDialog']) {
|
} else if (params['editDialog']) {
|
||||||
if (this.transactions) {
|
if (this.transactions) {
|
||||||
const transaction = this.transactions.find((transaction) => {
|
const transaction = this.transactions.find(({ id }) => {
|
||||||
return transaction.id === params['transactionId'];
|
return id === params['transactionId'];
|
||||||
});
|
});
|
||||||
|
|
||||||
this.openUpdateTransactionDialog(transaction);
|
this.openUpdateTransactionDialog(transaction);
|
||||||
@ -93,6 +102,10 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
|||||||
if (state?.user) {
|
if (state?.user) {
|
||||||
this.user = state.user;
|
this.user = state.user;
|
||||||
|
|
||||||
|
this.defaultAccountId = this.user?.accounts.find((account) => {
|
||||||
|
return account.isDefault;
|
||||||
|
})?.id;
|
||||||
|
|
||||||
this.hasPermissionToCreateOrder = hasPermission(
|
this.hasPermissionToCreateOrder = hasPermission(
|
||||||
this.user.permissions,
|
this.user.permissions,
|
||||||
permissions.createOrder
|
permissions.createOrder
|
||||||
@ -157,9 +170,12 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
|||||||
|
|
||||||
public onImport() {
|
public onImport() {
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
|
input.accept = 'application/JSON, .csv';
|
||||||
input.type = 'file';
|
input.type = 'file';
|
||||||
|
|
||||||
input.onchange = (event) => {
|
input.onchange = (event) => {
|
||||||
|
this.snackBar.open('⏳ Importing data...');
|
||||||
|
|
||||||
// Getting the file reference
|
// Getting the file reference
|
||||||
const file = (event.target as HTMLInputElement).files[0];
|
const file = (event.target as HTMLInputElement).files[0];
|
||||||
|
|
||||||
@ -167,35 +183,51 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
|||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.readAsText(file, 'UTF-8');
|
reader.readAsText(file, 'UTF-8');
|
||||||
|
|
||||||
reader.onload = (readerEvent) => {
|
reader.onload = async (readerEvent) => {
|
||||||
|
const fileContent = readerEvent.target.result as string;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = JSON.parse(readerEvent.target.result as string);
|
if (file.type === 'application/json') {
|
||||||
|
const content = JSON.parse(fileContent);
|
||||||
|
try {
|
||||||
|
await this.importTransactionsService.importJson({
|
||||||
|
content: content.orders,
|
||||||
|
defaultAccountId: this.defaultAccountId
|
||||||
|
});
|
||||||
|
|
||||||
this.snackBar.open('⏳ Importing data...');
|
this.handleImportSuccess();
|
||||||
|
} catch (error) {
|
||||||
|
this.handleImportError({ error, orders: content.orders });
|
||||||
|
}
|
||||||
|
|
||||||
this.dataService
|
return;
|
||||||
.postImport({
|
} else if (file.type === 'text/csv') {
|
||||||
orders: content.orders
|
try {
|
||||||
})
|
await this.importTransactionsService.importCsv({
|
||||||
.pipe(
|
fileContent,
|
||||||
catchError((error) => {
|
defaultAccountId: this.defaultAccountId,
|
||||||
this.handleImportError(error);
|
primaryDataSource: this.primaryDataSource
|
||||||
|
});
|
||||||
|
|
||||||
return EMPTY;
|
this.handleImportSuccess();
|
||||||
}),
|
} catch (error) {
|
||||||
takeUntil(this.unsubscribeSubject)
|
this.handleImportError({
|
||||||
)
|
error: {
|
||||||
.subscribe({
|
error: { message: error?.error?.message ?? [error?.message] }
|
||||||
next: () => {
|
},
|
||||||
this.fetchOrders();
|
orders: error?.orders ?? []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this.snackBar.open('✅ Import has been completed', undefined, {
|
return;
|
||||||
duration: 3000
|
}
|
||||||
});
|
|
||||||
}
|
throw new Error();
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.handleImportError(error);
|
this.handleImportError({
|
||||||
|
error: { error: { message: ['Unexpected format'] } },
|
||||||
|
orders: []
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@ -281,20 +313,32 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
|||||||
a.click();
|
a.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleImportError(aError: unknown) {
|
private handleImportError({ error, orders }: { error: any; orders: any[] }) {
|
||||||
console.error(aError);
|
this.snackBar.dismiss();
|
||||||
this.snackBar.open('❌ Oops, something went wrong...');
|
|
||||||
|
this.dialog.open(ImportTransactionDialog, {
|
||||||
|
data: {
|
||||||
|
orders,
|
||||||
|
deviceType: this.deviceType,
|
||||||
|
messages: error?.error?.message
|
||||||
|
},
|
||||||
|
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleImportSuccess() {
|
||||||
|
this.fetchOrders();
|
||||||
|
|
||||||
|
this.snackBar.open('✅ Import has been completed', undefined, {
|
||||||
|
duration: 3000
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private openCreateTransactionDialog(aTransaction?: OrderModel): void {
|
private openCreateTransactionDialog(aTransaction?: OrderModel): void {
|
||||||
const dialogRef = this.dialog.open(CreateOrUpdateTransactionDialog, {
|
const dialogRef = this.dialog.open(CreateOrUpdateTransactionDialog, {
|
||||||
data: {
|
data: {
|
||||||
transaction: {
|
transaction: {
|
||||||
accountId:
|
accountId: aTransaction?.accountId ?? this.defaultAccountId,
|
||||||
aTransaction?.accountId ??
|
|
||||||
this.user?.accounts.find((account) => {
|
|
||||||
return account.isDefault;
|
|
||||||
})?.id,
|
|
||||||
currency: aTransaction?.currency ?? null,
|
currency: aTransaction?.currency ?? null,
|
||||||
dataSource: aTransaction?.dataSource ?? null,
|
dataSource: aTransaction?.dataSource ?? null,
|
||||||
date: new Date(),
|
date: new Date(),
|
||||||
|
@ -4,8 +4,10 @@ import { MatButtonModule } from '@angular/material/button';
|
|||||||
import { MatSnackBarModule } from '@angular/material/snack-bar';
|
import { MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
import { GfTransactionsTableModule } from '@ghostfolio/client/components/transactions-table/transactions-table.module';
|
import { GfTransactionsTableModule } from '@ghostfolio/client/components/transactions-table/transactions-table.module';
|
||||||
|
import { ImportTransactionsService } from '@ghostfolio/client/services/import-transactions.service';
|
||||||
|
|
||||||
import { CreateOrUpdateTransactionDialogModule } from './create-or-update-transaction-dialog/create-or-update-transaction-dialog.module';
|
import { GfCreateOrUpdateTransactionDialogModule } from './create-or-update-transaction-dialog/create-or-update-transaction-dialog.module';
|
||||||
|
import { GfImportTransactionDialogModule } from './import-transaction-dialog/import-transaction-dialog.module';
|
||||||
import { TransactionsPageRoutingModule } from './transactions-page-routing.module';
|
import { TransactionsPageRoutingModule } from './transactions-page-routing.module';
|
||||||
import { TransactionsPageComponent } from './transactions-page.component';
|
import { TransactionsPageComponent } from './transactions-page.component';
|
||||||
|
|
||||||
@ -14,14 +16,15 @@ import { TransactionsPageComponent } from './transactions-page.component';
|
|||||||
exports: [],
|
exports: [],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
CreateOrUpdateTransactionDialogModule,
|
GfCreateOrUpdateTransactionDialogModule,
|
||||||
|
GfImportTransactionDialogModule,
|
||||||
GfTransactionsTableModule,
|
GfTransactionsTableModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatSnackBarModule,
|
MatSnackBarModule,
|
||||||
RouterModule,
|
RouterModule,
|
||||||
TransactionsPageRoutingModule
|
TransactionsPageRoutingModule
|
||||||
],
|
],
|
||||||
providers: [],
|
providers: [ImportTransactionsService],
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
export class TransactionsPageModule {}
|
export class TransactionsPageModule {}
|
||||||
|
@ -7,9 +7,10 @@ import { Subject } from 'rxjs';
|
|||||||
import { takeUntil } from 'rxjs/operators';
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
host: { class: 'mb-5' },
|
||||||
selector: 'gf-pricing-page',
|
selector: 'gf-pricing-page',
|
||||||
templateUrl: './pricing-page.html',
|
styleUrls: ['./pricing-page.scss'],
|
||||||
styleUrls: ['./pricing-page.scss']
|
templateUrl: './pricing-page.html'
|
||||||
})
|
})
|
||||||
export class PricingPageComponent implements OnDestroy, OnInit {
|
export class PricingPageComponent implements OnDestroy, OnInit {
|
||||||
public baseCurrency = baseCurrency;
|
public baseCurrency = baseCurrency;
|
||||||
|
@ -0,0 +1,15 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { RouterModule, Routes } from '@angular/router';
|
||||||
|
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
|
||||||
|
|
||||||
|
import { PublicPageComponent } from './public-page.component';
|
||||||
|
|
||||||
|
const routes: Routes = [
|
||||||
|
{ path: ':id', component: PublicPageComponent, canActivate: [AuthGuard] }
|
||||||
|
];
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [RouterModule.forChild(routes)],
|
||||||
|
exports: [RouterModule]
|
||||||
|
})
|
||||||
|
export class PublicPageRoutingModule {}
|
183
apps/client/src/app/pages/public/public-page.component.ts
Normal file
183
apps/client/src/app/pages/public/public-page.component.ts
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
|
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||||
|
import {
|
||||||
|
PortfolioPosition,
|
||||||
|
PortfolioPublicDetails
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
|
import { StatusCodes } from 'http-status-codes';
|
||||||
|
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||||
|
import { EMPTY, Subject } from 'rxjs';
|
||||||
|
import { catchError, takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
host: { class: 'mb-5' },
|
||||||
|
selector: 'gf-public-page',
|
||||||
|
styleUrls: ['./public-page.scss'],
|
||||||
|
templateUrl: './public-page.html'
|
||||||
|
})
|
||||||
|
export class PublicPageComponent implements OnInit {
|
||||||
|
public continents: {
|
||||||
|
[code: string]: { name: string; value: number };
|
||||||
|
};
|
||||||
|
public countries: {
|
||||||
|
[code: string]: { name: string; value: number };
|
||||||
|
};
|
||||||
|
public deviceType: string;
|
||||||
|
public portfolioPublicDetails: PortfolioPublicDetails;
|
||||||
|
public positions: {
|
||||||
|
[symbol: string]: Pick<PortfolioPosition, 'name' | 'value'>;
|
||||||
|
};
|
||||||
|
public sectors: {
|
||||||
|
[name: string]: { name: string; value: number };
|
||||||
|
};
|
||||||
|
public symbols: {
|
||||||
|
[name: string]: { name: string; symbol: string; value: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
private id: string;
|
||||||
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
public constructor(
|
||||||
|
private activatedRoute: ActivatedRoute,
|
||||||
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
|
private dataService: DataService,
|
||||||
|
private deviceService: DeviceDetectorService,
|
||||||
|
private router: Router
|
||||||
|
) {
|
||||||
|
this.activatedRoute.params.subscribe((params) => {
|
||||||
|
this.id = params['id'];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the controller
|
||||||
|
*/
|
||||||
|
public ngOnInit() {
|
||||||
|
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||||
|
|
||||||
|
this.dataService
|
||||||
|
.fetchPortfolioPublic(this.id)
|
||||||
|
.pipe(
|
||||||
|
takeUntil(this.unsubscribeSubject),
|
||||||
|
catchError((error) => {
|
||||||
|
if (error.status === StatusCodes.NOT_FOUND) {
|
||||||
|
console.error(error);
|
||||||
|
this.router.navigate(['/']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return EMPTY;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe((portfolioPublicDetails) => {
|
||||||
|
this.portfolioPublicDetails = portfolioPublicDetails;
|
||||||
|
|
||||||
|
this.initializeAnalysisData();
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public initializeAnalysisData() {
|
||||||
|
this.continents = {
|
||||||
|
[UNKNOWN_KEY]: {
|
||||||
|
name: UNKNOWN_KEY,
|
||||||
|
value: 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.countries = {
|
||||||
|
[UNKNOWN_KEY]: {
|
||||||
|
name: UNKNOWN_KEY,
|
||||||
|
value: 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.positions = {};
|
||||||
|
this.sectors = {
|
||||||
|
[UNKNOWN_KEY]: {
|
||||||
|
name: UNKNOWN_KEY,
|
||||||
|
value: 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.symbols = {
|
||||||
|
[UNKNOWN_KEY]: {
|
||||||
|
name: UNKNOWN_KEY,
|
||||||
|
symbol: UNKNOWN_KEY,
|
||||||
|
value: 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [symbol, position] of Object.entries(
|
||||||
|
this.portfolioPublicDetails.holdings
|
||||||
|
)) {
|
||||||
|
const value = position.allocationCurrent;
|
||||||
|
|
||||||
|
this.positions[symbol] = {
|
||||||
|
value,
|
||||||
|
name: position.name
|
||||||
|
};
|
||||||
|
|
||||||
|
if (position.countries.length > 0) {
|
||||||
|
for (const country of position.countries) {
|
||||||
|
const { code, continent, name, weight } = country;
|
||||||
|
|
||||||
|
if (this.continents[continent]?.value) {
|
||||||
|
this.continents[continent].value += weight * position.value;
|
||||||
|
} else {
|
||||||
|
this.continents[continent] = {
|
||||||
|
name: continent,
|
||||||
|
value: weight * this.portfolioPublicDetails.holdings[symbol].value
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.countries[code]?.value) {
|
||||||
|
this.countries[code].value += weight * position.value;
|
||||||
|
} else {
|
||||||
|
this.countries[code] = {
|
||||||
|
name,
|
||||||
|
value: weight * this.portfolioPublicDetails.holdings[symbol].value
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.continents[UNKNOWN_KEY].value +=
|
||||||
|
this.portfolioPublicDetails.holdings[symbol].value;
|
||||||
|
|
||||||
|
this.countries[UNKNOWN_KEY].value +=
|
||||||
|
this.portfolioPublicDetails.holdings[symbol].value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (position.sectors.length > 0) {
|
||||||
|
for (const sector of position.sectors) {
|
||||||
|
const { name, weight } = sector;
|
||||||
|
|
||||||
|
if (this.sectors[name]?.value) {
|
||||||
|
this.sectors[name].value += weight * position.value;
|
||||||
|
} else {
|
||||||
|
this.sectors[name] = {
|
||||||
|
name,
|
||||||
|
value: weight * this.portfolioPublicDetails.holdings[symbol].value
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.sectors[UNKNOWN_KEY].value +=
|
||||||
|
this.portfolioPublicDetails.holdings[symbol].value;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.symbols[symbol] = {
|
||||||
|
symbol,
|
||||||
|
name: position.name,
|
||||||
|
value: position.value
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.unsubscribeSubject.next();
|
||||||
|
this.unsubscribeSubject.complete();
|
||||||
|
}
|
||||||
|
}
|
38
apps/client/src/app/pages/public/public-page.html
Normal file
38
apps/client/src/app/pages/public/public-page.html
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<h3 class="d-flex justify-content-center mb-3" i18n>Portfolio</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="proportion-charts row">
|
||||||
|
<div class="col-md-12 allocations-by-symbol">
|
||||||
|
<mat-card class="mb-3">
|
||||||
|
<mat-card-content>
|
||||||
|
<gf-portfolio-proportion-chart
|
||||||
|
class="mx-auto"
|
||||||
|
[isInPercent]="true"
|
||||||
|
[keys]="['symbol']"
|
||||||
|
[positions]="symbols"
|
||||||
|
[showLabels]="deviceType !== 'mobile'"
|
||||||
|
></gf-portfolio-proportion-chart>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row my-5">
|
||||||
|
<div class="col-md-8 offset-md-2">
|
||||||
|
<h2 class="h4 mb-1 text-center">
|
||||||
|
Would you like to <strong>refine</strong> your
|
||||||
|
<strong>personal investment strategy</strong>?
|
||||||
|
</h2>
|
||||||
|
<p class="lead mb-3 text-center" i18n>
|
||||||
|
Ghostfolio empowers you to keep track of your wealth.
|
||||||
|
</p>
|
||||||
|
<div class="py-2 text-center">
|
||||||
|
<a color="primary" i18n mat-flat-button [routerLink]="['/']">
|
||||||
|
Get Started
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
23
apps/client/src/app/pages/public/public-page.module.ts
Normal file
23
apps/client/src/app/pages/public/public-page.module.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
|
||||||
|
|
||||||
|
import { PublicPageRoutingModule } from './public-page-routing.module';
|
||||||
|
import { PublicPageComponent } from './public-page.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [PublicPageComponent],
|
||||||
|
exports: [],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
GfPortfolioProportionChartModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatCardModule,
|
||||||
|
PublicPageRoutingModule
|
||||||
|
],
|
||||||
|
providers: [],
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
|
})
|
||||||
|
export class PublicPageModule {}
|
12
apps/client/src/app/pages/public/public-page.scss
Normal file
12
apps/client/src/app/pages/public/public-page.scss
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
:host {
|
||||||
|
color: rgb(var(--dark-primary-text));
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
gf-portfolio-proportion-chart {
|
||||||
|
max-width: 80vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.is-dark-theme) {
|
||||||
|
color: rgb(var(--light-primary-text));
|
||||||
|
}
|
@ -12,9 +12,10 @@ import { takeUntil } from 'rxjs/operators';
|
|||||||
import { ShowAccessTokenDialog } from './show-access-token-dialog/show-access-token-dialog.component';
|
import { ShowAccessTokenDialog } from './show-access-token-dialog/show-access-token-dialog.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
host: { class: 'mb-5' },
|
||||||
selector: 'gf-register-page',
|
selector: 'gf-register-page',
|
||||||
templateUrl: './register-page.html',
|
styleUrls: ['./register-page.scss'],
|
||||||
styleUrls: ['./register-page.scss']
|
templateUrl: './register-page.html'
|
||||||
})
|
})
|
||||||
export class RegisterPageComponent implements OnDestroy, OnInit {
|
export class RegisterPageComponent implements OnDestroy, OnInit {
|
||||||
public currentYear = format(new Date(), 'yyyy');
|
public currentYear = format(new Date(), 'yyyy');
|
||||||
|
@ -2,9 +2,10 @@ import { Component, OnInit } from '@angular/core';
|
|||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
host: { class: 'mb-5' },
|
||||||
selector: 'gf-resources-page',
|
selector: 'gf-resources-page',
|
||||||
templateUrl: './resources-page.html',
|
styleUrls: ['./resources-page.scss'],
|
||||||
styleUrls: ['./resources-page.scss']
|
templateUrl: './resources-page.html'
|
||||||
})
|
})
|
||||||
export class ResourcesPageComponent implements OnInit {
|
export class ResourcesPageComponent implements OnInit {
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
@ -6,9 +6,10 @@ import { Subject } from 'rxjs';
|
|||||||
import { takeUntil } from 'rxjs/operators';
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
host: { class: 'mb-5' },
|
||||||
selector: 'gf-webauthn-page',
|
selector: 'gf-webauthn-page',
|
||||||
templateUrl: './webauthn-page.html',
|
styleUrls: ['./webauthn-page.scss'],
|
||||||
styleUrls: ['./webauthn-page.scss']
|
templateUrl: './webauthn-page.html'
|
||||||
})
|
})
|
||||||
export class WebauthnPageComponent implements OnDestroy, OnInit {
|
export class WebauthnPageComponent implements OnDestroy, OnInit {
|
||||||
public hasError = false;
|
public hasError = false;
|
||||||
|
@ -0,0 +1,3 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
@ -22,6 +22,7 @@ import {
|
|||||||
InfoItem,
|
InfoItem,
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
PortfolioPerformance,
|
PortfolioPerformance,
|
||||||
|
PortfolioPublicDetails,
|
||||||
PortfolioReport,
|
PortfolioReport,
|
||||||
PortfolioSummary,
|
PortfolioSummary,
|
||||||
User
|
User
|
||||||
@ -39,18 +40,13 @@ import { cloneDeep } from 'lodash';
|
|||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { map } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
|
|
||||||
import { SettingsStorageService } from './settings-storage.service';
|
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class DataService {
|
export class DataService {
|
||||||
private info: InfoItem;
|
private info: InfoItem;
|
||||||
|
|
||||||
public constructor(
|
public constructor(private http: HttpClient) {}
|
||||||
private http: HttpClient,
|
|
||||||
private settingsStorageService: SettingsStorageService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public createCheckoutSession({
|
public createCheckoutSession({
|
||||||
couponId,
|
couponId,
|
||||||
@ -169,6 +165,12 @@ export class DataService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public fetchPortfolioPublic(aId: string) {
|
||||||
|
return this.http.get<PortfolioPublicDetails>(
|
||||||
|
`/api/portfolio/public/${aId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public fetchPortfolioReport() {
|
public fetchPortfolioReport() {
|
||||||
return this.http.get<PortfolioReport>('/api/portfolio/report');
|
return this.http.get<PortfolioReport>('/api/portfolio/report');
|
||||||
}
|
}
|
||||||
@ -199,10 +201,6 @@ export class DataService {
|
|||||||
return this.http.post<OrderModel>(`/api/account`, aAccount);
|
return this.http.post<OrderModel>(`/api/account`, aAccount);
|
||||||
}
|
}
|
||||||
|
|
||||||
public postImport(aImportData: ImportDataDto) {
|
|
||||||
return this.http.post<void>('/api/import', aImportData);
|
|
||||||
}
|
|
||||||
|
|
||||||
public postOrder(aOrder: CreateOrderDto) {
|
public postOrder(aOrder: CreateOrderDto) {
|
||||||
return this.http.post<OrderModel>(`/api/order`, aOrder);
|
return this.http.post<OrderModel>(`/api/order`, aOrder);
|
||||||
}
|
}
|
||||||
|
254
apps/client/src/app/services/import-transactions.service.ts
Normal file
254
apps/client/src/app/services/import-transactions.service.ts
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||||
|
import { DataSource, Type } from '@prisma/client';
|
||||||
|
import { parse } from 'date-fns';
|
||||||
|
import { isNumber } from 'lodash';
|
||||||
|
import { parse as csvToJson } from 'papaparse';
|
||||||
|
import { EMPTY } from 'rxjs';
|
||||||
|
import { catchError } from 'rxjs/operators';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class ImportTransactionsService {
|
||||||
|
private static CURRENCY_KEYS = ['ccy', 'currency'];
|
||||||
|
private static DATE_KEYS = ['date'];
|
||||||
|
private static FEE_KEYS = ['commission', 'fee'];
|
||||||
|
private static QUANTITY_KEYS = ['qty', 'quantity', 'shares'];
|
||||||
|
private static SYMBOL_KEYS = ['code', 'symbol'];
|
||||||
|
private static TYPE_KEYS = ['action', 'type'];
|
||||||
|
private static UNIT_PRICE_KEYS = ['price', 'unitprice', 'value'];
|
||||||
|
|
||||||
|
public constructor(private http: HttpClient) {}
|
||||||
|
|
||||||
|
public async importCsv({
|
||||||
|
defaultAccountId,
|
||||||
|
fileContent,
|
||||||
|
primaryDataSource
|
||||||
|
}: {
|
||||||
|
defaultAccountId: string;
|
||||||
|
fileContent: string;
|
||||||
|
primaryDataSource: DataSource;
|
||||||
|
}) {
|
||||||
|
const content = csvToJson(fileContent, {
|
||||||
|
dynamicTyping: true,
|
||||||
|
header: true,
|
||||||
|
skipEmptyLines: true
|
||||||
|
}).data;
|
||||||
|
|
||||||
|
const orders: CreateOrderDto[] = [];
|
||||||
|
|
||||||
|
for (const [index, item] of content.entries()) {
|
||||||
|
orders.push({
|
||||||
|
accountId: defaultAccountId,
|
||||||
|
currency: this.parseCurrency({ content, index, item }),
|
||||||
|
dataSource: primaryDataSource,
|
||||||
|
date: this.parseDate({ content, index, item }),
|
||||||
|
fee: this.parseFee({ content, index, item }),
|
||||||
|
quantity: this.parseQuantity({ content, index, item }),
|
||||||
|
symbol: this.parseSymbol({ content, index, item }),
|
||||||
|
type: this.parseType({ content, index, item }),
|
||||||
|
unitPrice: this.parseUnitPrice({ content, index, item })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.importJson({ defaultAccountId, content: orders });
|
||||||
|
}
|
||||||
|
|
||||||
|
public importJson({
|
||||||
|
content,
|
||||||
|
defaultAccountId
|
||||||
|
}: {
|
||||||
|
content: CreateOrderDto[];
|
||||||
|
defaultAccountId: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.postImport({
|
||||||
|
orders: content.map((order) => {
|
||||||
|
return { ...order, accountId: defaultAccountId };
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.pipe(
|
||||||
|
catchError((error) => {
|
||||||
|
reject(error);
|
||||||
|
return EMPTY;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private lowercaseKeys(aObject: any) {
|
||||||
|
return Object.keys(aObject).reduce((acc, key) => {
|
||||||
|
acc[key.toLowerCase()] = aObject[key];
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseCurrency({
|
||||||
|
content,
|
||||||
|
index,
|
||||||
|
item
|
||||||
|
}: {
|
||||||
|
content: any[];
|
||||||
|
index: number;
|
||||||
|
item: any;
|
||||||
|
}) {
|
||||||
|
item = this.lowercaseKeys(item);
|
||||||
|
|
||||||
|
for (const key of ImportTransactionsService.CURRENCY_KEYS) {
|
||||||
|
if (item[key]) {
|
||||||
|
return item[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw { message: `orders.${index}.currency is not valid`, orders: content };
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseDate({
|
||||||
|
content,
|
||||||
|
index,
|
||||||
|
item
|
||||||
|
}: {
|
||||||
|
content: any[];
|
||||||
|
index: number;
|
||||||
|
item: any;
|
||||||
|
}) {
|
||||||
|
item = this.lowercaseKeys(item);
|
||||||
|
let date: string;
|
||||||
|
|
||||||
|
for (const key of ImportTransactionsService.DATE_KEYS) {
|
||||||
|
if (item[key]) {
|
||||||
|
try {
|
||||||
|
date = parse(item[key], 'dd-MM-yyyy', new Date()).toISOString();
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
date = parse(item[key], 'dd/MM/yyyy', new Date()).toISOString();
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
if (date) {
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw { message: `orders.${index}.date is not valid`, orders: content };
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseFee({
|
||||||
|
content,
|
||||||
|
index,
|
||||||
|
item
|
||||||
|
}: {
|
||||||
|
content: any[];
|
||||||
|
index: number;
|
||||||
|
item: any;
|
||||||
|
}) {
|
||||||
|
item = this.lowercaseKeys(item);
|
||||||
|
|
||||||
|
for (const key of ImportTransactionsService.FEE_KEYS) {
|
||||||
|
if ((item[key] || item[key] === 0) && isNumber(item[key])) {
|
||||||
|
return item[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw { message: `orders.${index}.fee is not valid`, orders: content };
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseQuantity({
|
||||||
|
content,
|
||||||
|
index,
|
||||||
|
item
|
||||||
|
}: {
|
||||||
|
content: any[];
|
||||||
|
index: number;
|
||||||
|
item: any;
|
||||||
|
}) {
|
||||||
|
item = this.lowercaseKeys(item);
|
||||||
|
|
||||||
|
for (const key of ImportTransactionsService.QUANTITY_KEYS) {
|
||||||
|
if (item[key] && isNumber(item[key])) {
|
||||||
|
return item[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw { message: `orders.${index}.quantity is not valid`, orders: content };
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseSymbol({
|
||||||
|
content,
|
||||||
|
index,
|
||||||
|
item
|
||||||
|
}: {
|
||||||
|
content: any[];
|
||||||
|
index: number;
|
||||||
|
item: any;
|
||||||
|
}) {
|
||||||
|
item = this.lowercaseKeys(item);
|
||||||
|
|
||||||
|
for (const key of ImportTransactionsService.SYMBOL_KEYS) {
|
||||||
|
if (item[key]) {
|
||||||
|
return item[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw { message: `orders.${index}.symbol is not valid`, orders: content };
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseType({
|
||||||
|
content,
|
||||||
|
index,
|
||||||
|
item
|
||||||
|
}: {
|
||||||
|
content: any[];
|
||||||
|
index: number;
|
||||||
|
item: any;
|
||||||
|
}) {
|
||||||
|
item = this.lowercaseKeys(item);
|
||||||
|
|
||||||
|
for (const key of ImportTransactionsService.TYPE_KEYS) {
|
||||||
|
if (item[key]) {
|
||||||
|
if (item[key].toLowerCase() === 'buy') {
|
||||||
|
return Type.BUY;
|
||||||
|
} else if (item[key].toLowerCase() === 'sell') {
|
||||||
|
return Type.SELL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw { message: `orders.${index}.type is not valid`, orders: content };
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseUnitPrice({
|
||||||
|
content,
|
||||||
|
index,
|
||||||
|
item
|
||||||
|
}: {
|
||||||
|
content: any[];
|
||||||
|
index: number;
|
||||||
|
item: any;
|
||||||
|
}) {
|
||||||
|
item = this.lowercaseKeys(item);
|
||||||
|
|
||||||
|
for (const key of ImportTransactionsService.UNIT_PRICE_KEYS) {
|
||||||
|
if (item[key] && isNumber(item[key])) {
|
||||||
|
return item[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw {
|
||||||
|
message: `orders.${index}.unitPrice is not valid`,
|
||||||
|
orders: content
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private postImport(aImportData: { orders: CreateOrderDto[] }) {
|
||||||
|
return this.http.post<void>('/api/import', aImportData);
|
||||||
|
}
|
||||||
|
}
|
@ -1,3 +1,5 @@
|
|||||||
export interface Access {
|
export interface Access {
|
||||||
granteeAlias: string;
|
granteeAlias: string;
|
||||||
|
id: string;
|
||||||
|
type: 'PUBLIC' | 'RESTRICTED_VIEW';
|
||||||
}
|
}
|
||||||
|
@ -7,6 +7,7 @@ import { PortfolioItem } from './portfolio-item.interface';
|
|||||||
import { PortfolioOverview } from './portfolio-overview.interface';
|
import { PortfolioOverview } from './portfolio-overview.interface';
|
||||||
import { PortfolioPerformance } from './portfolio-performance.interface';
|
import { PortfolioPerformance } from './portfolio-performance.interface';
|
||||||
import { PortfolioPosition } from './portfolio-position.interface';
|
import { PortfolioPosition } from './portfolio-position.interface';
|
||||||
|
import { PortfolioPublicDetails } from './portfolio-public-details.interface';
|
||||||
import { PortfolioReportRule } from './portfolio-report-rule.interface';
|
import { PortfolioReportRule } from './portfolio-report-rule.interface';
|
||||||
import { PortfolioReport } from './portfolio-report.interface';
|
import { PortfolioReport } from './portfolio-report.interface';
|
||||||
import { PortfolioSummary } from './portfolio-summary.interface';
|
import { PortfolioSummary } from './portfolio-summary.interface';
|
||||||
@ -26,6 +27,7 @@ export {
|
|||||||
PortfolioOverview,
|
PortfolioOverview,
|
||||||
PortfolioPerformance,
|
PortfolioPerformance,
|
||||||
PortfolioPosition,
|
PortfolioPosition,
|
||||||
|
PortfolioPublicDetails,
|
||||||
PortfolioReport,
|
PortfolioReport,
|
||||||
PortfolioReportRule,
|
PortfolioReportRule,
|
||||||
PortfolioSummary,
|
PortfolioSummary,
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { DataSource } from '@prisma/client';
|
||||||
|
|
||||||
import { Statistics } from './statistics.interface';
|
import { Statistics } from './statistics.interface';
|
||||||
import { Subscription } from './subscription.interface';
|
import { Subscription } from './subscription.interface';
|
||||||
|
|
||||||
@ -11,6 +13,7 @@ export interface InfoItem {
|
|||||||
type: string;
|
type: string;
|
||||||
};
|
};
|
||||||
platforms: { id: string; name: string }[];
|
platforms: { id: string; name: string }[];
|
||||||
|
primaryDataSource: DataSource;
|
||||||
statistics: Statistics;
|
statistics: Statistics;
|
||||||
stripePublicKey?: string;
|
stripePublicKey?: string;
|
||||||
subscriptions: Subscription[];
|
subscriptions: Subscription[];
|
||||||
|
@ -0,0 +1,10 @@
|
|||||||
|
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
|
export interface PortfolioPublicDetails {
|
||||||
|
holdings: {
|
||||||
|
[symbol: string]: Pick<
|
||||||
|
PortfolioPosition,
|
||||||
|
'allocationCurrent' | 'countries' | 'name' | 'sectors' | 'value'
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
}
|
@ -1,9 +1,9 @@
|
|||||||
<ngx-skeleton-loader
|
<ngx-skeleton-loader
|
||||||
*ngIf="isLoading"
|
*ngIf="isLoading"
|
||||||
animation="pulse"
|
animation="pulse"
|
||||||
|
class="h-100"
|
||||||
[theme]="{
|
[theme]="{
|
||||||
height: '15rem',
|
height: '100%'
|
||||||
width: '100%'
|
|
||||||
}"
|
}"
|
||||||
></ngx-skeleton-loader>
|
></ngx-skeleton-loader>
|
||||||
<canvas
|
<canvas
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
:host {
|
:host {
|
||||||
|
aspect-ratio: 1;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
@ -75,6 +75,7 @@ export class PortfolioProportionChartComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
public ngOnDestroy() {
|
public ngOnDestroy() {
|
||||||
|
Chart.unregister(ChartDataLabels);
|
||||||
this.chart?.destroy();
|
this.chart?.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ghostfolio",
|
"name": "ghostfolio",
|
||||||
"version": "1.58.1",
|
"version": "1.63.0",
|
||||||
"homepage": "https://ghostfol.io",
|
"homepage": "https://ghostfol.io",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -73,6 +73,7 @@
|
|||||||
"@simplewebauthn/server": "4.1.0",
|
"@simplewebauthn/server": "4.1.0",
|
||||||
"@simplewebauthn/typescript-types": "4.0.0",
|
"@simplewebauthn/typescript-types": "4.0.0",
|
||||||
"@stripe/stripe-js": "1.15.0",
|
"@stripe/stripe-js": "1.15.0",
|
||||||
|
"@types/papaparse": "5.2.6",
|
||||||
"alphavantage": "2.2.0",
|
"alphavantage": "2.2.0",
|
||||||
"angular-material-css-vars": "2.1.2",
|
"angular-material-css-vars": "2.1.2",
|
||||||
"bent": "7.3.12",
|
"bent": "7.3.12",
|
||||||
@ -99,6 +100,7 @@
|
|||||||
"ngx-markdown": "12.0.1",
|
"ngx-markdown": "12.0.1",
|
||||||
"ngx-skeleton-loader": "2.9.1",
|
"ngx-skeleton-loader": "2.9.1",
|
||||||
"ngx-stripe": "12.0.2",
|
"ngx-stripe": "12.0.2",
|
||||||
|
"papaparse": "5.3.1",
|
||||||
"passport": "0.4.1",
|
"passport": "0.4.1",
|
||||||
"passport-google-oauth20": "2.0.0",
|
"passport-google-oauth20": "2.0.0",
|
||||||
"passport-jwt": "4.0.0",
|
"passport-jwt": "4.0.0",
|
||||||
|
@ -0,0 +1,5 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Access" ALTER COLUMN "granteeUserId" DROP NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Account" ALTER COLUMN "currency" DROP NOT NULL;
|
@ -14,8 +14,8 @@ generator client {
|
|||||||
|
|
||||||
model Access {
|
model Access {
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
GranteeUser User @relation(fields: [granteeUserId], name: "accessGet", references: [id])
|
GranteeUser User? @relation(fields: [granteeUserId], name: "accessGet", references: [id])
|
||||||
granteeUserId String
|
granteeUserId String?
|
||||||
id String @default(uuid())
|
id String @default(uuid())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
User User @relation(fields: [userId], name: "accessGive", references: [id])
|
User User @relation(fields: [userId], name: "accessGive", references: [id])
|
||||||
|
18
test/import/invalid-date.json
Normal file
18
test/import/invalid-date.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"date": "2021-01-01T00:00:00.000Z",
|
||||||
|
"version": "dev"
|
||||||
|
},
|
||||||
|
"orders": [
|
||||||
|
{
|
||||||
|
"currency": "USD",
|
||||||
|
"dataSource": "YAHOO",
|
||||||
|
"date": "<invalid>",
|
||||||
|
"fee": 0,
|
||||||
|
"quantity": 20,
|
||||||
|
"symbol": "AAPL",
|
||||||
|
"type": "BUY",
|
||||||
|
"unitPrice": 100.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
2
test/import/invalid-fee.csv
Normal file
2
test/import/invalid-fee.csv
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
Date,Code,CCY,Price,Qty,Action,Amount,Fee
|
||||||
|
16/09/2021,MSFT,USD,298.580,5,buy,1496.09,<invalid>
|
|
18
test/import/invalid-symbol.json
Normal file
18
test/import/invalid-symbol.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"date": "2021-01-01T00:00:00.000Z",
|
||||||
|
"version": "dev"
|
||||||
|
},
|
||||||
|
"orders": [
|
||||||
|
{
|
||||||
|
"currency": "USD",
|
||||||
|
"dataSource": "YAHOO",
|
||||||
|
"date": "2021-01-01T00:00:00.000Z",
|
||||||
|
"fee": 0,
|
||||||
|
"quantity": 20,
|
||||||
|
"symbol": "<invalid>",
|
||||||
|
"type": "BUY",
|
||||||
|
"unitPrice": 100.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
1
test/import/unexpected-format.json
Normal file
1
test/import/unexpected-format.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
<invalid>
|
12
yarn.lock
12
yarn.lock
@ -3920,6 +3920,13 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@types/overlayscrollbars/-/overlayscrollbars-1.12.1.tgz#fb637071b545834fb12aea94ee309a2ff4cdc0a8"
|
resolved "https://registry.yarnpkg.com/@types/overlayscrollbars/-/overlayscrollbars-1.12.1.tgz#fb637071b545834fb12aea94ee309a2ff4cdc0a8"
|
||||||
integrity sha512-V25YHbSoKQN35UasHf0EKD9U2vcmexRSp78qa8UglxFH8H3D+adEa9zGZwrqpH4TdvqeMrgMqVqsLB4woAryrQ==
|
integrity sha512-V25YHbSoKQN35UasHf0EKD9U2vcmexRSp78qa8UglxFH8H3D+adEa9zGZwrqpH4TdvqeMrgMqVqsLB4woAryrQ==
|
||||||
|
|
||||||
|
"@types/papaparse@5.2.6":
|
||||||
|
version "5.2.6"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/papaparse/-/papaparse-5.2.6.tgz#0bba18de4d15eff65883bc7c0794e0134de9e7c7"
|
||||||
|
integrity sha512-xGKSd0UTn58N1h0+zf8mW863Rv8BvXcGibEgKFtBIXZlcDXAmX/T4RdDO2mwmrmOypUDt5vRgo2v32a78JdqUA==
|
||||||
|
dependencies:
|
||||||
|
"@types/node" "*"
|
||||||
|
|
||||||
"@types/parse-json@^4.0.0":
|
"@types/parse-json@^4.0.0":
|
||||||
version "4.0.0"
|
version "4.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0"
|
resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0"
|
||||||
@ -13146,6 +13153,11 @@ pako@^1.0.3, pako@~1.0.5:
|
|||||||
resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf"
|
resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf"
|
||||||
integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==
|
integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==
|
||||||
|
|
||||||
|
papaparse@5.3.1:
|
||||||
|
version "5.3.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/papaparse/-/papaparse-5.3.1.tgz#770b7a9124d821d4b2132132b7bd7dce7194b5b1"
|
||||||
|
integrity sha512-Dbt2yjLJrCwH2sRqKFFJaN5XgIASO9YOFeFP8rIBRG2Ain8mqk5r1M6DkfvqEVozVcz3r3HaUGw253hA1nLIcA==
|
||||||
|
|
||||||
parallel-transform@^1.1.0:
|
parallel-transform@^1.1.0:
|
||||||
version "1.2.0"
|
version "1.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/parallel-transform/-/parallel-transform-1.2.0.tgz#9049ca37d6cb2182c3b1d2c720be94d14a5814fc"
|
resolved "https://registry.yarnpkg.com/parallel-transform/-/parallel-transform-1.2.0.tgz#9049ca37d6cb2182c3b1d2c720be94d14a5814fc"
|
||||||
|
Reference in New Issue
Block a user