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 | 
					# Run linting and stop the commit process if any errors are found | 
				
			||||
# --quiet suppresses warnings (temporary until all warnings are fixed) | 
					# --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 | 
					# Check formatting on modified and uncommitted files, stop the commit if issues are found | 
				
			||||
npm run format:check --uncommitted || exit 1 | 
					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 { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; | 
				
			||||
 | 
					import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module'; | 
				
			||||
 | 
					
 | 
				
			||||
import { Module } from '@nestjs/common'; | 
					import { Module } from '@nestjs/common'; | 
				
			||||
 | 
					
 | 
				
			||||
import { SitemapController } from './sitemap.controller'; | 
					import { SitemapController } from './sitemap.controller'; | 
				
			||||
 | 
					import { SitemapService } from './sitemap.service'; | 
				
			||||
 | 
					
 | 
				
			||||
@Module({ | 
					@Module({ | 
				
			||||
  controllers: [SitemapController], | 
					  controllers: [SitemapController], | 
				
			||||
  imports: [ConfigurationModule] | 
					  imports: [ConfigurationModule, I18nModule], | 
				
			||||
 | 
					  providers: [SitemapService] | 
				
			||||
}) | 
					}) | 
				
			||||
export class SitemapModule {} | 
					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 { 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 { 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 { 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 { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; | 
				
			||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.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 { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; | 
				
			||||
 | 
					
 | 
				
			||||
import { Module } from '@nestjs/common'; | 
					import { Module } from '@nestjs/common'; | 
				
			||||
 | 
					
 | 
				
			||||
import { BenchmarkController } from './benchmark.controller'; | 
					import { WatchlistController } from './watchlist.controller'; | 
				
			||||
import { BenchmarkService } from './benchmark.service'; | 
					import { WatchlistService } from './watchlist.service'; | 
				
			||||
 | 
					
 | 
				
			||||
@Module({ | 
					@Module({ | 
				
			||||
  controllers: [BenchmarkController], | 
					  controllers: [WatchlistController], | 
				
			||||
  exports: [BenchmarkService], | 
					 | 
				
			||||
  imports: [ | 
					  imports: [ | 
				
			||||
    ConfigurationModule, | 
					    BenchmarkModule, | 
				
			||||
 | 
					    DataGatheringModule, | 
				
			||||
    DataProviderModule, | 
					    DataProviderModule, | 
				
			||||
    ExchangeRateDataModule, | 
					    ImpersonationModule, | 
				
			||||
    MarketDataModule, | 
					    MarketDataModule, | 
				
			||||
    PrismaModule, | 
					    PrismaModule, | 
				
			||||
    PropertyModule, | 
					 | 
				
			||||
    RedisCacheModule, | 
					 | 
				
			||||
    SymbolModule, | 
					 | 
				
			||||
    SymbolProfileModule, | 
					    SymbolProfileModule, | 
				
			||||
    TransformDataSourceInRequestModule, | 
					    TransformDataSourceInRequestModule, | 
				
			||||
    TransformDataSourceInResponseModule | 
					    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 { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; | 
				
			||||
 | 
					
 | 
				
			||||
import { Type } from 'class-transformer'; | 
					import { Type } from 'class-transformer'; | 
				
			||||
import { IsArray, IsOptional, ValidateNested } from 'class-validator'; | 
					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 { | 
					export class ImportDataDto { | 
				
			||||
  @IsOptional() | 
					 | 
				
			||||
  @IsArray() | 
					  @IsArray() | 
				
			||||
  @Type(() => CreateAccountDto) | 
					  @IsOptional() | 
				
			||||
 | 
					  @Type(() => CreateAccountWithBalancesDto) | 
				
			||||
  @ValidateNested({ each: true }) | 
					  @ValidateNested({ each: true }) | 
				
			||||
  accounts: CreateAccountDto[]; | 
					  accounts?: CreateAccountWithBalancesDto[]; | 
				
			||||
 | 
					
 | 
				
			||||
  @IsArray() | 
					  @IsArray() | 
				
			||||
  @Type(() => CreateOrderDto) | 
					  @Type(() => CreateOrderDto) | 
				
			||||
  @ValidateNested({ each: true }) | 
					  @ValidateNested({ each: true }) | 
				
			||||
  activities: CreateOrderDto[]; | 
					  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