Feature/extend public api with portfolio performance metrics endpoint (#3762)
* Extend Public API with portfolio performance metrics endpoint * Update changelog
This commit is contained in:
parent
9059d4f971
commit
583c14128b
@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- Extended the _Public API_ with a new endpoint that provides portfolio performance metrics (experimental)
|
||||||
- Added a blog post: _Hacktoberfest 2024_
|
- Added a blog post: _Hacktoberfest 2024_
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
30
README.md
30
README.md
@ -220,6 +220,36 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Portfolio (experimental)
|
||||||
|
|
||||||
|
#### Prerequisites
|
||||||
|
|
||||||
|
Grant access of type _Public_ in the _Access_ tab of _My Ghostfolio_.
|
||||||
|
|
||||||
|
#### Request
|
||||||
|
|
||||||
|
`GET http://localhost:3333/api/v1/public/<INSERT_ACCESS_ID>/portfolio`
|
||||||
|
|
||||||
|
#### Response
|
||||||
|
|
||||||
|
##### Success
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"performance": {
|
||||||
|
"1d": {
|
||||||
|
"relativeChange": 0 // normalized from -1 to 1
|
||||||
|
};
|
||||||
|
"ytd": {
|
||||||
|
"relativeChange": 0 // normalized from -1 to 1
|
||||||
|
},
|
||||||
|
"max": {
|
||||||
|
"relativeChange": 0 // normalized from -1 to 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Community Projects
|
## Community Projects
|
||||||
|
|
||||||
Discover a variety of community projects for Ghostfolio: https://github.com/topics/ghostfolio
|
Discover a variety of community projects for Ghostfolio: https://github.com/topics/ghostfolio
|
||||||
|
@ -31,6 +31,7 @@ import { AuthDeviceModule } from './auth-device/auth-device.module';
|
|||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { BenchmarkModule } from './benchmark/benchmark.module';
|
import { BenchmarkModule } from './benchmark/benchmark.module';
|
||||||
import { CacheModule } from './cache/cache.module';
|
import { CacheModule } from './cache/cache.module';
|
||||||
|
import { PublicModule } from './endpoints/public/public.module';
|
||||||
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
|
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
|
||||||
import { ExportModule } from './export/export.module';
|
import { ExportModule } from './export/export.module';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
@ -85,6 +86,7 @@ import { UserModule } from './user/user.module';
|
|||||||
PortfolioSnapshotQueueModule,
|
PortfolioSnapshotQueueModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
PropertyModule,
|
PropertyModule,
|
||||||
|
PublicModule,
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
ServeStaticModule.forRoot({
|
ServeStaticModule.forRoot({
|
||||||
|
134
apps/api/src/app/endpoints/public/public.controller.ts
Normal file
134
apps/api/src/app/endpoints/public/public.controller.ts
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
import { AccessService } from '@ghostfolio/api/app/access/access.service';
|
||||||
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||||
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
|
||||||
|
import { getSum } from '@ghostfolio/common/helper';
|
||||||
|
import { PublicPortfolioResponse } from '@ghostfolio/common/interfaces';
|
||||||
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpException,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
UseInterceptors
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { REQUEST } from '@nestjs/core';
|
||||||
|
import { Big } from 'big.js';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
|
@Controller('public')
|
||||||
|
export class PublicController {
|
||||||
|
public constructor(
|
||||||
|
private readonly accessService: AccessService,
|
||||||
|
private readonly configurationService: ConfigurationService,
|
||||||
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
|
private readonly portfolioService: PortfolioService,
|
||||||
|
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||||
|
private readonly userService: UserService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get(':accessId/portfolio')
|
||||||
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
|
public async getPublicPortfolio(
|
||||||
|
@Param('accessId') accessId
|
||||||
|
): Promise<PublicPortfolioResponse> {
|
||||||
|
const access = await this.accessService.access({ id: accessId });
|
||||||
|
|
||||||
|
if (!access) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
|
StatusCodes.NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasDetails = true;
|
||||||
|
|
||||||
|
const user = await this.userService.user({
|
||||||
|
id: access.userId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
|
hasDetails = user.subscription.type === 'Premium';
|
||||||
|
}
|
||||||
|
|
||||||
|
const [
|
||||||
|
{ holdings },
|
||||||
|
{ performance: performance1d },
|
||||||
|
{ performance: performanceMax },
|
||||||
|
{ performance: performanceYtd }
|
||||||
|
] = await Promise.all([
|
||||||
|
this.portfolioService.getDetails({
|
||||||
|
filters: [{ id: 'EQUITY', type: 'ASSET_CLASS' }],
|
||||||
|
impersonationId: access.userId,
|
||||||
|
userId: user.id,
|
||||||
|
withMarkets: true
|
||||||
|
}),
|
||||||
|
...['1d', 'max', 'ytd'].map((dateRange) => {
|
||||||
|
return this.portfolioService.getPerformance({
|
||||||
|
dateRange,
|
||||||
|
impersonationId: undefined,
|
||||||
|
userId: user.id
|
||||||
|
});
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
const publicPortfolioResponse: PublicPortfolioResponse = {
|
||||||
|
hasDetails,
|
||||||
|
alias: access.alias,
|
||||||
|
holdings: {},
|
||||||
|
performance: {
|
||||||
|
'1d': {
|
||||||
|
relativeChange:
|
||||||
|
performance1d.netPerformancePercentageWithCurrencyEffect
|
||||||
|
},
|
||||||
|
max: {
|
||||||
|
relativeChange:
|
||||||
|
performanceMax.netPerformancePercentageWithCurrencyEffect
|
||||||
|
},
|
||||||
|
ytd: {
|
||||||
|
relativeChange:
|
||||||
|
performanceYtd.netPerformancePercentageWithCurrencyEffect
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalValue = getSum(
|
||||||
|
Object.values(holdings).map(({ currency, marketPrice, quantity }) => {
|
||||||
|
return new Big(
|
||||||
|
this.exchangeRateDataService.toCurrency(
|
||||||
|
quantity * marketPrice,
|
||||||
|
currency,
|
||||||
|
this.request.user?.Settings?.settings.baseCurrency ??
|
||||||
|
DEFAULT_CURRENCY
|
||||||
|
)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
).toNumber();
|
||||||
|
|
||||||
|
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||||
|
publicPortfolioResponse.holdings[symbol] = {
|
||||||
|
allocationInPercentage:
|
||||||
|
portfolioPosition.valueInBaseCurrency / totalValue,
|
||||||
|
countries: hasDetails ? portfolioPosition.countries : [],
|
||||||
|
currency: hasDetails ? portfolioPosition.currency : undefined,
|
||||||
|
dataSource: portfolioPosition.dataSource,
|
||||||
|
dateOfFirstActivity: portfolioPosition.dateOfFirstActivity,
|
||||||
|
markets: hasDetails ? portfolioPosition.markets : undefined,
|
||||||
|
name: portfolioPosition.name,
|
||||||
|
netPerformancePercentWithCurrencyEffect:
|
||||||
|
portfolioPosition.netPerformancePercentWithCurrencyEffect,
|
||||||
|
sectors: hasDetails ? portfolioPosition.sectors : [],
|
||||||
|
symbol: portfolioPosition.symbol,
|
||||||
|
url: portfolioPosition.url,
|
||||||
|
valueInPercentage: portfolioPosition.valueInBaseCurrency / totalValue
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return publicPortfolioResponse;
|
||||||
|
}
|
||||||
|
}
|
49
apps/api/src/app/endpoints/public/public.module.ts
Normal file
49
apps/api/src/app/endpoints/public/public.module.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { AccessModule } from '@ghostfolio/api/app/access/access.module';
|
||||||
|
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
||||||
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
|
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||||
|
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||||
|
import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service';
|
||||||
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
|
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
||||||
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
|
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
||||||
|
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
|
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
|
||||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
|
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { PublicController } from './public.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [PublicController],
|
||||||
|
imports: [
|
||||||
|
AccessModule,
|
||||||
|
DataProviderModule,
|
||||||
|
ExchangeRateDataModule,
|
||||||
|
ImpersonationModule,
|
||||||
|
MarketDataModule,
|
||||||
|
OrderModule,
|
||||||
|
PortfolioSnapshotQueueModule,
|
||||||
|
PrismaModule,
|
||||||
|
RedisCacheModule,
|
||||||
|
SymbolProfileModule,
|
||||||
|
TransformDataSourceInRequestModule,
|
||||||
|
UserModule
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
AccountBalanceService,
|
||||||
|
AccountService,
|
||||||
|
CurrentRateService,
|
||||||
|
PortfolioCalculatorFactory,
|
||||||
|
PortfolioService,
|
||||||
|
RulesService
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class PublicModule {}
|
@ -1,6 +1,5 @@
|
|||||||
import { AccessService } from '@ghostfolio/api/app/access/access.service';
|
import { AccessService } from '@ghostfolio/api/app/access/access.service';
|
||||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
|
||||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import {
|
import {
|
||||||
@ -13,20 +12,15 @@ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interce
|
|||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
|
||||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||||
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
|
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
|
||||||
import {
|
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||||
DEFAULT_CURRENCY,
|
|
||||||
HEADER_KEY_IMPERSONATION
|
|
||||||
} from '@ghostfolio/common/config';
|
|
||||||
import {
|
import {
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
PortfolioDividends,
|
PortfolioDividends,
|
||||||
PortfolioHoldingsResponse,
|
PortfolioHoldingsResponse,
|
||||||
PortfolioInvestments,
|
PortfolioInvestments,
|
||||||
PortfolioPerformanceResponse,
|
PortfolioPerformanceResponse,
|
||||||
PortfolioPublicDetails,
|
|
||||||
PortfolioReport
|
PortfolioReport
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import {
|
import {
|
||||||
@ -70,12 +64,10 @@ export class PortfolioController {
|
|||||||
private readonly accessService: AccessService,
|
private readonly accessService: AccessService,
|
||||||
private readonly apiService: ApiService,
|
private readonly apiService: ApiService,
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
|
||||||
private readonly impersonationService: ImpersonationService,
|
private readonly impersonationService: ImpersonationService,
|
||||||
private readonly orderService: OrderService,
|
private readonly orderService: OrderService,
|
||||||
private readonly portfolioService: PortfolioService,
|
private readonly portfolioService: PortfolioService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
private readonly userService: UserService
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('details')
|
@Get('details')
|
||||||
@ -497,75 +489,6 @@ export class PortfolioController {
|
|||||||
return performanceInformation;
|
return performanceInformation;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('public/:accessId')
|
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
|
||||||
public async getPublic(
|
|
||||||
@Param('accessId') accessId
|
|
||||||
): Promise<PortfolioPublicDetails> {
|
|
||||||
const access = await this.accessService.access({ id: accessId });
|
|
||||||
|
|
||||||
if (!access) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
|
||||||
StatusCodes.NOT_FOUND
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let hasDetails = true;
|
|
||||||
|
|
||||||
const user = await this.userService.user({
|
|
||||||
id: access.userId
|
|
||||||
});
|
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
|
||||||
hasDetails = user.subscription.type === 'Premium';
|
|
||||||
}
|
|
||||||
|
|
||||||
const { holdings } = await this.portfolioService.getDetails({
|
|
||||||
filters: [{ id: 'EQUITY', type: 'ASSET_CLASS' }],
|
|
||||||
impersonationId: access.userId,
|
|
||||||
userId: user.id,
|
|
||||||
withMarkets: true
|
|
||||||
});
|
|
||||||
|
|
||||||
const portfolioPublicDetails: PortfolioPublicDetails = {
|
|
||||||
hasDetails,
|
|
||||||
alias: access.alias,
|
|
||||||
holdings: {}
|
|
||||||
};
|
|
||||||
|
|
||||||
const totalValue = Object.values(holdings)
|
|
||||||
.map((portfolioPosition) => {
|
|
||||||
return this.exchangeRateDataService.toCurrency(
|
|
||||||
portfolioPosition.quantity * portfolioPosition.marketPrice,
|
|
||||||
portfolioPosition.currency,
|
|
||||||
this.request.user?.Settings?.settings.baseCurrency ?? DEFAULT_CURRENCY
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.reduce((a, b) => a + b, 0);
|
|
||||||
|
|
||||||
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
|
||||||
portfolioPublicDetails.holdings[symbol] = {
|
|
||||||
allocationInPercentage:
|
|
||||||
portfolioPosition.valueInBaseCurrency / totalValue,
|
|
||||||
countries: hasDetails ? portfolioPosition.countries : [],
|
|
||||||
currency: hasDetails ? portfolioPosition.currency : undefined,
|
|
||||||
dataSource: portfolioPosition.dataSource,
|
|
||||||
dateOfFirstActivity: portfolioPosition.dateOfFirstActivity,
|
|
||||||
markets: hasDetails ? portfolioPosition.markets : undefined,
|
|
||||||
name: portfolioPosition.name,
|
|
||||||
netPerformancePercentWithCurrencyEffect:
|
|
||||||
portfolioPosition.netPerformancePercentWithCurrencyEffect,
|
|
||||||
sectors: hasDetails ? portfolioPosition.sectors : [],
|
|
||||||
symbol: portfolioPosition.symbol,
|
|
||||||
url: portfolioPosition.url,
|
|
||||||
valueInPercentage: portfolioPosition.valueInBaseCurrency / totalValue
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return portfolioPublicDetails;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('position/:dataSource/:symbol')
|
@Get('position/:dataSource/:symbol')
|
||||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
|
@ -39,6 +39,15 @@
|
|||||||
getPublicUrl(element.id)
|
getPublicUrl(element.id)
|
||||||
}}</a>
|
}}</a>
|
||||||
</div>
|
</div>
|
||||||
|
@if (user?.settings?.isExperimentalFeatures) {
|
||||||
|
<div>
|
||||||
|
<code
|
||||||
|
>GET {{ baseUrl }}/api/v1/public/{{
|
||||||
|
element.id
|
||||||
|
}}/portfolio</code
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { ConfirmationDialogType } from '@ghostfolio/client/core/notification/confirmation-dialog/confirmation-dialog.type';
|
import { ConfirmationDialogType } from '@ghostfolio/client/core/notification/confirmation-dialog/confirmation-dialog.type';
|
||||||
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
|
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
|
||||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||||
import { Access } from '@ghostfolio/common/interfaces';
|
import { Access, User } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { Clipboard } from '@angular/cdk/clipboard';
|
import { Clipboard } from '@angular/cdk/clipboard';
|
||||||
import {
|
import {
|
||||||
@ -24,6 +24,7 @@ import { MatTableDataSource } from '@angular/material/table';
|
|||||||
export class AccessTableComponent implements OnChanges, OnInit {
|
export class AccessTableComponent implements OnChanges, OnInit {
|
||||||
@Input() accesses: Access[];
|
@Input() accesses: Access[];
|
||||||
@Input() showActions: boolean;
|
@Input() showActions: boolean;
|
||||||
|
@Input() user: User;
|
||||||
|
|
||||||
@Output() accessDeleted = new EventEmitter<string>();
|
@Output() accessDeleted = new EventEmitter<string>();
|
||||||
|
|
||||||
|
@ -10,6 +10,7 @@
|
|||||||
<gf-access-table
|
<gf-access-table
|
||||||
[accesses]="accesses"
|
[accesses]="accesses"
|
||||||
[showActions]="hasPermissionToDeleteAccess"
|
[showActions]="hasPermissionToDeleteAccess"
|
||||||
|
[user]="user"
|
||||||
(accessDeleted)="onDeleteAccess($event)"
|
(accessDeleted)="onDeleteAccess($event)"
|
||||||
/>
|
/>
|
||||||
@if (hasPermissionToCreateAccess) {
|
@if (hasPermissionToCreateAccess) {
|
||||||
|
@ -3,7 +3,7 @@ import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
|||||||
import { prettifySymbol } from '@ghostfolio/common/helper';
|
import { prettifySymbol } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
PortfolioPosition,
|
PortfolioPosition,
|
||||||
PortfolioPublicDetails
|
PublicPortfolioResponse
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { Market } from '@ghostfolio/common/types';
|
import { Market } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
@ -29,16 +29,16 @@ export class PublicPageComponent implements OnInit {
|
|||||||
[code: string]: { name: string; value: number };
|
[code: string]: { name: string; value: number };
|
||||||
};
|
};
|
||||||
public deviceType: string;
|
public deviceType: string;
|
||||||
public holdings: PortfolioPublicDetails['holdings'][string][];
|
public holdings: PublicPortfolioResponse['holdings'][string][];
|
||||||
public markets: {
|
public markets: {
|
||||||
[key in Market]: { name: string; value: number };
|
[key in Market]: { name: string; value: number };
|
||||||
};
|
};
|
||||||
public portfolioPublicDetails: PortfolioPublicDetails;
|
|
||||||
public positions: {
|
public positions: {
|
||||||
[symbol: string]: Pick<PortfolioPosition, 'currency' | 'name'> & {
|
[symbol: string]: Pick<PortfolioPosition, 'currency' | 'name'> & {
|
||||||
value: number;
|
value: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
public publicPortfolioDetails: PublicPortfolioResponse;
|
||||||
public sectors: {
|
public sectors: {
|
||||||
[name: string]: { name: string; value: number };
|
[name: string]: { name: string; value: number };
|
||||||
};
|
};
|
||||||
@ -47,7 +47,7 @@ export class PublicPageComponent implements OnInit {
|
|||||||
};
|
};
|
||||||
public UNKNOWN_KEY = UNKNOWN_KEY;
|
public UNKNOWN_KEY = UNKNOWN_KEY;
|
||||||
|
|
||||||
private id: string;
|
private accessId: string;
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
@ -58,7 +58,7 @@ export class PublicPageComponent implements OnInit {
|
|||||||
private router: Router
|
private router: Router
|
||||||
) {
|
) {
|
||||||
this.activatedRoute.params.subscribe((params) => {
|
this.activatedRoute.params.subscribe((params) => {
|
||||||
this.id = params['id'];
|
this.accessId = params['id'];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -66,7 +66,7 @@ export class PublicPageComponent implements OnInit {
|
|||||||
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||||
|
|
||||||
this.dataService
|
this.dataService
|
||||||
.fetchPortfolioPublic(this.id)
|
.fetchPublicPortfolio(this.accessId)
|
||||||
.pipe(
|
.pipe(
|
||||||
takeUntil(this.unsubscribeSubject),
|
takeUntil(this.unsubscribeSubject),
|
||||||
catchError((error) => {
|
catchError((error) => {
|
||||||
@ -79,7 +79,7 @@ export class PublicPageComponent implements OnInit {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.subscribe((portfolioPublicDetails) => {
|
.subscribe((portfolioPublicDetails) => {
|
||||||
this.portfolioPublicDetails = portfolioPublicDetails;
|
this.publicPortfolioDetails = portfolioPublicDetails;
|
||||||
|
|
||||||
this.initializeAnalysisData();
|
this.initializeAnalysisData();
|
||||||
|
|
||||||
@ -135,7 +135,7 @@ export class PublicPageComponent implements OnInit {
|
|||||||
};
|
};
|
||||||
|
|
||||||
for (const [symbol, position] of Object.entries(
|
for (const [symbol, position] of Object.entries(
|
||||||
this.portfolioPublicDetails.holdings
|
this.publicPortfolioDetails.holdings
|
||||||
)) {
|
)) {
|
||||||
this.holdings.push(position);
|
this.holdings.push(position);
|
||||||
|
|
||||||
@ -164,7 +164,7 @@ export class PublicPageComponent implements OnInit {
|
|||||||
name: continent,
|
name: continent,
|
||||||
value:
|
value:
|
||||||
weight *
|
weight *
|
||||||
this.portfolioPublicDetails.holdings[symbol].valueInBaseCurrency
|
this.publicPortfolioDetails.holdings[symbol].valueInBaseCurrency
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -175,19 +175,19 @@ export class PublicPageComponent implements OnInit {
|
|||||||
name,
|
name,
|
||||||
value:
|
value:
|
||||||
weight *
|
weight *
|
||||||
this.portfolioPublicDetails.holdings[symbol].valueInBaseCurrency
|
this.publicPortfolioDetails.holdings[symbol].valueInBaseCurrency
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.continents[UNKNOWN_KEY].value +=
|
this.continents[UNKNOWN_KEY].value +=
|
||||||
this.portfolioPublicDetails.holdings[symbol].valueInBaseCurrency;
|
this.publicPortfolioDetails.holdings[symbol].valueInBaseCurrency;
|
||||||
|
|
||||||
this.countries[UNKNOWN_KEY].value +=
|
this.countries[UNKNOWN_KEY].value +=
|
||||||
this.portfolioPublicDetails.holdings[symbol].valueInBaseCurrency;
|
this.publicPortfolioDetails.holdings[symbol].valueInBaseCurrency;
|
||||||
|
|
||||||
this.markets[UNKNOWN_KEY].value +=
|
this.markets[UNKNOWN_KEY].value +=
|
||||||
this.portfolioPublicDetails.holdings[symbol].valueInBaseCurrency;
|
this.publicPortfolioDetails.holdings[symbol].valueInBaseCurrency;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (position.sectors.length > 0) {
|
if (position.sectors.length > 0) {
|
||||||
@ -201,13 +201,13 @@ export class PublicPageComponent implements OnInit {
|
|||||||
name,
|
name,
|
||||||
value:
|
value:
|
||||||
weight *
|
weight *
|
||||||
this.portfolioPublicDetails.holdings[symbol].valueInBaseCurrency
|
this.publicPortfolioDetails.holdings[symbol].valueInBaseCurrency
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.sectors[UNKNOWN_KEY].value +=
|
this.sectors[UNKNOWN_KEY].value +=
|
||||||
this.portfolioPublicDetails.holdings[symbol].valueInBaseCurrency;
|
this.publicPortfolioDetails.holdings[symbol].valueInBaseCurrency;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.symbols[prettifySymbol(symbol)] = {
|
this.symbols[prettifySymbol(symbol)] = {
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h1 class="h4 mb-3 text-center" i18n>
|
<h1 class="h4 mb-3 text-center" i18n>
|
||||||
Hello, {{ portfolioPublicDetails?.alias ?? 'someone' }} has shared a
|
Hello, {{ publicPortfolioDetails?.alias ?? 'someone' }} has shared a
|
||||||
<strong>Portfolio</strong> with you!
|
<strong>Portfolio</strong> with you!
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
@ -24,7 +24,7 @@
|
|||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
</div>
|
</div>
|
||||||
@if (portfolioPublicDetails?.hasDetails) {
|
@if (publicPortfolioDetails?.hasDetails) {
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<mat-card appearance="outlined" class="mb-3">
|
<mat-card appearance="outlined" class="mb-3">
|
||||||
<mat-card-header class="overflow-hidden w-100">
|
<mat-card-header class="overflow-hidden w-100">
|
||||||
@ -43,7 +43,7 @@
|
|||||||
</mat-card>
|
</mat-card>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (portfolioPublicDetails?.hasDetails) {
|
@if (publicPortfolioDetails?.hasDetails) {
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<mat-card appearance="outlined" class="mb-3">
|
<mat-card appearance="outlined" class="mb-3">
|
||||||
<mat-card-header class="overflow-hidden w-100">
|
<mat-card-header class="overflow-hidden w-100">
|
||||||
@ -60,7 +60,7 @@
|
|||||||
</mat-card>
|
</mat-card>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (portfolioPublicDetails?.hasDetails) {
|
@if (publicPortfolioDetails?.hasDetails) {
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<mat-card appearance="outlined" class="mb-3">
|
<mat-card appearance="outlined" class="mb-3">
|
||||||
<mat-card-header class="overflow-hidden w-100">
|
<mat-card-header class="overflow-hidden w-100">
|
||||||
@ -79,7 +79,7 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (portfolioPublicDetails?.hasDetails) {
|
@if (publicPortfolioDetails?.hasDetails) {
|
||||||
<div class="row world-map-chart">
|
<div class="row world-map-chart">
|
||||||
<div class="col-lg">
|
<div class="col-lg">
|
||||||
<mat-card appearance="outlined" class="mb-3">
|
<mat-card appearance="outlined" class="mb-3">
|
||||||
|
@ -36,8 +36,8 @@ import {
|
|||||||
PortfolioHoldingsResponse,
|
PortfolioHoldingsResponse,
|
||||||
PortfolioInvestments,
|
PortfolioInvestments,
|
||||||
PortfolioPerformanceResponse,
|
PortfolioPerformanceResponse,
|
||||||
PortfolioPublicDetails,
|
|
||||||
PortfolioReport,
|
PortfolioReport,
|
||||||
|
PublicPortfolioResponse,
|
||||||
User
|
User
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { filterGlobalPermissions } from '@ghostfolio/common/permissions';
|
import { filterGlobalPermissions } from '@ghostfolio/common/permissions';
|
||||||
@ -611,9 +611,13 @@ export class DataService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public fetchPortfolioPublic(aId: string) {
|
public fetchPortfolioReport() {
|
||||||
|
return this.http.get<PortfolioReport>('/api/v1/portfolio/report');
|
||||||
|
}
|
||||||
|
|
||||||
|
public fetchPublicPortfolio(aAccessId: string) {
|
||||||
return this.http
|
return this.http
|
||||||
.get<PortfolioPublicDetails>(`/api/v1/portfolio/public/${aId}`)
|
.get<PublicPortfolioResponse>(`/api/v1/public/${aAccessId}/portfolio`)
|
||||||
.pipe(
|
.pipe(
|
||||||
map((response) => {
|
map((response) => {
|
||||||
if (response.holdings) {
|
if (response.holdings) {
|
||||||
@ -631,10 +635,6 @@ export class DataService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public fetchPortfolioReport() {
|
|
||||||
return this.http.get<PortfolioReport>('/api/v1/portfolio/report');
|
|
||||||
}
|
|
||||||
|
|
||||||
public loginAnonymous(accessToken: string) {
|
public loginAnonymous(accessToken: string) {
|
||||||
return this.http.post<OAuthResponse>(`/api/v1/auth/anonymous`, {
|
return this.http.post<OAuthResponse>(`/api/v1/auth/anonymous`, {
|
||||||
accessToken
|
accessToken
|
||||||
|
@ -31,7 +31,6 @@ import type { PortfolioItem } from './portfolio-item.interface';
|
|||||||
import type { PortfolioOverview } from './portfolio-overview.interface';
|
import type { PortfolioOverview } from './portfolio-overview.interface';
|
||||||
import type { PortfolioPerformance } from './portfolio-performance.interface';
|
import type { PortfolioPerformance } from './portfolio-performance.interface';
|
||||||
import type { PortfolioPosition } from './portfolio-position.interface';
|
import type { PortfolioPosition } from './portfolio-position.interface';
|
||||||
import type { PortfolioPublicDetails } from './portfolio-public-details.interface';
|
|
||||||
import type { PortfolioReportRule } from './portfolio-report-rule.interface';
|
import type { PortfolioReportRule } from './portfolio-report-rule.interface';
|
||||||
import type { PortfolioReport } from './portfolio-report.interface';
|
import type { PortfolioReport } from './portfolio-report.interface';
|
||||||
import type { PortfolioSummary } from './portfolio-summary.interface';
|
import type { PortfolioSummary } from './portfolio-summary.interface';
|
||||||
@ -44,6 +43,7 @@ import type { ImportResponse } from './responses/import-response.interface';
|
|||||||
import type { OAuthResponse } from './responses/oauth-response.interface';
|
import type { OAuthResponse } from './responses/oauth-response.interface';
|
||||||
import type { PortfolioHoldingsResponse } from './responses/portfolio-holdings-response.interface';
|
import type { PortfolioHoldingsResponse } from './responses/portfolio-holdings-response.interface';
|
||||||
import type { PortfolioPerformanceResponse } from './responses/portfolio-performance-response.interface';
|
import type { PortfolioPerformanceResponse } from './responses/portfolio-performance-response.interface';
|
||||||
|
import type { PublicPortfolioResponse } from './responses/public-portfolio-response.interface';
|
||||||
import type { ScraperConfiguration } from './scraper-configuration.interface';
|
import type { ScraperConfiguration } from './scraper-configuration.interface';
|
||||||
import type { Statistics } from './statistics.interface';
|
import type { Statistics } from './statistics.interface';
|
||||||
import type { Subscription } from './subscription.interface';
|
import type { Subscription } from './subscription.interface';
|
||||||
@ -91,12 +91,12 @@ export {
|
|||||||
PortfolioPerformance,
|
PortfolioPerformance,
|
||||||
PortfolioPerformanceResponse,
|
PortfolioPerformanceResponse,
|
||||||
PortfolioPosition,
|
PortfolioPosition,
|
||||||
PortfolioPublicDetails,
|
|
||||||
PortfolioReport,
|
PortfolioReport,
|
||||||
PortfolioReportRule,
|
PortfolioReportRule,
|
||||||
PortfolioSummary,
|
PortfolioSummary,
|
||||||
Position,
|
Position,
|
||||||
Product,
|
Product,
|
||||||
|
PublicPortfolioResponse,
|
||||||
ResponseError,
|
ResponseError,
|
||||||
ScraperConfiguration,
|
ScraperConfiguration,
|
||||||
Statistics,
|
Statistics,
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
import { PortfolioPosition } from '../portfolio-position.interface';
|
||||||
|
|
||||||
export interface PortfolioPublicDetails {
|
export interface PublicPortfolioResponse extends PublicPortfolioResponseV1 {
|
||||||
alias?: string;
|
alias?: string;
|
||||||
hasDetails: boolean;
|
hasDetails: boolean;
|
||||||
holdings: {
|
holdings: {
|
||||||
@ -22,3 +22,17 @@ export interface PortfolioPublicDetails {
|
|||||||
>;
|
>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PublicPortfolioResponseV1 {
|
||||||
|
performance: {
|
||||||
|
'1d': {
|
||||||
|
relativeChange: number;
|
||||||
|
};
|
||||||
|
max: {
|
||||||
|
relativeChange: number;
|
||||||
|
};
|
||||||
|
ytd: {
|
||||||
|
relativeChange: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user