mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
933 changed files with 135084 additions and 81754 deletions
@ -1,157 +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/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/consistent-type-definitions": "warn", |
|||
"@typescript-eslint/prefer-function-type": "warn", |
|||
"@typescript-eslint/no-empty-function": "warn", |
|||
"@typescript-eslint/prefer-nullish-coalescing": "warn", // TODO: Requires strictNullChecks: true |
|||
"@typescript-eslint/consistent-type-assertions": "warn", |
|||
"@typescript-eslint/prefer-optional-chain": "warn", |
|||
"@typescript-eslint/consistent-indexed-object-style": "warn", |
|||
"@typescript-eslint/consistent-generic-constructors": "warn" |
|||
} |
|||
} |
|||
], |
|||
"extends": ["plugin:storybook/recommended"] |
|||
} |
|||
@ -1 +1,2 @@ |
|||
custom: ['https://www.buymeacoffee.com/ghostfolio'] |
|||
buy_me_a_coffee: ghostfolio |
|||
github: ghostfolio |
|||
|
|||
@ -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,6 +1,6 @@ |
|||
# Run linting and stop the commit process if any errors are found |
|||
# --quiet suppresses warnings (temporary until all warnings are fixed) |
|||
npm run lint --quiet || exit 1 |
|||
npm run affected:lint --base=main --head=HEAD --parallel=2 --quiet || exit 1 |
|||
|
|||
# Check formatting on modified and uncommitted files, stop the commit if issues are found |
|||
npm run format:check --uncommitted || exit 1 |
|||
|
|||
@ -1 +1 @@ |
|||
v20 |
|||
v22 |
|||
|
|||
File diff suppressed because it is too large
@ -0,0 +1,13 @@ |
|||
# Security Policy |
|||
|
|||
## Reporting Security Issues |
|||
|
|||
If you discover a security vulnerability in this repository, please report it to security[at]ghostfol.io. We will acknowledge your report and provide guidance on the next steps. |
|||
|
|||
To help us resolve the issue, please include the following details: |
|||
|
|||
- A description of the vulnerability |
|||
- Steps to reproduce the vulnerability |
|||
- Affected versions of the software |
|||
|
|||
We appreciate your responsible disclosure and will work to address the issue promptly. |
|||
@ -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,19 @@ |
|||
import { AccessPermission } from '@prisma/client'; |
|||
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; |
|||
|
|||
export class UpdateAccessDto { |
|||
@IsOptional() |
|||
@IsString() |
|||
alias?: string; |
|||
|
|||
@IsOptional() |
|||
@IsUUID() |
|||
granteeUserId?: string; |
|||
|
|||
@IsString() |
|||
id: string; |
|||
|
|||
@IsEnum(AccessPermission, { each: true }) |
|||
@IsOptional() |
|||
permissions?: AccessPermission[]; |
|||
} |
|||
@ -0,0 +1,92 @@ |
|||
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code'; |
|||
|
|||
import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client'; |
|||
import { |
|||
IsArray, |
|||
IsBoolean, |
|||
IsEnum, |
|||
IsObject, |
|||
IsOptional, |
|||
IsString, |
|||
IsUrl |
|||
} from 'class-validator'; |
|||
|
|||
export class CreateAssetProfileDto { |
|||
@IsEnum(AssetClass, { each: true }) |
|||
@IsOptional() |
|||
assetClass?: AssetClass; |
|||
|
|||
@IsEnum(AssetSubClass, { each: true }) |
|||
@IsOptional() |
|||
assetSubClass?: AssetSubClass; |
|||
|
|||
@IsOptional() |
|||
@IsString() |
|||
comment?: string; |
|||
|
|||
@IsArray() |
|||
@IsOptional() |
|||
countries?: Prisma.InputJsonArray; |
|||
|
|||
@IsCurrencyCode() |
|||
currency: string; |
|||
|
|||
@IsOptional() |
|||
@IsString() |
|||
cusip?: string; |
|||
|
|||
@IsEnum(DataSource) |
|||
dataSource: DataSource; |
|||
|
|||
@IsOptional() |
|||
@IsString() |
|||
figi?: string; |
|||
|
|||
@IsOptional() |
|||
@IsString() |
|||
figiComposite?: string; |
|||
|
|||
@IsOptional() |
|||
@IsString() |
|||
figiShareClass?: string; |
|||
|
|||
@IsArray() |
|||
@IsOptional() |
|||
holdings?: Prisma.InputJsonArray; |
|||
|
|||
@IsBoolean() |
|||
@IsOptional() |
|||
isActive?: boolean; |
|||
|
|||
@IsOptional() |
|||
@IsString() |
|||
isin?: string; |
|||
|
|||
@IsOptional() |
|||
@IsString() |
|||
name?: string; |
|||
|
|||
@IsObject() |
|||
@IsOptional() |
|||
scraperConfiguration?: Prisma.InputJsonObject; |
|||
|
|||
@IsArray() |
|||
@IsOptional() |
|||
sectors?: Prisma.InputJsonArray; |
|||
|
|||
@IsString() |
|||
symbol: string; |
|||
|
|||
@IsObject() |
|||
@IsOptional() |
|||
symbolMapping?: { |
|||
[dataProvider: string]: string; |
|||
}; |
|||
|
|||
@IsOptional() |
|||
@IsUrl({ |
|||
protocols: ['https'], |
|||
require_protocol: true |
|||
}) |
|||
url?: string; |
|||
} |
|||
@ -0,0 +1,70 @@ |
|||
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 ' }, false); |
|||
} |
|||
|
|||
public async validate(apiKey: string) { |
|||
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 } |
|||
}); |
|||
} |
|||
|
|||
return user; |
|||
} |
|||
|
|||
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,59 @@ |
|||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; |
|||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
|||
import { ApiService } from '@ghostfolio/api/services/api/api.service'; |
|||
import { AiPromptResponse } from '@ghostfolio/common/interfaces'; |
|||
import { permissions } from '@ghostfolio/common/permissions'; |
|||
import type { AiPromptMode, RequestWithUser } from '@ghostfolio/common/types'; |
|||
|
|||
import { |
|||
Controller, |
|||
Get, |
|||
Inject, |
|||
Param, |
|||
Query, |
|||
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, |
|||
private readonly apiService: ApiService, |
|||
@Inject(REQUEST) private readonly request: RequestWithUser |
|||
) {} |
|||
|
|||
@Get('prompt/:mode') |
|||
@HasPermission(permissions.readAiPrompt) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async getPrompt( |
|||
@Param('mode') mode: AiPromptMode, |
|||
@Query('accounts') filterByAccounts?: string, |
|||
@Query('assetClasses') filterByAssetClasses?: string, |
|||
@Query('dataSource') filterByDataSource?: string, |
|||
@Query('symbol') filterBySymbol?: string, |
|||
@Query('tags') filterByTags?: string |
|||
): Promise<AiPromptResponse> { |
|||
const filters = this.apiService.buildFiltersFromQueryParams({ |
|||
filterByAccounts, |
|||
filterByAssetClasses, |
|||
filterByDataSource, |
|||
filterBySymbol, |
|||
filterByTags |
|||
}); |
|||
|
|||
const prompt = await this.aiService.getPrompt({ |
|||
filters, |
|||
mode, |
|||
impersonationId: undefined, |
|||
languageCode: this.request.user.settings.settings.language, |
|||
userCurrency: this.request.user.settings.settings.baseCurrency, |
|||
userId: this.request.user.id |
|||
}); |
|||
|
|||
return { prompt }; |
|||
} |
|||
} |
|||
@ -0,0 +1,59 @@ |
|||
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 { ApiModule } from '@ghostfolio/api/services/api/api.module'; |
|||
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.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 { I18nModule } from '@ghostfolio/api/services/i18n/i18n.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 { PropertyModule } from '@ghostfolio/api/services/property/property.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: [ |
|||
ApiModule, |
|||
BenchmarkModule, |
|||
ConfigurationModule, |
|||
DataProviderModule, |
|||
ExchangeRateDataModule, |
|||
I18nModule, |
|||
ImpersonationModule, |
|||
MarketDataModule, |
|||
OrderModule, |
|||
PortfolioSnapshotQueueModule, |
|||
PrismaModule, |
|||
PropertyModule, |
|||
RedisCacheModule, |
|||
SymbolProfileModule, |
|||
UserModule |
|||
], |
|||
providers: [ |
|||
AccountBalanceService, |
|||
AccountService, |
|||
AiService, |
|||
CurrentRateService, |
|||
MarketDataService, |
|||
PortfolioCalculatorFactory, |
|||
PortfolioService, |
|||
RulesService |
|||
] |
|||
}) |
|||
export class AiModule {} |
|||
@ -0,0 +1,117 @@ |
|||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; |
|||
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; |
|||
import { |
|||
PROPERTY_API_KEY_OPENROUTER, |
|||
PROPERTY_OPENROUTER_MODEL |
|||
} from '@ghostfolio/common/config'; |
|||
import { Filter } from '@ghostfolio/common/interfaces'; |
|||
import type { AiPromptMode } from '@ghostfolio/common/types'; |
|||
|
|||
import { Injectable } from '@nestjs/common'; |
|||
import { createOpenRouter } from '@openrouter/ai-sdk-provider'; |
|||
import { generateText } from 'ai'; |
|||
import tablemark, { ColumnDescriptor } from 'tablemark'; |
|||
|
|||
@Injectable() |
|||
export class AiService { |
|||
public constructor( |
|||
private readonly portfolioService: PortfolioService, |
|||
private readonly propertyService: PropertyService |
|||
) {} |
|||
|
|||
public async generateText({ prompt }: { prompt: string }) { |
|||
const openRouterApiKey = await this.propertyService.getByKey<string>( |
|||
PROPERTY_API_KEY_OPENROUTER |
|||
); |
|||
|
|||
const openRouterModel = await this.propertyService.getByKey<string>( |
|||
PROPERTY_OPENROUTER_MODEL |
|||
); |
|||
|
|||
const openRouterService = createOpenRouter({ |
|||
apiKey: openRouterApiKey |
|||
}); |
|||
|
|||
return generateText({ |
|||
prompt, |
|||
model: openRouterService.chat(openRouterModel) |
|||
}); |
|||
} |
|||
|
|||
public async getPrompt({ |
|||
filters, |
|||
impersonationId, |
|||
languageCode, |
|||
mode, |
|||
userCurrency, |
|||
userId |
|||
}: { |
|||
filters?: Filter[]; |
|||
impersonationId: string; |
|||
languageCode: string; |
|||
mode: AiPromptMode; |
|||
userCurrency: string; |
|||
userId: string; |
|||
}) { |
|||
const { holdings } = await this.portfolioService.getDetails({ |
|||
filters, |
|||
impersonationId, |
|||
userId |
|||
}); |
|||
|
|||
const holdingsTableColumns: ColumnDescriptor[] = [ |
|||
{ name: 'Name' }, |
|||
{ name: 'Symbol' }, |
|||
{ name: 'Currency' }, |
|||
{ name: 'Asset Class' }, |
|||
{ name: 'Asset Sub Class' }, |
|||
{ align: 'right', name: 'Allocation in Percentage' } |
|||
]; |
|||
|
|||
const holdingsTableRows = Object.values(holdings) |
|||
.sort((a, b) => { |
|||
return b.allocationInPercentage - a.allocationInPercentage; |
|||
}) |
|||
.map( |
|||
({ |
|||
allocationInPercentage, |
|||
assetClass, |
|||
assetSubClass, |
|||
currency, |
|||
name, |
|||
symbol |
|||
}) => { |
|||
return { |
|||
Name: name, |
|||
Symbol: symbol, |
|||
Currency: currency, |
|||
'Asset Class': assetClass ?? '', |
|||
'Asset Sub Class': assetSubClass ?? '', |
|||
'Allocation in Percentage': `${(allocationInPercentage * 100).toFixed(3)}%` |
|||
}; |
|||
} |
|||
); |
|||
|
|||
const holdingsTableString = tablemark(holdingsTableRows, { |
|||
columns: holdingsTableColumns |
|||
}); |
|||
|
|||
if (mode === 'portfolio') { |
|||
return holdingsTableString; |
|||
} |
|||
|
|||
return [ |
|||
`You are a neutral financial assistant. Please analyze the following investment portfolio (base currency being ${userCurrency}) in simple words.`, |
|||
holdingsTableString, |
|||
'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,46 @@ |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
import { interpolate } from '@ghostfolio/common/helper'; |
|||
|
|||
import { |
|||
Controller, |
|||
Get, |
|||
Param, |
|||
Res, |
|||
Version, |
|||
VERSION_NEUTRAL |
|||
} from '@nestjs/common'; |
|||
import { Response } from 'express'; |
|||
import { readFileSync } from 'node:fs'; |
|||
import { join } from 'node:path'; |
|||
|
|||
@Controller('assets') |
|||
export class AssetsController { |
|||
private webManifest = ''; |
|||
|
|||
public constructor( |
|||
public readonly configurationService: ConfigurationService |
|||
) { |
|||
try { |
|||
this.webManifest = readFileSync( |
|||
join(__dirname, 'assets', 'site.webmanifest'), |
|||
'utf8' |
|||
); |
|||
} catch {} |
|||
} |
|||
|
|||
@Get('/:languageCode/site.webmanifest') |
|||
@Version(VERSION_NEUTRAL) |
|||
public getWebManifest( |
|||
@Param('languageCode') languageCode: string, |
|||
@Res() response: Response |
|||
): void { |
|||
const rootUrl = this.configurationService.get('ROOT_URL'); |
|||
const webManifest = interpolate(this.webManifest, { |
|||
languageCode, |
|||
rootUrl |
|||
}); |
|||
|
|||
response.setHeader('Content-Type', 'application/json'); |
|||
response.send(webManifest); |
|||
} |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
|
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { AssetsController } from './assets.controller'; |
|||
|
|||
@Module({ |
|||
controllers: [AssetsController], |
|||
providers: [ConfigurationService] |
|||
}) |
|||
export class AssetsModule {} |
|||
@ -0,0 +1,65 @@ |
|||
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 { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; |
|||
import { UserModule } from '@ghostfolio/api/app/user/user.module'; |
|||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; |
|||
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; |
|||
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; |
|||
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; |
|||
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 { I18nModule } from '@ghostfolio/api/services/i18n/i18n.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 { PropertyModule } from '@ghostfolio/api/services/property/property.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 { BenchmarksController } from './benchmarks.controller'; |
|||
import { BenchmarksService } from './benchmarks.service'; |
|||
|
|||
@Module({ |
|||
controllers: [BenchmarksController], |
|||
imports: [ |
|||
ApiModule, |
|||
ConfigurationModule, |
|||
DataProviderModule, |
|||
ExchangeRateDataModule, |
|||
I18nModule, |
|||
ImpersonationModule, |
|||
MarketDataModule, |
|||
OrderModule, |
|||
PortfolioSnapshotQueueModule, |
|||
PrismaModule, |
|||
PropertyModule, |
|||
RedisCacheModule, |
|||
SymbolModule, |
|||
SymbolProfileModule, |
|||
TransformDataSourceInRequestModule, |
|||
TransformDataSourceInResponseModule, |
|||
UserModule |
|||
], |
|||
providers: [ |
|||
AccountBalanceService, |
|||
AccountService, |
|||
BenchmarkService, |
|||
BenchmarksService, |
|||
CurrentRateService, |
|||
MarketDataService, |
|||
PortfolioCalculatorFactory, |
|||
PortfolioService, |
|||
RulesService |
|||
] |
|||
}) |
|||
export class BenchmarksModule {} |
|||
@ -0,0 +1,163 @@ |
|||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; |
|||
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; |
|||
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; |
|||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
|||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; |
|||
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper'; |
|||
import { |
|||
AssetProfileIdentifier, |
|||
BenchmarkMarketDataDetailsResponse, |
|||
Filter |
|||
} from '@ghostfolio/common/interfaces'; |
|||
import { DateRange, UserWithSettings } from '@ghostfolio/common/types'; |
|||
|
|||
import { Injectable, Logger } from '@nestjs/common'; |
|||
import { format, isSameDay } from 'date-fns'; |
|||
import { isNumber } from 'lodash'; |
|||
|
|||
@Injectable() |
|||
export class BenchmarksService { |
|||
public constructor( |
|||
private readonly benchmarkService: BenchmarkService, |
|||
private readonly exchangeRateDataService: ExchangeRateDataService, |
|||
private readonly marketDataService: MarketDataService, |
|||
private readonly portfolioService: PortfolioService, |
|||
private readonly symbolService: SymbolService |
|||
) {} |
|||
|
|||
public async getMarketDataForUser({ |
|||
dataSource, |
|||
dateRange, |
|||
endDate = new Date(), |
|||
filters, |
|||
impersonationId, |
|||
startDate, |
|||
symbol, |
|||
user, |
|||
withExcludedAccounts |
|||
}: { |
|||
dateRange: DateRange; |
|||
endDate?: Date; |
|||
filters?: Filter[]; |
|||
impersonationId: string; |
|||
startDate: Date; |
|||
user: UserWithSettings; |
|||
withExcludedAccounts?: boolean; |
|||
} & AssetProfileIdentifier): Promise<BenchmarkMarketDataDetailsResponse> { |
|||
const marketData: { date: string; value: number }[] = []; |
|||
const userCurrency = user.settings.settings.baseCurrency; |
|||
const userId = user.id; |
|||
|
|||
const { chart } = await this.portfolioService.getPerformance({ |
|||
dateRange, |
|||
filters, |
|||
impersonationId, |
|||
userId, |
|||
withExcludedAccounts |
|||
}); |
|||
|
|||
const [currentSymbolItem, marketDataItems] = await Promise.all([ |
|||
this.symbolService.get({ |
|||
dataGatheringItem: { |
|||
dataSource, |
|||
symbol |
|||
} |
|||
}), |
|||
this.marketDataService.marketDataItems({ |
|||
orderBy: { |
|||
date: 'asc' |
|||
}, |
|||
where: { |
|||
dataSource, |
|||
symbol, |
|||
date: { |
|||
in: chart.map(({ date }) => { |
|||
return resetHours(parseDate(date)); |
|||
}) |
|||
} |
|||
} |
|||
}) |
|||
]); |
|||
|
|||
const exchangeRates = |
|||
await this.exchangeRateDataService.getExchangeRatesByCurrency({ |
|||
startDate, |
|||
currencies: [currentSymbolItem.currency], |
|||
targetCurrency: userCurrency |
|||
}); |
|||
|
|||
const exchangeRateAtStartDate = |
|||
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[ |
|||
format(startDate, DATE_FORMAT) |
|||
]; |
|||
|
|||
const marketPriceAtStartDate = marketDataItems?.find(({ date }) => { |
|||
return isSameDay(date, startDate); |
|||
})?.marketPrice; |
|||
|
|||
if (!marketPriceAtStartDate) { |
|||
Logger.error( |
|||
`No historical market data has been found for ${symbol} (${dataSource}) at ${format( |
|||
startDate, |
|||
DATE_FORMAT |
|||
)}`,
|
|||
'BenchmarkService' |
|||
); |
|||
|
|||
return { marketData }; |
|||
} |
|||
|
|||
for (const marketDataItem of marketDataItems) { |
|||
const exchangeRate = |
|||
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[ |
|||
format(marketDataItem.date, DATE_FORMAT) |
|||
]; |
|||
|
|||
const exchangeRateFactor = |
|||
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate) |
|||
? exchangeRate / exchangeRateAtStartDate |
|||
: 1; |
|||
|
|||
marketData.push({ |
|||
date: format(marketDataItem.date, DATE_FORMAT), |
|||
value: |
|||
marketPriceAtStartDate === 0 |
|||
? 0 |
|||
: this.benchmarkService.calculateChangeInPercentage( |
|||
marketPriceAtStartDate, |
|||
marketDataItem.marketPrice * exchangeRateFactor |
|||
) * 100 |
|||
}); |
|||
} |
|||
|
|||
const includesEndDate = isSameDay( |
|||
parseDate(marketData.at(-1).date), |
|||
endDate |
|||
); |
|||
|
|||
if (currentSymbolItem?.marketPrice && !includesEndDate) { |
|||
const exchangeRate = |
|||
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[ |
|||
format(endDate, DATE_FORMAT) |
|||
]; |
|||
|
|||
const exchangeRateFactor = |
|||
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate) |
|||
? exchangeRate / exchangeRateAtStartDate |
|||
: 1; |
|||
|
|||
marketData.push({ |
|||
date: format(endDate, DATE_FORMAT), |
|||
value: |
|||
this.benchmarkService.calculateChangeInPercentage( |
|||
marketPriceAtStartDate, |
|||
currentSymbolItem.marketPrice * exchangeRateFactor |
|||
) * 100 |
|||
}); |
|||
} |
|||
|
|||
return { |
|||
marketData |
|||
}; |
|||
} |
|||
} |
|||
@ -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,249 @@ |
|||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; |
|||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
|||
import { AssetProfileInvalidError } from '@ghostfolio/api/services/data-provider/errors/asset-profile-invalid.error'; |
|||
import { parseDate } from '@ghostfolio/common/helper'; |
|||
import { |
|||
DataProviderGhostfolioAssetProfileResponse, |
|||
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 { isISIN } from 'class-validator'; |
|||
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 |
|||
) {} |
|||
|
|||
@Get('asset-profile/:symbol') |
|||
@HasPermission(permissions.enableDataProviderGhostfolio) |
|||
@UseGuards(AuthGuard('api-key'), HasPermissionGuard) |
|||
public async getAssetProfile( |
|||
@Param('symbol') symbol: string |
|||
): Promise<DataProviderGhostfolioAssetProfileResponse> { |
|||
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 assetProfile = await this.ghostfolioService.getAssetProfile({ |
|||
symbol |
|||
}); |
|||
|
|||
await this.ghostfolioService.incrementDailyRequests({ |
|||
userId: this.request.user.id |
|||
}); |
|||
|
|||
return assetProfile; |
|||
} catch (error) { |
|||
if (error instanceof AssetProfileInvalidError) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.NOT_FOUND), |
|||
StatusCodes.NOT_FOUND |
|||
); |
|||
} |
|||
|
|||
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 |
|||
); |
|||
} |
|||
} |
|||
|
|||
@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 |
|||
); |
|||
} |
|||
} |
|||
|
|||
@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: isISIN(query.toUpperCase()) |
|||
? query.toUpperCase() |
|||
: 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('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 |
|||
); |
|||
} |
|||
} |
|||
|
|||
@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,375 @@ |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; |
|||
import { GhostfolioService as GhostfolioDataProviderService } from '@ghostfolio/api/services/data-provider/ghostfolio/ghostfolio.service'; |
|||
import { |
|||
GetAssetProfileParams, |
|||
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 { |
|||
DataProviderGhostfolioAssetProfileResponse, |
|||
DataProviderInfo, |
|||
DividendsResponse, |
|||
HistoricalResponse, |
|||
LookupItem, |
|||
LookupResponse, |
|||
QuotesResponse |
|||
} from '@ghostfolio/common/interfaces'; |
|||
import { UserWithSettings } from '@ghostfolio/common/types'; |
|||
|
|||
import { Injectable, Logger } from '@nestjs/common'; |
|||
import { DataSource, SymbolProfile } 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 getAssetProfile({ symbol }: GetAssetProfileParams) { |
|||
let result: DataProviderGhostfolioAssetProfileResponse = {}; |
|||
|
|||
try { |
|||
const promises: Promise<Partial<SymbolProfile>>[] = []; |
|||
|
|||
for (const dataProviderService of this.getDataProviderServices()) { |
|||
promises.push( |
|||
this.dataProviderService |
|||
.getAssetProfiles([ |
|||
{ |
|||
symbol, |
|||
dataSource: dataProviderService.getName() |
|||
} |
|||
]) |
|||
.then(async (assetProfiles) => { |
|||
const assetProfile = assetProfiles[symbol]; |
|||
const dataSourceOrigin = DataSource.GHOSTFOLIO; |
|||
|
|||
if (assetProfile) { |
|||
await this.prismaService.assetProfileResolution.upsert({ |
|||
create: { |
|||
dataSourceOrigin, |
|||
currency: assetProfile.currency, |
|||
dataSourceTarget: assetProfile.dataSource, |
|||
symbolOrigin: symbol, |
|||
symbolTarget: assetProfile.symbol |
|||
}, |
|||
update: { |
|||
requestCount: { |
|||
increment: 1 |
|||
} |
|||
}, |
|||
where: { |
|||
dataSourceOrigin_symbolOrigin: { |
|||
dataSourceOrigin, |
|||
symbolOrigin: symbol |
|||
} |
|||
} |
|||
}); |
|||
} |
|||
|
|||
result = { |
|||
...result, |
|||
...assetProfile, |
|||
dataSource: dataSourceOrigin |
|||
}; |
|||
|
|||
return assetProfile; |
|||
}) |
|||
); |
|||
} |
|||
|
|||
await Promise.all(promises); |
|||
|
|||
return result; |
|||
} catch (error) { |
|||
Logger.error(error, 'GhostfolioService'); |
|||
|
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
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<string>( |
|||
PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS |
|||
)) || '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 { |
|||
const ghostfolioDataProviderService = new GhostfolioDataProviderService( |
|||
this.configurationService, |
|||
this.propertyService |
|||
); |
|||
|
|||
return { |
|||
...ghostfolioDataProviderService.getDataProviderInfo(), |
|||
isPremium: false, |
|||
name: 'Ghostfolio Premium' |
|||
}; |
|||
} |
|||
|
|||
private getDataProviderServices() { |
|||
return this.configurationService |
|||
.get('DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER') |
|||
.map((dataSource) => { |
|||
return this.dataProviderService.getDataProvider(DataSource[dataSource]); |
|||
}); |
|||
} |
|||
} |
|||
@ -0,0 +1,189 @@ |
|||
import { AdminService } from '@ghostfolio/api/app/admin/admin.service'; |
|||
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; |
|||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; |
|||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
|||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; |
|||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; |
|||
import { |
|||
ghostfolioFearAndGreedIndexDataSourceCryptocurrencies, |
|||
ghostfolioFearAndGreedIndexDataSourceStocks, |
|||
ghostfolioFearAndGreedIndexSymbolCryptocurrencies, |
|||
ghostfolioFearAndGreedIndexSymbolStocks |
|||
} from '@ghostfolio/common/config'; |
|||
import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper'; |
|||
import { |
|||
MarketDataDetailsResponse, |
|||
MarketDataOfMarketsResponse |
|||
} 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, |
|||
Query, |
|||
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, |
|||
private readonly symbolService: SymbolService |
|||
) {} |
|||
|
|||
@Get('markets') |
|||
@HasPermission(permissions.readMarketDataOfMarkets) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async getMarketDataOfMarkets( |
|||
@Query('includeHistoricalData') includeHistoricalData = 0 |
|||
): Promise<MarketDataOfMarketsResponse> { |
|||
const [ |
|||
marketDataFearAndGreedIndexCryptocurrencies, |
|||
marketDataFearAndGreedIndexStocks |
|||
] = await Promise.all([ |
|||
this.symbolService.get({ |
|||
includeHistoricalData, |
|||
dataGatheringItem: { |
|||
dataSource: ghostfolioFearAndGreedIndexDataSourceCryptocurrencies, |
|||
symbol: ghostfolioFearAndGreedIndexSymbolCryptocurrencies |
|||
} |
|||
}), |
|||
this.symbolService.get({ |
|||
includeHistoricalData, |
|||
dataGatheringItem: { |
|||
dataSource: ghostfolioFearAndGreedIndexDataSourceStocks, |
|||
symbol: ghostfolioFearAndGreedIndexSymbolStocks |
|||
} |
|||
}) |
|||
]); |
|||
|
|||
return { |
|||
fearAndGreedIndex: { |
|||
CRYPTOCURRENCIES: { |
|||
...marketDataFearAndGreedIndexCryptocurrencies |
|||
}, |
|||
STOCKS: { |
|||
...marketDataFearAndGreedIndexStocks |
|||
} |
|||
} |
|||
}; |
|||
} |
|||
|
|||
@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 && !isCurrency(getCurrencyFromSymbol(symbol))) { |
|||
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 && !isCurrency(getCurrencyFromSymbol(symbol))) { |
|||
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,19 @@ |
|||
import { AdminModule } from '@ghostfolio/api/app/admin/admin.module'; |
|||
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.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, |
|||
SymbolModule, |
|||
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,52 @@ |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
import { |
|||
DATE_FORMAT, |
|||
getYesterday, |
|||
interpolate |
|||
} from '@ghostfolio/common/helper'; |
|||
|
|||
import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common'; |
|||
import { format } from 'date-fns'; |
|||
import { Response } from 'express'; |
|||
import { readFileSync } from 'node:fs'; |
|||
import { join } from 'node:path'; |
|||
|
|||
import { SitemapService } from './sitemap.service'; |
|||
|
|||
@Controller('sitemap.xml') |
|||
export class SitemapController { |
|||
public sitemapXml = ''; |
|||
|
|||
public constructor( |
|||
private readonly configurationService: ConfigurationService, |
|||
private readonly sitemapService: SitemapService |
|||
) { |
|||
try { |
|||
this.sitemapXml = readFileSync( |
|||
join(__dirname, 'assets', 'sitemap.xml'), |
|||
'utf8' |
|||
); |
|||
} catch {} |
|||
} |
|||
|
|||
@Get() |
|||
@Version(VERSION_NEUTRAL) |
|||
public getSitemapXml(@Res() response: Response) { |
|||
const currentDate = format(getYesterday(), DATE_FORMAT); |
|||
|
|||
response.setHeader('content-type', 'application/xml'); |
|||
response.send( |
|||
interpolate(this.sitemapXml, { |
|||
blogPosts: this.sitemapService.getBlogPosts({ currentDate }), |
|||
personalFinanceTools: this.configurationService.get( |
|||
'ENABLE_FEATURE_SUBSCRIPTION' |
|||
) |
|||
? this.sitemapService.getPersonalFinanceTools({ currentDate }) |
|||
: '', |
|||
publicRoutes: this.sitemapService.getPublicRoutes({ |
|||
currentDate |
|||
}) |
|||
}) |
|||
); |
|||
} |
|||
} |
|||
@ -1,11 +1,14 @@ |
|||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; |
|||
import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module'; |
|||
|
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { SitemapController } from './sitemap.controller'; |
|||
import { SitemapService } from './sitemap.service'; |
|||
|
|||
@Module({ |
|||
controllers: [SitemapController], |
|||
imports: [ConfigurationModule] |
|||
imports: [ConfigurationModule, I18nModule], |
|||
providers: [SitemapService] |
|||
}) |
|||
export class SitemapModule {} |
|||
@ -0,0 +1,252 @@ |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; |
|||
import { SUPPORTED_LANGUAGE_CODES } from '@ghostfolio/common/config'; |
|||
import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools'; |
|||
import { PublicRoute } from '@ghostfolio/common/routes/interfaces/public-route.interface'; |
|||
import { publicRoutes } from '@ghostfolio/common/routes/routes'; |
|||
|
|||
import { Injectable } from '@nestjs/common'; |
|||
|
|||
@Injectable() |
|||
export class SitemapService { |
|||
private static readonly TRANSLATION_TAGGED_MESSAGE_REGEX = |
|||
/:.*@@(?<id>[a-zA-Z0-9.]+):(?<message>.+)/; |
|||
|
|||
public constructor( |
|||
private readonly configurationService: ConfigurationService, |
|||
private readonly i18nService: I18nService |
|||
) {} |
|||
|
|||
public getBlogPosts({ currentDate }: { currentDate: string }) { |
|||
const rootUrl = this.configurationService.get('ROOT_URL'); |
|||
|
|||
return [ |
|||
{ |
|||
languageCode: 'de', |
|||
routerLink: ['2021', '07', 'hallo-ghostfolio'] |
|||
}, |
|||
{ |
|||
languageCode: 'en', |
|||
routerLink: ['2021', '07', 'hello-ghostfolio'] |
|||
}, |
|||
{ |
|||
languageCode: 'en', |
|||
routerLink: ['2022', '01', 'ghostfolio-first-months-in-open-source'] |
|||
}, |
|||
{ |
|||
languageCode: 'en', |
|||
routerLink: ['2022', '07', 'ghostfolio-meets-internet-identity'] |
|||
}, |
|||
{ |
|||
languageCode: 'en', |
|||
routerLink: ['2022', '07', 'how-do-i-get-my-finances-in-order'] |
|||
}, |
|||
{ |
|||
languageCode: 'en', |
|||
routerLink: ['2022', '08', '500-stars-on-github'] |
|||
}, |
|||
{ |
|||
languageCode: 'en', |
|||
routerLink: ['2022', '10', 'hacktoberfest-2022'] |
|||
}, |
|||
{ |
|||
languageCode: 'en', |
|||
routerLink: ['2022', '11', 'black-friday-2022'] |
|||
}, |
|||
{ |
|||
languageCode: 'en', |
|||
routerLink: [ |
|||
'2022', |
|||
'12', |
|||
'the-importance-of-tracking-your-personal-finances' |
|||
] |
|||
}, |
|||
{ |
|||
languageCode: 'de', |
|||
routerLink: ['2023', '01', 'ghostfolio-auf-sackgeld-vorgestellt'] |
|||
}, |
|||
{ |
|||
languageCode: 'en', |
|||
routerLink: ['2023', '02', 'ghostfolio-meets-umbrel'] |
|||
}, |
|||
{ |
|||
languageCode: 'en', |
|||
routerLink: ['2023', '03', 'ghostfolio-reaches-1000-stars-on-github'] |
|||
}, |
|||
{ |
|||
languageCode: 'en', |
|||
routerLink: [ |
|||
'2023', |
|||
'05', |
|||
'unlock-your-financial-potential-with-ghostfolio' |
|||
] |
|||
}, |
|||
{ |
|||
languageCode: 'en', |
|||
routerLink: ['2023', '07', 'exploring-the-path-to-fire'] |
|||
}, |
|||
{ |
|||
languageCode: 'en', |
|||
routerLink: ['2023', '08', 'ghostfolio-joins-oss-friends'] |
|||
}, |
|||
{ |
|||
languageCode: 'en', |
|||
routerLink: ['2023', '09', 'ghostfolio-2'] |
|||
}, |
|||
{ |
|||
languageCode: 'en', |
|||
routerLink: ['2023', '09', 'hacktoberfest-2023'] |
|||
}, |
|||
{ |
|||
languageCode: 'en', |
|||
routerLink: ['2023', '11', 'black-week-2023'] |
|||
}, |
|||
{ |
|||
languageCode: 'en', |
|||
routerLink: ['2023', '11', 'hacktoberfest-2023-debriefing'] |
|||
}, |
|||
{ |
|||
languageCode: 'en', |
|||
routerLink: ['2024', '09', 'hacktoberfest-2024'] |
|||
}, |
|||
{ |
|||
languageCode: 'en', |
|||
routerLink: ['2024', '11', 'black-weeks-2024'] |
|||
}, |
|||
{ |
|||
languageCode: 'en', |
|||
routerLink: ['2025', '09', 'hacktoberfest-2025'] |
|||
} |
|||
] |
|||
.map(({ languageCode, routerLink }) => { |
|||
return this.createRouteSitemapUrl({ |
|||
currentDate, |
|||
languageCode, |
|||
rootUrl, |
|||
route: { |
|||
routerLink: [publicRoutes.blog.path, ...routerLink], |
|||
path: undefined |
|||
} |
|||
}); |
|||
}) |
|||
.join('\n'); |
|||
} |
|||
|
|||
public getPersonalFinanceTools({ currentDate }: { currentDate: string }) { |
|||
const rootUrl = this.configurationService.get('ROOT_URL'); |
|||
|
|||
return SUPPORTED_LANGUAGE_CODES.flatMap((languageCode) => { |
|||
const resourcesPath = this.i18nService.getTranslation({ |
|||
languageCode, |
|||
id: publicRoutes.resources.path.match( |
|||
SitemapService.TRANSLATION_TAGGED_MESSAGE_REGEX |
|||
).groups.id |
|||
}); |
|||
|
|||
const personalFinanceToolsPath = this.i18nService.getTranslation({ |
|||
languageCode, |
|||
id: publicRoutes.resources.subRoutes.personalFinanceTools.path.match( |
|||
SitemapService.TRANSLATION_TAGGED_MESSAGE_REGEX |
|||
).groups.id |
|||
}); |
|||
|
|||
const productPath = this.i18nService.getTranslation({ |
|||
languageCode, |
|||
id: publicRoutes.resources.subRoutes.personalFinanceTools.subRoutes.product.path.match( |
|||
SitemapService.TRANSLATION_TAGGED_MESSAGE_REGEX |
|||
).groups.id |
|||
}); |
|||
|
|||
return personalFinanceTools.map(({ alias, key }) => { |
|||
const routerLink = [ |
|||
resourcesPath, |
|||
personalFinanceToolsPath, |
|||
`${productPath}-${alias ?? key}` |
|||
]; |
|||
|
|||
return this.createRouteSitemapUrl({ |
|||
currentDate, |
|||
languageCode, |
|||
rootUrl, |
|||
route: { |
|||
routerLink, |
|||
path: undefined |
|||
} |
|||
}); |
|||
}); |
|||
}).join('\n'); |
|||
} |
|||
|
|||
public getPublicRoutes({ currentDate }: { currentDate: string }) { |
|||
const rootUrl = this.configurationService.get('ROOT_URL'); |
|||
|
|||
return SUPPORTED_LANGUAGE_CODES.flatMap((languageCode) => { |
|||
const params = { |
|||
currentDate, |
|||
languageCode, |
|||
rootUrl |
|||
}; |
|||
|
|||
return [ |
|||
this.createRouteSitemapUrl(params), |
|||
...this.createSitemapUrls(params, publicRoutes) |
|||
]; |
|||
}).join('\n'); |
|||
} |
|||
|
|||
private createRouteSitemapUrl({ |
|||
currentDate, |
|||
languageCode, |
|||
rootUrl, |
|||
route |
|||
}: { |
|||
currentDate: string; |
|||
languageCode: string; |
|||
rootUrl: string; |
|||
route?: PublicRoute; |
|||
}): string { |
|||
const segments = |
|||
route?.routerLink.map((link) => { |
|||
const match = link.match( |
|||
SitemapService.TRANSLATION_TAGGED_MESSAGE_REGEX |
|||
); |
|||
|
|||
const segment = match |
|||
? (this.i18nService.getTranslation({ |
|||
languageCode, |
|||
id: match.groups.id |
|||
}) ?? match.groups.message) |
|||
: link; |
|||
|
|||
return segment.replace(/^\/+|\/+$/, ''); |
|||
}) ?? []; |
|||
|
|||
const location = [rootUrl, languageCode, ...segments].join('/'); |
|||
|
|||
return [ |
|||
' <url>', |
|||
` <loc>${location}</loc>`, |
|||
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`, |
|||
' </url>' |
|||
].join('\n'); |
|||
} |
|||
|
|||
private createSitemapUrls( |
|||
params: { currentDate: string; languageCode: string; rootUrl: string }, |
|||
routes: Record<string, PublicRoute> |
|||
): string[] { |
|||
return Object.values(routes).flatMap((route) => { |
|||
if (route.excludeFromSitemap) { |
|||
return []; |
|||
} |
|||
|
|||
const urls = [this.createRouteSitemapUrl({ ...params, route })]; |
|||
|
|||
if (route.subRoutes) { |
|||
urls.push(...this.createSitemapUrls(params, route.subRoutes)); |
|||
} |
|||
|
|||
return urls; |
|||
}); |
|||
} |
|||
} |
|||
@ -0,0 +1,14 @@ |
|||
import { IsOptional, IsString } from 'class-validator'; |
|||
|
|||
export class CreateTagDto { |
|||
@IsOptional() |
|||
@IsString() |
|||
id?: string; |
|||
|
|||
@IsString() |
|||
name: string; |
|||
|
|||
@IsOptional() |
|||
@IsString() |
|||
userId?: string; |
|||
} |
|||
@ -0,0 +1,12 @@ |
|||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
|||
import { TagModule } from '@ghostfolio/api/services/tag/tag.module'; |
|||
|
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { TagsController } from './tags.controller'; |
|||
|
|||
@Module({ |
|||
controllers: [TagsController], |
|||
imports: [PrismaModule, TagModule] |
|||
}) |
|||
export class TagsModule {} |
|||
@ -0,0 +1,13 @@ |
|||
import { IsOptional, IsString } from 'class-validator'; |
|||
|
|||
export class UpdateTagDto { |
|||
@IsString() |
|||
id: string; |
|||
|
|||
@IsString() |
|||
name: string; |
|||
|
|||
@IsOptional() |
|||
@IsString() |
|||
userId?: string; |
|||
} |
|||
@ -0,0 +1,10 @@ |
|||
import { DataSource } from '@prisma/client'; |
|||
import { IsEnum, IsString } from 'class-validator'; |
|||
|
|||
export class CreateWatchlistItemDto { |
|||
@IsEnum(DataSource) |
|||
dataSource: DataSource; |
|||
|
|||
@IsString() |
|||
symbol: string; |
|||
} |
|||
@ -0,0 +1,100 @@ |
|||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; |
|||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
|||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; |
|||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; |
|||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; |
|||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; |
|||
import { WatchlistResponse } from '@ghostfolio/common/interfaces'; |
|||
import { permissions } from '@ghostfolio/common/permissions'; |
|||
import { RequestWithUser } from '@ghostfolio/common/types'; |
|||
|
|||
import { |
|||
Body, |
|||
Controller, |
|||
Delete, |
|||
Get, |
|||
Headers, |
|||
HttpException, |
|||
Inject, |
|||
Param, |
|||
Post, |
|||
UseGuards, |
|||
UseInterceptors |
|||
} from '@nestjs/common'; |
|||
import { REQUEST } from '@nestjs/core'; |
|||
import { AuthGuard } from '@nestjs/passport'; |
|||
import { DataSource } from '@prisma/client'; |
|||
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
|||
|
|||
import { CreateWatchlistItemDto } from './create-watchlist-item.dto'; |
|||
import { WatchlistService } from './watchlist.service'; |
|||
|
|||
@Controller('watchlist') |
|||
export class WatchlistController { |
|||
public constructor( |
|||
private readonly impersonationService: ImpersonationService, |
|||
@Inject(REQUEST) private readonly request: RequestWithUser, |
|||
private readonly watchlistService: WatchlistService |
|||
) {} |
|||
|
|||
@Post() |
|||
@HasPermission(permissions.createWatchlistItem) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
@UseInterceptors(TransformDataSourceInRequestInterceptor) |
|||
public async createWatchlistItem(@Body() data: CreateWatchlistItemDto) { |
|||
return this.watchlistService.createWatchlistItem({ |
|||
dataSource: data.dataSource, |
|||
symbol: data.symbol, |
|||
userId: this.request.user.id |
|||
}); |
|||
} |
|||
|
|||
@Delete(':dataSource/:symbol') |
|||
@HasPermission(permissions.deleteWatchlistItem) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
@UseInterceptors(TransformDataSourceInRequestInterceptor) |
|||
public async deleteWatchlistItem( |
|||
@Param('dataSource') dataSource: DataSource, |
|||
@Param('symbol') symbol: string |
|||
) { |
|||
const watchlistItems = await this.watchlistService.getWatchlistItems( |
|||
this.request.user.id |
|||
); |
|||
|
|||
const watchlistItem = watchlistItems.find((item) => { |
|||
return item.dataSource === dataSource && item.symbol === symbol; |
|||
}); |
|||
|
|||
if (!watchlistItem) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.NOT_FOUND), |
|||
StatusCodes.NOT_FOUND |
|||
); |
|||
} |
|||
|
|||
return this.watchlistService.deleteWatchlistItem({ |
|||
dataSource, |
|||
symbol, |
|||
userId: this.request.user.id |
|||
}); |
|||
} |
|||
|
|||
@Get() |
|||
@HasPermission(permissions.readWatchlist) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
@UseInterceptors(TransformDataSourceInResponseInterceptor) |
|||
public async getWatchlistItems( |
|||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string |
|||
): Promise<WatchlistResponse> { |
|||
const impersonationUserId = |
|||
await this.impersonationService.validateImpersonationId(impersonationId); |
|||
|
|||
const watchlist = await this.watchlistService.getWatchlistItems( |
|||
impersonationUserId || this.request.user.id |
|||
); |
|||
|
|||
return { |
|||
watchlist |
|||
}; |
|||
} |
|||
} |
|||
@ -1,36 +1,31 @@ |
|||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; |
|||
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; |
|||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; |
|||
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; |
|||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; |
|||
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; |
|||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; |
|||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; |
|||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; |
|||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; |
|||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
|||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; |
|||
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; |
|||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; |
|||
|
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { BenchmarkController } from './benchmark.controller'; |
|||
import { BenchmarkService } from './benchmark.service'; |
|||
import { WatchlistController } from './watchlist.controller'; |
|||
import { WatchlistService } from './watchlist.service'; |
|||
|
|||
@Module({ |
|||
controllers: [BenchmarkController], |
|||
exports: [BenchmarkService], |
|||
controllers: [WatchlistController], |
|||
imports: [ |
|||
ConfigurationModule, |
|||
BenchmarkModule, |
|||
DataGatheringModule, |
|||
DataProviderModule, |
|||
ExchangeRateDataModule, |
|||
ImpersonationModule, |
|||
MarketDataModule, |
|||
PrismaModule, |
|||
PropertyModule, |
|||
RedisCacheModule, |
|||
SymbolModule, |
|||
SymbolProfileModule, |
|||
TransformDataSourceInRequestModule, |
|||
TransformDataSourceInResponseModule |
|||
], |
|||
providers: [BenchmarkService] |
|||
providers: [WatchlistService] |
|||
}) |
|||
export class BenchmarkModule {} |
|||
export class WatchlistModule {} |
|||
@ -0,0 +1,155 @@ |
|||
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; |
|||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; |
|||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; |
|||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
|||
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; |
|||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; |
|||
import { WatchlistResponse } from '@ghostfolio/common/interfaces'; |
|||
|
|||
import { BadRequestException, Injectable } from '@nestjs/common'; |
|||
import { DataSource, Prisma } from '@prisma/client'; |
|||
|
|||
@Injectable() |
|||
export class WatchlistService { |
|||
public constructor( |
|||
private readonly benchmarkService: BenchmarkService, |
|||
private readonly dataGatheringService: DataGatheringService, |
|||
private readonly dataProviderService: DataProviderService, |
|||
private readonly marketDataService: MarketDataService, |
|||
private readonly prismaService: PrismaService, |
|||
private readonly symbolProfileService: SymbolProfileService |
|||
) {} |
|||
|
|||
public async createWatchlistItem({ |
|||
dataSource, |
|||
symbol, |
|||
userId |
|||
}: { |
|||
dataSource: DataSource; |
|||
symbol: string; |
|||
userId: string; |
|||
}): Promise<void> { |
|||
const symbolProfile = await this.prismaService.symbolProfile.findUnique({ |
|||
where: { |
|||
dataSource_symbol: { dataSource, symbol } |
|||
} |
|||
}); |
|||
|
|||
if (!symbolProfile) { |
|||
const assetProfiles = await this.dataProviderService.getAssetProfiles([ |
|||
{ dataSource, symbol } |
|||
]); |
|||
|
|||
if (!assetProfiles[symbol]?.currency) { |
|||
throw new BadRequestException( |
|||
`Asset profile not found for ${symbol} (${dataSource})` |
|||
); |
|||
} |
|||
|
|||
await this.symbolProfileService.add( |
|||
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput |
|||
); |
|||
} |
|||
|
|||
await this.dataGatheringService.gatherSymbol({ |
|||
dataSource, |
|||
symbol |
|||
}); |
|||
|
|||
await this.prismaService.user.update({ |
|||
data: { |
|||
watchlist: { |
|||
connect: { |
|||
dataSource_symbol: { dataSource, symbol } |
|||
} |
|||
} |
|||
}, |
|||
where: { id: userId } |
|||
}); |
|||
} |
|||
|
|||
public async deleteWatchlistItem({ |
|||
dataSource, |
|||
symbol, |
|||
userId |
|||
}: { |
|||
dataSource: DataSource; |
|||
symbol: string; |
|||
userId: string; |
|||
}) { |
|||
await this.prismaService.user.update({ |
|||
data: { |
|||
watchlist: { |
|||
disconnect: { |
|||
dataSource_symbol: { dataSource, symbol } |
|||
} |
|||
} |
|||
}, |
|||
where: { id: userId } |
|||
}); |
|||
} |
|||
|
|||
public async getWatchlistItems( |
|||
userId: string |
|||
): Promise<WatchlistResponse['watchlist']> { |
|||
const user = await this.prismaService.user.findUnique({ |
|||
select: { |
|||
watchlist: { |
|||
select: { dataSource: true, symbol: true } |
|||
} |
|||
}, |
|||
where: { id: userId } |
|||
}); |
|||
|
|||
const [assetProfiles, quotes] = await Promise.all([ |
|||
this.symbolProfileService.getSymbolProfiles(user.watchlist), |
|||
this.dataProviderService.getQuotes({ |
|||
items: user.watchlist.map(({ dataSource, symbol }) => { |
|||
return { dataSource, symbol }; |
|||
}) |
|||
}) |
|||
]); |
|||
|
|||
const watchlist = await Promise.all( |
|||
user.watchlist.map(async ({ dataSource, symbol }) => { |
|||
const assetProfile = assetProfiles.find((profile) => { |
|||
return profile.dataSource === dataSource && profile.symbol === symbol; |
|||
}); |
|||
|
|||
const [allTimeHigh, trends] = await Promise.all([ |
|||
this.marketDataService.getMax({ |
|||
dataSource, |
|||
symbol |
|||
}), |
|||
this.benchmarkService.getBenchmarkTrends({ dataSource, symbol }) |
|||
]); |
|||
|
|||
const performancePercent = |
|||
this.benchmarkService.calculateChangeInPercentage( |
|||
allTimeHigh?.marketPrice, |
|||
quotes[symbol]?.marketPrice |
|||
); |
|||
|
|||
return { |
|||
dataSource, |
|||
symbol, |
|||
marketCondition: |
|||
this.benchmarkService.getMarketCondition(performancePercent), |
|||
name: assetProfile?.name, |
|||
performances: { |
|||
allTimeHigh: { |
|||
performancePercent, |
|||
date: allTimeHigh?.date |
|||
} |
|||
}, |
|||
trend50d: trends.trend50d, |
|||
trend200d: trends.trend200d |
|||
}; |
|||
}) |
|||
); |
|||
|
|||
return watchlist.sort((a, b) => { |
|||
return a.name.localeCompare(b.name); |
|||
}); |
|||
} |
|||
} |
|||
@ -0,0 +1,10 @@ |
|||
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto'; |
|||
import { AccountBalance } from '@ghostfolio/common/interfaces'; |
|||
|
|||
import { IsArray, IsOptional } from 'class-validator'; |
|||
|
|||
export class CreateAccountWithBalancesDto extends CreateAccountDto { |
|||
@IsArray() |
|||
@IsOptional() |
|||
balances?: AccountBalance[]; |
|||
} |
|||
@ -0,0 +1,17 @@ |
|||
import { MarketData } from '@ghostfolio/common/interfaces'; |
|||
|
|||
import { DataSource } from '@prisma/client'; |
|||
import { IsArray, IsEnum, IsOptional } from 'class-validator'; |
|||
|
|||
import { CreateAssetProfileDto } from '../admin/create-asset-profile.dto'; |
|||
|
|||
export class CreateAssetProfileWithMarketDataDto extends CreateAssetProfileDto { |
|||
@IsEnum([DataSource.MANUAL], { |
|||
message: `dataSource must be '${DataSource.MANUAL}'` |
|||
}) |
|||
dataSource: DataSource; |
|||
|
|||
@IsArray() |
|||
@IsOptional() |
|||
marketData?: MarketData[]; |
|||
} |
|||
@ -1,18 +1,33 @@ |
|||
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto'; |
|||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; |
|||
|
|||
import { Type } from 'class-transformer'; |
|||
import { IsArray, IsOptional, ValidateNested } from 'class-validator'; |
|||
|
|||
import { CreateTagDto } from '../endpoints/tags/create-tag.dto'; |
|||
import { CreateAccountWithBalancesDto } from './create-account-with-balances.dto'; |
|||
import { CreateAssetProfileWithMarketDataDto } from './create-asset-profile-with-market-data.dto'; |
|||
|
|||
export class ImportDataDto { |
|||
@IsOptional() |
|||
@IsArray() |
|||
@Type(() => CreateAccountDto) |
|||
@IsOptional() |
|||
@Type(() => CreateAccountWithBalancesDto) |
|||
@ValidateNested({ each: true }) |
|||
accounts: CreateAccountDto[]; |
|||
accounts?: CreateAccountWithBalancesDto[]; |
|||
|
|||
@IsArray() |
|||
@Type(() => CreateOrderDto) |
|||
@ValidateNested({ each: true }) |
|||
activities: CreateOrderDto[]; |
|||
|
|||
@IsArray() |
|||
@IsOptional() |
|||
@Type(() => CreateAssetProfileWithMarketDataDto) |
|||
@ValidateNested({ each: true }) |
|||
assetProfiles?: CreateAssetProfileWithMarketDataDto[]; |
|||
|
|||
@IsArray() |
|||
@IsOptional() |
|||
@Type(() => CreateTagDto) |
|||
@ValidateNested({ each: true }) |
|||
tags?: CreateTagDto[]; |
|||
} |
|||
|
|||
@ -0,0 +1,208 @@ |
|||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; |
|||
import { |
|||
activityDummyData, |
|||
symbolProfileDummyData, |
|||
userDummyData |
|||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; |
|||
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; |
|||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; |
|||
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; |
|||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; |
|||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
|||
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; |
|||
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; |
|||
import { parseDate } from '@ghostfolio/common/helper'; |
|||
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; |
|||
|
|||
import { Big } from 'big.js'; |
|||
|
|||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { |
|||
return { |
|||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|||
CurrentRateService: jest.fn().mockImplementation(() => { |
|||
return CurrentRateServiceMock; |
|||
}) |
|||
}; |
|||
}); |
|||
|
|||
jest.mock( |
|||
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', |
|||
() => { |
|||
return { |
|||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|||
PortfolioSnapshotService: jest.fn().mockImplementation(() => { |
|||
return PortfolioSnapshotServiceMock; |
|||
}) |
|||
}; |
|||
} |
|||
); |
|||
|
|||
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { |
|||
return { |
|||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|||
RedisCacheService: jest.fn().mockImplementation(() => { |
|||
return RedisCacheServiceMock; |
|||
}) |
|||
}; |
|||
}); |
|||
|
|||
describe('PortfolioCalculator', () => { |
|||
let configurationService: ConfigurationService; |
|||
let currentRateService: CurrentRateService; |
|||
let exchangeRateDataService: ExchangeRateDataService; |
|||
let portfolioCalculatorFactory: PortfolioCalculatorFactory; |
|||
let portfolioSnapshotService: PortfolioSnapshotService; |
|||
let redisCacheService: RedisCacheService; |
|||
|
|||
beforeEach(() => { |
|||
configurationService = new ConfigurationService(); |
|||
|
|||
currentRateService = new CurrentRateService(null, null, null, null); |
|||
|
|||
exchangeRateDataService = new ExchangeRateDataService( |
|||
null, |
|||
null, |
|||
null, |
|||
null |
|||
); |
|||
|
|||
portfolioSnapshotService = new PortfolioSnapshotService(null); |
|||
|
|||
redisCacheService = new RedisCacheService(null, null); |
|||
|
|||
portfolioCalculatorFactory = new PortfolioCalculatorFactory( |
|||
configurationService, |
|||
currentRateService, |
|||
exchangeRateDataService, |
|||
portfolioSnapshotService, |
|||
redisCacheService |
|||
); |
|||
}); |
|||
|
|||
describe('get current positions', () => { |
|||
it.only('with BALN.SW buy and buy', async () => { |
|||
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); |
|||
|
|||
const activities: Activity[] = [ |
|||
{ |
|||
...activityDummyData, |
|||
date: new Date('2021-11-22'), |
|||
feeInAssetProfileCurrency: 1.55, |
|||
quantity: 2, |
|||
SymbolProfile: { |
|||
...symbolProfileDummyData, |
|||
currency: 'CHF', |
|||
dataSource: 'YAHOO', |
|||
name: 'Bâloise Holding AG', |
|||
symbol: 'BALN.SW' |
|||
}, |
|||
type: 'BUY', |
|||
unitPriceInAssetProfileCurrency: 142.9 |
|||
}, |
|||
{ |
|||
...activityDummyData, |
|||
date: new Date('2021-11-30'), |
|||
feeInAssetProfileCurrency: 1.65, |
|||
quantity: 2, |
|||
SymbolProfile: { |
|||
...symbolProfileDummyData, |
|||
currency: 'CHF', |
|||
dataSource: 'YAHOO', |
|||
name: 'Bâloise Holding AG', |
|||
symbol: 'BALN.SW' |
|||
}, |
|||
type: 'BUY', |
|||
unitPriceInAssetProfileCurrency: 136.6 |
|||
} |
|||
]; |
|||
|
|||
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ |
|||
activities, |
|||
calculationType: PerformanceCalculationType.ROAI, |
|||
currency: 'CHF', |
|||
userId: userDummyData.id |
|||
}); |
|||
|
|||
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); |
|||
|
|||
const investments = portfolioCalculator.getInvestments(); |
|||
|
|||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ |
|||
data: portfolioSnapshot.historicalData, |
|||
groupBy: 'month' |
|||
}); |
|||
|
|||
expect(portfolioSnapshot).toMatchObject({ |
|||
currentValueInBaseCurrency: new Big('595.6'), |
|||
errors: [], |
|||
hasErrors: false, |
|||
positions: [ |
|||
{ |
|||
averagePrice: new Big('139.75'), |
|||
currency: 'CHF', |
|||
dataSource: 'YAHOO', |
|||
dividend: new Big('0'), |
|||
dividendInBaseCurrency: new Big('0'), |
|||
fee: new Big('3.2'), |
|||
feeInBaseCurrency: new Big('3.2'), |
|||
firstBuyDate: '2021-11-22', |
|||
grossPerformance: new Big('36.6'), |
|||
grossPerformancePercentage: new Big('0.07706261539956593567'), |
|||
grossPerformancePercentageWithCurrencyEffect: new Big( |
|||
'0.07706261539956593567' |
|||
), |
|||
grossPerformanceWithCurrencyEffect: new Big('36.6'), |
|||
investment: new Big('559'), |
|||
investmentWithCurrencyEffect: new Big('559'), |
|||
netPerformance: new Big('33.4'), |
|||
netPerformancePercentage: new Big('0.07032490039195361342'), |
|||
netPerformancePercentageWithCurrencyEffectMap: { |
|||
max: new Big('0.06986689805847808234') |
|||
}, |
|||
netPerformanceWithCurrencyEffectMap: { |
|||
max: new Big('33.4') |
|||
}, |
|||
marketPrice: 148.9, |
|||
marketPriceInBaseCurrency: 148.9, |
|||
quantity: new Big('4'), |
|||
symbol: 'BALN.SW', |
|||
tags: [], |
|||
timeWeightedInvestment: new Big('474.93846153846153846154'), |
|||
timeWeightedInvestmentWithCurrencyEffect: new Big( |
|||
'474.93846153846153846154' |
|||
), |
|||
transactionCount: 2, |
|||
valueInBaseCurrency: new Big('595.6') |
|||
} |
|||
], |
|||
totalFeesWithCurrencyEffect: new Big('3.2'), |
|||
totalInterestWithCurrencyEffect: new Big('0'), |
|||
totalInvestment: new Big('559'), |
|||
totalInvestmentWithCurrencyEffect: new Big('559'), |
|||
totalLiabilitiesWithCurrencyEffect: new Big('0') |
|||
}); |
|||
|
|||
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( |
|||
expect.objectContaining({ |
|||
netPerformance: 33.4, |
|||
netPerformanceInPercentage: 0.07032490039195362, |
|||
netPerformanceInPercentageWithCurrencyEffect: 0.07032490039195362, |
|||
netPerformanceWithCurrencyEffect: 33.4, |
|||
totalInvestmentValueWithCurrencyEffect: 559 |
|||
}) |
|||
); |
|||
|
|||
expect(investments).toEqual([ |
|||
{ date: '2021-11-22', investment: new Big('285.8') }, |
|||
{ date: '2021-11-30', investment: new Big('559') } |
|||
]); |
|||
|
|||
expect(investmentsByMonth).toEqual([ |
|||
{ date: '2021-11-01', investment: 559 }, |
|||
{ date: '2021-12-01', investment: 0 } |
|||
]); |
|||
}); |
|||
}); |
|||
}); |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue