mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
45 changed files with 1002 additions and 302 deletions
@ -0,0 +1,136 @@ |
|||
import { AdminService } from '@ghostfolio/api/app/admin/admin.service'; |
|||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; |
|||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; |
|||
import { MarketDataDetailsResponse } from '@ghostfolio/common/interfaces'; |
|||
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; |
|||
import { RequestWithUser } from '@ghostfolio/common/types'; |
|||
|
|||
import { |
|||
Body, |
|||
Controller, |
|||
Get, |
|||
HttpException, |
|||
Inject, |
|||
Param, |
|||
Post, |
|||
UseGuards |
|||
} from '@nestjs/common'; |
|||
import { REQUEST } from '@nestjs/core'; |
|||
import { AuthGuard } from '@nestjs/passport'; |
|||
import { DataSource, Prisma } from '@prisma/client'; |
|||
import { parseISO } from 'date-fns'; |
|||
import { getReasonPhrase, StatusCodes } from 'http-status-codes'; |
|||
|
|||
import { UpdateBulkMarketDataDto } from './update-bulk-market-data.dto'; |
|||
|
|||
@Controller('market-data') |
|||
export class MarketDataController { |
|||
public constructor( |
|||
private readonly adminService: AdminService, |
|||
private readonly marketDataService: MarketDataService, |
|||
@Inject(REQUEST) private readonly request: RequestWithUser, |
|||
private readonly symbolProfileService: SymbolProfileService |
|||
) {} |
|||
|
|||
@Get(':dataSource/:symbol') |
|||
@UseGuards(AuthGuard('jwt')) |
|||
public async getMarketDataBySymbol( |
|||
@Param('dataSource') dataSource: DataSource, |
|||
@Param('symbol') symbol: string |
|||
): Promise<MarketDataDetailsResponse> { |
|||
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([ |
|||
{ dataSource, symbol } |
|||
]); |
|||
|
|||
if (!assetProfile) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.NOT_FOUND), |
|||
StatusCodes.NOT_FOUND |
|||
); |
|||
} |
|||
|
|||
const canReadAllAssetProfiles = hasPermission( |
|||
this.request.user.permissions, |
|||
permissions.readMarketData |
|||
); |
|||
|
|||
const canReadOwnAssetProfile = |
|||
assetProfile.userId === this.request.user.id && |
|||
hasPermission( |
|||
this.request.user.permissions, |
|||
permissions.readMarketDataOfOwnAssetProfile |
|||
); |
|||
|
|||
if (!canReadAllAssetProfiles && !canReadOwnAssetProfile) { |
|||
throw new HttpException( |
|||
assetProfile.userId |
|||
? getReasonPhrase(StatusCodes.NOT_FOUND) |
|||
: getReasonPhrase(StatusCodes.FORBIDDEN), |
|||
assetProfile.userId ? StatusCodes.NOT_FOUND : StatusCodes.FORBIDDEN |
|||
); |
|||
} |
|||
|
|||
return this.adminService.getMarketDataBySymbol({ dataSource, symbol }); |
|||
} |
|||
|
|||
@Post(':dataSource/:symbol') |
|||
@UseGuards(AuthGuard('jwt')) |
|||
public async updateMarketData( |
|||
@Body() data: UpdateBulkMarketDataDto, |
|||
@Param('dataSource') dataSource: DataSource, |
|||
@Param('symbol') symbol: string |
|||
) { |
|||
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([ |
|||
{ dataSource, symbol } |
|||
]); |
|||
|
|||
if (!assetProfile) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.NOT_FOUND), |
|||
StatusCodes.NOT_FOUND |
|||
); |
|||
} |
|||
|
|||
const canUpsertAllAssetProfiles = |
|||
hasPermission( |
|||
this.request.user.permissions, |
|||
permissions.createMarketData |
|||
) && |
|||
hasPermission( |
|||
this.request.user.permissions, |
|||
permissions.updateMarketData |
|||
); |
|||
|
|||
const canUpsertOwnAssetProfile = |
|||
assetProfile.userId === this.request.user.id && |
|||
hasPermission( |
|||
this.request.user.permissions, |
|||
permissions.createMarketDataOfOwnAssetProfile |
|||
) && |
|||
hasPermission( |
|||
this.request.user.permissions, |
|||
permissions.updateMarketDataOfOwnAssetProfile |
|||
); |
|||
|
|||
if (!canUpsertAllAssetProfiles && !canUpsertOwnAssetProfile) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.FORBIDDEN), |
|||
StatusCodes.FORBIDDEN |
|||
); |
|||
} |
|||
|
|||
const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map( |
|||
({ date, marketPrice }) => ({ |
|||
dataSource, |
|||
marketPrice, |
|||
symbol, |
|||
date: parseISO(date), |
|||
state: 'CLOSE' |
|||
}) |
|||
); |
|||
|
|||
return this.marketDataService.updateMany({ |
|||
data: dataBulkUpdate |
|||
}); |
|||
} |
|||
} |
@ -0,0 +1,13 @@ |
|||
import { AdminModule } from '@ghostfolio/api/app/admin/admin.module'; |
|||
import { MarketDataModule as MarketDataServiceModule } from '@ghostfolio/api/services/market-data/market-data.module'; |
|||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; |
|||
|
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { MarketDataController } from './market-data.controller'; |
|||
|
|||
@Module({ |
|||
controllers: [MarketDataController], |
|||
imports: [AdminModule, MarketDataServiceModule, SymbolProfileModule] |
|||
}) |
|||
export class MarketDataModule {} |
@ -0,0 +1,24 @@ |
|||
import { Type } from 'class-transformer'; |
|||
import { |
|||
ArrayNotEmpty, |
|||
IsArray, |
|||
IsISO8601, |
|||
IsNumber, |
|||
IsOptional |
|||
} from 'class-validator'; |
|||
|
|||
export class UpdateBulkMarketDataDto { |
|||
@ArrayNotEmpty() |
|||
@IsArray() |
|||
@Type(() => UpdateMarketDataDto) |
|||
marketData: UpdateMarketDataDto[]; |
|||
} |
|||
|
|||
class UpdateMarketDataDto { |
|||
@IsISO8601() |
|||
@IsOptional() |
|||
date?: string; |
|||
|
|||
@IsNumber() |
|||
marketPrice: number; |
|||
} |
@ -0,0 +1,95 @@ |
|||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; |
|||
import { Rule } from '@ghostfolio/api/models/rule'; |
|||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
|||
import { PortfolioPosition, UserSettings } from '@ghostfolio/common/interfaces'; |
|||
|
|||
export class AssetClassClusterRiskEquity extends Rule<Settings> { |
|||
private holdings: PortfolioPosition[]; |
|||
|
|||
public constructor( |
|||
protected exchangeRateDataService: ExchangeRateDataService, |
|||
holdings: PortfolioPosition[] |
|||
) { |
|||
super(exchangeRateDataService, { |
|||
key: AssetClassClusterRiskEquity.name, |
|||
name: 'Equity' |
|||
}); |
|||
|
|||
this.holdings = holdings; |
|||
} |
|||
|
|||
public evaluate(ruleSettings: Settings) { |
|||
const holdingsGroupedByAssetClass = this.groupCurrentHoldingsByAttribute( |
|||
this.holdings, |
|||
'assetClass', |
|||
ruleSettings.baseCurrency |
|||
); |
|||
let totalValue = 0; |
|||
|
|||
const equityValueInBaseCurrency = |
|||
holdingsGroupedByAssetClass.find(({ groupKey }) => { |
|||
return groupKey === 'EQUITY'; |
|||
})?.value ?? 0; |
|||
|
|||
for (const { value } of holdingsGroupedByAssetClass) { |
|||
totalValue += value; |
|||
} |
|||
|
|||
const equityValueRatio = totalValue |
|||
? equityValueInBaseCurrency / totalValue |
|||
: 0; |
|||
|
|||
if (equityValueRatio > ruleSettings.thresholdMax) { |
|||
return { |
|||
evaluation: `The equity contribution of your current investment (${(equityValueRatio * 100).toPrecision(3)}%) exceeds ${( |
|||
ruleSettings.thresholdMax * 100 |
|||
).toPrecision(3)}%`,
|
|||
value: false |
|||
}; |
|||
} else if (equityValueRatio < ruleSettings.thresholdMin) { |
|||
return { |
|||
evaluation: `The equity contribution of your current investment (${(equityValueRatio * 100).toPrecision(3)}%) is below ${( |
|||
ruleSettings.thresholdMin * 100 |
|||
).toPrecision(3)}%`,
|
|||
value: false |
|||
}; |
|||
} |
|||
|
|||
return { |
|||
evaluation: `The equity contribution of your current investment (${(equityValueRatio * 100).toPrecision(3)}%) is within the range of ${( |
|||
ruleSettings.thresholdMin * 100 |
|||
).toPrecision( |
|||
3 |
|||
)}% and ${(ruleSettings.thresholdMax * 100).toPrecision(3)}%`,
|
|||
value: true |
|||
}; |
|||
} |
|||
|
|||
public getConfiguration() { |
|||
return { |
|||
threshold: { |
|||
max: 1, |
|||
min: 0, |
|||
step: 0.01, |
|||
unit: '%' |
|||
}, |
|||
thresholdMax: true, |
|||
thresholdMin: true |
|||
}; |
|||
} |
|||
|
|||
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { |
|||
return { |
|||
baseCurrency, |
|||
isActive: xRayRules?.[this.getKey()]?.isActive ?? true, |
|||
thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.82, |
|||
thresholdMin: xRayRules?.[this.getKey()]?.thresholdMin ?? 0.78 |
|||
}; |
|||
} |
|||
} |
|||
|
|||
interface Settings extends RuleSettings { |
|||
baseCurrency: string; |
|||
thresholdMin: number; |
|||
thresholdMax: number; |
|||
} |
@ -0,0 +1,95 @@ |
|||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; |
|||
import { Rule } from '@ghostfolio/api/models/rule'; |
|||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
|||
import { PortfolioPosition, UserSettings } from '@ghostfolio/common/interfaces'; |
|||
|
|||
export class AssetClassClusterRiskFixedIncome extends Rule<Settings> { |
|||
private holdings: PortfolioPosition[]; |
|||
|
|||
public constructor( |
|||
protected exchangeRateDataService: ExchangeRateDataService, |
|||
holdings: PortfolioPosition[] |
|||
) { |
|||
super(exchangeRateDataService, { |
|||
key: AssetClassClusterRiskFixedIncome.name, |
|||
name: 'Fixed Income' |
|||
}); |
|||
|
|||
this.holdings = holdings; |
|||
} |
|||
|
|||
public evaluate(ruleSettings: Settings) { |
|||
const holdingsGroupedByAssetClass = this.groupCurrentHoldingsByAttribute( |
|||
this.holdings, |
|||
'assetClass', |
|||
ruleSettings.baseCurrency |
|||
); |
|||
let totalValue = 0; |
|||
|
|||
const fixedIncomeValueInBaseCurrency = |
|||
holdingsGroupedByAssetClass.find(({ groupKey }) => { |
|||
return groupKey === 'FIXED_INCOME'; |
|||
})?.value ?? 0; |
|||
|
|||
for (const { value } of holdingsGroupedByAssetClass) { |
|||
totalValue += value; |
|||
} |
|||
|
|||
const fixedIncomeValueRatio = totalValue |
|||
? fixedIncomeValueInBaseCurrency / totalValue |
|||
: 0; |
|||
|
|||
if (fixedIncomeValueRatio > ruleSettings.thresholdMax) { |
|||
return { |
|||
evaluation: `The fixed income contribution of your current investment (${(fixedIncomeValueRatio * 100).toPrecision(3)}%) exceeds ${( |
|||
ruleSettings.thresholdMax * 100 |
|||
).toPrecision(3)}%`,
|
|||
value: false |
|||
}; |
|||
} else if (fixedIncomeValueRatio < ruleSettings.thresholdMin) { |
|||
return { |
|||
evaluation: `The fixed income contribution of your current investment (${(fixedIncomeValueRatio * 100).toPrecision(3)}%) is below ${( |
|||
ruleSettings.thresholdMin * 100 |
|||
).toPrecision(3)}%`,
|
|||
value: false |
|||
}; |
|||
} |
|||
|
|||
return { |
|||
evaluation: `The fixed income contribution of your current investment (${(fixedIncomeValueRatio * 100).toPrecision(3)}%) is within the range of ${( |
|||
ruleSettings.thresholdMin * 100 |
|||
).toPrecision( |
|||
3 |
|||
)}% and ${(ruleSettings.thresholdMax * 100).toPrecision(3)}%`,
|
|||
value: true |
|||
}; |
|||
} |
|||
|
|||
public getConfiguration() { |
|||
return { |
|||
threshold: { |
|||
max: 1, |
|||
min: 0, |
|||
step: 0.01, |
|||
unit: '%' |
|||
}, |
|||
thresholdMax: true, |
|||
thresholdMin: true |
|||
}; |
|||
} |
|||
|
|||
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { |
|||
return { |
|||
baseCurrency, |
|||
isActive: xRayRules?.[this.getKey()]?.isActive ?? true, |
|||
thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.22, |
|||
thresholdMin: xRayRules?.[this.getKey()]?.thresholdMin ?? 0.18 |
|||
}; |
|||
} |
|||
} |
|||
|
|||
interface Settings extends RuleSettings { |
|||
baseCurrency: string; |
|||
thresholdMin: number; |
|||
thresholdMax: number; |
|||
} |
@ -0,0 +1,8 @@ |
|||
import { MarketData } from '@prisma/client'; |
|||
|
|||
import { EnhancedSymbolProfile } from '../enhanced-symbol-profile.interface'; |
|||
|
|||
export interface MarketDataDetailsResponse { |
|||
assetProfile: Partial<EnhancedSymbolProfile>; |
|||
marketData: MarketData[]; |
|||
} |
@ -0,0 +1,15 @@ |
|||
-- AlterTable |
|||
ALTER TABLE "SymbolProfile" ADD COLUMN "userId" TEXT; |
|||
|
|||
-- AddForeignKey |
|||
ALTER TABLE "SymbolProfile" ADD CONSTRAINT "SymbolProfile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; |
|||
|
|||
-- Set userIds in SymbolProfile for 'MANUAL' data source |
|||
UPDATE "SymbolProfile" |
|||
SET "userId" = ( |
|||
SELECT "userId" |
|||
FROM "Order" |
|||
WHERE "Order"."symbolProfileId" = "SymbolProfile"."id" |
|||
LIMIT 1 |
|||
) |
|||
WHERE "dataSource" = 'MANUAL'; |
Loading…
Reference in new issue