Feature/extend public api with portfolio performance metrics endpoint (#3762)

* Extend Public API with portfolio performance metrics endpoint

* Update changelog
pull/3792/head
Thomas Kaul 3 months ago committed by GitHub
parent 9059d4f971
commit 583c14128b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Extended the _Public API_ with a new endpoint that provides portfolio performance metrics (experimental)
- Added a blog post: _Hacktoberfest 2024_
### Changed

@ -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
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 { BenchmarkModule } from './benchmark/benchmark.module';
import { CacheModule } from './cache/cache.module';
import { PublicModule } from './endpoints/public/public.module';
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
import { ExportModule } from './export/export.module';
import { HealthModule } from './health/health.module';
@ -85,6 +86,7 @@ import { UserModule } from './user/user.module';
PortfolioSnapshotQueueModule,
PrismaModule,
PropertyModule,
PublicModule,
RedisCacheModule,
ScheduleModule.forRoot(),
ServeStaticModule.forRoot({

@ -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;
}
}

@ -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 { 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 { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
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 { ApiService } from '@ghostfolio/api/services/api/api.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 { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import {
DEFAULT_CURRENCY,
HEADER_KEY_IMPERSONATION
} from '@ghostfolio/common/config';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import {
PortfolioDetails,
PortfolioDividends,
PortfolioHoldingsResponse,
PortfolioInvestments,
PortfolioPerformanceResponse,
PortfolioPublicDetails,
PortfolioReport
} from '@ghostfolio/common/interfaces';
import {
@ -70,12 +64,10 @@ export class PortfolioController {
private readonly accessService: AccessService,
private readonly apiService: ApiService,
private readonly configurationService: ConfigurationService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly impersonationService: ImpersonationService,
private readonly orderService: OrderService,
private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Get('details')
@ -497,75 +489,6 @@ export class PortfolioController {
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')
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor)

@ -39,6 +39,15 @@
getPublicUrl(element.id)
}}</a>
</div>
@if (user?.settings?.isExperimentalFeatures) {
<div>
<code
>GET {{ baseUrl }}/api/v1/public/{{
element.id
}}/portfolio</code
>
</div>
}
}
</td>
</ng-container>

@ -1,7 +1,7 @@
import { ConfirmationDialogType } from '@ghostfolio/client/core/notification/confirmation-dialog/confirmation-dialog.type';
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
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 {
@ -24,6 +24,7 @@ import { MatTableDataSource } from '@angular/material/table';
export class AccessTableComponent implements OnChanges, OnInit {
@Input() accesses: Access[];
@Input() showActions: boolean;
@Input() user: User;
@Output() accessDeleted = new EventEmitter<string>();

@ -10,6 +10,7 @@
<gf-access-table
[accesses]="accesses"
[showActions]="hasPermissionToDeleteAccess"
[user]="user"
(accessDeleted)="onDeleteAccess($event)"
/>
@if (hasPermissionToCreateAccess) {

@ -3,7 +3,7 @@ import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { prettifySymbol } from '@ghostfolio/common/helper';
import {
PortfolioPosition,
PortfolioPublicDetails
PublicPortfolioResponse
} from '@ghostfolio/common/interfaces';
import { Market } from '@ghostfolio/common/types';
@ -29,16 +29,16 @@ export class PublicPageComponent implements OnInit {
[code: string]: { name: string; value: number };
};
public deviceType: string;
public holdings: PortfolioPublicDetails['holdings'][string][];
public holdings: PublicPortfolioResponse['holdings'][string][];
public markets: {
[key in Market]: { name: string; value: number };
};
public portfolioPublicDetails: PortfolioPublicDetails;
public positions: {
[symbol: string]: Pick<PortfolioPosition, 'currency' | 'name'> & {
value: number;
};
};
public publicPortfolioDetails: PublicPortfolioResponse;
public sectors: {
[name: string]: { name: string; value: number };
};
@ -47,7 +47,7 @@ export class PublicPageComponent implements OnInit {
};
public UNKNOWN_KEY = UNKNOWN_KEY;
private id: string;
private accessId: string;
private unsubscribeSubject = new Subject<void>();
public constructor(
@ -58,7 +58,7 @@ export class PublicPageComponent implements OnInit {
private router: Router
) {
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.dataService
.fetchPortfolioPublic(this.id)
.fetchPublicPortfolio(this.accessId)
.pipe(
takeUntil(this.unsubscribeSubject),
catchError((error) => {
@ -79,7 +79,7 @@ export class PublicPageComponent implements OnInit {
})
)
.subscribe((portfolioPublicDetails) => {
this.portfolioPublicDetails = portfolioPublicDetails;
this.publicPortfolioDetails = portfolioPublicDetails;
this.initializeAnalysisData();
@ -135,7 +135,7 @@ export class PublicPageComponent implements OnInit {
};
for (const [symbol, position] of Object.entries(
this.portfolioPublicDetails.holdings
this.publicPortfolioDetails.holdings
)) {
this.holdings.push(position);
@ -164,7 +164,7 @@ export class PublicPageComponent implements OnInit {
name: continent,
value:
weight *
this.portfolioPublicDetails.holdings[symbol].valueInBaseCurrency
this.publicPortfolioDetails.holdings[symbol].valueInBaseCurrency
};
}
@ -175,19 +175,19 @@ export class PublicPageComponent implements OnInit {
name,
value:
weight *
this.portfolioPublicDetails.holdings[symbol].valueInBaseCurrency
this.publicPortfolioDetails.holdings[symbol].valueInBaseCurrency
};
}
}
} else {
this.continents[UNKNOWN_KEY].value +=
this.portfolioPublicDetails.holdings[symbol].valueInBaseCurrency;
this.publicPortfolioDetails.holdings[symbol].valueInBaseCurrency;
this.countries[UNKNOWN_KEY].value +=
this.portfolioPublicDetails.holdings[symbol].valueInBaseCurrency;
this.publicPortfolioDetails.holdings[symbol].valueInBaseCurrency;
this.markets[UNKNOWN_KEY].value +=
this.portfolioPublicDetails.holdings[symbol].valueInBaseCurrency;
this.publicPortfolioDetails.holdings[symbol].valueInBaseCurrency;
}
if (position.sectors.length > 0) {
@ -201,13 +201,13 @@ export class PublicPageComponent implements OnInit {
name,
value:
weight *
this.portfolioPublicDetails.holdings[symbol].valueInBaseCurrency
this.publicPortfolioDetails.holdings[symbol].valueInBaseCurrency
};
}
}
} else {
this.sectors[UNKNOWN_KEY].value +=
this.portfolioPublicDetails.holdings[symbol].valueInBaseCurrency;
this.publicPortfolioDetails.holdings[symbol].valueInBaseCurrency;
}
this.symbols[prettifySymbol(symbol)] = {

@ -2,7 +2,7 @@
<div class="row">
<div class="col">
<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!
</h1>
</div>
@ -24,7 +24,7 @@
</mat-card-content>
</mat-card>
</div>
@if (portfolioPublicDetails?.hasDetails) {
@if (publicPortfolioDetails?.hasDetails) {
<div class="col-md-4">
<mat-card appearance="outlined" class="mb-3">
<mat-card-header class="overflow-hidden w-100">
@ -43,7 +43,7 @@
</mat-card>
</div>
}
@if (portfolioPublicDetails?.hasDetails) {
@if (publicPortfolioDetails?.hasDetails) {
<div class="col-md-4">
<mat-card appearance="outlined" class="mb-3">
<mat-card-header class="overflow-hidden w-100">
@ -60,7 +60,7 @@
</mat-card>
</div>
}
@if (portfolioPublicDetails?.hasDetails) {
@if (publicPortfolioDetails?.hasDetails) {
<div class="col-md-4">
<mat-card appearance="outlined" class="mb-3">
<mat-card-header class="overflow-hidden w-100">
@ -79,7 +79,7 @@
</div>
}
</div>
@if (portfolioPublicDetails?.hasDetails) {
@if (publicPortfolioDetails?.hasDetails) {
<div class="row world-map-chart">
<div class="col-lg">
<mat-card appearance="outlined" class="mb-3">

@ -36,8 +36,8 @@ import {
PortfolioHoldingsResponse,
PortfolioInvestments,
PortfolioPerformanceResponse,
PortfolioPublicDetails,
PortfolioReport,
PublicPortfolioResponse,
User
} from '@ghostfolio/common/interfaces';
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
.get<PortfolioPublicDetails>(`/api/v1/portfolio/public/${aId}`)
.get<PublicPortfolioResponse>(`/api/v1/public/${aAccessId}/portfolio`)
.pipe(
map((response) => {
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) {
return this.http.post<OAuthResponse>(`/api/v1/auth/anonymous`, {
accessToken

@ -31,7 +31,6 @@ import type { PortfolioItem } from './portfolio-item.interface';
import type { PortfolioOverview } from './portfolio-overview.interface';
import type { PortfolioPerformance } from './portfolio-performance.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 { PortfolioReport } from './portfolio-report.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 { PortfolioHoldingsResponse } from './responses/portfolio-holdings-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 { Statistics } from './statistics.interface';
import type { Subscription } from './subscription.interface';
@ -91,12 +91,12 @@ export {
PortfolioPerformance,
PortfolioPerformanceResponse,
PortfolioPosition,
PortfolioPublicDetails,
PortfolioReport,
PortfolioReportRule,
PortfolioSummary,
Position,
Product,
PublicPortfolioResponse,
ResponseError,
ScraperConfiguration,
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;
hasDetails: boolean;
holdings: {
@ -22,3 +22,17 @@ export interface PortfolioPublicDetails {
>;
};
}
interface PublicPortfolioResponseV1 {
performance: {
'1d': {
relativeChange: number;
};
max: {
relativeChange: number;
};
ytd: {
relativeChange: number;
};
};
}
Loading…
Cancel
Save