Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 21m26s

This commit is contained in:
sudacode 2025-05-04 01:46:12 -07:00
commit e4073608e5
23 changed files with 317 additions and 135 deletions

View File

@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Changed
- Deprecated the endpoint to get a portfolio position in favor of get a holding
- Deprecated the endpoint to update portfolio position tags in favor of update holding tags
## 2.159.0 - 2025-05-02
### Added
- Extended the watchlist by the date of the last all time high, the current change to the all time high and the current market condition (experimental)
- Added support for the impersonation mode in the watchlist (experimental)
### Changed
- Improved the language localization for Français (`fr`)
- Upgraded `bootstrap` from version `4.6.0` to `4.6.2`
### Fixed
- Fixed the currency code validation by allowing `GBp`
## 2.158.0 - 2025-04-30
### Added

View File

@ -9,7 +9,7 @@ import { ImpersonationService } from '@ghostfolio/api/services/impersonation/imp
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import {
AccountBalancesResponse,
Accounts
AccountsResponse
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import type {
@ -90,7 +90,7 @@ export class AccountController {
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string
): Promise<Accounts> {
): Promise<AccountsResponse> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);

View File

@ -2,6 +2,8 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { WatchlistResponse } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
@ -11,6 +13,7 @@ import {
Controller,
Delete,
Get,
Headers,
HttpException,
Inject,
Param,
@ -29,6 +32,7 @@ import { WatchlistService } from './watchlist.service';
@Controller('watchlist')
export class WatchlistController {
public constructor(
private readonly impersonationService: ImpersonationService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly watchlistService: WatchlistService
) {}
@ -79,9 +83,14 @@ export class WatchlistController {
@HasPermission(permissions.readWatchlist)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getWatchlistItems(): Promise<WatchlistResponse> {
public async getWatchlistItems(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string
): Promise<WatchlistResponse> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
const watchlist = await this.watchlistService.getWatchlistItems(
this.request.user.id
impersonationUserId || this.request.user.id
);
return {

View File

@ -1,6 +1,9 @@
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.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 { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
@ -13,8 +16,11 @@ import { WatchlistService } from './watchlist.service';
@Module({
controllers: [WatchlistController],
imports: [
BenchmarkModule,
DataGatheringModule,
DataProviderModule,
ImpersonationModule,
MarketDataModule,
PrismaModule,
SymbolProfileModule,
TransformDataSourceInRequestModule,

View File

@ -1,8 +1,10 @@
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { WatchlistResponse } from '@ghostfolio/common/interfaces';
import { BadRequestException, Injectable } from '@nestjs/common';
import { DataSource, Prisma } from '@prisma/client';
@ -10,8 +12,10 @@ import { DataSource, Prisma } from '@prisma/client';
@Injectable()
export class WatchlistService {
public constructor(
private readonly benchmarkService: BenchmarkService,
private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService
) {}
@ -87,7 +91,7 @@ export class WatchlistService {
public async getWatchlistItems(
userId: string
): Promise<AssetProfileIdentifier[]> {
): Promise<WatchlistResponse['watchlist']> {
const user = await this.prismaService.user.findUnique({
select: {
watchlist: {
@ -97,6 +101,50 @@ export class WatchlistService {
where: { id: userId }
});
return user.watchlist ?? [];
const [assetProfiles, quotes] = await Promise.all([
this.symbolProfileService.getSymbolProfiles(user.watchlist),
this.dataProviderService.getQuotes({
items: user.watchlist.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
})
})
]);
const watchlist = await Promise.all(
user.watchlist.map(async ({ dataSource, symbol }) => {
const assetProfile = assetProfiles.find((profile) => {
return profile.dataSource === dataSource && profile.symbol === symbol;
});
const allTimeHigh = await this.marketDataService.getMax({
dataSource,
symbol
});
const performancePercent =
this.benchmarkService.calculateChangeInPercentage(
allTimeHigh?.marketPrice,
quotes[symbol]?.marketPrice
);
return {
dataSource,
symbol,
marketCondition:
this.benchmarkService.getMarketCondition(performancePercent),
name: assetProfile?.name,
performances: {
allTimeHigh: {
performancePercent,
date: allTimeHigh?.date
}
}
};
})
);
return watchlist.sort((a, b) => {
return a.name.localeCompare(b.name);
});
}
}

View File

@ -49,8 +49,8 @@ export class ImportService {
symbol
}: AssetProfileIdentifier): Promise<Activity[]> {
try {
const { firstBuyDate, historicalData, orders } =
await this.portfolioService.getPosition(dataSource, undefined, symbol);
const { activities, firstBuyDate, historicalData } =
await this.portfolioService.getHolding(dataSource, undefined, symbol);
const [[assetProfile], dividends] = await Promise.all([
this.symbolProfileService.getSymbolProfiles([
@ -68,7 +68,7 @@ export class ImportService {
})
]);
const accounts = orders
const accounts = activities
.filter(({ Account }) => {
return !!Account;
})
@ -88,7 +88,7 @@ export class ImportService {
const value = new Big(quantity).mul(marketPrice).toNumber();
const date = parseDate(dateString);
const isDuplicate = orders.some((activity) => {
const isDuplicate = activities.some((activity) => {
return (
activity.accountId === Account?.id &&
activity.SymbolProfile.currency === assetProfile.currency &&

View File

@ -20,6 +20,7 @@ import {
import {
PortfolioDetails,
PortfolioDividends,
PortfolioHoldingResponse,
PortfolioHoldingsResponse,
PortfolioInvestments,
PortfolioPerformanceResponse,
@ -56,7 +57,6 @@ import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
import { Big } from 'big.js';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface';
import { PortfolioService } from './portfolio.service';
import { UpdateHoldingTagsDto } from './update-holding-tags.dto';
@ -365,6 +365,32 @@ export class PortfolioController {
return { dividends };
}
@Get('holding/:dataSource/:symbol')
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getHolding(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<PortfolioHoldingResponse> {
const holding = await this.portfolioService.getHolding(
dataSource,
impersonationId,
symbol
);
if (!holding) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
return holding;
}
@Get('holdings')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor)
@ -583,6 +609,9 @@ export class PortfolioController {
return performanceInformation;
}
/**
* @deprecated
*/
@Get('position/:dataSource/:symbol')
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@ -592,8 +621,8 @@ export class PortfolioController {
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<PortfolioHoldingDetail> {
const holding = await this.portfolioService.getPosition(
): Promise<PortfolioHoldingResponse> {
const holding = await this.portfolioService.getHolding(
dataSource,
impersonationId,
symbol
@ -634,7 +663,7 @@ export class PortfolioController {
}
@HasPermission(permissions.updateOrder)
@Put('position/:dataSource/:symbol/tags')
@Put('holding/:dataSource/:symbol/tags')
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateHoldingTags(
@ -643,7 +672,42 @@ export class PortfolioController {
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<void> {
const holding = await this.portfolioService.getPosition(
const holding = await this.portfolioService.getHolding(
dataSource,
impersonationId,
symbol
);
if (!holding) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
await this.portfolioService.updateTags({
dataSource,
impersonationId,
symbol,
tags: data.tags,
userId: this.request.user.id
});
}
/**
* @deprecated
*/
@HasPermission(permissions.updateOrder)
@Put('position/:dataSource/:symbol/tags')
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updatePositionTags(
@Body() data: UpdateHoldingTagsDto,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<void> {
const holding = await this.portfolioService.getHolding(
dataSource,
impersonationId,
symbol

View File

@ -35,12 +35,13 @@ import {
} from '@ghostfolio/common/config';
import { DATE_FORMAT, getSum, parseDate } from '@ghostfolio/common/helper';
import {
Accounts,
AccountsResponse,
EnhancedSymbolProfile,
Filter,
HistoricalDataItem,
InvestmentItem,
PortfolioDetails,
PortfolioHoldingResponse,
PortfolioInvestments,
PortfolioPerformanceResponse,
PortfolioPosition,
@ -87,7 +88,6 @@ import { isEmpty } from 'lodash';
import { PortfolioCalculator } from './calculator/portfolio-calculator';
import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory';
import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface';
import { RulesService } from './rules.service';
const asiaPacificMarkets = require('../../assets/countries/asia-pacific-markets.json');
@ -209,7 +209,7 @@ export class PortfolioService {
filters?: Filter[];
userId: string;
withExcludedAccounts?: boolean;
}): Promise<Accounts> {
}): Promise<AccountsResponse> {
const accounts = await this.getAccounts({
filters,
userId,
@ -631,11 +631,11 @@ export class PortfolioService {
};
}
public async getPosition(
public async getHolding(
aDataSource: DataSource,
aImpersonationId: string,
aSymbol: string
): Promise<PortfolioHoldingDetail> {
): Promise<PortfolioHoldingResponse> {
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user);
@ -648,6 +648,7 @@ export class PortfolioService {
if (activities.length === 0) {
return {
activities: [],
averagePrice: undefined,
dataProviderInfo: undefined,
dividendInBaseCurrency: undefined,
@ -662,13 +663,12 @@ export class PortfolioService {
historicalData: [],
investment: undefined,
marketPrice: undefined,
maxPrice: undefined,
minPrice: undefined,
marketPriceMax: undefined,
marketPriceMin: undefined,
netPerformance: undefined,
netPerformancePercent: undefined,
netPerformancePercentWithCurrencyEffect: undefined,
netPerformanceWithCurrencyEffect: undefined,
orders: [],
quantity: undefined,
SymbolProfile: undefined,
tags: [],
@ -714,7 +714,7 @@ export class PortfolioService {
transactionCount
} = position;
const activitiesOfPosition = activities.filter(({ SymbolProfile }) => {
const activitiesOfHolding = activities.filter(({ SymbolProfile }) => {
return (
SymbolProfile.dataSource === dataSource &&
SymbolProfile.symbol === symbol
@ -748,12 +748,12 @@ export class PortfolioService {
);
const historicalDataArray: HistoricalDataItem[] = [];
let maxPrice = Math.max(
activitiesOfPosition[0].unitPriceInAssetProfileCurrency,
let marketPriceMax = Math.max(
activitiesOfHolding[0].unitPriceInAssetProfileCurrency,
marketPrice
);
let minPrice = Math.min(
activitiesOfPosition[0].unitPriceInAssetProfileCurrency,
let marketPriceMin = Math.min(
activitiesOfHolding[0].unitPriceInAssetProfileCurrency,
marketPrice
);
@ -793,27 +793,31 @@ export class PortfolioService {
quantity: currentQuantity
});
maxPrice = Math.max(marketPrice ?? 0, maxPrice);
minPrice = Math.min(marketPrice ?? Number.MAX_SAFE_INTEGER, minPrice);
marketPriceMax = Math.max(marketPrice ?? 0, marketPriceMax);
marketPriceMin = Math.min(
marketPrice ?? Number.MAX_SAFE_INTEGER,
marketPriceMin
);
}
} else {
// Add historical entry for buy date, if no historical data available
historicalDataArray.push({
averagePrice: activitiesOfPosition[0].unitPriceInAssetProfileCurrency,
averagePrice: activitiesOfHolding[0].unitPriceInAssetProfileCurrency,
date: firstBuyDate,
marketPrice: activitiesOfPosition[0].unitPriceInAssetProfileCurrency,
quantity: activitiesOfPosition[0].quantity
marketPrice: activitiesOfHolding[0].unitPriceInAssetProfileCurrency,
quantity: activitiesOfHolding[0].quantity
});
}
return {
firstBuyDate,
marketPrice,
maxPrice,
minPrice,
marketPriceMax,
marketPriceMin,
SymbolProfile,
tags,
transactionCount,
activities: activitiesOfHolding,
averagePrice: averagePrice.toNumber(),
dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0],
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
@ -842,7 +846,6 @@ export class PortfolioService {
]?.toNumber(),
netPerformanceWithCurrencyEffect:
position.netPerformanceWithCurrencyEffectMap?.['max']?.toNumber(),
orders: activitiesOfPosition,
quantity: quantity.toNumber(),
value: this.exchangeRateDataService.toCurrency(
quantity.mul(marketPrice ?? 0).toNumber(),
@ -881,8 +884,8 @@ export class PortfolioService {
}
const historicalDataArray: HistoricalDataItem[] = [];
let maxPrice = marketPrice;
let minPrice = marketPrice;
let marketPriceMax = marketPrice;
let marketPriceMin = marketPrice;
for (const [date, { marketPrice }] of Object.entries(
historicalData[aSymbol]
@ -892,15 +895,19 @@ export class PortfolioService {
value: marketPrice
});
maxPrice = Math.max(marketPrice ?? 0, maxPrice);
minPrice = Math.min(marketPrice ?? Number.MAX_SAFE_INTEGER, minPrice);
marketPriceMax = Math.max(marketPrice ?? 0, marketPriceMax);
marketPriceMin = Math.min(
marketPrice ?? Number.MAX_SAFE_INTEGER,
marketPriceMin
);
}
return {
marketPrice,
maxPrice,
minPrice,
marketPriceMax,
marketPriceMin,
SymbolProfile,
activities: [],
averagePrice: 0,
dataProviderInfo: undefined,
dividendInBaseCurrency: 0,
@ -918,7 +925,6 @@ export class PortfolioService {
netPerformancePercent: undefined,
netPerformancePercentWithCurrencyEffect: undefined,
netPerformanceWithCurrencyEffect: undefined,
orders: [],
quantity: 0,
tags: [],
transactionCount: undefined,
@ -927,7 +933,7 @@ export class PortfolioService {
}
}
public async getPositions({
public async getHoldings({
dateRange = 'max',
filters,
impersonationId

View File

@ -212,6 +212,18 @@ export class BenchmarkService {
};
}
public getMarketCondition(
aPerformanceInPercent: number
): Benchmark['marketCondition'] {
if (aPerformanceInPercent >= 0) {
return 'ALL_TIME_HIGH';
} else if (aPerformanceInPercent <= -0.2) {
return 'BEAR_MARKET';
} else {
return 'NEUTRAL_MARKET';
}
}
private async calculateAndCacheBenchmarks({
enableSharing = false
}): Promise<BenchmarkResponse['benchmarks']> {
@ -302,16 +314,4 @@ export class BenchmarkService {
return benchmarks;
}
private getMarketCondition(
aPerformanceInPercent: number
): Benchmark['marketCondition'] {
if (aPerformanceInPercent >= 0) {
return 'ALL_TIME_HIGH';
} else if (aPerformanceInPercent <= -0.2) {
return 'BEAR_MARKET';
} else {
return 'NEUTRAL_MARKET';
}
}
}

View File

@ -1,4 +1,4 @@
import { DERIVED_CURRENCIES } from '@ghostfolio/common/config';
import { isDerivedCurrency } from '@ghostfolio/common/helper';
import {
registerDecorator,
@ -28,17 +28,11 @@ export class IsExtendedCurrencyConstraint
return '$property must be a valid ISO4217 currency code';
}
public validate(currency: any) {
// Return true if currency is a standard ISO 4217 code or a derived currency
public validate(currency: string) {
// Return true if currency is a derived currency or a standard ISO 4217 code
return (
this.isUpperCase(currency) &&
(isISO4217CurrencyCode(currency) ||
[
...DERIVED_CURRENCIES.map((derivedCurrency) => {
return derivedCurrency.currency;
}),
'USX'
].includes(currency))
isDerivedCurrency(currency) ||
(this.isUpperCase(currency) && isISO4217CurrencyCode(currency))
);
}

View File

@ -105,8 +105,8 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
public investmentPrecision = 2;
public marketDataItems: MarketData[] = [];
public marketPrice: number;
public maxPrice: number;
public minPrice: number;
public marketPriceMax: number;
public marketPriceMin: number;
public netPerformance: number;
public netPerformancePrecision = 2;
public netPerformancePercent: number;
@ -234,8 +234,8 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
historicalData,
investment,
marketPrice,
maxPrice,
minPrice,
marketPriceMax,
marketPriceMin,
netPerformance,
netPerformancePercent,
netPerformancePercentWithCurrencyEffect,
@ -297,8 +297,8 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
}
this.marketPrice = marketPrice;
this.maxPrice = maxPrice;
this.minPrice = minPrice;
this.marketPriceMax = marketPriceMax;
this.marketPriceMin = marketPriceMin;
this.netPerformance = netPerformance;
if (

View File

@ -106,11 +106,11 @@
[locale]="data.locale"
[ngClass]="{
'text-danger':
minPrice?.toFixed(2) === marketPrice?.toFixed(2) &&
maxPrice?.toFixed(2) !== minPrice?.toFixed(2)
marketPriceMin?.toFixed(2) === marketPrice?.toFixed(2) &&
marketPriceMax?.toFixed(2) !== marketPriceMin?.toFixed(2)
}"
[unit]="SymbolProfile?.currency"
[value]="minPrice"
[value]="marketPriceMin"
>Minimum Price</gf-value
>
</div>
@ -122,11 +122,11 @@
[locale]="data.locale"
[ngClass]="{
'text-success':
maxPrice?.toFixed(2) === marketPrice?.toFixed(2) &&
maxPrice?.toFixed(2) !== minPrice?.toFixed(2)
marketPriceMax?.toFixed(2) === marketPrice?.toFixed(2) &&
marketPriceMax?.toFixed(2) !== marketPriceMin?.toFixed(2)
}"
[unit]="SymbolProfile?.currency"
[value]="maxPrice"
[value]="marketPriceMax"
>Maximum Price</gf-value
>
</div>

View File

@ -1,4 +1,5 @@
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
AssetProfileIdentifier,
@ -6,6 +7,7 @@ import {
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { BenchmarkTrend } from '@ghostfolio/common/types';
import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
@ -44,6 +46,7 @@ import { CreateWatchlistItemDialogParams } from './create-watchlist-item-dialog/
})
export class HomeWatchlistComponent implements OnDestroy, OnInit {
public deviceType: string;
public hasImpersonationId: boolean;
public hasPermissionToCreateWatchlistItem: boolean;
public hasPermissionToDeleteWatchlistItem: boolean;
public user: User;
@ -56,12 +59,20 @@ export class HomeWatchlistComponent implements OnDestroy, OnInit {
private dataService: DataService,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private impersonationStorageService: ImpersonationStorageService,
private route: ActivatedRoute,
private router: Router,
private userService: UserService
) {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
});
this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
@ -76,11 +87,15 @@ export class HomeWatchlistComponent implements OnDestroy, OnInit {
if (state?.user) {
this.user = state.user;
this.hasPermissionToCreateWatchlistItem = hasPermission(
this.hasPermissionToCreateWatchlistItem =
!this.hasImpersonationId &&
hasPermission(
this.user.permissions,
permissions.createWatchlistItem
);
this.hasPermissionToDeleteWatchlistItem = hasPermission(
this.hasPermissionToDeleteWatchlistItem =
!this.hasImpersonationId &&
hasPermission(
this.user.permissions,
permissions.deleteWatchlistItem
);
@ -118,15 +133,17 @@ export class HomeWatchlistComponent implements OnDestroy, OnInit {
.fetchWatchlist()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ watchlist }) => {
this.watchlist = watchlist.map(({ dataSource, symbol }) => ({
this.watchlist = watchlist.map(
({ dataSource, marketCondition, name, performances, symbol }) => ({
dataSource,
marketCondition,
name,
performances,
symbol,
marketCondition: null,
name: symbol,
performances: null,
trend50d: 'UNKNOWN',
trend200d: 'UNKNOWN'
}));
trend50d: 'UNKNOWN' as BenchmarkTrend,
trend200d: 'UNKNOWN' as BenchmarkTrend
})
);
this.changeDetectorRef.markForCheck();
});

View File

@ -7,7 +7,7 @@
}
</span>
</h1>
<div class="mb-3 row">
<div class="row">
<div class="col-xs-12 col-md-8 offset-md-2">
<gf-benchmark
[benchmarks]="watchlist"
@ -20,7 +20,7 @@
</div>
</div>
</div>
@if (hasPermissionToCreateWatchlistItem) {
@if (!hasImpersonationId && hasPermissionToCreateWatchlistItem) {
<div class="fab-container">
<a
class="align-items-center d-flex justify-content-center"

View File

@ -13,7 +13,6 @@ import {
Activity
} from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { PortfolioHoldingDetail } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-holding-detail.interface';
import { SymbolItem } from '@ghostfolio/api/app/symbol/interfaces/symbol-item.interface';
import { DeleteOwnUserDto } from '@ghostfolio/api/app/user/delete-own-user.dto';
import { UserItem } from '@ghostfolio/api/app/user/interfaces/user-item.interface';
@ -25,7 +24,7 @@ import {
Access,
AccessTokenResponse,
AccountBalancesResponse,
Accounts,
AccountsResponse,
AiPromptResponse,
ApiKeyResponse,
AssetProfileIdentifier,
@ -40,6 +39,7 @@ import {
OAuthResponse,
PortfolioDetails,
PortfolioDividends,
PortfolioHoldingResponse,
PortfolioHoldingsResponse,
PortfolioInvestments,
PortfolioPerformanceResponse,
@ -191,7 +191,7 @@ export class DataService {
public fetchAccounts({ filters }: { filters?: Filter[] } = {}) {
const params = this.buildFiltersAsQueryParams({ filters });
return this.http.get<Accounts>('/api/v1/account', { params });
return this.http.get<AccountsResponse>('/api/v1/account', { params });
}
public fetchActivities({
@ -406,13 +406,13 @@ export class DataService {
symbol: string;
}) {
return this.http
.get<PortfolioHoldingDetail>(
`/api/v1/portfolio/position/${dataSource}/${symbol}`
.get<PortfolioHoldingResponse>(
`/api/v1/portfolio/holding/${dataSource}/${symbol}`
)
.pipe(
map((data) => {
if (data.orders) {
for (const order of data.orders) {
if (data.activities) {
for (const order of data.activities) {
order.createdAt = parseISO(order.createdAt as unknown as string);
order.date = parseISO(order.date as unknown as string);
}
@ -776,7 +776,7 @@ export class DataService {
tags
}: { tags: Tag[] } & AssetProfileIdentifier) {
return this.http.put<void>(
`/api/v1/portfolio/position/${dataSource}/${symbol}/tags`,
`/api/v1/portfolio/holding/${dataSource}/${symbol}/tags`,
{ tags }
);
}

View File

@ -2831,7 +2831,7 @@
</trans-unit>
<trans-unit id="a4a68fbb5bf56e4bccaf5e73ba2d9567f754e7ca" datatype="html">
<source> Hello, <x id="INTERPOLATION" equiv-text="{{ publicPortfolioDetails?.alias ?? defaultAlias }}"/> has shared a <x id="START_TAG_STRONG" ctype="x-strong" equiv-text="&lt;strong&gt;"/>Portfolio<x id="CLOSE_TAG_STRONG" ctype="x-strong" equiv-text="&lt;/strong&gt;"/> with you! </source>
<target state="new"> Bonjour, <x id="INTERPOLATION" equiv-text="{{ portfolioPublicDetails?.alias ?? defaultAlias }}"/> a partagé un <x id="START_TAG_STRONG" ctype="x-strong" equiv-text="&lt;strong&gt;"/>Portefeuille<x id="CLOSE_TAG_STRONG" ctype="x-strong" equiv-text="&lt;/strong&gt;"/> avec vous ! </target>
<target state="translated"> Bonjour, <x id="INTERPOLATION" equiv-text="{{ portfolioPublicDetails?.alias ?? defaultAlias }}"/> a partagé un <x id="START_TAG_STRONG" ctype="x-strong" equiv-text="&lt;strong&gt;"/>Portefeuille<x id="CLOSE_TAG_STRONG" ctype="x-strong" equiv-text="&lt;/strong&gt;"/> avec vous ! </target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/public/public-page.html</context>
<context context-type="linenumber">4</context>
@ -7931,7 +7931,7 @@
</trans-unit>
<trans-unit id="1efa64b89c9852e7099159ab06af9dcf49870438" datatype="html">
<source>Add asset to watchlist</source>
<target state="new">Add asset to watchlist</target>
<target state="translated">Ajouter un actif à la liste de suivi</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/create-watchlist-item-dialog.html</context>
<context context-type="linenumber">7</context>
@ -7939,7 +7939,7 @@
</trans-unit>
<trans-unit id="7a6d28bd1c36c8298c95b7965abf226b218be50d" datatype="html">
<source>Watchlist</source>
<target state="new">Watchlist</target>
<target state="translated">Liste de suivi</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/home-watchlist/home-watchlist.html</context>
<context context-type="linenumber">4</context>
@ -7947,7 +7947,7 @@
</trans-unit>
<trans-unit id="4558213855845176930" datatype="html">
<source>Watchlist</source>
<target state="new">Watchlist</target>
<target state="translated">Liste de suivi</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/home/home-page-routing.module.ts</context>
<context context-type="linenumber">44</context>
@ -7959,7 +7959,7 @@
</trans-unit>
<trans-unit id="e9d59bb8bf6c08243d5411c55ddbdf925c7c799c" datatype="html">
<source>Get Early Access</source>
<target state="new">Get Early Access</target>
<target state="translated">Obtenir un accès anticipé</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.html</context>
<context context-type="linenumber">29</context>
@ -7967,7 +7967,7 @@
</trans-unit>
<trans-unit id="627795342008207050" datatype="html">
<source>Do you really want to delete this item?</source>
<target state="new">Do you really want to delete this item?</target>
<target state="translated">Voulez-vous vraiment supprimer cet élément?</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/benchmark/benchmark.component.ts</context>
<context context-type="linenumber">122</context>

View File

@ -349,7 +349,7 @@ export function isDerivedCurrency(aCurrency: string) {
return true;
}
return DERIVED_CURRENCIES.find(({ currency }) => {
return DERIVED_CURRENCIES.some(({ currency }) => {
return currency === aCurrency;
});
}

View File

@ -1,6 +1,5 @@
import type { Access } from './access.interface';
import type { AccountBalance } from './account-balance.interface';
import type { Accounts } from './accounts.interface';
import type { AdminData } from './admin-data.interface';
import type { AdminJobs } from './admin-jobs.interface';
import type { AdminMarketDataDetails } from './admin-market-data-details.interface';
@ -40,6 +39,7 @@ import type { Position } from './position.interface';
import type { Product } from './product';
import type { AccessTokenResponse } from './responses/access-token-response.interface';
import type { AccountBalancesResponse } from './responses/account-balances-response.interface';
import type { AccountsResponse } from './responses/accounts-response.interface';
import type { AiPromptResponse } from './responses/ai-prompt-response.interface';
import type { ApiKeyResponse } from './responses/api-key-response.interface';
import type { BenchmarkResponse } from './responses/benchmark-response.interface';
@ -52,6 +52,7 @@ import type { ImportResponse } from './responses/import-response.interface';
import type { LookupResponse } from './responses/lookup-response.interface';
import type { MarketDataDetailsResponse } from './responses/market-data-details-response.interface';
import type { OAuthResponse } from './responses/oauth-response.interface';
import { PortfolioHoldingResponse } from './responses/portfolio-holding-response.interface';
import type { PortfolioHoldingsResponse } from './responses/portfolio-holdings-response.interface';
import type { PortfolioPerformanceResponse } from './responses/portfolio-performance-response.interface';
import type { PortfolioReportResponse } from './responses/portfolio-report.interface';
@ -74,7 +75,7 @@ export {
AccessTokenResponse,
AccountBalance,
AccountBalancesResponse,
Accounts,
AccountsResponse,
AdminData,
AdminJobs,
AdminMarketData,
@ -112,6 +113,7 @@ export {
PortfolioChart,
PortfolioDetails,
PortfolioDividends,
PortfolioHoldingResponse,
PortfolioHoldingsResponse,
PortfolioInvestments,
PortfolioItem,

View File

@ -1,6 +1,6 @@
import { AccountWithValue } from '@ghostfolio/common/types';
export interface Accounts {
export interface AccountsResponse {
accounts: AccountWithValue[];
totalBalanceInBaseCurrency: number;
totalValueInBaseCurrency: number;

View File

@ -7,7 +7,8 @@ import {
import { Tag } from '@prisma/client';
export interface PortfolioHoldingDetail {
export interface PortfolioHoldingResponse {
activities: Activity[];
averagePrice: number;
dataProviderInfo: DataProviderInfo;
dividendInBaseCurrency: number;
@ -22,13 +23,12 @@ export interface PortfolioHoldingDetail {
historicalData: HistoricalDataItem[];
investment: number;
marketPrice: number;
maxPrice: number;
minPrice: number;
marketPriceMax: number;
marketPriceMin: number;
netPerformance: number;
netPerformancePercent: number;
netPerformancePercentWithCurrencyEffect: number;
netPerformanceWithCurrencyEffect: number;
orders: Activity[];
quantity: number;
SymbolProfile: EnhancedSymbolProfile;
tags: Tag[];

View File

@ -1,5 +1,12 @@
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import {
AssetProfileIdentifier,
Benchmark
} from '@ghostfolio/common/interfaces';
export interface WatchlistResponse {
watchlist: AssetProfileIdentifier[];
watchlist: (AssetProfileIdentifier & {
marketCondition: Benchmark['marketCondition'];
name: string;
performances: Benchmark['performances'];
})[];
}

24
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "ghostfolio",
"version": "2.158.0",
"version": "2.159.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ghostfolio",
"version": "2.158.0",
"version": "2.159.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
@ -47,7 +47,7 @@
"@stripe/stripe-js": "5.4.0",
"alphavantage": "2.2.0",
"big.js": "6.2.2",
"bootstrap": "4.6.0",
"bootstrap": "4.6.2",
"bull": "4.16.5",
"cache-manager": "5.7.6",
"cache-manager-redis-yet": "5.1.4",
@ -14322,14 +14322,20 @@
"license": "ISC"
},
"node_modules/bootstrap": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.6.0.tgz",
"integrity": "sha512-Io55IuQY3kydzHtbGvQya3H+KorS/M9rSNyfCGCg9WZ4pyT/lCxIlpJgG1GXW/PswzC84Tr2fBYi+7+jFVQQBw==",
"license": "MIT",
"funding": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.6.2.tgz",
"integrity": "sha512-51Bbp/Uxr9aTuy6ca/8FbFloBUJZLHwnhTcnjIeRn2suQWsWzcuJhGjKDB5eppVte/8oCdOL3VuwxvZDUggwGQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/twbs"
},
{
"type": "opencollective",
"url": "https://opencollective.com/bootstrap"
},
}
],
"license": "MIT",
"peerDependencies": {
"jquery": "1.9.1 - 3",
"popper.js": "^1.16.1"

View File

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "2.158.0",
"version": "2.159.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio",
@ -93,7 +93,7 @@
"@stripe/stripe-js": "5.4.0",
"alphavantage": "2.2.0",
"big.js": "6.2.2",
"bootstrap": "4.6.0",
"bootstrap": "4.6.2",
"bull": "4.16.5",
"cache-manager": "5.7.6",
"cache-manager-redis-yet": "5.1.4",