Browse Source

Merge b2efcced89 into aaa3f93f22

pull/5650/merge
Kenrick Tandrian 6 days ago
committed by GitHub
parent
commit
49a603a159
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      CHANGELOG.md
  2. 116
      apps/api/src/app/order/order.service.ts
  3. 55
      apps/api/src/app/portfolio/portfolio.service.ts
  4. 4
      apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts
  5. 5
      apps/api/src/services/exchange-rate-data/interfaces/exchange-rate-data.interface.ts

4
CHANGELOG.md

@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## 2.221.0 - 2025-12-01
### Added
- Extended the holdings endpoint to include the performance with currency effect for cash
### Changed
- Refactored the API query parameters in various data provider services

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

@ -1,7 +1,10 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { AssetProfileChangedEvent } from '@ghostfolio/api/events/asset-profile-changed.event';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
@ -16,6 +19,7 @@ import {
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import {
ActivitiesResponse,
Activity,
AssetProfileIdentifier,
EnhancedSymbolProfile,
Filter
@ -43,7 +47,9 @@ import { randomUUID } from 'node:crypto';
export class OrderService {
public constructor(
private readonly accountService: AccountService,
private readonly accountBalanceService: AccountBalanceService,
private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService,
private readonly eventEmitter: EventEmitter2,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService,
@ -317,6 +323,98 @@ export class OrderService {
return count;
}
public async getCashOrders({
cashDetails,
userCurrency,
userId
}: {
cashDetails: CashDetails;
userCurrency: string;
userId: string;
}): Promise<ActivitiesResponse> {
const activities: Activity[] = [];
for (const account of cashDetails.accounts) {
const { balances } = await this.accountBalanceService.getAccountBalances({
filters: [{ id: account.id, type: 'ACCOUNT' }],
userCurrency,
userId
});
let currentBalance = 0;
let currentBalanceInBaseCurrency = 0;
for (const balanceItem of balances) {
const syntheticActivityTemplate: Activity = {
userId,
accountId: account.id,
accountUserId: account.userId,
comment: account.name,
createdAt: new Date(balanceItem.date),
currency: account.currency,
date: new Date(balanceItem.date),
fee: 0,
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
id: balanceItem.id,
isDraft: false,
quantity: 1,
SymbolProfile: {
activitiesCount: 0,
assetClass: 'LIQUIDITY',
assetSubClass: 'CASH',
countries: [],
createdAt: new Date(balanceItem.date),
currency: account.currency,
dataSource:
this.dataProviderService.getDataSourceForExchangeRates(),
holdings: [],
id: account.currency,
isActive: true,
sectors: [],
symbol: account.currency,
updatedAt: new Date(balanceItem.date)
},
symbolProfileId: account.currency,
type: 'BUY',
unitPrice: 1,
unitPriceInAssetProfileCurrency: 1,
updatedAt: new Date(balanceItem.date),
valueInBaseCurrency: 0,
value: 0
};
if (currentBalance < balanceItem.value) {
// BUY
activities.push({
...syntheticActivityTemplate,
quantity: balanceItem.value - currentBalance,
type: 'BUY',
value: balanceItem.value - currentBalance,
valueInBaseCurrency:
balanceItem.valueInBaseCurrency - currentBalanceInBaseCurrency
});
} else if (currentBalance > balanceItem.value) {
// SELL
activities.push({
...syntheticActivityTemplate,
quantity: currentBalance - balanceItem.value,
type: 'SELL',
value: currentBalance - balanceItem.value,
valueInBaseCurrency:
currentBalanceInBaseCurrency - balanceItem.valueInBaseCurrency
});
}
currentBalance = balanceItem.value;
currentBalanceInBaseCurrency = balanceItem.valueInBaseCurrency;
}
}
return {
activities,
count: activities.length
};
}
public async getLatestOrder({ dataSource, symbol }: AssetProfileIdentifier) {
return this.prismaService.order.findFirst({
orderBy: {
@ -620,12 +718,28 @@ export class OrderService {
userCurrency: string;
userId: string;
}) {
return this.getOrders({
const cashDetails = await this.accountService.getCashDetails({
filters,
userId,
currency: userCurrency
});
const cashOrders = await this.getCashOrders({
cashDetails,
userCurrency,
userId
});
const nonCashOrders = await this.getOrders({
filters,
userCurrency,
userId,
withExcludedAccountsAndActivities: false // TODO
});
return {
activities: [...nonCashOrders.activities, ...cashOrders.activities],
count: nonCashOrders.count + cashOrders.count
};
}
public async getStatisticsByCurrency(

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

@ -487,6 +487,7 @@ export class PortfolioService {
(user.settings?.settings as UserSettings)?.emergencyFund ?? 0
);
// Activities for non-cash assets
const { activities } =
await this.orderService.getOrdersForPortfolioCalculator({
filters,
@ -494,6 +495,13 @@ export class PortfolioService {
userId
});
// Synthetic activities for cash
const cashDetails = await this.accountService.getCashDetails({
filters,
userId,
currency: userCurrency
});
const portfolioCalculator = this.calculatorFactory.createCalculator({
activities,
filters,
@ -505,12 +513,6 @@ export class PortfolioService {
const { createdAt, currentValueInBaseCurrency, hasErrors, positions } =
await portfolioCalculator.getSnapshot();
const cashDetails = await this.accountService.getCashDetails({
filters,
userId,
currency: userCurrency
});
const holdings: PortfolioDetails['holdings'] = {};
const totalValueInBaseCurrency = currentValueInBaseCurrency.plus(
@ -522,10 +524,6 @@ export class PortfolioService {
return type === 'ACCOUNT';
}) ?? false;
const isFilteredByCash = filters?.some(({ id, type }) => {
return id === AssetClass.LIQUIDITY && type === 'ASSET_CLASS';
});
const isFilteredByClosedHoldings =
filters?.some(({ id, type }) => {
return id === 'CLOSED' && type === 'HOLDING_TYPE';
@ -621,10 +619,10 @@ export class PortfolioService {
allocationInPercentage: filteredValueInBaseCurrency.eq(0)
? 0
: valueInBaseCurrency.div(filteredValueInBaseCurrency).toNumber(),
assetClass: assetProfile.assetClass,
assetSubClass: assetProfile.assetSubClass,
countries: assetProfile.countries,
dataSource: assetProfile.dataSource,
assetClass: assetProfile?.assetClass,
assetSubClass: assetProfile?.assetSubClass,
countries: assetProfile?.countries,
dataSource: assetProfile?.dataSource,
dateOfFirstActivity: parseDate(firstBuyDate),
dividend: dividend?.toNumber() ?? 0,
grossPerformance: grossPerformance?.toNumber() ?? 0,
@ -633,8 +631,8 @@ export class PortfolioService {
grossPerformancePercentageWithCurrencyEffect?.toNumber() ?? 0,
grossPerformanceWithCurrencyEffect:
grossPerformanceWithCurrencyEffect?.toNumber() ?? 0,
holdings: assetProfile.holdings.map(
({ allocationInPercentage, name }) => {
holdings:
assetProfile?.holdings.map(({ allocationInPercentage, name }) => {
return {
allocationInPercentage,
name,
@ -642,10 +640,9 @@ export class PortfolioService {
.mul(allocationInPercentage)
.toNumber()
};
}
),
}) ?? [],
investment: investment.toNumber(),
name: assetProfile.name,
name: assetProfile?.name,
netPerformance: netPerformance?.toNumber() ?? 0,
netPerformancePercent: netPerformancePercentage?.toNumber() ?? 0,
netPerformancePercentWithCurrencyEffect:
@ -655,24 +652,12 @@ export class PortfolioService {
netPerformanceWithCurrencyEffect:
netPerformanceWithCurrencyEffectMap?.[dateRange]?.toNumber() ?? 0,
quantity: quantity.toNumber(),
sectors: assetProfile.sectors,
url: assetProfile.url,
sectors: assetProfile?.sectors,
url: assetProfile?.url,
valueInBaseCurrency: valueInBaseCurrency.toNumber()
};
}
if (filters?.length === 0 || isFilteredByAccount || isFilteredByCash) {
const cashPositions = this.getCashPositions({
cashDetails,
userCurrency,
value: filteredValueInBaseCurrency
});
for (const symbol of Object.keys(cashPositions)) {
holdings[symbol] = cashPositions[symbol];
}
}
const { accounts, platforms } = await this.getValueOfAccountsAndPlatforms({
activities,
filters,
@ -2157,7 +2142,7 @@ export class PortfolioService {
accounts[account?.id || UNKNOWN_KEY] = {
balance: 0,
currency: account?.currency,
name: account.name,
name: account?.name,
valueInBaseCurrency: currentValueOfSymbolInBaseCurrency
};
}
@ -2171,7 +2156,7 @@ export class PortfolioService {
platforms[account?.platformId || UNKNOWN_KEY] = {
balance: 0,
currency: account?.currency,
name: account.platform?.name,
name: account?.platform?.name,
valueInBaseCurrency: currentValueOfSymbolInBaseCurrency
};
}

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

@ -26,6 +26,8 @@ import {
import { isNumber } from 'lodash';
import ms from 'ms';
import { ExchangeRatesByCurrency } from './interfaces/exchange-rate-data.interface';
@Injectable()
export class ExchangeRateDataService {
private currencies: string[] = [];
@ -59,7 +61,7 @@ export class ExchangeRateDataService {
endDate?: Date;
startDate: Date;
targetCurrency: string;
}) {
}): Promise<ExchangeRatesByCurrency> {
if (!startDate) {
return {};
}

5
apps/api/src/services/exchange-rate-data/interfaces/exchange-rate-data.interface.ts

@ -0,0 +1,5 @@
export interface ExchangeRatesByCurrency {
[currency: string]: {
[dateString: string]: number;
};
}
Loading…
Cancel
Save