mirror of https://github.com/ghostfolio/ghostfolio
341 changed files with 42489 additions and 27924 deletions
@ -1,151 +0,0 @@ |
|||||
{ |
|
||||
"root": true, |
|
||||
"ignorePatterns": ["**/*"], |
|
||||
"plugins": ["@nx"], |
|
||||
"overrides": [ |
|
||||
{ |
|
||||
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"], |
|
||||
"rules": { |
|
||||
"@nx/enforce-module-boundaries": [ |
|
||||
"warn", |
|
||||
{ |
|
||||
"enforceBuildableLibDependency": true, |
|
||||
"allow": [], |
|
||||
"depConstraints": [ |
|
||||
{ |
|
||||
"sourceTag": "*", |
|
||||
"onlyDependOnLibsWithTags": ["*"] |
|
||||
} |
|
||||
] |
|
||||
} |
|
||||
], |
|
||||
"@typescript-eslint/no-extra-semi": "error", |
|
||||
"no-extra-semi": "off" |
|
||||
} |
|
||||
}, |
|
||||
{ |
|
||||
"files": ["*.ts", "*.tsx"], |
|
||||
"extends": ["plugin:@nx/typescript"] |
|
||||
}, |
|
||||
{ |
|
||||
"files": ["*.js", "*.jsx"], |
|
||||
"extends": ["plugin:@nx/javascript"] |
|
||||
}, |
|
||||
{ |
|
||||
"files": ["*.ts"], |
|
||||
"plugins": ["eslint-plugin-import", "@typescript-eslint"], |
|
||||
"extends": [ |
|
||||
"plugin:@typescript-eslint/recommended-type-checked", |
|
||||
"plugin:@typescript-eslint/stylistic-type-checked" |
|
||||
], |
|
||||
"rules": { |
|
||||
"@typescript-eslint/consistent-indexed-object-style": "off", |
|
||||
"@typescript-eslint/dot-notation": "off", |
|
||||
"@typescript-eslint/explicit-member-accessibility": [ |
|
||||
"off", |
|
||||
{ |
|
||||
"accessibility": "explicit" |
|
||||
} |
|
||||
], |
|
||||
"@typescript-eslint/member-ordering": "warn", |
|
||||
"@typescript-eslint/naming-convention": [ |
|
||||
"off", |
|
||||
{ |
|
||||
"selector": "default", |
|
||||
"format": ["camelCase"], |
|
||||
"leadingUnderscore": "allow", |
|
||||
"trailingUnderscore": "allow" |
|
||||
}, |
|
||||
{ |
|
||||
"selector": ["variable", "classProperty", "typeProperty"], |
|
||||
"format": ["camelCase", "UPPER_CASE"], |
|
||||
"leadingUnderscore": "allow", |
|
||||
"trailingUnderscore": "allow" |
|
||||
}, |
|
||||
{ |
|
||||
"selector": "objectLiteralProperty", |
|
||||
"format": null |
|
||||
}, |
|
||||
{ |
|
||||
"selector": "enumMember", |
|
||||
"format": ["camelCase", "UPPER_CASE", "PascalCase"] |
|
||||
}, |
|
||||
{ |
|
||||
"selector": "typeLike", |
|
||||
"format": ["PascalCase"] |
|
||||
} |
|
||||
], |
|
||||
"@typescript-eslint/no-empty-interface": "warn", |
|
||||
"@typescript-eslint/no-inferrable-types": [ |
|
||||
"warn", |
|
||||
{ |
|
||||
"ignoreParameters": true |
|
||||
} |
|
||||
], |
|
||||
"@typescript-eslint/no-non-null-assertion": "warn", |
|
||||
"@typescript-eslint/no-shadow": [ |
|
||||
"warn", |
|
||||
{ |
|
||||
"hoist": "all" |
|
||||
} |
|
||||
], |
|
||||
"@typescript-eslint/unified-signatures": "error", |
|
||||
"@typescript-eslint/no-loss-of-precision": "warn", |
|
||||
"@typescript-eslint/no-var-requires": "warn", |
|
||||
"@typescript-eslint/ban-types": "warn", |
|
||||
"arrow-body-style": "off", |
|
||||
"constructor-super": "error", |
|
||||
"eqeqeq": ["error", "smart"], |
|
||||
"guard-for-in": "warn", |
|
||||
"id-blacklist": "off", |
|
||||
"id-match": "off", |
|
||||
"import/no-deprecated": "warn", |
|
||||
"no-bitwise": "error", |
|
||||
"no-caller": "error", |
|
||||
"no-debugger": "error", |
|
||||
"no-empty": "off", |
|
||||
"no-eval": "error", |
|
||||
"no-fallthrough": "error", |
|
||||
"no-new-wrappers": "error", |
|
||||
"no-restricted-imports": ["error", "rxjs/Rx"], |
|
||||
"no-undef-init": "error", |
|
||||
"no-underscore-dangle": "off", |
|
||||
"no-var": "error", |
|
||||
"radix": "error", |
|
||||
"no-unsafe-optional-chaining": "warn", |
|
||||
"no-extra-boolean-cast": "warn", |
|
||||
"no-empty-pattern": "warn", |
|
||||
"no-useless-catch": "warn", |
|
||||
"no-unsafe-finally": "warn", |
|
||||
"no-prototype-builtins": "warn", |
|
||||
"no-async-promise-executor": "warn", |
|
||||
"no-constant-condition": "warn", |
|
||||
|
|
||||
// The following rules are part of @typescript-eslint/recommended-type-checked |
|
||||
// and can be remove once solved |
|
||||
"@typescript-eslint/await-thenable": "warn", |
|
||||
"@typescript-eslint/ban-ts-comment": "warn", |
|
||||
"@typescript-eslint/no-base-to-string": "warn", |
|
||||
"@typescript-eslint/no-explicit-any": "warn", |
|
||||
"@typescript-eslint/no-floating-promises": "warn", |
|
||||
"@typescript-eslint/no-misused-promises": "warn", |
|
||||
"@typescript-eslint/no-redundant-type-constituents": "warn", |
|
||||
"@typescript-eslint/no-unnecessary-type-assertion": "warn", |
|
||||
"@typescript-eslint/no-unsafe-argument": "warn", |
|
||||
"@typescript-eslint/no-unsafe-assignment": "warn", |
|
||||
"@typescript-eslint/no-unsafe-enum-comparison": "warn", |
|
||||
"@typescript-eslint/no-unsafe-member-access": "warn", |
|
||||
"@typescript-eslint/no-unsafe-return": "warn", |
|
||||
"@typescript-eslint/no-unsafe-call": "warn", |
|
||||
"@typescript-eslint/require-await": "warn", |
|
||||
"@typescript-eslint/restrict-template-expressions": "warn", |
|
||||
"@typescript-eslint/unbound-method": "warn", |
|
||||
|
|
||||
// The following rules are part of @typescript-eslint/stylistic-type-checked |
|
||||
// and can be remove once solved |
|
||||
"@typescript-eslint/prefer-nullish-coalescing": "warn" // TODO: Requires strictNullChecks: true |
|
||||
} |
|
||||
} |
|
||||
], |
|
||||
"extends": ["plugin:storybook/recommended"] |
|
||||
} |
|
@ -0,0 +1,40 @@ |
|||||
|
name: Extract locales |
||||
|
|
||||
|
on: |
||||
|
push: |
||||
|
branches: |
||||
|
- main |
||||
|
|
||||
|
permissions: |
||||
|
contents: write |
||||
|
pull-requests: write |
||||
|
|
||||
|
jobs: |
||||
|
extract_locales: |
||||
|
runs-on: ubuntu-latest |
||||
|
steps: |
||||
|
- name: Checkout code |
||||
|
uses: actions/checkout@v4 |
||||
|
with: |
||||
|
fetch-depth: 0 |
||||
|
|
||||
|
- name: Install dependencies |
||||
|
run: npm ci |
||||
|
|
||||
|
- name: Extract locales |
||||
|
run: npm run extract-locales |
||||
|
|
||||
|
- name: Check changes |
||||
|
id: verify-changed-files |
||||
|
uses: tj-actions/verify-changed-files@v20 |
||||
|
|
||||
|
- name: Create pull request |
||||
|
if: steps.verify-changed-files.outputs.files_changed == 'true' |
||||
|
uses: peter-evans/create-pull-request@v7 |
||||
|
with: |
||||
|
author: 'github-actions[bot] <github-actions[bot]@users.noreply.github.com>' |
||||
|
branch: 'feature/update-locales' |
||||
|
commit-message: 'Update locales' |
||||
|
delete-branch: true |
||||
|
title: 'Feature/update locales' |
||||
|
token: ${{ secrets.GITHUB_TOKEN }} |
@ -1,22 +0,0 @@ |
|||||
{ |
|
||||
"extends": "../../.eslintrc.json", |
|
||||
"ignorePatterns": ["!**/*"], |
|
||||
"rules": {}, |
|
||||
"overrides": [ |
|
||||
{ |
|
||||
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"], |
|
||||
"parserOptions": { |
|
||||
"project": ["apps/api/tsconfig.*?.json"] |
|
||||
}, |
|
||||
"rules": {} |
|
||||
}, |
|
||||
{ |
|
||||
"files": ["*.ts", "*.tsx"], |
|
||||
"rules": {} |
|
||||
}, |
|
||||
{ |
|
||||
"files": ["*.js", "*.jsx"], |
|
||||
"rules": {} |
|
||||
} |
|
||||
] |
|
||||
} |
|
@ -0,0 +1,31 @@ |
|||||
|
const baseConfig = require('../../eslint.config.cjs'); |
||||
|
|
||||
|
module.exports = [ |
||||
|
{ |
||||
|
ignores: ['**/dist'] |
||||
|
}, |
||||
|
...baseConfig, |
||||
|
{ |
||||
|
rules: {} |
||||
|
}, |
||||
|
{ |
||||
|
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], |
||||
|
// Override or add rules here |
||||
|
rules: {}, |
||||
|
languageOptions: { |
||||
|
parserOptions: { |
||||
|
project: ['apps/api/tsconfig.*?.json'] |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
{ |
||||
|
files: ['**/*.ts', '**/*.tsx'], |
||||
|
// Override or add rules here |
||||
|
rules: {} |
||||
|
}, |
||||
|
{ |
||||
|
files: ['**/*.js', '**/*.jsx'], |
||||
|
// Override or add rules here |
||||
|
rules: {} |
||||
|
} |
||||
|
]; |
@ -0,0 +1,76 @@ |
|||||
|
import { UserService } from '@ghostfolio/api/app/user/user.service'; |
||||
|
import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service'; |
||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
||||
|
import { HEADER_KEY_TOKEN } from '@ghostfolio/common/config'; |
||||
|
import { hasRole } from '@ghostfolio/common/permissions'; |
||||
|
|
||||
|
import { HttpException, Injectable } from '@nestjs/common'; |
||||
|
import { PassportStrategy } from '@nestjs/passport'; |
||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
||||
|
import { HeaderAPIKeyStrategy } from 'passport-headerapikey'; |
||||
|
|
||||
|
@Injectable() |
||||
|
export class ApiKeyStrategy extends PassportStrategy( |
||||
|
HeaderAPIKeyStrategy, |
||||
|
'api-key' |
||||
|
) { |
||||
|
public constructor( |
||||
|
private readonly apiKeyService: ApiKeyService, |
||||
|
private readonly configurationService: ConfigurationService, |
||||
|
private readonly prismaService: PrismaService, |
||||
|
private readonly userService: UserService |
||||
|
) { |
||||
|
super( |
||||
|
{ header: HEADER_KEY_TOKEN, prefix: 'Api-Key ' }, |
||||
|
true, |
||||
|
async (apiKey: string, done: (error: any, user?: any) => void) => { |
||||
|
try { |
||||
|
const user = await this.validateApiKey(apiKey); |
||||
|
|
||||
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { |
||||
|
if (hasRole(user, 'INACTIVE')) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), |
||||
|
StatusCodes.TOO_MANY_REQUESTS |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
await this.prismaService.analytics.upsert({ |
||||
|
create: { User: { connect: { id: user.id } } }, |
||||
|
update: { |
||||
|
activityCount: { increment: 1 }, |
||||
|
lastRequestAt: new Date() |
||||
|
}, |
||||
|
where: { userId: user.id } |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
done(null, user); |
||||
|
} catch (error) { |
||||
|
done(error, null); |
||||
|
} |
||||
|
} |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
private async validateApiKey(apiKey: string) { |
||||
|
if (!apiKey) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.UNAUTHORIZED), |
||||
|
StatusCodes.UNAUTHORIZED |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
const { id } = await this.apiKeyService.getUserByApiKey(apiKey); |
||||
|
|
||||
|
return this.userService.user({ id }); |
||||
|
} catch { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.UNAUTHORIZED), |
||||
|
StatusCodes.UNAUTHORIZED |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,39 @@ |
|||||
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; |
||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
||||
|
import { |
||||
|
DEFAULT_CURRENCY, |
||||
|
DEFAULT_LANGUAGE_CODE |
||||
|
} from '@ghostfolio/common/config'; |
||||
|
import { AiPromptResponse } from '@ghostfolio/common/interfaces'; |
||||
|
import { permissions } from '@ghostfolio/common/permissions'; |
||||
|
import type { RequestWithUser } from '@ghostfolio/common/types'; |
||||
|
|
||||
|
import { Controller, Get, Inject, UseGuards } from '@nestjs/common'; |
||||
|
import { REQUEST } from '@nestjs/core'; |
||||
|
import { AuthGuard } from '@nestjs/passport'; |
||||
|
|
||||
|
import { AiService } from './ai.service'; |
||||
|
|
||||
|
@Controller('ai') |
||||
|
export class AiController { |
||||
|
public constructor( |
||||
|
private readonly aiService: AiService, |
||||
|
@Inject(REQUEST) private readonly request: RequestWithUser |
||||
|
) {} |
||||
|
|
||||
|
@Get('prompt') |
||||
|
@HasPermission(permissions.readAiPrompt) |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
public async getPrompt(): Promise<AiPromptResponse> { |
||||
|
const prompt = await this.aiService.getPrompt({ |
||||
|
impersonationId: undefined, |
||||
|
languageCode: |
||||
|
this.request.user.Settings.settings.language ?? DEFAULT_LANGUAGE_CODE, |
||||
|
userCurrency: |
||||
|
this.request.user.Settings.settings.baseCurrency ?? DEFAULT_CURRENCY, |
||||
|
userId: this.request.user.id |
||||
|
}); |
||||
|
|
||||
|
return { prompt }; |
||||
|
} |
||||
|
} |
@ -0,0 +1,51 @@ |
|||||
|
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 { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.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 { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; |
||||
|
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 { AiController } from './ai.controller'; |
||||
|
import { AiService } from './ai.service'; |
||||
|
|
||||
|
@Module({ |
||||
|
controllers: [AiController], |
||||
|
imports: [ |
||||
|
ConfigurationModule, |
||||
|
DataProviderModule, |
||||
|
ExchangeRateDataModule, |
||||
|
ImpersonationModule, |
||||
|
MarketDataModule, |
||||
|
OrderModule, |
||||
|
PortfolioSnapshotQueueModule, |
||||
|
PrismaModule, |
||||
|
RedisCacheModule, |
||||
|
SymbolProfileModule, |
||||
|
UserModule |
||||
|
], |
||||
|
providers: [ |
||||
|
AccountBalanceService, |
||||
|
AccountService, |
||||
|
AiService, |
||||
|
CurrentRateService, |
||||
|
MarketDataService, |
||||
|
PortfolioCalculatorFactory, |
||||
|
PortfolioService, |
||||
|
RulesService |
||||
|
] |
||||
|
}) |
||||
|
export class AiModule {} |
@ -0,0 +1,60 @@ |
|||||
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; |
||||
|
|
||||
|
import { Injectable } from '@nestjs/common'; |
||||
|
|
||||
|
@Injectable() |
||||
|
export class AiService { |
||||
|
public constructor(private readonly portfolioService: PortfolioService) {} |
||||
|
|
||||
|
public async getPrompt({ |
||||
|
impersonationId, |
||||
|
languageCode, |
||||
|
userCurrency, |
||||
|
userId |
||||
|
}: { |
||||
|
impersonationId: string; |
||||
|
languageCode: string; |
||||
|
userCurrency: string; |
||||
|
userId: string; |
||||
|
}) { |
||||
|
const { holdings } = await this.portfolioService.getDetails({ |
||||
|
impersonationId, |
||||
|
userId |
||||
|
}); |
||||
|
|
||||
|
const holdingsTable = [ |
||||
|
'| Name | Symbol | Currency | Asset Class | Asset Sub Class | Allocation in Percentage |', |
||||
|
'| --- | --- | --- | --- | --- | --- |', |
||||
|
...Object.values(holdings) |
||||
|
.sort((a, b) => { |
||||
|
return b.allocationInPercentage - a.allocationInPercentage; |
||||
|
}) |
||||
|
.map( |
||||
|
({ |
||||
|
allocationInPercentage, |
||||
|
assetClass, |
||||
|
assetSubClass, |
||||
|
currency, |
||||
|
name, |
||||
|
symbol |
||||
|
}) => { |
||||
|
return `| ${name} | ${symbol} | ${currency} | ${assetClass} | ${assetSubClass} | ${(allocationInPercentage * 100).toFixed(3)}% |`; |
||||
|
} |
||||
|
) |
||||
|
]; |
||||
|
|
||||
|
return [ |
||||
|
`You are a neutral financial assistant. Please analyze the following investment portfolio (base currency being ${userCurrency}) in simple words.`, |
||||
|
...holdingsTable, |
||||
|
'Structure your answer with these sections:', |
||||
|
'Overview: Briefly summarize the portfolio’s composition and allocation rationale.', |
||||
|
'Risk Assessment: Identify potential risks, including market volatility, concentration, and sectoral imbalances.', |
||||
|
'Advantages: Highlight strengths, focusing on growth potential, diversification, or other benefits.', |
||||
|
'Disadvantages: Point out weaknesses, such as overexposure or lack of defensive assets.', |
||||
|
'Target Group: Discuss who this portfolio might suit (e.g., risk tolerance, investment goals, life stages, and experience levels).', |
||||
|
'Optimization Ideas: Offer ideas to complement the portfolio, ensuring they are constructive and neutral in tone.', |
||||
|
'Conclusion: Provide a concise summary highlighting key insights.', |
||||
|
`Provide your answer in the following language: ${languageCode}.` |
||||
|
].join('\n'); |
||||
|
} |
||||
|
} |
@ -0,0 +1,25 @@ |
|||||
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; |
||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
||||
|
import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service'; |
||||
|
import { ApiKeyResponse } from '@ghostfolio/common/interfaces'; |
||||
|
import { permissions } from '@ghostfolio/common/permissions'; |
||||
|
import type { RequestWithUser } from '@ghostfolio/common/types'; |
||||
|
|
||||
|
import { Controller, Inject, Post, UseGuards } from '@nestjs/common'; |
||||
|
import { REQUEST } from '@nestjs/core'; |
||||
|
import { AuthGuard } from '@nestjs/passport'; |
||||
|
|
||||
|
@Controller('api-keys') |
||||
|
export class ApiKeysController { |
||||
|
public constructor( |
||||
|
private readonly apiKeyService: ApiKeyService, |
||||
|
@Inject(REQUEST) private readonly request: RequestWithUser |
||||
|
) {} |
||||
|
|
||||
|
@HasPermission(permissions.createApiKey) |
||||
|
@Post() |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
public async createApiKey(): Promise<ApiKeyResponse> { |
||||
|
return this.apiKeyService.create({ userId: this.request.user.id }); |
||||
|
} |
||||
|
} |
@ -0,0 +1,11 @@ |
|||||
|
import { ApiKeyModule } from '@ghostfolio/api/services/api-key/api-key.module'; |
||||
|
|
||||
|
import { Module } from '@nestjs/common'; |
||||
|
|
||||
|
import { ApiKeysController } from './api-keys.controller'; |
||||
|
|
||||
|
@Module({ |
||||
|
controllers: [ApiKeysController], |
||||
|
imports: [ApiKeyModule] |
||||
|
}) |
||||
|
export class ApiKeysModule {} |
@ -0,0 +1,15 @@ |
|||||
|
import { Granularity } from '@ghostfolio/common/types'; |
||||
|
|
||||
|
import { IsIn, IsISO8601, IsOptional } from 'class-validator'; |
||||
|
|
||||
|
export class GetDividendsDto { |
||||
|
@IsISO8601() |
||||
|
from: string; |
||||
|
|
||||
|
@IsIn(['day', 'month'] as Granularity[]) |
||||
|
@IsOptional() |
||||
|
granularity: Granularity; |
||||
|
|
||||
|
@IsISO8601() |
||||
|
to: string; |
||||
|
} |
@ -0,0 +1,15 @@ |
|||||
|
import { Granularity } from '@ghostfolio/common/types'; |
||||
|
|
||||
|
import { IsIn, IsISO8601, IsOptional } from 'class-validator'; |
||||
|
|
||||
|
export class GetHistoricalDto { |
||||
|
@IsISO8601() |
||||
|
from: string; |
||||
|
|
||||
|
@IsIn(['day', 'month'] as Granularity[]) |
||||
|
@IsOptional() |
||||
|
granularity: Granularity; |
||||
|
|
||||
|
@IsISO8601() |
||||
|
to: string; |
||||
|
} |
@ -0,0 +1,10 @@ |
|||||
|
import { Transform } from 'class-transformer'; |
||||
|
import { IsString } from 'class-validator'; |
||||
|
|
||||
|
export class GetQuotesDto { |
||||
|
@IsString({ each: true }) |
||||
|
@Transform(({ value }) => |
||||
|
typeof value === 'string' ? value.split(',') : value |
||||
|
) |
||||
|
symbols: string[]; |
||||
|
} |
@ -0,0 +1,375 @@ |
|||||
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; |
||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
||||
|
import { parseDate } from '@ghostfolio/common/helper'; |
||||
|
import { |
||||
|
DataProviderGhostfolioStatusResponse, |
||||
|
DividendsResponse, |
||||
|
HistoricalResponse, |
||||
|
LookupResponse, |
||||
|
QuotesResponse |
||||
|
} from '@ghostfolio/common/interfaces'; |
||||
|
import { permissions } from '@ghostfolio/common/permissions'; |
||||
|
import { RequestWithUser } from '@ghostfolio/common/types'; |
||||
|
|
||||
|
import { |
||||
|
Controller, |
||||
|
Get, |
||||
|
HttpException, |
||||
|
Inject, |
||||
|
Param, |
||||
|
Query, |
||||
|
UseGuards, |
||||
|
Version |
||||
|
} from '@nestjs/common'; |
||||
|
import { REQUEST } from '@nestjs/core'; |
||||
|
import { AuthGuard } from '@nestjs/passport'; |
||||
|
import { getReasonPhrase, StatusCodes } from 'http-status-codes'; |
||||
|
|
||||
|
import { GetDividendsDto } from './get-dividends.dto'; |
||||
|
import { GetHistoricalDto } from './get-historical.dto'; |
||||
|
import { GetQuotesDto } from './get-quotes.dto'; |
||||
|
import { GhostfolioService } from './ghostfolio.service'; |
||||
|
|
||||
|
@Controller('data-providers/ghostfolio') |
||||
|
export class GhostfolioController { |
||||
|
public constructor( |
||||
|
private readonly ghostfolioService: GhostfolioService, |
||||
|
@Inject(REQUEST) private readonly request: RequestWithUser |
||||
|
) {} |
||||
|
|
||||
|
/** |
||||
|
* @deprecated |
||||
|
*/ |
||||
|
@Get('dividends/:symbol') |
||||
|
@HasPermission(permissions.enableDataProviderGhostfolio) |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
public async getDividendsV1( |
||||
|
@Param('symbol') symbol: string, |
||||
|
@Query() query: GetDividendsDto |
||||
|
): Promise<DividendsResponse> { |
||||
|
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); |
||||
|
|
||||
|
if ( |
||||
|
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests |
||||
|
) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), |
||||
|
StatusCodes.TOO_MANY_REQUESTS |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
const dividends = await this.ghostfolioService.getDividends({ |
||||
|
symbol, |
||||
|
from: parseDate(query.from), |
||||
|
granularity: query.granularity, |
||||
|
to: parseDate(query.to) |
||||
|
}); |
||||
|
|
||||
|
await this.ghostfolioService.incrementDailyRequests({ |
||||
|
userId: this.request.user.id |
||||
|
}); |
||||
|
|
||||
|
return dividends; |
||||
|
} catch { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), |
||||
|
StatusCodes.INTERNAL_SERVER_ERROR |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Get('dividends/:symbol') |
||||
|
@HasPermission(permissions.enableDataProviderGhostfolio) |
||||
|
@UseGuards(AuthGuard('api-key'), HasPermissionGuard) |
||||
|
@Version('2') |
||||
|
public async getDividends( |
||||
|
@Param('symbol') symbol: string, |
||||
|
@Query() query: GetDividendsDto |
||||
|
): Promise<DividendsResponse> { |
||||
|
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); |
||||
|
|
||||
|
if ( |
||||
|
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests |
||||
|
) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), |
||||
|
StatusCodes.TOO_MANY_REQUESTS |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
const dividends = await this.ghostfolioService.getDividends({ |
||||
|
symbol, |
||||
|
from: parseDate(query.from), |
||||
|
granularity: query.granularity, |
||||
|
to: parseDate(query.to) |
||||
|
}); |
||||
|
|
||||
|
await this.ghostfolioService.incrementDailyRequests({ |
||||
|
userId: this.request.user.id |
||||
|
}); |
||||
|
|
||||
|
return dividends; |
||||
|
} catch { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), |
||||
|
StatusCodes.INTERNAL_SERVER_ERROR |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* @deprecated |
||||
|
*/ |
||||
|
@Get('historical/:symbol') |
||||
|
@HasPermission(permissions.enableDataProviderGhostfolio) |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
public async getHistoricalV1( |
||||
|
@Param('symbol') symbol: string, |
||||
|
@Query() query: GetHistoricalDto |
||||
|
): Promise<HistoricalResponse> { |
||||
|
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); |
||||
|
|
||||
|
if ( |
||||
|
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests |
||||
|
) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), |
||||
|
StatusCodes.TOO_MANY_REQUESTS |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
const historicalData = await this.ghostfolioService.getHistorical({ |
||||
|
symbol, |
||||
|
from: parseDate(query.from), |
||||
|
granularity: query.granularity, |
||||
|
to: parseDate(query.to) |
||||
|
}); |
||||
|
|
||||
|
await this.ghostfolioService.incrementDailyRequests({ |
||||
|
userId: this.request.user.id |
||||
|
}); |
||||
|
|
||||
|
return historicalData; |
||||
|
} catch { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), |
||||
|
StatusCodes.INTERNAL_SERVER_ERROR |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Get('historical/:symbol') |
||||
|
@HasPermission(permissions.enableDataProviderGhostfolio) |
||||
|
@UseGuards(AuthGuard('api-key'), HasPermissionGuard) |
||||
|
@Version('2') |
||||
|
public async getHistorical( |
||||
|
@Param('symbol') symbol: string, |
||||
|
@Query() query: GetHistoricalDto |
||||
|
): Promise<HistoricalResponse> { |
||||
|
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); |
||||
|
|
||||
|
if ( |
||||
|
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests |
||||
|
) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), |
||||
|
StatusCodes.TOO_MANY_REQUESTS |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
const historicalData = await this.ghostfolioService.getHistorical({ |
||||
|
symbol, |
||||
|
from: parseDate(query.from), |
||||
|
granularity: query.granularity, |
||||
|
to: parseDate(query.to) |
||||
|
}); |
||||
|
|
||||
|
await this.ghostfolioService.incrementDailyRequests({ |
||||
|
userId: this.request.user.id |
||||
|
}); |
||||
|
|
||||
|
return historicalData; |
||||
|
} catch { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), |
||||
|
StatusCodes.INTERNAL_SERVER_ERROR |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* @deprecated |
||||
|
*/ |
||||
|
@Get('lookup') |
||||
|
@HasPermission(permissions.enableDataProviderGhostfolio) |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
public async lookupSymbolV1( |
||||
|
@Query('includeIndices') includeIndicesParam = 'false', |
||||
|
@Query('query') query = '' |
||||
|
): Promise<LookupResponse> { |
||||
|
const includeIndices = includeIndicesParam === 'true'; |
||||
|
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); |
||||
|
|
||||
|
if ( |
||||
|
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests |
||||
|
) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), |
||||
|
StatusCodes.TOO_MANY_REQUESTS |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
const result = await this.ghostfolioService.lookup({ |
||||
|
includeIndices, |
||||
|
query: query.toLowerCase() |
||||
|
}); |
||||
|
|
||||
|
await this.ghostfolioService.incrementDailyRequests({ |
||||
|
userId: this.request.user.id |
||||
|
}); |
||||
|
|
||||
|
return result; |
||||
|
} catch { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), |
||||
|
StatusCodes.INTERNAL_SERVER_ERROR |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Get('lookup') |
||||
|
@HasPermission(permissions.enableDataProviderGhostfolio) |
||||
|
@UseGuards(AuthGuard('api-key'), HasPermissionGuard) |
||||
|
@Version('2') |
||||
|
public async lookupSymbol( |
||||
|
@Query('includeIndices') includeIndicesParam = 'false', |
||||
|
@Query('query') query = '' |
||||
|
): Promise<LookupResponse> { |
||||
|
const includeIndices = includeIndicesParam === 'true'; |
||||
|
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); |
||||
|
|
||||
|
if ( |
||||
|
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests |
||||
|
) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), |
||||
|
StatusCodes.TOO_MANY_REQUESTS |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
const result = await this.ghostfolioService.lookup({ |
||||
|
includeIndices, |
||||
|
query: query.toLowerCase() |
||||
|
}); |
||||
|
|
||||
|
await this.ghostfolioService.incrementDailyRequests({ |
||||
|
userId: this.request.user.id |
||||
|
}); |
||||
|
|
||||
|
return result; |
||||
|
} catch { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), |
||||
|
StatusCodes.INTERNAL_SERVER_ERROR |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* @deprecated |
||||
|
*/ |
||||
|
@Get('quotes') |
||||
|
@HasPermission(permissions.enableDataProviderGhostfolio) |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
public async getQuotesV1( |
||||
|
@Query() query: GetQuotesDto |
||||
|
): Promise<QuotesResponse> { |
||||
|
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); |
||||
|
|
||||
|
if ( |
||||
|
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests |
||||
|
) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), |
||||
|
StatusCodes.TOO_MANY_REQUESTS |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
const quotes = await this.ghostfolioService.getQuotes({ |
||||
|
symbols: query.symbols |
||||
|
}); |
||||
|
|
||||
|
await this.ghostfolioService.incrementDailyRequests({ |
||||
|
userId: this.request.user.id |
||||
|
}); |
||||
|
|
||||
|
return quotes; |
||||
|
} catch { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), |
||||
|
StatusCodes.INTERNAL_SERVER_ERROR |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Get('quotes') |
||||
|
@HasPermission(permissions.enableDataProviderGhostfolio) |
||||
|
@UseGuards(AuthGuard('api-key'), HasPermissionGuard) |
||||
|
@Version('2') |
||||
|
public async getQuotes( |
||||
|
@Query() query: GetQuotesDto |
||||
|
): Promise<QuotesResponse> { |
||||
|
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); |
||||
|
|
||||
|
if ( |
||||
|
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests |
||||
|
) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), |
||||
|
StatusCodes.TOO_MANY_REQUESTS |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
const quotes = await this.ghostfolioService.getQuotes({ |
||||
|
symbols: query.symbols |
||||
|
}); |
||||
|
|
||||
|
await this.ghostfolioService.incrementDailyRequests({ |
||||
|
userId: this.request.user.id |
||||
|
}); |
||||
|
|
||||
|
return quotes; |
||||
|
} catch { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), |
||||
|
StatusCodes.INTERNAL_SERVER_ERROR |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* @deprecated |
||||
|
*/ |
||||
|
@Get('status') |
||||
|
@HasPermission(permissions.enableDataProviderGhostfolio) |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
public async getStatusV1(): Promise<DataProviderGhostfolioStatusResponse> { |
||||
|
return this.ghostfolioService.getStatus({ user: this.request.user }); |
||||
|
} |
||||
|
|
||||
|
@Get('status') |
||||
|
@HasPermission(permissions.enableDataProviderGhostfolio) |
||||
|
@UseGuards(AuthGuard('api-key'), HasPermissionGuard) |
||||
|
@Version('2') |
||||
|
public async getStatus(): Promise<DataProviderGhostfolioStatusResponse> { |
||||
|
return this.ghostfolioService.getStatus({ user: this.request.user }); |
||||
|
} |
||||
|
} |
@ -0,0 +1,83 @@ |
|||||
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; |
||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
||||
|
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module'; |
||||
|
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service'; |
||||
|
import { CoinGeckoService } from '@ghostfolio/api/services/data-provider/coingecko/coingecko.service'; |
||||
|
import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service'; |
||||
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; |
||||
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; |
||||
|
import { EodHistoricalDataService } from '@ghostfolio/api/services/data-provider/eod-historical-data/eod-historical-data.service'; |
||||
|
import { FinancialModelingPrepService } from '@ghostfolio/api/services/data-provider/financial-modeling-prep/financial-modeling-prep.service'; |
||||
|
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service'; |
||||
|
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service'; |
||||
|
import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service'; |
||||
|
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service'; |
||||
|
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; |
||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
||||
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; |
||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; |
||||
|
|
||||
|
import { Module } from '@nestjs/common'; |
||||
|
|
||||
|
import { GhostfolioController } from './ghostfolio.controller'; |
||||
|
import { GhostfolioService } from './ghostfolio.service'; |
||||
|
|
||||
|
@Module({ |
||||
|
controllers: [GhostfolioController], |
||||
|
imports: [ |
||||
|
CryptocurrencyModule, |
||||
|
DataProviderModule, |
||||
|
MarketDataModule, |
||||
|
PrismaModule, |
||||
|
PropertyModule, |
||||
|
RedisCacheModule, |
||||
|
SymbolProfileModule |
||||
|
], |
||||
|
providers: [ |
||||
|
AlphaVantageService, |
||||
|
CoinGeckoService, |
||||
|
ConfigurationService, |
||||
|
DataProviderService, |
||||
|
EodHistoricalDataService, |
||||
|
FinancialModelingPrepService, |
||||
|
GhostfolioService, |
||||
|
GoogleSheetsService, |
||||
|
ManualService, |
||||
|
RapidApiService, |
||||
|
YahooFinanceService, |
||||
|
YahooFinanceDataEnhancerService, |
||||
|
{ |
||||
|
inject: [ |
||||
|
AlphaVantageService, |
||||
|
CoinGeckoService, |
||||
|
EodHistoricalDataService, |
||||
|
FinancialModelingPrepService, |
||||
|
GoogleSheetsService, |
||||
|
ManualService, |
||||
|
RapidApiService, |
||||
|
YahooFinanceService |
||||
|
], |
||||
|
provide: 'DataProviderInterfaces', |
||||
|
useFactory: ( |
||||
|
alphaVantageService, |
||||
|
coinGeckoService, |
||||
|
eodHistoricalDataService, |
||||
|
financialModelingPrepService, |
||||
|
googleSheetsService, |
||||
|
manualService, |
||||
|
rapidApiService, |
||||
|
yahooFinanceService |
||||
|
) => [ |
||||
|
alphaVantageService, |
||||
|
coinGeckoService, |
||||
|
eodHistoricalDataService, |
||||
|
financialModelingPrepService, |
||||
|
googleSheetsService, |
||||
|
manualService, |
||||
|
rapidApiService, |
||||
|
yahooFinanceService |
||||
|
] |
||||
|
} |
||||
|
] |
||||
|
}) |
||||
|
export class GhostfolioModule {} |
@ -0,0 +1,303 @@ |
|||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
||||
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; |
||||
|
import { |
||||
|
GetDividendsParams, |
||||
|
GetHistoricalParams, |
||||
|
GetQuotesParams, |
||||
|
GetSearchParams |
||||
|
} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; |
||||
|
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; |
||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
||||
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; |
||||
|
import { |
||||
|
DEFAULT_CURRENCY, |
||||
|
DERIVED_CURRENCIES |
||||
|
} from '@ghostfolio/common/config'; |
||||
|
import { PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS } from '@ghostfolio/common/config'; |
||||
|
import { |
||||
|
DataProviderInfo, |
||||
|
DividendsResponse, |
||||
|
HistoricalResponse, |
||||
|
LookupItem, |
||||
|
LookupResponse, |
||||
|
QuotesResponse |
||||
|
} from '@ghostfolio/common/interfaces'; |
||||
|
import { UserWithSettings } from '@ghostfolio/common/types'; |
||||
|
|
||||
|
import { Injectable, Logger } from '@nestjs/common'; |
||||
|
import { DataSource } from '@prisma/client'; |
||||
|
import { Big } from 'big.js'; |
||||
|
|
||||
|
@Injectable() |
||||
|
export class GhostfolioService { |
||||
|
public constructor( |
||||
|
private readonly configurationService: ConfigurationService, |
||||
|
private readonly dataProviderService: DataProviderService, |
||||
|
private readonly prismaService: PrismaService, |
||||
|
private readonly propertyService: PropertyService |
||||
|
) {} |
||||
|
|
||||
|
public async getDividends({ |
||||
|
from, |
||||
|
granularity, |
||||
|
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'), |
||||
|
symbol, |
||||
|
to |
||||
|
}: GetDividendsParams) { |
||||
|
const result: DividendsResponse = { dividends: {} }; |
||||
|
|
||||
|
try { |
||||
|
const promises: Promise<{ |
||||
|
[date: string]: IDataProviderHistoricalResponse; |
||||
|
}>[] = []; |
||||
|
|
||||
|
for (const dataProviderService of this.getDataProviderServices()) { |
||||
|
promises.push( |
||||
|
dataProviderService |
||||
|
.getDividends({ |
||||
|
from, |
||||
|
granularity, |
||||
|
requestTimeout, |
||||
|
symbol, |
||||
|
to |
||||
|
}) |
||||
|
.then((dividends) => { |
||||
|
result.dividends = dividends; |
||||
|
|
||||
|
return dividends; |
||||
|
}) |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
await Promise.all(promises); |
||||
|
|
||||
|
return result; |
||||
|
} catch (error) { |
||||
|
Logger.error(error, 'GhostfolioService'); |
||||
|
|
||||
|
throw error; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public async getHistorical({ |
||||
|
from, |
||||
|
granularity, |
||||
|
requestTimeout, |
||||
|
to, |
||||
|
symbol |
||||
|
}: GetHistoricalParams) { |
||||
|
const result: HistoricalResponse = { historicalData: {} }; |
||||
|
|
||||
|
try { |
||||
|
const promises: Promise<{ |
||||
|
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; |
||||
|
}>[] = []; |
||||
|
|
||||
|
for (const dataProviderService of this.getDataProviderServices()) { |
||||
|
promises.push( |
||||
|
dataProviderService |
||||
|
.getHistorical({ |
||||
|
from, |
||||
|
granularity, |
||||
|
requestTimeout, |
||||
|
symbol, |
||||
|
to |
||||
|
}) |
||||
|
.then((historicalData) => { |
||||
|
result.historicalData = historicalData[symbol]; |
||||
|
|
||||
|
return historicalData; |
||||
|
}) |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
await Promise.all(promises); |
||||
|
|
||||
|
return result; |
||||
|
} catch (error) { |
||||
|
Logger.error(error, 'GhostfolioService'); |
||||
|
|
||||
|
throw error; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public async getMaxDailyRequests() { |
||||
|
return parseInt( |
||||
|
((await this.propertyService.getByKey( |
||||
|
PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS |
||||
|
)) as string) || '0', |
||||
|
10 |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
public async getQuotes({ requestTimeout, symbols }: GetQuotesParams) { |
||||
|
const results: QuotesResponse = { quotes: {} }; |
||||
|
|
||||
|
try { |
||||
|
const promises: Promise<any>[] = []; |
||||
|
|
||||
|
for (const dataProvider of this.getDataProviderServices()) { |
||||
|
const maximumNumberOfSymbolsPerRequest = |
||||
|
dataProvider.getMaxNumberOfSymbolsPerRequest?.() ?? |
||||
|
Number.MAX_SAFE_INTEGER; |
||||
|
|
||||
|
for ( |
||||
|
let i = 0; |
||||
|
i < symbols.length; |
||||
|
i += maximumNumberOfSymbolsPerRequest |
||||
|
) { |
||||
|
const symbolsChunk = symbols.slice( |
||||
|
i, |
||||
|
i + maximumNumberOfSymbolsPerRequest |
||||
|
); |
||||
|
|
||||
|
const promise = Promise.resolve( |
||||
|
dataProvider.getQuotes({ requestTimeout, symbols: symbolsChunk }) |
||||
|
); |
||||
|
|
||||
|
promises.push( |
||||
|
promise.then(async (result) => { |
||||
|
for (const [symbol, dataProviderResponse] of Object.entries( |
||||
|
result |
||||
|
)) { |
||||
|
dataProviderResponse.dataSource = 'GHOSTFOLIO'; |
||||
|
|
||||
|
if ( |
||||
|
[ |
||||
|
...DERIVED_CURRENCIES.map(({ currency }) => { |
||||
|
return `${DEFAULT_CURRENCY}${currency}`; |
||||
|
}), |
||||
|
`${DEFAULT_CURRENCY}USX` |
||||
|
].includes(symbol) |
||||
|
) { |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
results.quotes[symbol] = dataProviderResponse; |
||||
|
|
||||
|
for (const { |
||||
|
currency, |
||||
|
factor, |
||||
|
rootCurrency |
||||
|
} of DERIVED_CURRENCIES) { |
||||
|
if (symbol === `${DEFAULT_CURRENCY}${rootCurrency}`) { |
||||
|
results.quotes[`${DEFAULT_CURRENCY}${currency}`] = { |
||||
|
...dataProviderResponse, |
||||
|
currency, |
||||
|
marketPrice: new Big( |
||||
|
result[`${DEFAULT_CURRENCY}${rootCurrency}`].marketPrice |
||||
|
) |
||||
|
.mul(factor) |
||||
|
.toNumber(), |
||||
|
marketState: 'open' |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
}) |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
await Promise.all(promises); |
||||
|
} |
||||
|
|
||||
|
return results; |
||||
|
} catch (error) { |
||||
|
Logger.error(error, 'GhostfolioService'); |
||||
|
|
||||
|
throw error; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public async getStatus({ user }: { user: UserWithSettings }) { |
||||
|
return { |
||||
|
dailyRequests: user.dataProviderGhostfolioDailyRequests, |
||||
|
dailyRequestsMax: await this.getMaxDailyRequests(), |
||||
|
subscription: user.subscription |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
public async incrementDailyRequests({ userId }: { userId: string }) { |
||||
|
await this.prismaService.analytics.update({ |
||||
|
data: { |
||||
|
dataProviderGhostfolioDailyRequests: { increment: 1 } |
||||
|
}, |
||||
|
where: { userId } |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public async lookup({ |
||||
|
includeIndices = false, |
||||
|
query |
||||
|
}: GetSearchParams): Promise<LookupResponse> { |
||||
|
const results: LookupResponse = { items: [] }; |
||||
|
|
||||
|
if (!query) { |
||||
|
return results; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
let lookupItems: LookupItem[] = []; |
||||
|
const promises: Promise<{ items: LookupItem[] }>[] = []; |
||||
|
|
||||
|
if (query?.length < 2) { |
||||
|
return { items: lookupItems }; |
||||
|
} |
||||
|
|
||||
|
for (const dataProviderService of this.getDataProviderServices()) { |
||||
|
promises.push( |
||||
|
dataProviderService.search({ |
||||
|
includeIndices, |
||||
|
query |
||||
|
}) |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const searchResults = await Promise.all(promises); |
||||
|
|
||||
|
for (const { items } of searchResults) { |
||||
|
if (items?.length > 0) { |
||||
|
lookupItems = lookupItems.concat(items); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const filteredItems = lookupItems |
||||
|
.filter(({ currency }) => { |
||||
|
// Only allow symbols with supported currency
|
||||
|
return currency ? true : false; |
||||
|
}) |
||||
|
.sort(({ name: name1 }, { name: name2 }) => { |
||||
|
return name1?.toLowerCase().localeCompare(name2?.toLowerCase()); |
||||
|
}) |
||||
|
.map((lookupItem) => { |
||||
|
lookupItem.dataProviderInfo = this.getDataProviderInfo(); |
||||
|
lookupItem.dataSource = 'GHOSTFOLIO'; |
||||
|
|
||||
|
return lookupItem; |
||||
|
}); |
||||
|
|
||||
|
results.items = filteredItems; |
||||
|
return results; |
||||
|
} catch (error) { |
||||
|
Logger.error(error, 'GhostfolioService'); |
||||
|
|
||||
|
throw error; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private getDataProviderInfo(): DataProviderInfo { |
||||
|
return { |
||||
|
isPremium: false, |
||||
|
name: 'Ghostfolio Premium', |
||||
|
url: 'https://ghostfol.io' |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
private getDataProviderServices() { |
||||
|
return this.configurationService |
||||
|
.get('DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER') |
||||
|
.map((dataSource) => { |
||||
|
return this.dataProviderService.getDataProvider(DataSource[dataSource]); |
||||
|
}); |
||||
|
} |
||||
|
} |
@ -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; |
||||
|
} |
File diff suppressed because it is too large
@ -0,0 +1,14 @@ |
|||||
|
import { randomBytes } from 'crypto'; |
||||
|
|
||||
|
export function getRandomString(length: number) { |
||||
|
const bytes = randomBytes(length); |
||||
|
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; |
||||
|
const result = []; |
||||
|
|
||||
|
for (let i = 0; i < length; i++) { |
||||
|
const randomByte = bytes[i]; |
||||
|
result.push(characters[randomByte % characters.length]); |
||||
|
} |
||||
|
|
||||
|
return result.join(''); |
||||
|
} |
@ -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,12 @@ |
|||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
||||
|
|
||||
|
import { Module } from '@nestjs/common'; |
||||
|
|
||||
|
import { ApiKeyService } from './api-key.service'; |
||||
|
|
||||
|
@Module({ |
||||
|
exports: [ApiKeyService], |
||||
|
imports: [PrismaModule], |
||||
|
providers: [ApiKeyService] |
||||
|
}) |
||||
|
export class ApiKeyModule {} |
@ -0,0 +1,63 @@ |
|||||
|
import { getRandomString } from '@ghostfolio/api/helper/string.helper'; |
||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
||||
|
import { ApiKeyResponse } from '@ghostfolio/common/interfaces'; |
||||
|
|
||||
|
import { Injectable } from '@nestjs/common'; |
||||
|
import { pbkdf2Sync } from 'crypto'; |
||||
|
|
||||
|
@Injectable() |
||||
|
export class ApiKeyService { |
||||
|
private readonly algorithm = 'sha256'; |
||||
|
private readonly iterations = 100000; |
||||
|
private readonly keyLength = 64; |
||||
|
|
||||
|
public constructor(private readonly prismaService: PrismaService) {} |
||||
|
|
||||
|
public async create({ userId }: { userId: string }): Promise<ApiKeyResponse> { |
||||
|
const apiKey = this.generateApiKey(); |
||||
|
const hashedKey = this.hashApiKey(apiKey); |
||||
|
|
||||
|
await this.prismaService.apiKey.deleteMany({ where: { userId } }); |
||||
|
|
||||
|
await this.prismaService.apiKey.create({ |
||||
|
data: { |
||||
|
hashedKey, |
||||
|
userId |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
return { apiKey }; |
||||
|
} |
||||
|
|
||||
|
public async getUserByApiKey(apiKey: string) { |
||||
|
const hashedKey = this.hashApiKey(apiKey); |
||||
|
|
||||
|
const { user } = await this.prismaService.apiKey.findUnique({ |
||||
|
include: { user: true }, |
||||
|
where: { hashedKey } |
||||
|
}); |
||||
|
|
||||
|
return user; |
||||
|
} |
||||
|
|
||||
|
public hashApiKey(apiKey: string): string { |
||||
|
return pbkdf2Sync( |
||||
|
apiKey, |
||||
|
'', |
||||
|
this.iterations, |
||||
|
this.keyLength, |
||||
|
this.algorithm |
||||
|
).toString('hex'); |
||||
|
} |
||||
|
|
||||
|
private generateApiKey(): string { |
||||
|
return getRandomString(32) |
||||
|
.split('') |
||||
|
.reduce((acc, char, index) => { |
||||
|
const chunkIndex = Math.floor(index / 4); |
||||
|
acc[chunkIndex] = (acc[chunkIndex] || '') + char; |
||||
|
return acc; |
||||
|
}, []) |
||||
|
.join('-'); |
||||
|
} |
||||
|
} |
@ -0,0 +1,277 @@ |
|||||
|
import { environment } from '@ghostfolio/api/environments/environment'; |
||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
||||
|
import { |
||||
|
DataProviderInterface, |
||||
|
GetDividendsParams, |
||||
|
GetHistoricalParams, |
||||
|
GetQuotesParams, |
||||
|
GetSearchParams |
||||
|
} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; |
||||
|
import { |
||||
|
IDataProviderHistoricalResponse, |
||||
|
IDataProviderResponse |
||||
|
} from '@ghostfolio/api/services/interfaces/interfaces'; |
||||
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; |
||||
|
import { |
||||
|
HEADER_KEY_TOKEN, |
||||
|
PROPERTY_API_KEY_GHOSTFOLIO |
||||
|
} from '@ghostfolio/common/config'; |
||||
|
import { DATE_FORMAT } from '@ghostfolio/common/helper'; |
||||
|
import { |
||||
|
DataProviderInfo, |
||||
|
DividendsResponse, |
||||
|
HistoricalResponse, |
||||
|
LookupResponse, |
||||
|
QuotesResponse |
||||
|
} from '@ghostfolio/common/interfaces'; |
||||
|
|
||||
|
import { Injectable, Logger } from '@nestjs/common'; |
||||
|
import { DataSource, SymbolProfile } from '@prisma/client'; |
||||
|
import { format } from 'date-fns'; |
||||
|
import { StatusCodes } from 'http-status-codes'; |
||||
|
|
||||
|
@Injectable() |
||||
|
export class GhostfolioService implements DataProviderInterface { |
||||
|
private readonly URL = environment.production |
||||
|
? 'https://ghostfol.io/api' |
||||
|
: `${this.configurationService.get('ROOT_URL')}/api`; |
||||
|
|
||||
|
public constructor( |
||||
|
private readonly configurationService: ConfigurationService, |
||||
|
private readonly propertyService: PropertyService |
||||
|
) {} |
||||
|
|
||||
|
public canHandle() { |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
public async getAssetProfile({ |
||||
|
symbol |
||||
|
}: { |
||||
|
symbol: string; |
||||
|
}): Promise<Partial<SymbolProfile>> { |
||||
|
const { items } = await this.search({ query: symbol }); |
||||
|
const searchResult = items?.[0]; |
||||
|
|
||||
|
return { |
||||
|
symbol, |
||||
|
assetClass: searchResult?.assetClass, |
||||
|
assetSubClass: searchResult?.assetSubClass, |
||||
|
currency: searchResult?.currency, |
||||
|
dataSource: this.getName(), |
||||
|
name: searchResult?.name |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
public getDataProviderInfo(): DataProviderInfo { |
||||
|
return { |
||||
|
isPremium: true, |
||||
|
name: 'Ghostfolio', |
||||
|
url: 'https://ghostfo.io' |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
public async getDividends({ |
||||
|
from, |
||||
|
granularity = 'day', |
||||
|
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'), |
||||
|
symbol, |
||||
|
to |
||||
|
}: GetDividendsParams): Promise<{ |
||||
|
[date: string]: IDataProviderHistoricalResponse; |
||||
|
}> { |
||||
|
let response: { |
||||
|
[date: string]: IDataProviderHistoricalResponse; |
||||
|
} = {}; |
||||
|
|
||||
|
try { |
||||
|
const { dividends } = (await fetch( |
||||
|
`${this.URL}/v2/data-providers/ghostfolio/dividends/${symbol}?from=${format(from, DATE_FORMAT)}&granularity=${granularity}&to=${format( |
||||
|
to, |
||||
|
DATE_FORMAT |
||||
|
)}`,
|
||||
|
{ |
||||
|
headers: await this.getRequestHeaders(), |
||||
|
signal: AbortSignal.timeout(requestTimeout) |
||||
|
} |
||||
|
).then((res) => res.json())) as DividendsResponse; |
||||
|
|
||||
|
response = dividends; |
||||
|
} catch (error) { |
||||
|
let message = error; |
||||
|
|
||||
|
if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) { |
||||
|
message = 'RequestError: The daily request limit has been exceeded'; |
||||
|
} else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) { |
||||
|
if (!error.request?.options?.headers?.authorization?.includes('-')) { |
||||
|
message = |
||||
|
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.'; |
||||
|
} else { |
||||
|
message = |
||||
|
'RequestError: The provided API key has expired. Please request a new one and update it in the Settings section of the Admin Control panel.'; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Logger.error(message, 'GhostfolioService'); |
||||
|
} |
||||
|
|
||||
|
return response; |
||||
|
} |
||||
|
|
||||
|
public async getHistorical({ |
||||
|
from, |
||||
|
granularity = 'day', |
||||
|
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'), |
||||
|
symbol, |
||||
|
to |
||||
|
}: GetHistoricalParams): Promise<{ |
||||
|
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; |
||||
|
}> { |
||||
|
try { |
||||
|
const { historicalData } = (await fetch( |
||||
|
`${this.URL}/v2/data-providers/ghostfolio/historical/${symbol}?from=${format(from, DATE_FORMAT)}&granularity=${granularity}&to=${format( |
||||
|
to, |
||||
|
DATE_FORMAT |
||||
|
)}`,
|
||||
|
{ |
||||
|
headers: await this.getRequestHeaders(), |
||||
|
signal: AbortSignal.timeout(requestTimeout) |
||||
|
} |
||||
|
).then((res) => res.json())) as HistoricalResponse; |
||||
|
|
||||
|
return { |
||||
|
[symbol]: historicalData |
||||
|
}; |
||||
|
} catch (error) { |
||||
|
let message = error; |
||||
|
|
||||
|
if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) { |
||||
|
message = 'RequestError: The daily request limit has been exceeded'; |
||||
|
} else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) { |
||||
|
if (!error.request?.options?.headers?.authorization?.includes('-')) { |
||||
|
message = |
||||
|
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.'; |
||||
|
} else { |
||||
|
message = |
||||
|
'RequestError: The provided API key has expired. Please request a new one and update it in the Settings section of the Admin Control panel.'; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Logger.error(message, 'GhostfolioService'); |
||||
|
|
||||
|
throw new Error( |
||||
|
`Could not get historical market data for ${symbol} (${this.getName()}) from ${format( |
||||
|
from, |
||||
|
DATE_FORMAT |
||||
|
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}` |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public getMaxNumberOfSymbolsPerRequest() { |
||||
|
return 20; |
||||
|
} |
||||
|
|
||||
|
public getName(): DataSource { |
||||
|
return DataSource.GHOSTFOLIO; |
||||
|
} |
||||
|
|
||||
|
public async getQuotes({ |
||||
|
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'), |
||||
|
symbols |
||||
|
}: GetQuotesParams): Promise<{ |
||||
|
[symbol: string]: IDataProviderResponse; |
||||
|
}> { |
||||
|
let response: { [symbol: string]: IDataProviderResponse } = {}; |
||||
|
|
||||
|
if (symbols.length <= 0) { |
||||
|
return response; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
const { quotes } = (await fetch( |
||||
|
`${this.URL}/v2/data-providers/ghostfolio/quotes?symbols=${symbols.join(',')}`, |
||||
|
{ |
||||
|
headers: await this.getRequestHeaders(), |
||||
|
signal: AbortSignal.timeout(requestTimeout) |
||||
|
} |
||||
|
).then((res) => res.json())) as QuotesResponse; |
||||
|
|
||||
|
response = quotes; |
||||
|
} catch (error) { |
||||
|
let message = error; |
||||
|
|
||||
|
if (error.name === 'AbortError') { |
||||
|
message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${( |
||||
|
this.configurationService.get('REQUEST_TIMEOUT') / 1000 |
||||
|
).toFixed(3)} seconds`;
|
||||
|
} else if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) { |
||||
|
message = 'RequestError: The daily request limit has been exceeded'; |
||||
|
} else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) { |
||||
|
if (!error.request?.options?.headers?.authorization?.includes('-')) { |
||||
|
message = |
||||
|
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.'; |
||||
|
} else { |
||||
|
message = |
||||
|
'RequestError: The provided API key has expired. Please request a new one and update it in the Settings section of the Admin Control panel.'; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Logger.error(message, 'GhostfolioService'); |
||||
|
} |
||||
|
|
||||
|
return response; |
||||
|
} |
||||
|
|
||||
|
public getTestSymbol() { |
||||
|
return 'AAPL.US'; |
||||
|
} |
||||
|
|
||||
|
public async search({ query }: GetSearchParams): Promise<LookupResponse> { |
||||
|
let searchResult: LookupResponse = { items: [] }; |
||||
|
|
||||
|
try { |
||||
|
searchResult = (await fetch( |
||||
|
`${this.URL}/v2/data-providers/ghostfolio/lookup?query=${query}`, |
||||
|
{ |
||||
|
headers: await this.getRequestHeaders(), |
||||
|
signal: AbortSignal.timeout( |
||||
|
this.configurationService.get('REQUEST_TIMEOUT') |
||||
|
) |
||||
|
} |
||||
|
).then((res) => res.json())) as LookupResponse; |
||||
|
} catch (error) { |
||||
|
let message = error; |
||||
|
|
||||
|
if (error.name === 'AbortError') { |
||||
|
message = `RequestError: The operation to search for ${query} was aborted because the request to the data provider took more than ${( |
||||
|
this.configurationService.get('REQUEST_TIMEOUT') / 1000 |
||||
|
).toFixed(3)} seconds`;
|
||||
|
} else if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) { |
||||
|
message = 'RequestError: The daily request limit has been exceeded'; |
||||
|
} else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) { |
||||
|
if (!error.request?.options?.headers?.authorization?.includes('-')) { |
||||
|
message = |
||||
|
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.'; |
||||
|
} else { |
||||
|
message = |
||||
|
'RequestError: The provided API key has expired. Please request a new one and update it in the Settings section of the Admin Control panel.'; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Logger.error(message, 'GhostfolioService'); |
||||
|
} |
||||
|
|
||||
|
return searchResult; |
||||
|
} |
||||
|
|
||||
|
private async getRequestHeaders() { |
||||
|
const apiKey = (await this.propertyService.getByKey( |
||||
|
PROPERTY_API_KEY_GHOSTFOLIO |
||||
|
)) as string; |
||||
|
|
||||
|
return { |
||||
|
[HEADER_KEY_TOKEN]: `Api-Key ${apiKey}` |
||||
|
}; |
||||
|
} |
||||
|
} |
@ -1,40 +0,0 @@ |
|||||
{ |
|
||||
"extends": ["../../.eslintrc.json"], |
|
||||
"ignorePatterns": ["!**/*"], |
|
||||
"overrides": [ |
|
||||
{ |
|
||||
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"], |
|
||||
"parserOptions": { |
|
||||
"project": ["apps/client/tsconfig.*?.json"] |
|
||||
}, |
|
||||
"rules": {} |
|
||||
}, |
|
||||
{ |
|
||||
"files": ["*.ts", "*.tsx"], |
|
||||
"rules": {} |
|
||||
}, |
|
||||
{ |
|
||||
"files": ["*.js", "*.jsx"], |
|
||||
"rules": {} |
|
||||
} |
|
||||
], |
|
||||
"plugins": ["@angular-eslint/eslint-plugin", "@typescript-eslint"], |
|
||||
"rules": { |
|
||||
"@angular-eslint/component-selector": [ |
|
||||
"error", |
|
||||
{ |
|
||||
"type": "element", |
|
||||
"prefix": "gf", |
|
||||
"style": "kebab-case" |
|
||||
} |
|
||||
], |
|
||||
"@angular-eslint/directive-selector": [ |
|
||||
"error", |
|
||||
{ |
|
||||
"type": "attribute", |
|
||||
"prefix": "gf", |
|
||||
"style": "camelCase" |
|
||||
} |
|
||||
] |
|
||||
} |
|
||||
} |
|
@ -0,0 +1,62 @@ |
|||||
|
const baseConfig = require('../../eslint.config.cjs'); |
||||
|
const angularEslintPlugin = require('@angular-eslint/eslint-plugin'); |
||||
|
const typescriptEslintPlugin = require('@typescript-eslint/eslint-plugin'); |
||||
|
|
||||
|
module.exports = [ |
||||
|
{ |
||||
|
ignores: ['**/dist'] |
||||
|
}, |
||||
|
...baseConfig, |
||||
|
{ |
||||
|
plugins: { |
||||
|
'@angular-eslint': angularEslintPlugin, |
||||
|
'@typescript-eslint': typescriptEslintPlugin |
||||
|
} |
||||
|
}, |
||||
|
{ |
||||
|
rules: { |
||||
|
'@angular-eslint/component-selector': [ |
||||
|
'error', |
||||
|
{ |
||||
|
type: 'element', |
||||
|
prefix: 'gf', |
||||
|
style: 'kebab-case' |
||||
|
} |
||||
|
], |
||||
|
'@angular-eslint/directive-selector': [ |
||||
|
'error', |
||||
|
{ |
||||
|
type: 'attribute', |
||||
|
prefix: 'gf', |
||||
|
style: 'camelCase' |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
}, |
||||
|
{ |
||||
|
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], |
||||
|
// Override or add rules here |
||||
|
rules: {}, |
||||
|
languageOptions: { |
||||
|
parserOptions: { |
||||
|
project: ['apps/client/tsconfig.*?.json'] |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
{ |
||||
|
files: ['**/*.ts', '**/*.tsx'], |
||||
|
// Override or add rules here |
||||
|
rules: {} |
||||
|
}, |
||||
|
{ |
||||
|
files: ['**/*.js', '**/*.jsx'], |
||||
|
// Override or add rules here |
||||
|
rules: {} |
||||
|
}, |
||||
|
{ |
||||
|
files: ['**/*.ts'], |
||||
|
rules: { |
||||
|
'@angular-eslint/prefer-standalone': 'off' |
||||
|
} |
||||
|
} |
||||
|
]; |
@ -1,15 +0,0 @@ |
|||||
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart'; |
|
||||
|
|
||||
import { CommonModule } from '@angular/common'; |
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; |
|
||||
|
|
||||
import { AdminMarketDataDetailComponent } from './admin-market-data-detail.component'; |
|
||||
import { GfMarketDataDetailDialogModule } from './market-data-detail-dialog/market-data-detail-dialog.module'; |
|
||||
|
|
||||
@NgModule({ |
|
||||
declarations: [AdminMarketDataDetailComponent], |
|
||||
exports: [AdminMarketDataDetailComponent], |
|
||||
imports: [CommonModule, GfLineChartComponent, GfMarketDataDetailDialogModule], |
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA] |
|
||||
}) |
|
||||
export class GfAdminMarketDataDetailModule {} |
|
@ -1,26 +0,0 @@ |
|||||
import { CommonModule } from '@angular/common'; |
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; |
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; |
|
||||
import { MatButtonModule } from '@angular/material/button'; |
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker'; |
|
||||
import { MatDialogModule } from '@angular/material/dialog'; |
|
||||
import { MatFormFieldModule } from '@angular/material/form-field'; |
|
||||
import { MatInputModule } from '@angular/material/input'; |
|
||||
|
|
||||
import { MarketDataDetailDialog } from './market-data-detail-dialog.component'; |
|
||||
|
|
||||
@NgModule({ |
|
||||
declarations: [MarketDataDetailDialog], |
|
||||
imports: [ |
|
||||
CommonModule, |
|
||||
FormsModule, |
|
||||
MatButtonModule, |
|
||||
MatDatepickerModule, |
|
||||
MatDialogModule, |
|
||||
MatFormFieldModule, |
|
||||
MatInputModule, |
|
||||
ReactiveFormsModule |
|
||||
], |
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA] |
|
||||
}) |
|
||||
export class GfMarketDataDetailDialogModule {} |
|
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue