Browse Source

Task/merge main into feature/migrate-from-angular-material-design-2-to-3 (#6144)

* Task/improve changelog entry (#5738)

* Update changelog

* Feature/set up Storybook story for holdings table component (#5697)

* Set up Storybook story for holdings table component

* Update changelog

* Feature/improve accounts table Storybook story (#5739)

* Improve Storybook story

* Feature/use asset profile resolutions in getQuotes() of FMP service (#5743)

* Use asset profile resolutions in getQuotes()

* Update changelog

* Task/extract footer to component (#5702)

* Extract footer to component

* Update changelog

* Feature/improve currency validation in search functionality of data provider service (#5745)

* Improve currency validation

* Update changelog

* Feature/use asset profile resolutions in getQuotes() of FMP service (part 2) (#5750)

* Use asset profile resolutions in getQuotes()

* Bugfix/respect includeIndices flag in search functionality of FMP service (#5746)

* Respect includeIndices in search()

* Update changelog

* Feature/improve currency validation in getAssetProfiles() functionality of data provider service (#5747)

* Improve currency validation

* Update changelog

* Bugfix/reset scroll position on page change (#5753)

* Reset scroll position on page change

* Update changelog

* Feature/add Stealth Wealth to glossary (#5754)

* Add Stealth Wealth

* Update changelog

* Bugfix/dark mode in logo carousel component (#5758)

* Fix dark mode

* Update changelog

* Task/refactor interest to interestInBaseCurrency in portfolio summary interface (#5763)

* Refactor interest to interestInBaseCurrency

* Feature/extend pricing page (#5761)

* Extend pricing page

* Update changelog

* Bugfix/fix word wrap in menus of activities table (#5764)

* Fix word wrap

* Update changelog

* Feature/improve portfolio calculator unit tests by loading currency from user settings (#5765)

* Use currency from user settings

* Update changelog

* Task/migrate blog page component to standalone (#5742)

* Migrate blog page component to standalone

* Update changelog

* Task/refactor PortfolioDividends interface to PortfolioDividendsResponse interface (#5773)

* Refactor PortfolioDividends to PortfolioDividendsResponse

* Bugfix/total buy and sell calculation in summary (#5759)

* Fix total buy and sell calculation related to activities in custom currency

* Update changelog

* Bugfix/import of activity with MANUAL data source (CSV file) (#5749)

* Fix import of activity with MANUAL data source

* Update changelog

---------

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>

* Bugfix/fix penthouse-apartment.json (#5775)

* Fix penthouse-apartment.json

* Feature/update locales (#5714)

* Update locales

* Update translations

* Update changelog

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>

* Task/refactor PortfolioInvestments interface to PortfolioInvestmentsResponse interface (#5774)

* Refactor PortfolioInvestments to PortfolioInvestmentsResponse

* Task/refactor BenchmarkMarketDataDetails to BenchmarkMarketDataDetailsResponse (#5771)

* Refactor BenchmarkMarketDataDetails to BenchmarkMarketDataDetailsResponse

* Release 2.209.0 (#5780)

* Feature/allow data gathering by date range (#5762)

* Allow data gathering by date range

* Update changelog

* Feature/update locales (#5788)

* Update locales

* Update translations

* Update changelog

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>

* Task/auto-pad holdings table in AI prompt using tablemark (#5772)

* Auto-pad holdings table in AI prompt using tablemark

* Update changelog

* Task/improve changelog entry (#5790)

* Update changelog

* Task/harmonize wording in glossary (#5781)

* Harmonize wording

* Task/improve typings of getInfo() functionality (#5803)

* Improve typings

* Task/harmonize interfaces naming (#5796)

* Harmonize interfaces naming

* Task/improve typings of getAsset() functionality (#5804)

* Improve typings of getAsset() functionality

* Task/extract portfolio filter sub form of assistant to reusable component (#5618)

* Extract portfolio filter sub form of assistant to reusable component

* Update changelog

* Task/extend rule settings interface by locale (#5802)

* Extend rule settings interface by locale

* Task/refactor Export interface to ExportResponse interface (#5805)

* Refactor Export interface to ExportResponse interface

* Task/improve typings of getOrderById() functionality (#5810)

* Improve typings of getOrderById() functionality

* Bugfix/database seed (#5792)

* Fix database seed

* Update changelog

---------

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>

* Task/upgrade ioredis to forfeit overriding defaults (#5813)

* Upgrade ioredis to forfeit overriding defaults

* Update changelog

* Bugfix/submit form of login with access token dialog with enter key press (#5751)

* Fix form submit with enter key press

* Update changelog

* Task/introduce interface for create Stripe checkout session response (#5791)

* Introduce interface

* Task/clean up unused eslint-disable-next-line directives (#5782)

* Clean up unused eslint-disable-next-line directives

* Release 2.210.0 (#5814)

* Bugfix/include missing dotenv packages (#5817)

* Include missing dotenv packages

* Release 2.210.1 (#5818)

* Task/upgrade prisma to version 6.18.0 (#5823)

* Upgrade prisma to version 6.18.0

* Update changelog

* Task/format value in Buying Power rule (#5824)

* Format value in Buying Power rule

* Update changelog

* Task/move prisma.config.ts to .config/prisma.ts (#5821)

* Move prisma.config.ts to .config/prisma.ts

* Update changelog

* Task/extend export response by performanceCalculationType (#5816)

* Extend export response by performanceCalculationType

* Update changelog

* Bugfix/footer row style of accounts table component (#5826)

* Fix style of footer row

* Update changelog

* Task/migrate tablemark to v4 (#5809)

* Migrate tablemark to v4

* Update changelog

* Bugfix/custom asset name rendering in import activities dialog (#5787)

* Fix custom asset name rendering in import activities dialog 

* Update changelog

* Release 2.211.0-beta.0 (#5829)

* Feature/set up user detail dialog in admin control panel (#5819)

* Set up user detail dialog

* Update changelog

* Feature/update locales (#5807)

* Update locales

* Update translations

* Update changelog

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>

* Task/refactor Activities interface to ActivitiesResponse interface (#5835)

* Refactor Activities interface to ActivitiesResponse interface

* Feature/integrate SelfhostedHub into logo carousel (#5786)

* Add SelfhostedHub

* Update changelog

---------

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>

* Bugfix/market price in base currency during the portfolio snapshot calculation (#5828)

* Add fallback for market price in base currency

* Update changelog

* Release 2.211.0 (#5837)

* Release 2.211.0 (#5838)

* Bugfix/provide missing locale to rule settings dialog (#5845)

* Provide locale to rule settings dialog

* Update changelog

* Feature/extend user detail dialog (#5844)

* Extend user detail dialog

* Update changelog

* Feature/add close holding button to holding detail dialog (#5832)

* Add close holding button to holding detail dialog

* Update changelog

* Feature/update locales (#5847)

* Update locales

* Update translations

* Update changelog

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>

* Task/improve typings of dialogs (#5846)

* Improve typings

* Task/extend Contributing section in README.md (#5864)

* Add GitHub Sponsors

* Task/add LambdaTest to Sponsors in README.md (#5861)

* Add LambdaTest

* Task/refactor column definitions in AI service (#5834)

* Refactor column definitions in AI service

* Update changelog

* Feature/set up sponsors section on about page (#5862)

* Set up sponsors section

* Update changelog

* Bugfix/fix typography hierarchy in resources pages (#5863)

* Fix hierarchy

* Feature/improve usability of user detail dialog (#5868)

* Do not reload on close

* Update changelog

* Release 2.212.0 (#5871)

* Feature/extend menu in activities table component (#5855)

* Extend menu in activities table component

* Update changelog

* Feature/improve icon of holdings tabs (#5842)

* Improve icon of holdings tabs

* Update changelog

* Feature/update locales (#5852)

* Update locales

* Update translation

* Update changelog

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>

* Task/add error logging to symbol lookup in Trackinsight data enhancer (#5872)

* Add error logging

* Update changelog

* Bugfix/LambdaTest logo (#5873)

* Fix LambdaTest logo

* Task/migrate tags selector component in holding detail dialog to form control (#5850)

* Migrate tags selector component to form control

* Update changelog

* Feature/upgrade NestJS to version 11.1.8 (#5874)

* Upgrade nestjs to version 11.1.8

* Update changelog

* Release 2.213.0 (#5876)

* Feature/refresh cryptocurrencies list 20251031 (#5880)

* Update cryptocurrencies.json

* Update changelog

* Task/conditionally show Sponsors section on about page (#5882)

* Conditionally show sponsors section

* Feature/update OSS friends 20251031 (#5879)

* Update OSS friends

* Feature/improve icon of View Holding menu item in activities table (#5881)

* Improve icon

* Update changelog

* Task/resolve @typescript-eslint/prefer-regexp-exec ESLint rule (#5885)

* fix(lint): remove @typescript-eslint/prefer-regexp-exec override

* fix(lint): resolve eslint errors

* Task/upgrade ng-extract-i18n-merge to version 3.1.0 (#5886)

* Upgrade ng-extract-i18n-merge to version 3.1.0

* Update changelog

* Task/resolve @typescript-eslint/no-unsafe-function-type ESLint rule (#5889)

* fix(lint): remove @typescript-eslint/no-unsafe-function-type override

* fix(lint): resolve eslint errors

* Task/resolve no-constant-binary-expression ESLint rule (#5890)

* fix(lint): remove no-constant-binary-expression override

* fix(lint): resolve eslint errors

* Task/extend user settings in test files (#5836)

* Extend user settings by performance calculation type

* Feature/set up unit test for BTCEUR in base currency EUR (#5778)

* Set up test

* Feature/atomic data replacement during historical market data gathering (#5858)

* Atomic data replacement during historical market data gathering

* Update changelog

* Task/remove Internet Identity as social login provider (#5891)

* Remove Internet Identity

* Update changelog

* Task/upgrade countries-list to version 3.2.0 (#5888)

* Upgrade countries-list to version 3.2.0

* Update changelog

* Task/upgrade twitter-api-v2 to version 1.27.0 (#5892)

* Upgrade twitter-api-v2 to version 1.27.0

* Update changelog

* Release 2.214.0 (#5893)

* Feature/migrate client build executor to @nx/angular:browser-esbuild (#5883)

* Migrate client build executor to @nx/angular:browser-esbuild

* Update changelog

* Bugfix/fix style of safe withdrawal rate selector (#5899)

* Fix style of selector

* Update changelog

* Task/introduce interface for get admin users response (#5903)

* Introduce interface for get admin users response

* Task/improve localization in lib components (#5907)

* Improve localization

* Task/improve localization of get started buttons (#5913)

* Improve localization

* Feature/update locales (#5875)

* Update locales

* Update changelog

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>

* Feature/improve usability for benchmark and markets management in asset profile dialog (#5911)

* Improve usability for benchmark and markets management

* Update changelog

* Feature/update locales (#5916)

* Update locales

* Update translation

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>

* Bugfix/header alignment in admin platform and tag tables (#5908)

* Fix header alignment

* Update changelog

* Task/migrate app component to standalone (#5906)

* Migrate app component to standalone

* Update changelog

* Bugfix/assign admin role to first user signing up (#5914)

* Assign admin role to first user signing up

* Update changelog

* Release 2.215.0-beta.1 (#5918)

* Feature/add endpoint to get user by id (#5910)

* Add endpoint to get user by id

* Update changelog

---------

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>

* Task/upgrade @ionic/angular to version 8.7.8 (#5909)

* Upgrade @ionic/angular to version 8.7.8

* Update changelog

* Release 2.215.0 (#5922)

* Task/reorder lifecycle hooks in various components (#5919)

* Reorder lifecycle hooks

* Task/upgrade chart.js to version 4.5.1 (#5905)

* Upgrade chart.js to version 4.5.1

* Update changelog

* Task/upgrade svgmap to version 2.14.0 (#5904)

* Upgrade svgmap to version 2.14.0

* Update changelog

* Task/introduce interface for get account response (#5902)

* Introduce interface for get account response

* Feature/improve language localization for ZH 20251110 (#5928)

* Improve language localization for ZH

* Update changelog

* Task/improve localization of limited offer (#5929)

* Improve localization

* Task/improve promotion system (#5930)

* Add fallback to promotion logic

* Task/refactor primary text colors (#5900)

* Refactor primary text colors

* Release 2.216.0 (#5932)

* Task/improve localization of auto-renewal (#5933)

* Improve localization

* Task/refactor getHolding() in portfolio service (#5898)

* Refactor getHolding() if no holding has been found

* Update changelog

* Feature/update locales (#5931)

* Update locales

* Update translation

* Update changelog

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>

* Task/upgrade prisma to version 6.19.0 (#5937)

* Upgrade prisma to version 6.19.0

* Update changelog

* Task/fetch user data on demand in user detail dialog (#5923)

* Fetch user data on demand in user detail dialog

* Update changelog

* Task/enforce module boundaries for api and common modules (#5925)

* feat(lint): allow circular self deps

* feat(lint): enforce module boundaries

* feat(lib): move data provider response interface to common

* feat(lib): move symbol item interface to common

* feat(lib): move activity interface to common

* feat(lint): temporarily disable @nx/enforce-module-boundaries for ui files

* feat(lint): temporarily disable @nx/enforce-module-boundaries for client files

* feat(lint): ignore circular deps between client and ui

* feat(common): implement barrel export for data provider response interface

* feat(common): implement barrel export for activity interface

* feat(common): implement barrel export for symbol item interface

* Feature/separate Google OAuth and token authentication (#5915)

* Separate Google OAuth and token authentication

* Update changelog

* Feature/automatically gather required exchange rates (#5917)

* Automatically gather required exchange rates

* Update changelog

* Task/enforce module boundaries for client module (#5944)

* feat(lib): move SymbolPipe to common lib

* feat(lib): move CreateAccountBalanceDto to common lib

* feat(lib): move IsCurrencyCode validator to common lib

* feat(lib): move UpdateAssetProfileDto to common lib

* feat(lib): move UpdateUserSettingDto to common lib

* feat(lib): move CreateAccessDto to common lib

* feat(lib): move UpdateAccessDto to common lib

* feat(lib): move CreateTagDto to common lib

* feat(lib): move UpdateTagDto to common lib

* feat(lib): move CreatePlatformDto to common lib

* feat(lib): move UpdatePlatformDto to common lib

* feat(lib): move CreateOrderDto to common lib

* feat(lib): move UpdateOrderDto to common lib

* feat(lib): move RuleSettings interface to common lib

* feat(lib): move CreateAccountDto TransferBalanceDto UpdateAccountDto to common lib

* feat(lib): move CreateAccountWithBalancesDto to common lib

* feat(lib): move CreateAssetProfileDto and CreateAssetProfileWithMarketDataDto to common lib

* feat(lib): move AuthDeviceDto to common lib

* feat(lib): move simplewebauthn interfaces to common lib

This includes AssertionCredentialJSON, AttestationCredentialJSON, PublicKeyCredentialCreationOptionsJSON, PublicKeyCredentialRequestOptionsJSON.

* feat(lib): move UpdateMarketDataDto to common lib

* feat(lib): move UpdateBulkMarketDataDto to common lib

* feat(lib): move CreateWatchlistItemDto to common lib

* feat(lib): move DeleteOwnUserDto to common lib

* feat(lib): move UserItem interface to common lib

* feat(lib): move UpdateOwnAccessTokenDto to common lib

* feat(lib): move PropertyDto to common lib

* Bugfix/fix type error in CreateAccountWithBalancesDto (#5945)

* Refactor import

* Feature/add black weeks 2025 blog post (#5942)

* Add Black Weeks 2025 blog post

* Update changelog

* Release 2.217.0 (#5946)

* Bugfix/missing reflect-metadata polyfill in apps/client (#5952)

* Add reflect-metadata to polyfill

* Task/ignore forex in search results of FMP service (#5951)

* Ignore forex in search

* Update changelog

* Release 2.217.1 (#5954)

* Task/enforce module boundaries for ui module (#5947)

* feat(lib): move ConfirmationDialogType to common lib

* fix(lib): move SubscriptionType to enums

* feat(lib): move validateObjectForForm util to common lib

* feat(lib): move GfDialogFooterComponent to ui lib

* feat(lib): move GfDialogHeaderComponent to ui lib

* Task/integrate OSS Gallery into logo carousel (#5959)

* Integrate OSS Gallery

* Update changelog

* Task/upgrade yahoo-finance2 to version 3.10.1 (#5956)

* Upgrade yahoo-finance2 to version 3.10.1

* Update changelog

* Feature/extend menu in accounts table component (#5960)

* Extend menu

* Update changelog

* Feature/simplify portfolio summary on mobile (#5962)

* Simplify portfolio summary on mobile

* Improve numerical precision
* Hide hints

* Update changelog

* Feature/extend portfolio summary by percentage values (#5964)

* Extend summary by percentage values

* Update changelog

* Release 2.218.0 (#5965)

* Feature/add authentication method to user detail dialog (#5970)

* Extend user detail dialog

* Update changelog

* Feature/improve transform data source in request and response interceptor (#5972)

* Resolve data source of GHOSTFOLIO data provider

* Update changelog

* Feature/update locales (#5940)

* Update locales

* Update translation

* Update changelog

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>

* Feature/disable delete activities action if empty table (#5971)

* Disable delete activities action

* Update changelog

* Bugfix/edit of draft activities (#5974)

* Fix edit of draft activities

* Update changelog

* Task/refresh cryptocurrencies list 20251121 (#5967)

* Update cryptocurrencies.json

* Update changelog

* Task/improve validation of currency management in Admin Control panel (#5973)

* Improve validation (disallow GBp)

* Update changelog

* Feature/various improvements on pricing page (#5982)

* Various improvements

* Update changelog

* Task/update OSS friends (#5968)

* Update OSS friends

* Feature/update locales (#5977)

* Update locales

* Update translation

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>

* Task/upgrade yahoo-finance2 to version 3.10.2 (#5978)

* Upgrade yahoo-finance2 to version 3.10.2

* Update changelog

* Release 2.219.0 (#5983)

* Bugfix/exchange rate calculation when converting derived currencies (#5961)

* Fix exchange rate calculation when converting derived currencies

* Update changelog

* Task/refactor from v4 as uuidv4 from uuid to crypto.randomUUID() (#5990)

* Refactor from v4 as uuidv4 from uuid to randomUUID() from node:crypto

* Update changelog

* Task/restructure user detail dialog (#5985)

* Restructure user detail dialog

* Task/upgrade color to version 5.0.3 (#5984)

* Upgrade color to version 5.0.3

* Update changelog

* Task/remove obsolete includeDrafts attribute in public controller (#5975)

* Remove obsolete includeDrafts attribute

* Feature/improve asset profile data gathering (#5997)

* Improve asset profile data gathering

* Update changelog

* Task/remove Cypress setup (#5995)

* Remove Cypress setup

* Update changelog

* Task/improve usability of actions in various tables (#5992)

* Improve usability of actions

* Task/upgrade prettier to version 3.7.2 (#5999)

* Upgrade prettier to version 3.7.2

* Update changelog

* Release 2.220.0 (#6001)

* Feature/extend Storybook story of portfolio proportion chart component by story in percentage (#6007)

* Extend Storybook story

* Update changelog

* Bugfix/countries in FMP service (#6005)

* Introduce countries mapping

* Update changelog

* Task/upgrade @internationalized/number to version 3.6.5 (#6000)

* Upgrade @internationalized/number to version 3.6.5

* Update changelog

* Task/refactor query parameters in data provider services (#6011)

* Refactor query parameters

* Update changelog

* Task/upgrade prettier to version 3.7.3 (#6014)

* Upgrade prettier to version 3.7.3

* Update changelog

* Bugfix/improve search by name in FMP service (#6012)

* Improve search by name

* Update changelog

* Task/extend subscription offer key type (#6022)

* Extend SubscriptionOfferKey

* Bugfix/user endpoint of admin control panel (#6021)

* Fix user endpoint

* Update changelog

* Release 2.221.0 (#6024)

* Task/upgrade envalid to version 8.1.1 (#6026)

* Upgrade envalid to version 8.1.1

* Update changelog

* Bugfix/clean up CHANGELOG.md (#5976)

* Clean up

* Task/remove return type in parseSector() of YahooFinanceService (#6006)

* Remove return type

* Task/upgrade prettier to version 3.7.4 (#6031)

* Upgrade prettier to version 3.7.4

* Update changelog

* Task/prettify files 20251204 (#6033)

* Prettify files

* Task/restructure pricing page (#6037)

* Restructure pricing page

* Task/increase numerical precision for cryptocurrency quantities in holding detail dialog (#6038)

* Increase numerical precision for cryptocurrency quantities

* Update changelog

* Feature/data source transformation in import for self-hosted environments (#6032)

* Introduce data source transformation support for self-hosted environments

* Update changelog

* Feature/add 3D hover effect to membership card component (#5966)

* Add 3D hover effect to membership card component

* Update changelog

* Feature/OIDC authentication (#5981)

* Set up OIDC authentication

* Update changelog

* Task/activate 3d hover effect in account membership overview (#6039)

* Activate 3d hover effect

* Release 2.222.0 (#6041)

* Task/improve OIDC login button label (#6043)

* Improve label

* Feature/update locales (#5993)

* Update locales

* Update translation

* Update changelog

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>

* Task/add OpenID Connect (OIDC) configuration details to README (#6044)

* Add OpenID Connect (OIDC) configuration details

* Task/move notification service to UI library (#6048)

* Move notification service to UI library

* Update changelog

* Task/update OSS Friends (#6047)

* Update OSS Friends

* Task/update VS Code extension of Prettier (#6010)

* Update VS Code extension of Prettier

* Bugfix/allocate remaining percentage to unknown data in portfolio proportion chart (#6054)

* Allocate remaining percentage to unknown data in portfolio proportion chart

* Update changelog

* Feature/extend FIRE page with projection information at retirement date (#6034)

* Extend FIRE page with projection information at retirement date

* Update changelog

---------

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>

* Task/update note in personal finance tools (#6053)

* Update note

* Feature/update locales (#6049)

* Update locales

* Update translations

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>

* Task/improve language localization for de 20251214 (#6066)

* Improve language localization

* Feature/update locales (#6065)

* Update locales

* Update translations

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>

* Release 2.223.0 (#6067)

* Task/refresh cryptocurrencies list 20251215 (#6073)

* Update cryptocurrencies.json

* Update changelog

* Task/remove deprecated Angular CLI decorator (#6071)

* Remove deprecated Angular CLI decorator

* Update changelog

* Task/include first and last date of each calendar year in getChartDateMap() (#6069)

* Include first and last date of each calendar year in getChartDateMap()

* Update changelog

* Task/improve localization of various components (#6074)

* Improve localization

* Bugfix/localize date formatting in FIRE calculator (#6077)

* Localize date formatting

* Update changelog

* Feature/add ISIN number to asset profile dialog (#6076)

* Add ISIN number

* Update changelog

* Task/refactor language in various components (#6078)

* Refactor language

* Feature/restore support for specific calendar year date ranges in assistant (#6079)

* Restore specific calendar year date ranges

* Update changelog

* Update locales (#6068)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* Release 2.224.0 (#6080)

* Revert "Task/refactor language in various components (#6078)" (#6081)

This reverts commit 80c278555c.

* Release 2.224.1 (#6084)

* Release 2.224.2 (#6086)

* Feature/extend personal finance tools 20251220 (#6087)

* Add BlueBudget, Moneyspire and Pennies

* Task/refactor href in user account settings component (#6085)

* Refactoring

* Task/upgrade shx to version 0.4.0 (#6088)

* Upgrade shx to version 0.4.0

* Update changelog

* Bugfix/add missing currency suffix to cash balance field in create or update account dialog (#6092)

* Add missing currency suffix to cash balance field in create or update account dialog

* Update changelog

* Bugfix/fix asset profile deletion and allow editing asset profile identifiers with MANUAL data source (#6090)

* Fix asset profile deletion and allow editing asset profile identifiers with MANUAL data source

* Update changelog

* Task/refactor read platforms permission (#6095)

* Refactor read platforms permission

* Task/upgrade to Nx 22.0.4 and Angular 20.3 (#6098)

* feat(nx): migrate to v22.0.4

* fix(deps): install with Node.js 22

* Update changelog

* Task/upgrade to Nx 22.1.3 and Storybook 10.1.10 (#6105)

* chore(deps): bump nx from 22.0 to 22.1

* fix(ts): set moduleResolution to bundler

This is a requirement for Storybook 10 migration. See https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#require-tsconfigjson-moduleresolution-set-to-value-that-supports-types-condition.

* feat(sb): change main.js to ESM

See https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#the-storybookmain-file-and-other-presets-must-be-valid-esm.

* fix(ts): set target to es2022

This resolves the warning: TypeScript compiler options "target" and "useDefineForClassFields" are set to "ES2022" and "false" respectively by the Angular CLI. To control ECMA version and features use the Browserslist configuration. For more information, see https://angular.dev/tools/cli/build#configuring-browser-compatibility.

* fix(ts): set esModuleInterop to true

* feat(docs): update changelog

* fix(ts): set moduleResolution to bundler

* fix(ts): revert target changes

* Update changelog

* Task/upgrade ng-extract-i18n-merge from 3.1.0 to 3.2.1 (#6106)

* chore(deps): bump ng-extract-i18n-merge from 3.1.0 to 3.2.1

* feat(docs): update changelog

* Task/revert adding styles to ui:storybook target (#6107)

* fix(sb): revert adding styles to ui:storybook target

* Task/upgrade jest to version 30.2.0 (#6110)

* chore(deps): bump jest to 30.2.0

* fix(test): update setup test scripts

* fix(deps): resolve vulns

* Bugfix/time in market for impersonation mode (#6103)

* Fix time in market for impersonation mode

* Update changelog

* Task/improve language localization for de 20251229 (#6108)

* Update translations

* Update changelog

* Feature/create endpoint to get all platforms (#6097)

* Create endpoint to get all platforms

* Update changelog

* Task/improve user detail dialog routing in Admin Control panel (#6104)

* Improve user detail dialog routing in Admin Control panel

* Update changelog

* Task/deprecate platforms in info item (#6114)

* Deprecate platforms

* Task/eliminate ngx-stripe (#6116)

* Eliminate ngx-stripe

* Update changelog

* Task/upgrade to marked 16.4.2 and ngx-markdown 20.1.0 (#6117)

* chore(deps): bump marked and ngx-markdown

* fix(client): change marked script file

* fix(client): change umd to esm

* fix(client): remove marked script file from project.json

* feat(docs): update changelog

* Task/upgrade to Nx 22.3.3 and Angular 21.0 (#6109)

* chore(deps): upgrade to Nx 22.3.3 and Angular 21.0

* feat(nx): run migrations

* fix(ts): change module and moduleResolution for api

* feat(ts): remove duplicates of bundler module resolution

* fix(ts): enable ESM interoperability

* fix(api): update imports

* fix(ts): set module to preserve in api spec

* fix(ts): update target

* resolve comments

* fix(api): set jest moduleNameMapper

* fix(ts): update tsconfig in api spec

* fix(api): revert import changes

* fix(apps): revert unnecessary changes

* fix(client): change marked js file

* fix(deps): remove duplicates

* Release 2.225.0 (#6120)

* Task/upgrade class-validator to version 0.14.3 (#6091)

* Upgrade class-validator to version 0.14.3

* Update changelog

* Task/update year to 2026 (#6119)

* Update year

* Task/extend data providers content in self-hosting FAQ (#6123)

* Extend data providers content

* Update changelog

* Task/upgrade yahoo-finance2 to version 3.11.2 (#6126)

* Upgrade yahoo-finance2 to version 3.11.2

* Update changelog

* Release 2.226.0 (#6127)

* Bugfix/jsonpath import in manual service (#6129)

* Fix jsonpath import

* Update changelog

* Bugfix/initialize input properties in fire calculator (#6122)

* Initialize input properties

* Update changelog

* Task/upgrade stripe to version 20.1.0 (#6132)

* Upgrade stripe to version 20.1.0

* Update changelog

* Task/remove deprecated public Stripe key (#6124)

* Remove deprecated public Stripe key

* Update changelog

* Release 2.227.0 (#6134)

* Task/move scraper configuration to tab in asset profile dialog (#6094)

* Move scraper configuration to tab in asset profile dialog

* Update changelog

* Task/lazy load platforms via API in create or update account dialog (#6130)

* Lazy load platforms via API

* Update changelog

* Task/remove deprecated public Stripe key (part 2) (#6138)

* Remove deprecated public Stripe key

* Feature/update locales (#6083)

* Update locales

* Update translations

* Update changelog

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>

* Task/rename branch and title in extract locales GitHub action (#6142)

* Rename branch and title

* Feature/extend holdings endpoint to include performance with currency effects for cash positions (#5650)

* Extend holdings endpoint to include performance with currency effects for cash positions

* Update changelog

* Bugfix/header alignment in accounts table on mobile (#6143)

* Fix header alignment

* Update changelog

* fix(client): remove unused variables

---------

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
Co-authored-by: David Requeno <108202767+DavidReque@users.noreply.github.com>
Co-authored-by: Aditya Pawar <143347456+JustAdi10@users.noreply.github.com>
Co-authored-by: Mariam Saeed <69825646+Mariam-Saeed@users.noreply.github.com>
Co-authored-by: Dibyendu Sahoo <dibyendusahoo03@gmail.com>
Co-authored-by: H_S <81474463+HarjobandeepSingh@users.noreply.github.com>
Co-authored-by: Umesh Pal <127204670+72umesh@users.noreply.github.com>
Co-authored-by: Sven Günther <sven.guenther@it-agile.de>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Aman <142656811+Amanthisside@users.noreply.github.com>
Co-authored-by: Google <53668973+GooglyBlox@users.noreply.github.com>
Co-authored-by: s-vamshi <62489114+s-vamshi@users.noreply.github.com>
Co-authored-by: vitalymatyushik <vitaly_matyushik@outlook.com>
Co-authored-by: Vansh <140736931+Vansh-Parate@users.noreply.github.com>
Co-authored-by: Germán Martín <github@gmartin.net>
Co-authored-by: Ani07-05 <86768646+Ani07-05@users.noreply.github.com>
Co-authored-by: Harsh Santwani <96873014+HydrallHarsh@users.noreply.github.com>
Co-authored-by: Arshad Jamal <arshad.jamal@vlinkinfo.com>
Co-authored-by: Szymon Łągiewka <slagiewka@users.noreply.github.com>
Co-authored-by: jjs2099 <140512982+jjs2099@users.noreply.github.com>
Co-authored-by: danielochinasa <danielochinasa@gmail.com>
Co-authored-by: Vaishnavi Parabkar <parabkarvaishnavi24@gmail.com>
Co-authored-by: Abhishek Singla <singlaabhishek849@gmail.com>
Co-authored-by: Arghya Das <alfaarghya.dev@gmail.com>
Co-authored-by: TMs <tms@live.cn>
Co-authored-by: Johnson Towoju <johnsontodimu@gmail.com>
Co-authored-by: Eshaan Gupta <146680427+Eshaan-byte@users.noreply.github.com>
Co-authored-by: Karel De Smet <karel.de.smet@outlook.com>
Co-authored-by: Eduardo Almeida <edualm@users.noreply.github.com>
Co-authored-by: Vahant Sharma <vahantofficials12315@gmail.com>
Co-authored-by: Paul van der lei <54599584+0pilatos0@users.noreply.github.com>
Co-authored-by: Omkar Gujja <67428719+omkarg01@users.noreply.github.com>
pull/6148/head
Kenrick Tandrian 1 week ago
committed by GitHub
parent
commit
49402b000b
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 14
      .config/prisma.ts
  2. 3
      .github/FUNDING.yml
  3. 4
      .github/workflows/extract-locales.yml
  4. 4
      .vscode/extensions.json
  5. 2
      .vscode/settings.json
  6. 573
      CHANGELOG.md
  7. 22
      Dockerfile
  8. 36
      README.md
  9. 60
      apps/api/src/app/access/access.controller.ts
  10. 19
      apps/api/src/app/access/access.service.ts
  11. 2
      apps/api/src/app/account-balance/account-balance.controller.ts
  12. 3
      apps/api/src/app/account-balance/account-balance.service.ts
  13. 16
      apps/api/src/app/account/account.controller.ts
  14. 42
      apps/api/src/app/admin/admin.controller.ts
  15. 92
      apps/api/src/app/admin/admin.service.ts
  16. 2
      apps/api/src/app/admin/queue/queue.service.ts
  17. 8
      apps/api/src/app/app.module.ts
  18. 4
      apps/api/src/app/asset/asset.controller.ts
  19. 61
      apps/api/src/app/auth/auth.controller.ts
  20. 82
      apps/api/src/app/auth/auth.module.ts
  21. 51
      apps/api/src/app/auth/auth.service.ts
  22. 3
      apps/api/src/app/auth/google.strategy.ts
  23. 21
      apps/api/src/app/auth/interfaces/interfaces.ts
  24. 114
      apps/api/src/app/auth/oidc-state.store.ts
  25. 69
      apps/api/src/app/auth/oidc.strategy.ts
  26. 11
      apps/api/src/app/auth/web-auth.service.ts
  27. 89
      apps/api/src/app/endpoints/ai/ai.service.ts
  28. 4
      apps/api/src/app/endpoints/assets/assets.controller.ts
  29. 4
      apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts
  30. 4
      apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts
  31. 10
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts
  32. 53
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts
  33. 3
      apps/api/src/app/endpoints/market-data/market-data.controller.ts
  34. 24
      apps/api/src/app/endpoints/market-data/update-bulk-market-data.dto.ts
  35. 22
      apps/api/src/app/endpoints/platforms/platforms.controller.ts
  36. 11
      apps/api/src/app/endpoints/platforms/platforms.module.ts
  37. 49
      apps/api/src/app/endpoints/public/public.controller.ts
  38. 5
      apps/api/src/app/endpoints/sitemap/sitemap.controller.ts
  39. 140
      apps/api/src/app/endpoints/sitemap/sitemap.service.ts
  40. 4
      apps/api/src/app/endpoints/tags/tags.controller.ts
  41. 2
      apps/api/src/app/endpoints/watchlist/watchlist.controller.ts
  42. 4
      apps/api/src/app/exchange-rate/exchange-rate.controller.ts
  43. 10
      apps/api/src/app/export/export.controller.ts
  44. 21
      apps/api/src/app/export/export.service.ts
  45. 11
      apps/api/src/app/import/import-data.dto.ts
  46. 114
      apps/api/src/app/import/import.service.ts
  47. 4
      apps/api/src/app/info/info.controller.ts
  48. 17
      apps/api/src/app/info/info.service.ts
  49. 13
      apps/api/src/app/order/order.controller.ts
  50. 174
      apps/api/src/app/order/order.service.ts
  51. 5
      apps/api/src/app/platform/platform.controller.ts
  52. 8
      apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts
  53. 7
      apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts
  54. 71
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  55. 205
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts
  56. 5
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts
  57. 5
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts
  58. 15
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts
  59. 150
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur-in-base-currency-eur.spec.ts
  60. 37
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts
  61. 6
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts
  62. 126
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts
  63. 37
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts
  64. 290
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts
  65. 5
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts
  66. 6
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts
  67. 5
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts
  68. 144
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts
  69. 5
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts
  70. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-orders.spec.ts
  71. 26
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  72. 26
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts
  73. 5
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts
  74. 13
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts
  75. 4
      apps/api/src/app/portfolio/interfaces/get-values-params.interface.ts
  76. 4
      apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts
  77. 3
      apps/api/src/app/portfolio/interfaces/transaction-point-symbol.interface.ts
  78. 81
      apps/api/src/app/portfolio/portfolio.controller.ts
  79. 409
      apps/api/src/app/portfolio/portfolio.service.ts
  80. 4
      apps/api/src/app/portfolio/rules.service.ts
  81. 2
      apps/api/src/app/redis-cache/redis-cache.service.ts
  82. 11
      apps/api/src/app/subscription/subscription.controller.ts
  83. 25
      apps/api/src/app/subscription/subscription.service.ts
  84. 10
      apps/api/src/app/symbol/symbol.controller.ts
  85. 17
      apps/api/src/app/symbol/symbol.service.ts
  86. 16
      apps/api/src/app/user/user.controller.ts
  87. 52
      apps/api/src/app/user/user.service.ts
  88. 1082
      apps/api/src/assets/cryptocurrencies/cryptocurrencies.json
  89. 1
      apps/api/src/assets/sitemap.xml
  90. 3
      apps/api/src/dependencies.ts
  91. 11
      apps/api/src/events/asset-profile-changed.event.ts
  92. 61
      apps/api/src/events/asset-profile-changed.listener.ts
  93. 17
      apps/api/src/events/events.module.ts
  94. 26
      apps/api/src/helper/object.helper.spec.ts
  95. 11
      apps/api/src/helper/object.helper.ts
  96. 2
      apps/api/src/helper/string.helper.ts
  97. 8
      apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts
  98. 33
      apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts
  99. 60
      apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts
  100. 16
      apps/api/src/main.ts

14
.config/prisma.ts

@ -0,0 +1,14 @@
import { defineConfig } from '@prisma/config';
import { config } from 'dotenv';
import { expand } from 'dotenv-expand';
import { join } from 'node:path';
expand(config({ quiet: true }));
export default defineConfig({
migrations: {
path: join(__dirname, '..', 'prisma', 'migrations'),
seed: `node ${join(__dirname, '..', 'prisma', 'seed.mts')}`
},
schema: join(__dirname, '..', 'prisma', 'schema.prisma')
});

3
.github/FUNDING.yml

@ -1 +1,2 @@
custom: ['https://www.buymeacoffee.com/ghostfolio']
buy_me_a_coffee: ghostfolio
github: ghostfolio

4
.github/workflows/extract-locales.yml

@ -33,8 +33,8 @@ jobs:
uses: peter-evans/create-pull-request@v7
with:
author: 'github-actions[bot] <github-actions[bot]@users.noreply.github.com>'
branch: 'feature/update-locales'
branch: 'task/update-locales'
commit-message: 'Update locales'
delete-branch: true
title: 'Feature/update locales'
title: 'Task/update locales'
token: ${{ secrets.GITHUB_TOKEN }}

4
.vscode/extensions.json

@ -1,8 +1,8 @@
{
"recommendations": [
"angular.ng-template",
"esbenp.prettier-vscode",
"firsttris.vscode-jest-runner",
"nrwl.angular-console"
"nrwl.angular-console",
"prettier.prettier-vscode"
]
}

2
.vscode/settings.json

@ -1,4 +1,4 @@
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.defaultFormatter": "prettier.prettier-vscode",
"editor.formatOnSave": true
}

573
CHANGELOG.md

@ -13,16 +13,587 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
### Added
- Extended the portfolio holdings to include performance with currency effects for cash positions
### Changed
- Integrated the endpoint to get all platforms (`GET api/v1/platforms`) into the create or update account dialog
- Extracted the scraper configuration to a dedicated tab in the asset profile details dialog of the admin control panel
- Improved the language localization for German (`de`)
### Fixed
- Improved the table headers’ alignment of the accounts table on mobile
## 2.227.0 - 2026-01-02
### Changed
- Initialized the input properties in the _FIRE_ calculator
- Removed the deprecated public _Stripe_ key
- Upgraded `stripe` from version `18.5.0` to `20.1.0`
### Fixed
- Fixed the import of `jsonpath` to support REST APIs (`JSON`) via the scraper configuration
## 2.226.0 - 2026-01-01
### Added
- Extended the content of the _Self-Hosting_ section by information about additional data providers on the Frequently Asked Questions (FAQ) page
### Changed
- Upgraded `class-validator` from version `0.14.2` to `0.14.3`
- Upgraded `yahoo-finance2` from version `3.10.2` to `3.11.2`
## 2.225.0 - 2025-12-31
### Added
- Added a new endpoint to get all platforms (`GET api/v1/platforms`)
- Added the session url to the endpoint response of the _Stripe_ checkout
### Changed
- Improved the routing of the user detail dialog in the users section of the admin control panel
- Lifted the asset profile identifier editing restriction for `MANUAL` data sources in the asset profile details dialog of the admin control panel
- Deprecated the public _Stripe_ key
- Improved the language localization for German (`de`)
- Eliminated `ngx-stripe`
- Upgraded `angular` from version `20.2.4` to `21.0.6`
- Upgraded `marked` from version `15.0.4` to `17.0.1`
- Upgraded `ngx-device-detector` from version `10.1.0` to `11.0.0`
- Upgraded `ng-extract-i18n-merge` from `3.1.0` to `3.2.1`
- Upgraded `ngx-markdown` from version `20.0.0` to `21.0.1`
- Upgraded `Nx` from version `21.5.1` to `22.3.3`
- Upgraded `shx` from version `0.3.4` to `0.4.0`
- Upgraded `storybook` from version `9.1.5` to `10.1.10`
- Upgraded `zone.js` from version `0.15.1` to `0.16.0`
### Fixed
- Added the missing currency suffix to the cash balance field in the create or update account dialog
- Fixed the time in market display of the portfolio summary tab on the home page for the impersonation mode
- Fixed the delete button in the asset profile details dialog of the admin control panel by providing the missing `watchedByCount` parameter
## 2.224.2 - 2025-12-20
### Added
- Included the calendar year boundaries in the portfolio calculations
- Added the ISIN number to the asset profile details dialog of the admin control panel
### Changed
- Restored the support for specific calendar year date ranges (`2024`, `2023`, `2022`, etc.) in the assistant (experimental)
- Removed the deprecated _Angular CLI_ decorator (`decorate-angular-cli.js`)
- Refreshed the cryptocurrencies list
### Fixed
- Localized date formatting across the _FIRE_ section
## 2.223.0 - 2025-12-14
### Added
- Included wealth projection data calculated for the retirement date in the _FIRE_ section (experimental)
### Changed
- Moved the notification module to `@ghostfolio/ui`
- Improved the language localization for German (`de`)
### Fixed
- Fixed a calculation issue that resulted in the incorrect assignment of unknown data in the portfolio proportion chart component
## 2.222.0 - 2025-12-07
### Added
- Introduced data source transformation support in the import functionality for self-hosted environments
- Added _OpenID Connect_ (`OIDC`) as a new login provider for self-hosted environments (experimental)
- Added an optional 3D hover effect to the membership card component
### Changed
- Increased the numerical precision for cryptocurrency quantities in the holding detail dialog
- Upgraded `envalid` from version `8.1.0` to `8.1.1`
- Upgraded `prettier` from version `3.7.3` to `3.7.4`
## 2.221.0 - 2025-12-01
### Changed
- Refactored the API query parameters in various data provider services
- Extended the _Storybook_ stories of the portfolio proportion chart component by a story using percentage values
- Upgraded `@internationalized/number` from version `3.6.3` to `3.6.5`
- Upgraded `prettier` from version `3.7.2` to `3.7.3`
### Fixed
- Improved the country weightings in the _Financial Modeling Prep_ service
- Improved the search functionality by name in the _Financial Modeling Prep_ service
- Resolved an issue in the user endpoint where the list was returning empty in the admin control panel’s users section
## 2.220.0 - 2025-11-29
### Changed
- Restricted the asset profile data gathering on Sundays to only process outdated asset profiles
- Removed the _Cypress_ testing setup
- Eliminated `uuid` in favor of using `randomUUID` from `node:crypto`
- Upgraded `color` from version `5.0.0` to `5.0.3`
- Upgraded `prettier` from version `3.6.2` to `3.7.2`
### Fixed
- Fixed an issue with the exchange rate calculation when converting between derived currencies and their root currencies
## 2.219.0 - 2025-11-23
### Added
- Extended the user detail dialog of the admin control panel’s users section by the authentication method
### Changed
- Disabled the action to delete activities if the activities table is empty
- Improved the validation of the currency management in the admin control panel
- Improved the content of the pricing page
- Resolved the data source of the `GHOSTFOLIO` data provider in the export functionality
- Resolved the data source of the `GHOSTFOLIO` data provider in the import functionality
- Refreshed the cryptocurrencies list
- Improved the language localization for German (`de`)
- Upgraded `yahoo-finance2` from version `3.10.1` to `3.10.2`
### Fixed
- Fixed an issue with the edit of future activities (drafts)
## 2.218.0 - 2025-11-20
### Added
- Extended the accounts table menu with a _View Details_ item
- Extended the portfolio summary tab on the home page by percentage values (experimental)
- Added the _OSS Gallery_ logo to the logo carousel on the landing page
### Changed
- Improved the dynamic numerical precision for various values in the portfolio summary tab on the home page
- Upgraded `yahoo-finance2` from version `3.10.0` to `3.10.1`
## 2.217.1 - 2025-11-16
### Added
- Introduced support for automatically gathering required exchange rates, exposed as an environment variable (`ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES`)
- Added a blog post: _Black Weeks 2025_
### Changed
- Refactored the get holding functionality in the portfolio service
- Changed the user data loading in the user detail dialog of the admin control panel’s users section to fetch data on demand
- Exposed the authentication with access token as an environment variable (`ENABLE_FEATURE_AUTH_TOKEN`)
- Improved the search functionality of the _Financial Modeling Prep_ service
- Improved the language localization for German (`de`)
- Upgraded `prisma` from version `6.18.0` to `6.19.0`
### Todo
- Rename the environment variable from `ENABLE_FEATURE_SOCIAL_LOGIN` to `ENABLE_FEATURE_AUTH_GOOGLE`
## 2.216.0 - 2025-11-10
### Changed
- Improved the language localization for Chinese (`zh`)
- Upgraded `chart.js` from version `4.5.0` to `4.5.1`
- Upgraded `svgmap` from version `2.12.2` to `2.14.0`
## 2.215.0 - 2025-11-06
### Added
- Added the endpoint `GET /api/v1/admin/user/:id`
### Changed
- Improved the _Self-Hosting_ section content for the _Compare with..._ concept on the Frequently Asked Questions (FAQ) page
- Improved the _Self-Hosting_ section content for the _Markets_ concept on the Frequently Asked Questions (FAQ) page
- Changed the build executor of the client from `@nx/angular:webpack-browser` to `@nx/angular:browser-esbuild`
- Refactored the app component to standalone
- Improved the language localization for German (`de`)
- Upgraded `@ionic/angular` from version `8.7.3` to `8.7.8`
### Fixed
- Fixed the style of the safe withdrawal rate selector in the _FIRE_ section (experimental)
- Assigned the `ADMIN` role to the first user signing up via a social login provider if no administrator existed
- Improved the table headers’ alignment in the platform management of the admin control panel
- Improved the table headers’ alignment in the tag management of the admin control panel
## 2.214.0 - 2025-11-01
### Changed
- Improved the icon of the _View Holding_ menu item in the activities table
- Ensured atomic data replacement during historical market data gathering
- Removed _Internet Identity_ as a social login provider
- Refreshed the cryptocurrencies list
- Upgraded `countries-list` from version `3.1.1` to `3.2.0`
- Upgraded `ng-extract-i18n-merge` from version `3.0.0` to `3.1.0`
- Upgraded `twitter-api-v2` from version `1.23.0` to `1.27.0`
## 2.213.0 - 2025-10-30
### Added
- Extended the activities table menu with a _View Holding_ item
- Added the error logging to the symbol lookup in the _Trackinsight_ data enhancer
### Changed
- Improved the icon of the holdings tab on the home page
- Improved the icon of the holdings tab on the home page for the _Zen Mode_
- Improved the icon of the holdings tab in the account detail dialog
- Migrated the tags selector component in the holding detail dialog to form control
- Improved the language localization for German (`de`)
- Upgraded `nestjs` from version `11.1.3` to `11.1.8`
## 2.212.0 - 2025-10-29
### Added
- Added a close holding button to the holding detail dialog
- Added the _Sponsors_ section to the about page
- Extended the user detail dialog in the users section of the admin control panel
### Changed
- Refactored the generation of the holdings table in the _Copy AI prompt to clipboard for analysis_ action on the analysis page (experimental)
- Refactored the generation of the holdings table in the _Copy portfolio data to clipboard for AI prompt_ action on the analysis page (experimental)
- Improved the usability of the user detail dialog in the users section of the admin control panel
- Improved the language localization for German (`de`)
### Fixed
- Ensured the locale is available in the settings dialog to customize the rule thresholds of the _X-ray_ page
## 2.211.0 - 2025-10-25
### Added
- Extended the export functionality by the user account’s performance calculation type
- Added the _SelfhostedHub_ logo to the logo carousel on the landing page
- Added a user detail dialog to the users section of the admin control panel
### Changed
- Localized the number formatting in the static portfolio analysis rule: _Liquidity_ (Buying Power)
- Moved the _Prisma Configuration File_ from `prisma.config.ts` to `.config/prisma.ts`
- Improved the language localization for German (`de`)
- Upgraded `prisma` from version `6.17.1` to `6.18.0`
- Upgraded `tablemark` from version `3.1.0` to `4.1.0`
### Fixed
- Fixed the style in the footer row of the accounts table
- Fixed the rendering of names and symbols for custom assets in the import activities dialog
- Fixed an issue with the market price in base currency during the portfolio snapshot calculation
## 2.210.1 - 2025-10-22
### Added
- Added support for data gathering by date range in the asset profile details dialog of the admin control panel
### Changed
- Extracted the portfolio filter form of the assistant to a reusable component
- Formatted the holdings table in the _Copy AI prompt to clipboard for analysis_ action on the analysis page (experimental)
- Formatted the holdings table in the _Copy portfolio data to clipboard for AI prompt_ action on the analysis page (experimental)
- Reverted the explicit configuration of the _Redis_ address family in the job queue module
- Improved the language localization for German (`de`)
- Upgraded `ioredis` from version `5.6.1` to `5.8.2`
### Fixed
- Fixed the enter key press to submit the form of the login with access token dialog
- Fixed an issue in the database seeding process caused by unresolved environment variables in `DATABASE_URL`
## 2.209.0 - 2025-10-18
### Added
- Extended the glossary of the resources page by _Stealth Wealth_
- Extended the content of the pricing page
- Added a _Storybook_ story for the holdings table component
### Changed
- Disabled the zoom functionality in the _Progressive Web App_ (PWA)
- Improved the currency validation in the get asset profiles functionality of the data provider service
- Improved the currency validation in the search functionality of the data provider service
- Optimized the get quotes functionality by utilizing the asset profile resolutions in the _Financial Modeling Prep_ service
- Extracted the footer to a component
- Refactored the blog page component to standalone
- Improved the portfolio calculator unit tests to load the user currency from the exported file
- Improved the language localization for German (`de`)
### Fixed
- Fixed an issue in the `csv` file import where custom asset profiles failed due to validation errors
- Fixed an issue with the total buy and sell calculation in the summary related to activities in a custom currency
- Respected the include indices flag in the search functionality of the _Financial Modeling Prep_ service
- Fixed an issue where the scroll position was not restored when changing pages
- Fixed the word wrap in the menus of the activities table component
- Fixed the dark mode in the _As seen in_ section on the landing page
## 2.208.0 - 2025-10-11
### Added
- Added support for configuring the safe withdrawal rate in the _FIRE_ section (experimental)
### Changed
- Changed the _As seen in_ section on the landing page to an animated carousel
- Refactored `transactionCount` to `activitiesCount` in the endpoint `GET api/v1/portfolio/holding/:dataSource/:symbol`
- Refactored various components to use self-closing tags
- Removed the deprecated endpoint `GET api/v1/portfolio/position/:dataSource/:symbol`
- Removed the deprecated endpoint `PUT api/v1/portfolio/position/:dataSource/:symbol/tags`
- Improved the language localization for German (`de`)
- Upgraded `prisma` from version `6.16.1` to `6.17.1`
### Fixed
- Fixed the server startup message to properly display IPv6 addresses
- Enabled IPv6 connectivity for _Redis_ in the job queue module by setting the address family
- Fixed an issue where importing custom asset profiles failed due to validation errors
## 2.207.0 - 2025-10-08
### Added
- Added support to edit a granted access (experimental)
- Introduced tabs to the asset profile details dialog in the admin control panel
- Added support for a date range query parameter in the data gathering endpoint
- Added a _Storybook_ story for the activities table component
### Changed
- Improved the spacing around the buttons in the holding detail dialog
- Extended the _Storybook_ stories of the accounts table component by a loading state story
- Refactored the auth page to standalone
- Improved the language localization for German (`de`)
### Fixed
- Fixed the word wrap in the menu of the access table component
- Fixed the word wrap in the menu of the activities table component
- Fixed the word wrap in the menu of the asset profile details dialog in the admin control panel
## 2.206.0 - 2025-10-04
### Changed
- Localized the number formatting in the settings dialog to customize the rule thresholds of the _X-ray_ page
- Improved the usability of the assistant by preselecting the first search result
- Improved the usability of the _Cancel_ / _Close_ buttons in the create watchlist item dialog
- Refactored the `fireWealth` from `number` type to a structured object in the summary of the portfolio details endpoint
- Refactored the _Open Startup_ (`/open`) page to standalone
- Refactored the file drop directive to standalone
- Refactored the symbol pipe to standalone
### Fixed
- Handled an exception in the get asset profile functionality of the _Financial Modeling Prep_ service
- Added the missing `CommonModule` import in the import activities dialog
## 2.205.0 - 2025-10-01
### Changed
- Restricted the selection of the retirement date picker in the _FIRE_ calculator to a future date
- Improved the support for mutual funds in the _Financial Modeling Prep_ service (get asset profiles)
- Improved the language localization for German (`de`)
- Upgraded `prisma` from version `6.16.1` to `6.16.3`
## 2.204.0 - 2025-09-30
### Added
- Added the safe withdrawal rate to the user settings (experimental)
### Changed
- Improved the number formatting of the y-axis labels in the investment chart component
- Localized the number formatting of the y-axis labels in the line chart component
- Improved the wording of the 4% rule in the _FIRE_ section
- Improved the usability of the create asset profile dialog in the market data section of the admin control panel
- Improved the language localization for German (`de`)
### Fixed
- Improved the table headers’ alignment of the activities table
## 2.203.0 - 2025-09-27
### Added
- Added support for column sorting to the queue jobs table in the admin control panel
- Added a blog post: _Hacktoberfest 2025_
### Changed
- Removed the deprecated `ITEM` activity type
## 2.202.0 - 2025-09-26
### Added
- Added `settings` to the `Access` model
### Changed
- Extended the tags selector component to support form control
- Changed the deprecated `ITEM` activity type to `VALUABLE` in the create or update activity dialog
### Fixed
- Fixed an issue where the save button was not enabled after editing tags in the create or update activity dialog
- Fixed an issue in the investment calculation when selling all units of a holding
## 2.201.0 - 2025-09-24
### Added
- Added the symbol to the benchmark component
- Added the latest activities to the public page (experimental)
- Added pagination to the activities table of the activities import dialog
- Added an option to configure the account column of the activities table component
### Changed
- Hid the account column from the activities table of the account detail dialog to avoid redundant information
- Renamed the show access token dialog component to user account registration dialog component
- Refreshed the cryptocurrencies list
- Improved the language localization for German (`de`)
- Upgraded `countup.js` from version `2.8.2` to `2.9.0`
### Fixed
- Fixed an issue with `unitPriceInAssetProfileCurrency` in the value redaction interceptor for the impersonation mode
## 2.200.0 - 2025-09-17
### Changed
- Refactored the show access token dialog component to standalone
- Upgraded `prisma` from version `6.15.0` to `6.16.1`
### Fixed
- Removed a temporary element from the activities table component
## 2.199.0 - 2025-09-14
### Added
- Extended the content of the performance calculation method by dividends on the Frequently Asked Questions (FAQ) page
- Added a _Storybook_ story for the entity logo image component
### Changed
- Improved the search in the _Yahoo Finance_ service
- Moved the holdings table into the holdings section on the public page
- Migrated to the _Prisma Configuration File_ approach (`prisma.config.ts`)
- Refactored the login with access token dialog component to standalone
- Prefixed the `crypto`, `fs` and `path` imports with `node:`
- Upgraded `yahoo-finance2` from version `3.8.0` to `3.10.0`
### Fixed
- Fixed a pagination issue in the market data endpoint by adding `id` as a secondary sort criterion to ensure consistent ordering in the admin control panel
- Fixed a pagination issue in the user endpoint by adding `id` as a secondary sort criterion to ensure consistent ordering in the admin control panel
## 2.198.0 - 2025-09-11
### Changed
- Extended the variations of the interstitials for the subscription
- Renamed the job identifier column in the jobs queue view of the admin control panel
- Refactored the markets page to standalone
- Refactored the fear and greed index component to standalone
- Refactored the header component to standalone
- Refactored the investment chart component to standalone
- Refactored the rule component to standalone
- Refactored the rules component to standalone
- Refactored the subscription interstitial dialog component to standalone
- Removed the `IonIcon` import from the landing page
- Improved the language localization for German (`de`)
- Upgraded `angular` from version `20.1.3` to `20.2.4`
- Upgraded `eslint` dependencies
- Upgraded `Nx` from version `21.3.9` to `21.5.1`
- Upgraded `storybook` from version `9.0.17` to `9.1.5`
### Fixed
- Fixed the holdings table on the public page
## 2.197.0 - 2025-09-07
### Added
- Enabled automatic data gathering for custom currencies added via the currency management in the admin control panel
- Added a _Storybook_ story for the world map chart component
### Changed
- Restructured the response of the portfolio report endpoint (_X-ray_)
- Modernized the templates with untagged template literals
- Moved the world map chart component to `@ghostfolio/ui`
- Refactored the create or update access dialog component to standalone
- Improved the language localization for German (`de`)
- Upgraded `envalid` from version `8.0.0` to `8.1.0`
- Upgraded `prisma` from version `6.14.0` to `6.15.0`
### Fixed
- Improved the handling of `0` buying power in the static portfolio analysis rule: _Liquidity_ (Buying Power)
- Fixed an issue related to the error handling in the data provider status component
## 2.196.0 - 2025-09-04
### Changed
- Localized the content of the about page
- Refactored the public page to standalone
- Refactored the dialog footer component
- Refactored the dialog header component
- Refactored the account detail dialog component to standalone
- Refactored the benchmark comparator component to standalone
- Refactored the portfolio summary component to standalone
- Refactored the world map chart component to standalone
- Enabled the trim option in the `extract-i18n` configuration
- Improved the language localization for German (`de`)
- Upgraded the _Stripe_ dependencies
- Upgraded `ngx-device-detector` from version `10.0.2` to `10.1.0`
- Upgraded `ngx-skeleton-loader` from version `11.2.1` to `11.3.0`
- Upgraded `yahoo-finance2` from version `3.6.4` to `3.8.0`
### Fixed
- Fixed an issue in the average price calculation for buy and sell activities of short positions
- Fixed the number of attempts in the queue jobs view of the admin control panel
## 2.195.0 - 2025-08-29
### Changed
@ -1804,7 +2375,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Fixed an issue in the portfolio summary with the currency conversion of fees
- Fixed an issue in the the search for a holding
- Fixed an issue in the search for a holding
- Removed the show condition of the experimental features setting in the user settings
## 2.95.0 - 2024-07-12

22
Dockerfile

@ -13,20 +13,17 @@ RUN apt-get update && apt-get install -y --no-install-suggests \
# Only add basic files without the application itself to avoid rebuilding
# layers when files (package.json etc.) have not changed
COPY ./.config .config/
COPY ./CHANGELOG.md CHANGELOG.md
COPY ./LICENSE LICENSE
COPY ./package.json package.json
COPY ./package-lock.json package-lock.json
COPY ./prisma/schema.prisma prisma/schema.prisma
COPY ./prisma/schema.prisma prisma/
RUN npm install
# See https://github.com/nrwl/nx/issues/6586 for further details
COPY ./decorate-angular-cli.js decorate-angular-cli.js
RUN node decorate-angular-cli.js
COPY ./apps apps
COPY ./libs libs
COPY ./apps apps/
COPY ./libs libs/
COPY ./jest.config.ts jest.config.ts
COPY ./jest.preset.js jest.preset.js
COPY ./nx.json nx.json
@ -40,14 +37,15 @@ RUN npm run build:production
WORKDIR /ghostfolio/dist/apps/api
# package.json was generated by the build process, however the original
# package-lock.json needs to be used to ensure the same versions
COPY ./package-lock.json /ghostfolio/dist/apps/api/package-lock.json
COPY ./package-lock.json /ghostfolio/dist/apps/api/
RUN npm install
COPY prisma /ghostfolio/dist/apps/api/prisma
COPY .config /ghostfolio/dist/apps/api/.config/
COPY prisma /ghostfolio/dist/apps/api/prisma/
# Overwrite the generated package.json with the original one to ensure having
# all the scripts
COPY package.json /ghostfolio/dist/apps/api
COPY package.json /ghostfolio/dist/apps/api/
RUN npm run database:generate-typings
# Image to run, copy everything needed from builder
@ -60,8 +58,8 @@ RUN apt-get update && apt-get install -y --no-install-suggests \
openssl \
&& rm -rf /var/lib/apt/lists/*
COPY --chown=node:node --from=builder /ghostfolio/dist/apps /ghostfolio/apps
COPY --chown=node:node ./docker/entrypoint.sh /ghostfolio/entrypoint.sh
COPY --chown=node:node --from=builder /ghostfolio/dist/apps /ghostfolio/apps/
COPY --chown=node:node ./docker/entrypoint.sh /ghostfolio/
WORKDIR /ghostfolio/apps/api
EXPOSE ${PORT:-3333}
USER node

36
README.md

@ -86,7 +86,7 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
### Supported Environment Variables
| Name | Type | Default Value | Description |
| ------------------------ | --------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| ------------------------ | --------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens |
| `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key |
| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key |
@ -103,6 +103,21 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
| `REDIS_PASSWORD` | `string` | | The password of _Redis_ |
| `REDIS_PORT` | `number` | | The port where _Redis_ is running |
| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds |
| `ROOT_URL` | `string` (optional) | `http://0.0.0.0:3333` | The root URL of the Ghostfolio application, used for generating callback URLs and external links. |
#### OpenID Connect OIDC (Experimental)
| Name | Type | Default Value | Description |
| -------------------------- | --------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------- |
| `ENABLE_FEATURE_AUTH_OIDC` | `boolean` (optional) | `false` | Enables _OpenID Connect_ authentication |
| `OIDC_AUTHORIZATION_URL` | `string` (optional) | | Manual override for the OIDC authorization endpoint (falls back to the discovery from the issuer) |
| `OIDC_CALLBACK_URL` | `string` (optional) | `${ROOT_URL}/api/auth/oidc/callback` | The OIDC callback URL |
| `OIDC_CLIENT_ID` | `string` | | The OIDC client ID |
| `OIDC_CLIENT_SECRET` | `string` | | The OIDC client secret |
| `OIDC_ISSUER` | `string` | | The OIDC issuer URL, used to discover the OIDC configuration via `/.well-known/openid-configuration` |
| `OIDC_SCOPE` | `string[]` (optional) | `["openid"]` | The OIDC scope to request, e.g. `["email","openid","profile"]` |
| `OIDC_TOKEN_URL` | `string` (optional) | | Manual override for the OIDC token endpoint (falls back to the discovery from the issuer) |
| `OIDC_USER_INFO_URL` | `string` (optional) | | Manual override for the OIDC user info endpoint (falls back to the discovery from the issuer) |
### Run with Docker Compose
@ -226,7 +241,7 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
| `accountId` | `string` (optional) | Id of the account |
| `comment` | `string` (optional) | Comment of the activity |
| `currency` | `string` | `CHF` \| `EUR` \| `USD` etc. |
| `dataSource` | `string` | `COINGECKO` \| `MANUAL` \| `YAHOO` |
| `dataSource` | `string` | `COINGECKO` \| `GHOSTFOLIO` [^1] \| `MANUAL` \| `YAHOO` |
| `date` | `string` | Date in the format `ISO-8601` |
| `fee` | `number` | Fee of the activity |
| `quantity` | `number` | Quantity of the activity |
@ -297,7 +312,18 @@ Ghostfolio is **100% free** and **open source**. We encourage and support an act
Not sure what to work on? We have [some ideas](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22%20no%3Aassignee), even for [newcomers](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%20no%3Aassignee). Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://x.com/ghostfolio_) on _X_. We would love to hear from you.
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).
If you like to support this project, become a [**Sponsor**](https://github.com/sponsors/ghostfolio), get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).
## Sponsors
<div align="center">
<p>
Browser testing via<br />
<a href="https://www.lambdatest.com?utm_medium=sponsor&utm_source=ghostfolio" target="_blank" title="LambdaTest - AI Powered Testing Tool">
<img alt="LambdaTest Logo" height="45" width="250" src="https://www.lambdatest.com/blue-logo.png" />
</a>
</p>
</div>
## Analytics
@ -305,6 +331,8 @@ If you like to support this project, get [**Ghostfolio Premium**](https://ghostf
## License
© 2021 - 2025 [Ghostfolio](https://ghostfol.io)
© 2021 - 2026 [Ghostfolio](https://ghostfol.io)
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).
[^1]: Available with [**Ghostfolio Premium**](https://ghostfol.io/en/pricing).

60
apps/api/src/app/access/access.controller.ts

@ -1,6 +1,7 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos';
import { Access } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
@ -14,6 +15,7 @@ import {
Inject,
Param,
Post,
Put,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
@ -22,7 +24,6 @@ import { Access as AccessModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccessService } from './access.service';
import { CreateAccessDto } from './create-access.dto';
@Controller('access')
export class AccessController {
@ -39,7 +40,7 @@ export class AccessController {
include: {
granteeUser: true
},
orderBy: { granteeUserId: 'asc' },
orderBy: [{ granteeUserId: 'desc' }, { createdAt: 'asc' }],
where: { userId: this.request.user.id }
});
@ -103,9 +104,12 @@ export class AccessController {
@HasPermission(permissions.deleteAccess)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteAccess(@Param('id') id: string): Promise<AccessModel> {
const access = await this.accessService.access({ id });
const originalAccess = await this.accessService.access({
id,
userId: this.request.user.id
});
if (!access || access.userId !== this.request.user.id) {
if (!originalAccess) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
@ -116,4 +120,52 @@ export class AccessController {
id
});
}
@HasPermission(permissions.updateAccess)
@Put(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateAccess(
@Body() data: UpdateAccessDto,
@Param('id') id: string
): Promise<AccessModel> {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const originalAccess = await this.accessService.access({
id,
userId: this.request.user.id
});
if (!originalAccess) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
try {
return this.accessService.updateAccess({
data: {
alias: data.alias,
granteeUser: data.granteeUserId
? { connect: { id: data.granteeUserId } }
: { disconnect: true },
permissions: data.permissions
},
where: { id }
});
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
}
}

19
apps/api/src/app/access/access.service.ts

@ -20,14 +20,14 @@ export class AccessService {
}
public async accesses(params: {
cursor?: Prisma.AccessWhereUniqueInput;
include?: Prisma.AccessInclude;
orderBy?: Prisma.Enumerable<Prisma.AccessOrderByWithRelationInput>;
skip?: number;
take?: number;
cursor?: Prisma.AccessWhereUniqueInput;
where?: Prisma.AccessWhereInput;
orderBy?: Prisma.AccessOrderByWithRelationInput;
}): Promise<AccessWithGranteeUser[]> {
const { include, skip, take, cursor, where, orderBy } = params;
const { cursor, include, orderBy, skip, take, where } = params;
return this.prismaService.access.findMany({
cursor,
@ -52,4 +52,17 @@ export class AccessService {
where
});
}
public async updateAccess({
data,
where
}: {
data: Prisma.AccessUpdateInput;
where: Prisma.AccessWhereUniqueInput;
}): Promise<Access> {
return this.prismaService.access.update({
data,
where
});
}
}

2
apps/api/src/app/account-balance/account-balance.controller.ts

@ -1,6 +1,7 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { CreateAccountBalanceDto } from '@ghostfolio/common/dtos';
import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
@ -20,7 +21,6 @@ import { AccountBalance } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccountBalanceService } from './account-balance.service';
import { CreateAccountBalanceDto } from './create-account-balance.dto';
@Controller('account-balance')
export class AccountBalanceController {

3
apps/api/src/app/account-balance/account-balance.service.ts

@ -2,6 +2,7 @@ import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { CreateAccountBalanceDto } from '@ghostfolio/common/dtos';
import { DATE_FORMAT, getSum, resetHours } from '@ghostfolio/common/helper';
import {
AccountBalancesResponse,
@ -15,8 +16,6 @@ import { AccountBalance, Prisma } from '@prisma/client';
import { Big } from 'big.js';
import { format, parseISO } from 'date-fns';
import { CreateAccountBalanceDto } from './create-account-balance.dto';
@Injectable()
export class AccountBalanceService {
public constructor(

16
apps/api/src/app/account/account.controller.ts

@ -7,15 +7,18 @@ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interce
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import {
CreateAccountDto,
TransferBalanceDto,
UpdateAccountDto
} from '@ghostfolio/common/dtos';
import {
AccountBalancesResponse,
AccountResponse,
AccountsResponse
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import type {
AccountWithValue,
RequestWithUser
} from '@ghostfolio/common/types';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
@ -38,9 +41,6 @@ import { Account as AccountModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccountService } from './account.service';
import { CreateAccountDto } from './create-account.dto';
import { TransferBalanceDto } from './transfer-balance.dto';
import { UpdateAccountDto } from './update-account.dto';
@Controller('account')
export class AccountController {
@ -114,7 +114,7 @@ export class AccountController {
public async getAccountById(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('id') id: string
): Promise<AccountWithValue> {
): Promise<AccountResponse> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);

42
apps/api/src/app/admin/admin.controller.ts

@ -4,24 +4,30 @@ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interce
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
import { DemoService } from '@ghostfolio/api/services/demo/demo.service';
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS
} from '@ghostfolio/common/config';
import {
UpdateAssetProfileDto,
UpdatePropertyDto
} from '@ghostfolio/common/dtos';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import {
AdminData,
AdminMarketData,
AdminUsers,
AdminUserResponse,
AdminUsersResponse,
EnhancedSymbolProfile,
ScraperConfiguration
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import type {
DateRange,
MarketDataPreset,
RequestWithUser
} from '@ghostfolio/common/types';
@ -49,7 +55,6 @@ import { isDate, parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AdminService } from './admin.service';
import { UpdateAssetProfileDto } from './update-asset-profile.dto';
@Controller('admin')
export class AdminController {
@ -88,7 +93,7 @@ export class AdminController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherMax(): Promise<void> {
const assetProfileIdentifiers =
await this.dataGatheringService.getAllActiveAssetProfileIdentifiers();
await this.dataGatheringService.getActiveAssetProfileIdentifiers();
await this.dataGatheringService.addJobsToQueue(
assetProfileIdentifiers.map(({ dataSource, symbol }) => {
@ -115,7 +120,7 @@ export class AdminController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherProfileData(): Promise<void> {
const assetProfileIdentifiers =
await this.dataGatheringService.getAllActiveAssetProfileIdentifiers();
await this.dataGatheringService.getActiveAssetProfileIdentifiers();
await this.dataGatheringService.addJobsToQueue(
assetProfileIdentifiers.map(({ dataSource, symbol }) => {
@ -161,9 +166,21 @@ export class AdminController {
@HasPermission(permissions.accessAdminControl)
public async gatherSymbol(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
@Param('symbol') symbol: string,
@Query('range') dateRange: DateRange
): Promise<void> {
this.dataGatheringService.gatherSymbol({ dataSource, symbol });
let date: Date;
if (dateRange) {
const { startDate } = getIntervalFromDateRange(dateRange);
date = startDate;
}
this.dataGatheringService.gatherSymbol({
dataSource,
date,
symbol
});
return;
}
@ -290,7 +307,7 @@ export class AdminController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateProperty(
@Param('key') key: string,
@Body() data: PropertyDto
@Body() data: UpdatePropertyDto
) {
return this.adminService.putSetting(key, data.value);
}
@ -301,10 +318,17 @@ export class AdminController {
public async getUsers(
@Query('skip') skip?: number,
@Query('take') take?: number
): Promise<AdminUsers> {
): Promise<AdminUsersResponse> {
return this.adminService.getUsers({
skip: isNaN(skip) ? undefined : skip,
take: isNaN(take) ? undefined : take
});
}
@Get('user/:id')
@HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getUser(@Param('id') id: string): Promise<AdminUserResponse> {
return this.adminService.getUser(id);
}
}

92
apps/api/src/app/admin/admin.service.ts

@ -23,7 +23,8 @@ import {
AdminMarketData,
AdminMarketDataDetails,
AdminMarketDataItem,
AdminUsers,
AdminUserResponse,
AdminUsersResponse,
AssetProfileIdentifier,
EnhancedSymbolProfile,
Filter
@ -35,7 +36,8 @@ import {
BadRequestException,
HttpException,
Injectable,
Logger
Logger,
NotFoundException
} from '@nestjs/common';
import {
AssetClass,
@ -136,7 +138,9 @@ export class AdminService {
public async get(): Promise<AdminData> {
const dataSources = Object.values(DataSource);
const [settings, transactionCount, userCount] = await Promise.all([
const [enabledDataSources, settings, transactionCount, userCount] =
await Promise.all([
this.dataProviderService.getDataSources(),
this.propertyService.get(),
this.prismaService.order.count(),
this.countUsersWithAnalytics()
@ -152,14 +156,23 @@ export class AdminService {
}
});
if (assetProfileCount > 0 || dataSource === 'GHOSTFOLIO') {
const isEnabled = enabledDataSources.includes(dataSource);
if (
assetProfileCount > 0 ||
dataSource === 'GHOSTFOLIO' ||
isEnabled
) {
const dataProviderInfo = this.dataProviderService
.getDataProvider(dataSource)
.getDataProviderInfo();
return {
...dataProviderInfo,
assetProfileCount
assetProfileCount,
useForExchangeRates:
dataSource ===
this.dataProviderService.getDataSourceForExchangeRates()
};
}
@ -181,7 +194,7 @@ export class AdminService {
filters,
presetId,
sortColumn,
sortDirection,
sortDirection = 'asc',
skip,
take = Number.MAX_SAFE_INTEGER
}: {
@ -251,11 +264,13 @@ export class AdminService {
orderBy = [{ [sortColumn]: sortDirection }];
if (sortColumn === 'activitiesCount') {
orderBy = {
orderBy = [
{
activities: {
_count: sortDirection
}
};
}
];
}
}
@ -264,10 +279,10 @@ export class AdminService {
try {
const symbolProfileResult = await Promise.all([
extendedPrismaClient.symbolProfile.findMany({
orderBy,
skip,
take,
where,
orderBy: [...orderBy, { id: sortDirection }],
select: {
_count: {
select: {
@ -494,16 +509,31 @@ export class AdminService {
};
}
public async getUser(id: string): Promise<AdminUserResponse> {
const [user] = await this.getUsersWithAnalytics({
where: { id }
});
if (!user) {
throw new NotFoundException(`User with ID ${id} not found`);
}
return user;
}
public async getUsers({
skip,
take = Number.MAX_SAFE_INTEGER
}: {
skip?: number;
take?: number;
}): Promise<AdminUsers> {
}): Promise<AdminUsersResponse> {
const [count, users] = await Promise.all([
this.countUsersWithAnalytics(),
this.getUsersWithAnalytics({ skip, take })
this.getUsersWithAnalytics({
skip,
take
})
]);
return { count, users };
@ -801,34 +831,46 @@ export class AdminService {
private async getUsersWithAnalytics({
skip,
take
take,
where
}: {
skip?: number;
take?: number;
}): Promise<AdminUsers['users']> {
let orderBy: Prisma.UserOrderByWithRelationInput = {
createdAt: 'desc'
};
let where: Prisma.UserWhereInput;
where?: Prisma.UserWhereInput;
}): Promise<AdminUsersResponse['users']> {
let orderBy: Prisma.Enumerable<Prisma.UserOrderByWithRelationInput> = [
{ createdAt: 'desc' }
];
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
orderBy = {
orderBy = [
{
analytics: {
lastRequestAt: 'desc'
}
};
where = {
NOT: {
analytics: null
}
];
const noAnalyticsCondition: Prisma.UserWhereInput['NOT'] = {
analytics: null
};
if (where) {
if (where.NOT) {
where.NOT = { ...where.NOT, ...noAnalyticsCondition };
} else {
where.NOT = noAnalyticsCondition;
}
} else {
where = { NOT: noAnalyticsCondition };
}
}
const usersWithAnalytics = await this.prismaService.user.findMany({
orderBy,
skip,
take,
where,
orderBy: [...orderBy, { id: 'desc' }],
select: {
_count: {
select: { accounts: true, activities: true }
@ -843,6 +885,7 @@ export class AdminService {
},
createdAt: true,
id: true,
provider: true,
role: true,
subscriptions: {
orderBy: {
@ -859,7 +902,7 @@ export class AdminService {
});
return usersWithAnalytics.map(
({ _count, analytics, createdAt, id, role, subscriptions }) => {
({ _count, analytics, createdAt, id, provider, role, subscriptions }) => {
const daysSinceRegistration =
differenceInDays(new Date(), createdAt) + 1;
const engagement = analytics
@ -876,6 +919,7 @@ export class AdminService {
createdAt,
engagement,
id,
provider,
role,
subscription,
accountCount: _count.accounts || 0,

2
apps/api/src/app/admin/queue/queue.service.ts

@ -71,7 +71,7 @@ export class QueueService {
.slice(0, limit)
.map(async (job) => {
return {
attemptsMade: job.attemptsMade + 1,
attemptsMade: job.attemptsMade,
data: job.data,
finishedOn: job.finishedOn,
id: job.id,

8
apps/api/src/app/app.module.ts

@ -21,7 +21,7 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule } from '@nestjs/schedule';
import { ServeStaticModule } from '@nestjs/serve-static';
import { StatusCodes } from 'http-status-codes';
import { join } from 'path';
import { join } from 'node:path';
import { AccessModule } from './access/access.module';
import { AccountModule } from './account/account.module';
@ -37,6 +37,7 @@ import { AssetsModule } from './endpoints/assets/assets.module';
import { BenchmarksModule } from './endpoints/benchmarks/benchmarks.module';
import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module';
import { MarketDataModule } from './endpoints/market-data/market-data.module';
import { PlatformsModule } from './endpoints/platforms/platforms.module';
import { PublicModule } from './endpoints/public/public.module';
import { SitemapModule } from './endpoints/sitemap/sitemap.module';
import { TagsModule } from './endpoints/tags/tags.module';
@ -72,8 +73,8 @@ import { UserModule } from './user/user.module';
redis: {
db: parseInt(process.env.REDIS_DB ?? '0', 10),
host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT ?? '6379', 10),
password: process.env.REDIS_PASSWORD
password: process.env.REDIS_PASSWORD,
port: parseInt(process.env.REDIS_PORT ?? '6379', 10)
}
}),
CacheModule,
@ -95,6 +96,7 @@ import { UserModule } from './user/user.module';
MarketDataModule,
OrderModule,
PlatformModule,
PlatformsModule,
PortfolioModule,
PortfolioSnapshotQueueModule,
PrismaModule,

4
apps/api/src/app/asset/asset.controller.ts

@ -1,7 +1,7 @@
import { AdminService } from '@ghostfolio/api/app/admin/admin.service';
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 type { AdminMarketDataDetails } from '@ghostfolio/common/interfaces';
import type { AssetResponse } from '@ghostfolio/common/interfaces';
import { Controller, Get, Param, UseInterceptors } from '@nestjs/common';
import { DataSource } from '@prisma/client';
@ -17,7 +17,7 @@ export class AssetController {
public async getAsset(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<AdminMarketDataDetails> {
): Promise<AssetResponse> {
const { assetProfile, marketData } =
await this.adminService.getMarketDataBySymbol({ dataSource, symbol });

61
apps/api/src/app/auth/auth.controller.ts

@ -2,7 +2,11 @@ import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { OAuthResponse } from '@ghostfolio/common/interfaces';
import {
AssertionCredentialJSON,
AttestationCredentialJSON,
OAuthResponse
} from '@ghostfolio/common/interfaces';
import {
Body,
@ -22,10 +26,6 @@ import { Request, Response } from 'express';
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { AuthService } from './auth.service';
import {
AssertionCredentialJSON,
AttestationCredentialJSON
} from './interfaces/simplewebauthn';
@Controller('auth')
export class AuthController {
@ -84,7 +84,6 @@ export class AuthController {
@Req() request: Request,
@Res() response: Response
) {
// Handles the Google OAuth2 callback
const jwt: string = (request.user as any).jwt;
if (jwt) {
@ -102,16 +101,11 @@ export class AuthController {
}
}
@Post('internet-identity')
public async internetIdentityLogin(
@Body() body: { principalId: string }
): Promise<OAuthResponse> {
try {
const authToken = await this.authService.validateInternetIdentityLogin(
body.principalId
);
return { authToken };
} catch {
@Get('oidc')
@UseGuards(AuthGuard('oidc'))
@Version(VERSION_NEUTRAL)
public oidcLogin() {
if (!this.configurationService.get('ENABLE_FEATURE_AUTH_OIDC')) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
@ -119,6 +113,34 @@ export class AuthController {
}
}
@Get('oidc/callback')
@UseGuards(AuthGuard('oidc'))
@Version(VERSION_NEUTRAL)
public oidcLoginCallback(@Req() request: Request, @Res() response: Response) {
const jwt: string = (request.user as any).jwt;
if (jwt) {
response.redirect(
`${this.configurationService.get(
'ROOT_URL'
)}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}`
);
} else {
response.redirect(
`${this.configurationService.get(
'ROOT_URL'
)}/${DEFAULT_LANGUAGE_CODE}/auth`
);
}
}
@Post('webauthn/generate-authentication-options')
public async generateAuthenticationOptions(
@Body() body: { deviceId: string }
) {
return this.webAuthService.generateAuthenticationOptions(body.deviceId);
}
@Get('webauthn/generate-registration-options')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async generateRegistrationOptions() {
@ -133,13 +155,6 @@ export class AuthController {
return this.webAuthService.verifyAttestation(body.credential);
}
@Post('webauthn/generate-authentication-options')
public async generateAuthenticationOptions(
@Body() body: { deviceId: string }
) {
return this.webAuthService.generateAuthenticationOptions(body.deviceId);
}
@Post('webauthn/verify-authentication')
public async verifyAuthentication(
@Body() body: { deviceId: string; credential: AssertionCredentialJSON }

82
apps/api/src/app/auth/auth.module.ts

@ -4,17 +4,20 @@ import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscriptio
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { Module } from '@nestjs/common';
import { Logger, Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import type { StrategyOptions } from 'passport-openidconnect';
import { ApiKeyStrategy } from './api-key.strategy';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { GoogleStrategy } from './google.strategy';
import { JwtStrategy } from './jwt.strategy';
import { OidcStrategy } from './oidc.strategy';
@Module({
controllers: [AuthController],
@ -36,6 +39,83 @@ import { JwtStrategy } from './jwt.strategy';
AuthService,
GoogleStrategy,
JwtStrategy,
{
inject: [AuthService, ConfigurationService],
provide: OidcStrategy,
useFactory: async (
authService: AuthService,
configurationService: ConfigurationService
) => {
const isOidcEnabled = configurationService.get(
'ENABLE_FEATURE_AUTH_OIDC'
);
if (!isOidcEnabled) {
return null;
}
const issuer = configurationService.get('OIDC_ISSUER');
const scope = configurationService.get('OIDC_SCOPE');
const callbackUrl =
configurationService.get('OIDC_CALLBACK_URL') ||
`${configurationService.get('ROOT_URL')}/api/auth/oidc/callback`;
// Check for manual URL overrides
const manualAuthorizationUrl = configurationService.get(
'OIDC_AUTHORIZATION_URL'
);
const manualTokenUrl = configurationService.get('OIDC_TOKEN_URL');
const manualUserInfoUrl =
configurationService.get('OIDC_USER_INFO_URL');
let authorizationURL: string;
let tokenURL: string;
let userInfoURL: string;
if (manualAuthorizationUrl && manualTokenUrl && manualUserInfoUrl) {
// Use manual URLs
authorizationURL = manualAuthorizationUrl;
tokenURL = manualTokenUrl;
userInfoURL = manualUserInfoUrl;
} else {
// Fetch OIDC configuration from discovery endpoint
try {
const response = await fetch(
`${issuer}/.well-known/openid-configuration`
);
const config = (await response.json()) as {
authorization_endpoint: string;
token_endpoint: string;
userinfo_endpoint: string;
};
// Manual URLs take priority over discovered ones
authorizationURL =
manualAuthorizationUrl || config.authorization_endpoint;
tokenURL = manualTokenUrl || config.token_endpoint;
userInfoURL = manualUserInfoUrl || config.userinfo_endpoint;
} catch (error) {
Logger.error(error, 'OidcStrategy');
throw new Error('Failed to fetch OIDC configuration from issuer');
}
}
const options: StrategyOptions = {
authorizationURL,
issuer,
scope,
tokenURL,
userInfoURL,
callbackURL: callbackUrl,
clientID: configurationService.get('OIDC_CLIENT_ID'),
clientSecret: configurationService.get('OIDC_CLIENT_SECRET')
};
return new OidcStrategy(authService, options);
}
},
WebAuthService
]
})

51
apps/api/src/app/auth/auth.service.ts

@ -4,7 +4,6 @@ import { PropertyService } from '@ghostfolio/api/services/property/property.serv
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Provider } from '@prisma/client';
import { ValidateOAuthLoginParams } from './interfaces/interfaces';
@ -18,8 +17,6 @@ export class AuthService {
) {}
public async validateAnonymousLogin(accessToken: string): Promise<string> {
return new Promise(async (resolve, reject) => {
try {
const hashedAccessToken = this.userService.createAccessToken({
password: accessToken,
salt: this.configurationService.get('ACCESS_TOKEN_SALT')
@ -30,54 +27,12 @@ export class AuthService {
});
if (user) {
const jwt = this.jwtService.sign({
id: user.id
});
resolve(jwt);
} else {
throw new Error();
}
} catch {
reject();
}
});
}
public async validateInternetIdentityLogin(principalId: string) {
try {
const provider: Provider = 'INTERNET_IDENTITY';
let [user] = await this.userService.users({
where: { provider, thirdPartyId: principalId }
});
if (!user) {
const isUserSignupEnabled =
await this.propertyService.isUserSignupEnabled();
if (!isUserSignupEnabled || true) {
throw new Error('Sign up forbidden');
}
// Create new user if not found
user = await this.userService.createUser({
data: {
provider,
thirdPartyId: principalId
}
});
}
return this.jwtService.sign({
id: user.id
});
} catch (error) {
throw new InternalServerErrorException(
'validateInternetIdentityLogin',
error.message
);
}
throw new Error();
}
public async validateOAuthLogin({
@ -112,7 +67,7 @@ export class AuthService {
} catch (error) {
throw new InternalServerErrorException(
'validateOAuthLogin',
error.message
error instanceof Error ? error.message : 'Unknown error'
);
}
}

3
apps/api/src/app/auth/google.strategy.ts

@ -3,6 +3,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con
import { Injectable, Logger } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Provider } from '@prisma/client';
import { DoneCallback } from 'passport';
import { Profile, Strategy } from 'passport-google-oauth20';
import { AuthService } from './auth.service';
@ -29,7 +30,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
_token: string,
_refreshToken: string,
profile: Profile,
done: Function
done: DoneCallback
) {
try {
const jwt = await this.authService.validateOAuthLogin({

21
apps/api/src/app/auth/interfaces/interfaces.ts

@ -1,4 +1,4 @@
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
import { AuthDeviceDto } from '@ghostfolio/common/dtos';
import { Provider } from '@prisma/client';
@ -6,6 +6,25 @@ export interface AuthDeviceDialogParams {
authDevice: AuthDeviceDto;
}
export interface OidcContext {
claims?: {
sub?: string;
};
}
export interface OidcIdToken {
sub?: string;
}
export interface OidcParams {
sub?: string;
}
export interface OidcProfile {
id?: string;
sub?: string;
}
export interface ValidateOAuthLoginParams {
provider: Provider;
thirdPartyId: string;

114
apps/api/src/app/auth/oidc-state.store.ts

@ -0,0 +1,114 @@
import ms from 'ms';
/**
* Custom state store for OIDC authentication that doesn't rely on express-session.
* This store manages OAuth2 state parameters in memory with automatic cleanup.
*/
export class OidcStateStore {
private readonly STATE_EXPIRY_MS = ms('10 minutes');
private stateMap = new Map<
string,
{
appState?: unknown;
ctx: { issued?: Date; maxAge?: number; nonce?: string };
meta?: unknown;
timestamp: number;
}
>();
/**
* Store request state.
* Signature matches passport-openidconnect SessionStore
*/
public store(
_req: unknown,
_meta: unknown,
appState: unknown,
ctx: { maxAge?: number; nonce?: string; issued?: Date },
callback: (err: Error | null, handle?: string) => void
) {
try {
// Generate a unique handle for this state
const handle = this.generateHandle();
this.stateMap.set(handle, {
appState,
ctx,
meta: _meta,
timestamp: Date.now()
});
// Clean up expired states
this.cleanup();
callback(null, handle);
} catch (error) {
callback(error as Error);
}
}
/**
* Verify request state.
* Signature matches passport-openidconnect SessionStore
*/
public verify(
_req: unknown,
handle: string,
callback: (
err: Error | null,
appState?: unknown,
ctx?: { maxAge?: number; nonce?: string; issued?: Date }
) => void
) {
try {
const data = this.stateMap.get(handle);
if (!data) {
return callback(null, undefined, undefined);
}
if (Date.now() - data.timestamp > this.STATE_EXPIRY_MS) {
// State has expired
this.stateMap.delete(handle);
return callback(null, undefined, undefined);
}
// Remove state after verification (one-time use)
this.stateMap.delete(handle);
callback(null, data.ctx, data.appState);
} catch (error) {
callback(error as Error);
}
}
/**
* Clean up expired states
*/
private cleanup() {
const now = Date.now();
const expiredKeys: string[] = [];
for (const [key, value] of this.stateMap.entries()) {
if (now - value.timestamp > this.STATE_EXPIRY_MS) {
expiredKeys.push(key);
}
}
for (const key of expiredKeys) {
this.stateMap.delete(key);
}
}
/**
* Generate a cryptographically secure random handle
*/
private generateHandle() {
return (
Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15) +
Date.now().toString(36)
);
}
}

69
apps/api/src/app/auth/oidc.strategy.ts

@ -0,0 +1,69 @@
import { Injectable, Logger } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Provider } from '@prisma/client';
import { Request } from 'express';
import { Strategy, type StrategyOptions } from 'passport-openidconnect';
import { AuthService } from './auth.service';
import {
OidcContext,
OidcIdToken,
OidcParams,
OidcProfile
} from './interfaces/interfaces';
import { OidcStateStore } from './oidc-state.store';
@Injectable()
export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') {
private static readonly stateStore = new OidcStateStore();
public constructor(
private readonly authService: AuthService,
options: StrategyOptions
) {
super({
...options,
passReqToCallback: true,
store: OidcStrategy.stateStore
});
}
public async validate(
_request: Request,
issuer: string,
profile: OidcProfile,
context: OidcContext,
idToken: OidcIdToken,
_accessToken: string,
_refreshToken: string,
params: OidcParams
) {
try {
const thirdPartyId =
profile?.id ??
profile?.sub ??
idToken?.sub ??
params?.sub ??
context?.claims?.sub;
const jwt = await this.authService.validateOAuthLogin({
thirdPartyId,
provider: Provider.OIDC
});
if (!thirdPartyId) {
Logger.error(
`Missing subject identifier in OIDC response from ${issuer}`,
'OidcStrategy'
);
throw new Error('Missing subject identifier in OIDC response');
}
return { jwt };
} catch (error) {
Logger.error(error, 'OidcStrategy');
throw error;
}
}
}

11
apps/api/src/app/auth/web-auth.service.ts

@ -1,7 +1,11 @@
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { AuthDeviceDto } from '@ghostfolio/common/dtos';
import {
AssertionCredentialJSON,
AttestationCredentialJSON
} from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
@ -27,11 +31,6 @@ import {
import { isoBase64URL, isoUint8Array } from '@simplewebauthn/server/helpers';
import ms from 'ms';
import {
AssertionCredentialJSON,
AttestationCredentialJSON
} from './interfaces/simplewebauthn';
@Injectable()
export class WebAuthService {
public constructor(

89
apps/api/src/app/endpoints/ai/ai.service.ts

@ -10,9 +10,31 @@ import type { AiPromptMode } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import { generateText } from 'ai';
import type { ColumnDescriptor } from 'tablemark';
@Injectable()
export class AiService {
private static readonly HOLDINGS_TABLE_COLUMN_DEFINITIONS: ({
key:
| 'ALLOCATION_PERCENTAGE'
| 'ASSET_CLASS'
| 'ASSET_SUB_CLASS'
| 'CURRENCY'
| 'NAME'
| 'SYMBOL';
} & ColumnDescriptor)[] = [
{ key: 'NAME', name: 'Name' },
{ key: 'SYMBOL', name: 'Symbol' },
{ key: 'CURRENCY', name: 'Currency' },
{ key: 'ASSET_CLASS', name: 'Asset Class' },
{ key: 'ASSET_SUB_CLASS', name: 'Asset Sub Class' },
{
align: 'right',
key: 'ALLOCATION_PERCENTAGE',
name: 'Allocation in Percentage'
}
];
public constructor(
private readonly portfolioService: PortfolioService,
private readonly propertyService: PropertyService
@ -58,10 +80,12 @@ export class AiService {
userId
});
const holdingsTable = [
'| Name | Symbol | Currency | Asset Class | Asset Sub Class | Allocation in Percentage |',
'| --- | --- | --- | --- | --- | --- |',
...Object.values(holdings)
const holdingsTableColumns: ColumnDescriptor[] =
AiService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.map(({ align, name }) => {
return { name, align: align ?? 'left' };
});
const holdingsTableRows = Object.values(holdings)
.sort((a, b) => {
return b.allocationInPercentage - a.allocationInPercentage;
})
@ -71,21 +95,66 @@ export class AiService {
assetClass,
assetSubClass,
currency,
name,
name: label,
symbol
}) => {
return `| ${name} | ${symbol} | ${currency} | ${assetClass} | ${assetSubClass} | ${(allocationInPercentage * 100).toFixed(3)}% |`;
return AiService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.reduce(
(row, { key, name }) => {
switch (key) {
case 'ALLOCATION_PERCENTAGE':
row[name] = `${(allocationInPercentage * 100).toFixed(3)}%`;
break;
case 'ASSET_CLASS':
row[name] = assetClass ?? '';
break;
case 'ASSET_SUB_CLASS':
row[name] = assetSubClass ?? '';
break;
case 'CURRENCY':
row[name] = currency;
break;
case 'NAME':
row[name] = label;
break;
case 'SYMBOL':
row[name] = symbol;
break;
default:
row[name] = '';
break;
}
)
];
return row;
},
{} as Record<string, string>
);
}
);
// Dynamic import to load ESM module from CommonJS context
// eslint-disable-next-line @typescript-eslint/no-implied-eval
const dynamicImport = new Function('s', 'return import(s)') as (
s: string
) => Promise<typeof import('tablemark')>;
const { tablemark } = await dynamicImport('tablemark');
const holdingsTableString = tablemark(holdingsTableRows, {
columns: holdingsTableColumns
});
if (mode === 'portfolio') {
return holdingsTable.join('\n');
return holdingsTableString;
}
return [
`You are a neutral financial assistant. Please analyze the following investment portfolio (base currency being ${userCurrency}) in simple words.`,
...holdingsTable,
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.',

4
apps/api/src/app/endpoints/assets/assets.controller.ts

@ -10,8 +10,8 @@ import {
VERSION_NEUTRAL
} from '@nestjs/common';
import { Response } from 'express';
import { readFileSync } from 'fs';
import { join } from 'path';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
@Controller('assets')
export class AssetsController {

4
apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts

@ -8,7 +8,7 @@ import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import type {
AssetProfileIdentifier,
BenchmarkMarketDataDetails,
BenchmarkMarketDataDetailsResponse,
BenchmarkResponse
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
@ -125,7 +125,7 @@ export class BenchmarksController {
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string,
@Query('withExcludedAccounts') withExcludedAccountsParam = 'false'
): Promise<BenchmarkMarketDataDetails> {
): Promise<BenchmarkMarketDataDetailsResponse> {
const { endDate, startDate } = getIntervalFromDateRange(
dateRange,
new Date(startDateString)

4
apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts

@ -6,7 +6,7 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data/market-d
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
BenchmarkMarketDataDetails,
BenchmarkMarketDataDetailsResponse,
Filter
} from '@ghostfolio/common/interfaces';
import { DateRange, UserWithSettings } from '@ghostfolio/common/types';
@ -43,7 +43,7 @@ export class BenchmarksService {
startDate: Date;
user: UserWithSettings;
withExcludedAccounts?: boolean;
} & AssetProfileIdentifier): Promise<BenchmarkMarketDataDetails> {
} & AssetProfileIdentifier): Promise<BenchmarkMarketDataDetailsResponse> {
const marketData: { date: string; value: number }[] = [];
const userCurrency = user.settings.settings.baseCurrency;
const userId = user.id;

10
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts

@ -1,5 +1,6 @@
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,
@ -66,7 +67,14 @@ export class GhostfolioController {
});
return assetProfile;
} catch {
} 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

53
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts

@ -8,7 +8,6 @@ import {
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 {
@ -18,6 +17,7 @@ import {
import { PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS } from '@ghostfolio/common/config';
import {
DataProviderGhostfolioAssetProfileResponse,
DataProviderHistoricalResponse,
DataProviderInfo,
DividendsResponse,
HistoricalResponse,
@ -40,10 +40,7 @@ export class GhostfolioService {
private readonly propertyService: PropertyService
) {}
public async getAssetProfile({
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbol
}: GetAssetProfileParams) {
public async getAssetProfile({ symbol }: GetAssetProfileParams) {
let result: DataProviderGhostfolioAssetProfileResponse = {};
try {
@ -51,16 +48,44 @@ export class GhostfolioService {
for (const dataProviderService of this.getDataProviderServices()) {
promises.push(
dataProviderService
.getAssetProfile({
requestTimeout,
symbol
})
.then((assetProfile) => {
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: DataSource.GHOSTFOLIO
dataSource: dataSourceOrigin
};
return assetProfile;
@ -89,7 +114,7 @@ export class GhostfolioService {
try {
const promises: Promise<{
[date: string]: IDataProviderHistoricalResponse;
[date: string]: DataProviderHistoricalResponse;
}>[] = [];
for (const dataProviderService of this.getDataProviderServices()) {
@ -131,7 +156,7 @@ export class GhostfolioService {
try {
const promises: Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
[symbol: string]: { [date: string]: DataProviderHistoricalResponse };
}>[] = [];
for (const dataProviderService of this.getDataProviderServices()) {

3
apps/api/src/app/endpoints/market-data/market-data.controller.ts

@ -10,6 +10,7 @@ import {
ghostfolioFearAndGreedIndexSymbolCryptocurrencies,
ghostfolioFearAndGreedIndexSymbolStocks
} from '@ghostfolio/common/config';
import { UpdateBulkMarketDataDto } from '@ghostfolio/common/dtos';
import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper';
import {
MarketDataDetailsResponse,
@ -35,8 +36,6 @@ 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(

24
apps/api/src/app/endpoints/market-data/update-bulk-market-data.dto.ts

@ -1,24 +0,0 @@
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;
}

22
apps/api/src/app/endpoints/platforms/platforms.controller.ts

@ -0,0 +1,22 @@
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { PlatformsResponse } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import { Controller, Get, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Controller('platforms')
export class PlatformsController {
public constructor(private readonly platformService: PlatformService) {}
@Get()
@HasPermission(permissions.readPlatforms)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getPlatforms(): Promise<PlatformsResponse> {
const platforms = await this.platformService.getPlatforms();
return { platforms };
}
}

11
apps/api/src/app/endpoints/platforms/platforms.module.ts

@ -0,0 +1,11 @@
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
import { Module } from '@nestjs/common';
import { PlatformsController } from './platforms.controller';
@Module({
controllers: [PlatformsController],
imports: [PlatformModule]
})
export class PlatformsModule {}

49
apps/api/src/app/endpoints/public/public.controller.ts

@ -1,6 +1,8 @@
import { AccessService } from '@ghostfolio/api/app/access/access.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
@ -18,6 +20,7 @@ import {
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { Type as ActivityType } from '@prisma/client';
import { Big } from 'big.js';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -27,15 +30,17 @@ export class PublicController {
private readonly accessService: AccessService,
private readonly configurationService: ConfigurationService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly orderService: OrderService,
private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
) {}
@Get(':accessId/portfolio')
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPublicPortfolio(
@Param('accessId') accessId
@Param('accessId') accessId: string
): Promise<PublicPortfolioResponse> {
const access = await this.accessService.access({ id: accessId });
@ -76,6 +81,47 @@ export class PublicController {
})
]);
const { activities } = await this.orderService.getOrders({
sortColumn: 'date',
sortDirection: 'desc',
take: 10,
types: [ActivityType.BUY, ActivityType.SELL],
userCurrency: user.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY,
userId: user.id,
withExcludedAccountsAndActivities: false
});
// Experimental
const latestActivities = this.configurationService.get(
'ENABLE_FEATURE_SUBSCRIPTION'
)
? []
: activities.map(
({
currency,
date,
fee,
quantity,
SymbolProfile,
type,
unitPrice,
value,
valueInBaseCurrency
}) => {
return {
currency,
date,
fee,
quantity,
SymbolProfile,
type,
unitPrice,
value,
valueInBaseCurrency
};
}
);
Object.values(markets ?? {}).forEach((market) => {
delete market.valueInBaseCurrency;
});
@ -83,6 +129,7 @@ export class PublicController {
const publicPortfolioResponse: PublicPortfolioResponse = {
createdAt,
hasDetails,
latestActivities,
markets,
alias: access.alias,
holdings: {},

5
apps/api/src/app/endpoints/sitemap/sitemap.controller.ts

@ -8,8 +8,8 @@ import {
import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common';
import { format } from 'date-fns';
import { Response } from 'express';
import { readFileSync } from 'fs';
import { join } from 'path';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { SitemapService } from './sitemap.service';
@ -37,6 +37,7 @@ export class SitemapController {
response.setHeader('content-type', 'application/xml');
response.send(
interpolate(this.sitemapXml, {
blogPosts: this.sitemapService.getBlogPosts({ currentDate }),
personalFinanceTools: this.configurationService.get(
'ENABLE_FEATURE_SUBSCRIPTION'
)

140
apps/api/src/app/endpoints/sitemap/sitemap.service.ts

@ -17,6 +17,125 @@ export class SitemapService {
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']
},
{
languageCode: 'en',
routerLink: ['2025', '11', 'black-weeks-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');
@ -43,20 +162,21 @@ export class SitemapService {
});
return personalFinanceTools.map(({ alias, key }) => {
const location = [
rootUrl,
languageCode,
const routerLink = [
resourcesPath,
personalFinanceToolsPath,
`${productPath}-${alias ?? key}`
].join('/');
];
return [
' <url>',
` <loc>${location}</loc>`,
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
' </url>'
].join('\n');
return this.createRouteSitemapUrl({
currentDate,
languageCode,
rootUrl,
route: {
routerLink,
path: undefined
}
});
});
}).join('\n');
}

4
apps/api/src/app/endpoints/tags/tags.controller.ts

@ -1,6 +1,7 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { CreateTagDto, UpdateTagDto } from '@ghostfolio/common/dtos';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
@ -21,9 +22,6 @@ import { AuthGuard } from '@nestjs/passport';
import { Tag } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreateTagDto } from './create-tag.dto';
import { UpdateTagDto } from './update-tag.dto';
@Controller('tags')
export class TagsController {
public constructor(

2
apps/api/src/app/endpoints/watchlist/watchlist.controller.ts

@ -4,6 +4,7 @@ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interce
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 { CreateWatchlistItemDto } from '@ghostfolio/common/dtos';
import { WatchlistResponse } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
@ -26,7 +27,6 @@ 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')

4
apps/api/src/app/exchange-rate/exchange-rate.controller.ts

@ -1,5 +1,5 @@
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import { DataProviderHistoricalResponse } from '@ghostfolio/common/interfaces';
import {
Controller,
@ -25,7 +25,7 @@ export class ExchangeRateController {
public async getExchangeRate(
@Param('dateString') dateString: string,
@Param('symbol') symbol: string
): Promise<IDataProviderHistoricalResponse> {
): Promise<DataProviderHistoricalResponse> {
const date = parseISO(dateString);
const exchangeRate = await this.exchangeRateService.getExchangeRate({

10
apps/api/src/app/export/export.controller.ts

@ -1,7 +1,8 @@
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 { ApiService } from '@ghostfolio/api/services/api/api.service';
import { Export } from '@ghostfolio/common/interfaces';
import { ExportResponse } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
@ -28,6 +29,7 @@ export class ExportController {
@Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async export(
@Query('accounts') filterByAccounts?: string,
@Query('activityIds') filterByActivityIds?: string,
@ -35,7 +37,7 @@ export class ExportController {
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string
): Promise<Export> {
): Promise<ExportResponse> {
const activityIds = filterByActivityIds?.split(',') ?? [];
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
@ -48,8 +50,8 @@ export class ExportController {
return this.exportService.export({
activityIds,
filters,
userCurrency: this.request.user.settings.settings.baseCurrency,
userId: this.request.user.id
userId: this.request.user.id,
userSettings: this.request.user.settings.settings
});
}
}

21
apps/api/src/app/export/export.service.ts

@ -3,7 +3,11 @@ import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { environment } from '@ghostfolio/api/environments/environment';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { Filter, Export } from '@ghostfolio/common/interfaces';
import {
ExportResponse,
Filter,
UserSettings
} from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { Platform, Prisma } from '@prisma/client';
@ -21,14 +25,14 @@ export class ExportService {
public async export({
activityIds,
filters,
userCurrency,
userId
userId,
userSettings
}: {
activityIds?: string[];
filters?: Filter[];
userCurrency: string;
userId: string;
}): Promise<Export> {
userSettings: UserSettings;
}): Promise<ExportResponse> {
const { ACCOUNT: filtersByAccount } = groupBy(filters, ({ type }) => {
return type;
});
@ -36,11 +40,11 @@ export class ExportService {
let { activities } = await this.orderService.getOrders({
filters,
userCurrency,
userId,
includeDrafts: true,
sortColumn: 'date',
sortDirection: 'asc',
userCurrency: userSettings?.baseCurrency,
withExcludedAccountsAndActivities: true
});
@ -244,7 +248,10 @@ export class ExportService {
}
),
user: {
settings: { currency: userCurrency }
settings: {
currency: userSettings?.baseCurrency,
performanceCalculationType: userSettings?.performanceCalculationType
}
}
};
}

11
apps/api/src/app/import/import-data.dto.ts

@ -1,12 +1,13 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import {
CreateAccountWithBalancesDto,
CreateAssetProfileWithMarketDataDto,
CreateOrderDto,
CreateTagDto
} from '@ghostfolio/common/dtos';
import { Type } from 'class-transformer';
import { IsArray, IsOptional, ValidateNested } from 'class-validator';
import { CreateTagDto } from '../endpoints/tags/create-tag.dto';
import { CreateAccountWithBalancesDto } from './create-account-with-balances.dto';
import { CreateAssetProfileWithMarketDataDto } from './create-asset-profile-with-market-data.dto';
export class ImportDataDto {
@IsArray()
@IsOptional()

114
apps/api/src/app/import/import.service.ts

@ -1,10 +1,4 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import {
Activity,
ActivityError
} from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
@ -15,11 +9,20 @@ import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathe
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config';
import {
CreateAssetProfileDto,
CreateAccountDto,
CreateOrderDto
} from '@ghostfolio/common/dtos';
import {
getAssetProfileIdentifier,
parseDate
} from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import {
Activity,
ActivityError,
AssetProfileIdentifier
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import {
AccountWithPlatform,
@ -32,9 +35,8 @@ import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
import { Big } from 'big.js';
import { endOfToday, isAfter, isSameSecond, parseISO } from 'date-fns';
import { omit, uniqBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid';
import { randomUUID } from 'node:crypto';
import { CreateAssetProfileDto } from '../admin/create-asset-profile.dto';
import { ImportDataDto } from './import-data.dto';
@Injectable()
@ -58,14 +60,19 @@ export class ImportService {
userId
}: AssetProfileIdentifier & { userId: string }): Promise<Activity[]> {
try {
const { activities, firstBuyDate, historicalData } =
await this.portfolioService.getHolding({
const holding = await this.portfolioService.getHolding({
dataSource,
symbol,
userId,
impersonationId: undefined
});
if (!holding) {
return [];
}
const { activities, firstBuyDate, historicalData } = holding;
const [[assetProfile], dividends] = await Promise.all([
this.symbolProfileService.getSymbolProfiles([
{
@ -270,7 +277,7 @@ export class ImportService {
// Asset profile belongs to a different user
if (existingAssetProfile) {
const symbol = uuidv4();
const symbol = randomUUID();
assetProfileSymbolMapping[assetProfile.symbol] = symbol;
assetProfile.symbol = symbol;
}
@ -373,6 +380,7 @@ export class ImportService {
const assetProfiles = await this.validateActivities({
activitiesDto,
assetProfilesWithMarketDataDto,
maxActivitiesToImport,
user
});
@ -488,7 +496,7 @@ export class ImportService {
accountId: validatedAccount?.id,
accountUserId: undefined,
createdAt: new Date(),
id: uuidv4(),
id: randomUUID(),
isDraft: isAfter(date, endOfToday()),
SymbolProfile: {
assetClass,
@ -538,6 +546,7 @@ export class ImportService {
connectOrCreate: {
create: {
dataSource,
name,
symbol,
currency: assetProfile.currency,
userId: dataSource === 'MANUAL' ? user.id : undefined
@ -698,10 +707,12 @@ export class ImportService {
private async validateActivities({
activitiesDto,
assetProfilesWithMarketDataDto,
maxActivitiesToImport,
user
}: {
activitiesDto: Partial<CreateOrderDto>[];
assetProfilesWithMarketDataDto: ImportDataDto['assetProfiles'];
maxActivitiesToImport: number;
user: UserWithSettings;
}) {
@ -718,12 +729,6 @@ export class ImportService {
index,
{ currency, dataSource, symbol, type }
] of activitiesDto.entries()) {
if (type === 'ITEM') {
throw new Error(
`activities.${index}.type ("${type}") is deprecated, please use "BUY" instead`
);
}
if (!dataSources.includes(dataSource)) {
throw new Error(
`activities.${index}.dataSource ("${dataSource}") is not valid`
@ -746,20 +751,73 @@ export class ImportService {
}
if (!assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })]) {
const assetProfile = {
if (['FEE', 'INTEREST', 'LIABILITY'].includes(type)) {
// Skip asset profile validation for FEE, INTEREST, and LIABILITY
// as these activity types don't require asset profiles
const assetProfileInImport = assetProfilesWithMarketDataDto?.find(
(profile) => {
return (
profile.dataSource === dataSource && profile.symbol === symbol
);
}
);
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] = {
currency,
...(
dataSource,
symbol,
name: assetProfileInImport?.name
};
continue;
}
let assetProfile: Partial<SymbolProfile> = { currency };
try {
assetProfile = (
await this.dataProviderService.getAssetProfiles([
{ dataSource, symbol }
])
)?.[symbol]
};
)?.[symbol];
} catch {}
if (
(dataSource !== 'MANUAL' && type === 'BUY') ||
type === 'DIVIDEND' ||
type === 'SELL'
) {
if (!assetProfile?.name) {
const assetProfileInImport = assetProfilesWithMarketDataDto?.find(
(profile) => {
return (
profile.dataSource === dataSource && profile.symbol === symbol
);
}
);
if (assetProfileInImport) {
// Merge all fields of custom asset profiles into the validation object
Object.assign(assetProfile, {
assetClass: assetProfileInImport.assetClass,
assetSubClass: assetProfileInImport.assetSubClass,
comment: assetProfileInImport.comment,
countries: assetProfileInImport.countries,
currency: assetProfileInImport.currency,
cusip: assetProfileInImport.cusip,
dataSource: assetProfileInImport.dataSource,
figi: assetProfileInImport.figi,
figiComposite: assetProfileInImport.figiComposite,
figiShareClass: assetProfileInImport.figiShareClass,
holdings: assetProfileInImport.holdings,
isActive: assetProfileInImport.isActive,
isin: assetProfileInImport.isin,
name: assetProfileInImport.name,
scraperConfiguration: assetProfileInImport.scraperConfiguration,
sectors: assetProfileInImport.sectors,
symbol: assetProfileInImport.symbol,
symbolMapping: assetProfileInImport.symbolMapping,
url: assetProfileInImport.url
});
}
}
if (!['FEE', 'INTEREST', 'LIABILITY'].includes(type)) {
if (!assetProfile?.name) {
throw new Error(
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`

4
apps/api/src/app/info/info.controller.ts

@ -1,5 +1,5 @@
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { InfoItem } from '@ghostfolio/common/interfaces';
import { InfoResponse } from '@ghostfolio/common/interfaces';
import { Controller, Get, UseInterceptors } from '@nestjs/common';
@ -11,7 +11,7 @@ export class InfoController {
@Get()
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getInfo(): Promise<InfoItem> {
public async getInfo(): Promise<InfoResponse> {
return this.infoService.get();
}
}

17
apps/api/src/app/info/info.service.ts

@ -51,6 +51,18 @@ export class InfoService {
const globalPermissions: string[] = [];
if (this.configurationService.get('ENABLE_FEATURE_AUTH_GOOGLE')) {
globalPermissions.push(permissions.enableAuthGoogle);
}
if (this.configurationService.get('ENABLE_FEATURE_AUTH_OIDC')) {
globalPermissions.push(permissions.enableAuthOidc);
}
if (this.configurationService.get('ENABLE_FEATURE_AUTH_TOKEN')) {
globalPermissions.push(permissions.enableAuthToken);
}
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
info.fearAndGreedDataSource = encodeDataSource(
@ -70,10 +82,6 @@ export class InfoService {
);
}
if (this.configurationService.get('ENABLE_FEATURE_SOCIAL_LOGIN')) {
globalPermissions.push(permissions.enableSocialLogin);
}
if (this.configurationService.get('ENABLE_FEATURE_STATISTICS')) {
globalPermissions.push(permissions.enableStatistics);
}
@ -85,7 +93,6 @@ export class InfoService {
(await this.propertyService.getByKey<string[]>(
PROPERTY_COUNTRIES_OF_SUBSCRIBERS
)) ?? [];
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
}
if (this.configurationService.get('ENABLE_FEATURE_SYSTEM_MESSAGE')) {

13
apps/api/src/app/order/order.controller.ts

@ -11,6 +11,11 @@ import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
HEADER_KEY_IMPERSONATION
} from '@ghostfolio/common/config';
import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos';
import {
ActivitiesResponse,
ActivityResponse
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
@ -35,10 +40,7 @@ import { Order as OrderModel, Prisma } from '@prisma/client';
import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreateOrderDto } from './create-order.dto';
import { Activities, Activity } from './interfaces/activities.interface';
import { OrderService } from './order.service';
import { UpdateOrderDto } from './update-order.dto';
@Controller('order')
export class OrderController {
@ -113,7 +115,7 @@ export class OrderController {
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string,
@Query('take') take?: number
): Promise<Activities> {
): Promise<ActivitiesResponse> {
let endDate: Date;
let startDate: Date;
@ -157,13 +159,14 @@ export class OrderController {
public async getOrderById(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('id') id: string
): Promise<Activity> {
): Promise<ActivityResponse> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.settings.settings.baseCurrency;
const { activities } = await this.orderService.getOrders({
userCurrency,
includeDrafts: true,
userId: impersonationUserId || this.request.user.id,
withExcludedAccountsAndActivities: true
});

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

@ -1,6 +1,10 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { AssetProfileChangedEvent } from '@ghostfolio/api/events/asset-profile-changed.event';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
@ -14,6 +18,8 @@ import {
} from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import {
ActivitiesResponse,
Activity,
AssetProfileIdentifier,
EnhancedSymbolProfile,
Filter
@ -35,15 +41,15 @@ import { Big } from 'big.js';
import { isUUID } from 'class-validator';
import { endOfToday, isAfter } from 'date-fns';
import { groupBy, uniqBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid';
import { Activities } from './interfaces/activities.interface';
import { randomUUID } from 'node:crypto';
@Injectable()
export class OrderService {
public constructor(
private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService,
private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService,
private readonly eventEmitter: EventEmitter2,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService,
@ -129,7 +135,7 @@ export class OrderService {
const assetSubClass = data.assetSubClass;
const dataSource: DataSource = 'MANUAL';
let name: string;
let name = data.SymbolProfile.connectOrCreate.create.name;
let symbol: string;
if (
@ -142,8 +148,8 @@ export class OrderService {
symbol = data.SymbolProfile.connectOrCreate.create.symbol;
} else {
// Create custom asset profile
name = data.SymbolProfile.connectOrCreate.create.symbol;
symbol = uuidv4();
name = name ?? data.SymbolProfile.connectOrCreate.create.symbol;
symbol = randomUUID();
}
data.SymbolProfile.connectOrCreate.create.assetClass = assetClass;
@ -226,6 +232,15 @@ export class OrderService {
});
}
this.eventEmitter.emit(
AssetProfileChangedEvent.getName(),
new AssetProfileChangedEvent({
currency: order.SymbolProfile.currency,
dataSource: order.SymbolProfile.dataSource,
symbol: order.SymbolProfile.symbol
})
);
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
@ -308,6 +323,111 @@ export class OrderService {
return count;
}
/**
* Generates synthetic orders for cash holdings based on account balance history.
* Treat currencies as assets with a fixed unit price of 1.0 (in their own currency) to allow
* performance tracking based on exchange rate fluctuations.
*
* @param cashDetails - The cash balance details.
* @param userCurrency - The base currency of the user.
* @param userId - The ID of the user.
* @returns A response containing the list of synthetic cash activities.
*/
public async getCashOrders({
cashDetails,
userCurrency,
userId
}: {
cashDetails: CashDetails;
userCurrency: string;
userId: string;
}): Promise<ActivitiesResponse> {
const activities: Activity[] = [];
for (const account of cashDetails.accounts) {
const { balances } = await this.accountBalanceService.getAccountBalances({
userCurrency,
userId,
filters: [{ id: account.id, type: 'ACCOUNT' }]
});
let currentBalance = 0;
let currentBalanceInBaseCurrency = 0;
for (const balanceItem of balances) {
const syntheticActivityTemplate: Activity = {
userId,
accountId: account.id,
accountUserId: account.userId,
comment: account.name,
createdAt: new Date(balanceItem.date),
currency: account.currency,
date: new Date(balanceItem.date),
fee: 0,
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
id: balanceItem.id,
isDraft: false,
quantity: 1,
SymbolProfile: {
activitiesCount: 0,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
countries: [],
createdAt: new Date(balanceItem.date),
currency: account.currency,
dataSource:
this.dataProviderService.getDataSourceForExchangeRates(),
holdings: [],
id: account.currency,
isActive: true,
name: account.currency,
sectors: [],
symbol: account.currency,
updatedAt: new Date(balanceItem.date)
},
symbolProfileId: account.currency,
type: ActivityType.BUY,
unitPrice: 1,
unitPriceInAssetProfileCurrency: 1,
updatedAt: new Date(balanceItem.date),
valueInBaseCurrency: 0,
value: 0
};
if (currentBalance < balanceItem.value) {
// BUY
activities.push({
...syntheticActivityTemplate,
quantity: balanceItem.value - currentBalance,
type: ActivityType.BUY,
value: balanceItem.value - currentBalance,
valueInBaseCurrency:
balanceItem.valueInBaseCurrency - currentBalanceInBaseCurrency
});
} else if (currentBalance > balanceItem.value) {
// SELL
activities.push({
...syntheticActivityTemplate,
quantity: currentBalance - balanceItem.value,
type: ActivityType.SELL,
value: currentBalance - balanceItem.value,
valueInBaseCurrency:
currentBalanceInBaseCurrency - balanceItem.valueInBaseCurrency
});
}
currentBalance = balanceItem.value;
currentBalanceInBaseCurrency = balanceItem.valueInBaseCurrency;
}
}
return {
activities,
count: activities.length
};
}
public async getLatestOrder({ dataSource, symbol }: AssetProfileIdentifier) {
return this.prismaService.order.findFirst({
orderBy: {
@ -325,7 +445,7 @@ export class OrderService {
includeDrafts = false,
skip,
sortColumn,
sortDirection,
sortDirection = 'asc',
startDate,
take = Number.MAX_SAFE_INTEGER,
types,
@ -345,11 +465,11 @@ export class OrderService {
userCurrency: string;
userId: string;
withExcludedAccountsAndActivities?: boolean;
}): Promise<Activities> {
}): Promise<ActivitiesResponse> {
let orderBy: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput> = [
{ date: 'asc' },
{ id: 'asc' }
{ date: 'asc' }
];
const where: Prisma.OrderWhereInput = { userId };
if (endDate || startDate) {
@ -483,7 +603,7 @@ export class OrderService {
}
if (sortColumn) {
orderBy = [{ [sortColumn]: sortDirection }, { id: sortDirection }];
orderBy = [{ [sortColumn]: sortDirection }];
}
if (types) {
@ -506,7 +626,6 @@ export class OrderService {
const [orders, count] = await Promise.all([
this.orders({
orderBy,
skip,
take,
where,
@ -519,7 +638,8 @@ export class OrderService {
// eslint-disable-next-line @typescript-eslint/naming-convention
SymbolProfile: true,
tags: true
}
},
orderBy: [...orderBy, { id: sortDirection }]
}),
this.prismaService.order.count({ where })
]);
@ -601,6 +721,15 @@ export class OrderService {
return { activities, count };
}
/**
* Retrieves all orders required for the portfolio calculator, including both standard asset orders
* and synthetic orders representing cash activities.
*
* @param filters - Optional filters to apply to the orders.
* @param userCurrency - The base currency of the user.
* @param userId - The ID of the user.
* @returns An object containing the combined list of activities and the total count.
*/
@LogPerformance
public async getOrdersForPortfolioCalculator({
filters,
@ -611,12 +740,29 @@ export class OrderService {
userCurrency: string;
userId: string;
}) {
return this.getOrders({
const nonCashOrders = await this.getOrders({
filters,
userCurrency,
userId,
withExcludedAccountsAndActivities: false // TODO
});
const cashDetails = await this.accountService.getCashDetails({
filters,
userId,
currency: userCurrency
});
const cashOrders = await this.getCashOrders({
cashDetails,
userCurrency,
userId
});
return {
activities: [...nonCashOrders.activities, ...cashOrders.activities],
count: nonCashOrders.count + cashOrders.count
};
}
public async getStatisticsByCurrency(

5
apps/api/src/app/platform/platform.controller.ts

@ -1,5 +1,6 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { CreatePlatformDto, UpdatePlatformDto } from '@ghostfolio/common/dtos';
import { permissions } from '@ghostfolio/common/permissions';
import {
@ -17,16 +18,14 @@ import { AuthGuard } from '@nestjs/passport';
import { Platform } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreatePlatformDto } from './create-platform.dto';
import { PlatformService } from './platform.service';
import { UpdatePlatformDto } from './update-platform.dto';
@Controller('platform')
export class PlatformController {
public constructor(private readonly platformService: PlatformService) {}
@Get()
@HasPermission(permissions.readPlatforms)
@HasPermission(permissions.readPlatformsWithAccountCount)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getPlatforms() {
return this.platformService.getPlatformsWithAccountCount();

8
apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts

@ -1,4 +1,6 @@
import { readFileSync } from 'fs';
import { ExportResponse } from '@ghostfolio/common/interfaces';
import { readFileSync } from 'node:fs';
export const activityDummyData = {
accountId: undefined,
@ -37,6 +39,6 @@ export const userDummyData = {
id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
};
export function loadActivityExportFile(filePath: string) {
return JSON.parse(readFileSync(filePath, 'utf8')).activities;
export function loadExportFile(filePath: string): ExportResponse {
return JSON.parse(readFileSync(filePath, 'utf8'));
}

7
apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts

@ -1,10 +1,13 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
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 { Filter, HistoricalDataItem } from '@ghostfolio/common/interfaces';
import {
Activity,
Filter,
HistoricalDataItem
} from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Injectable } from '@nestjs/common';

71
apps/api/src/app/portfolio/calculator/portfolio-calculator.ts

@ -1,4 +1,3 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface';
import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface';
@ -9,7 +8,7 @@ import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import {
@ -26,6 +25,7 @@ import {
resetHours
} from '@ghostfolio/common/helper';
import {
Activity,
AssetProfileIdentifier,
DataProviderInfo,
Filter,
@ -44,11 +44,15 @@ import { plainToClass } from 'class-transformer';
import {
differenceInDays,
eachDayOfInterval,
eachYearOfInterval,
endOfDay,
endOfYear,
format,
isAfter,
isBefore,
isWithinInterval,
min,
startOfYear,
subDays
} from 'date-fns';
import { isNumber, sortBy, sum, uniqBy } from 'lodash';
@ -193,19 +197,25 @@ export abstract class PortfolioCalculator {
}
const currencies: { [symbol: string]: string } = {};
const dataGatheringItems: IDataGatheringItem[] = [];
const dataGatheringItems: DataGatheringItem[] = [];
let firstIndex = transactionPoints.length;
let firstTransactionPoint: TransactionPoint = null;
let totalInterestWithCurrencyEffect = new Big(0);
let totalLiabilitiesWithCurrencyEffect = new Big(0);
for (const { currency, dataSource, symbol } of transactionPoints[
firstIndex - 1
].items) {
for (const {
assetSubClass,
currency,
dataSource,
symbol
} of transactionPoints[firstIndex - 1].items) {
// Gather data for all assets except CASH
if (assetSubClass !== 'CASH') {
dataGatheringItems.push({
dataSource,
symbol
});
}
currencies[symbol] = currency;
}
@ -336,7 +346,7 @@ export abstract class PortfolioCalculator {
).mul(
exchangeRatesByCurrency[`${item.currency}${this.currency}`]?.[
endDateString
]
] ?? 1
);
const {
@ -889,6 +899,24 @@ export abstract class PortfolioCalculator {
}
}
// Make sure the first and last date of each calendar year is present
const interval = { start: startDate, end: endDate };
for (const date of eachYearOfInterval(interval)) {
const yearStart = startOfYear(date);
const yearEnd = endOfYear(date);
if (isWithinInterval(yearStart, interval)) {
// Add start of year (YYYY-01-01)
chartDateMap[format(yearStart, DATE_FORMAT)] = true;
}
if (isWithinInterval(yearEnd, interval)) {
// Add end of year (YYYY-12-31)
chartDateMap[format(yearEnd, DATE_FORMAT)] = true;
}
}
return chartDateMap;
}
@ -911,6 +939,7 @@ export abstract class PortfolioCalculator {
} of this.activities) {
let currentTransactionPointItem: TransactionPointSymbol;
const assetSubClass = SymbolProfile.assetSubClass;
const currency = SymbolProfile.currency;
const dataSource = SymbolProfile.dataSource;
const factor = getFactor(type);
@ -922,29 +951,48 @@ export abstract class PortfolioCalculator {
if (oldAccumulatedSymbol) {
let investment = oldAccumulatedSymbol.investment;
const newQuantity = quantity
let newQuantity = quantity
.mul(factor)
.plus(oldAccumulatedSymbol.quantity);
if (type === 'BUY') {
if (oldAccumulatedSymbol.investment.gte(0)) {
investment = oldAccumulatedSymbol.investment.plus(
quantity.mul(unitPrice)
);
} else {
investment = oldAccumulatedSymbol.investment.plus(
quantity.mul(oldAccumulatedSymbol.averagePrice)
);
}
} else if (type === 'SELL') {
if (oldAccumulatedSymbol.investment.gt(0)) {
investment = oldAccumulatedSymbol.investment.minus(
quantity.mul(oldAccumulatedSymbol.averagePrice)
);
} else {
investment = oldAccumulatedSymbol.investment.minus(
quantity.mul(unitPrice)
);
}
}
if (newQuantity.abs().lt(Number.EPSILON)) {
// Reset to zero if quantity is (almost) zero to avoid rounding issues
investment = new Big(0);
newQuantity = new Big(0);
}
currentTransactionPointItem = {
assetSubClass,
currency,
dataSource,
investment,
skipErrors,
symbol,
averagePrice: newQuantity.gt(0)
? investment.div(newQuantity)
: new Big(0),
averagePrice: newQuantity.eq(0)
? new Big(0)
: investment.div(newQuantity).abs(),
dividend: new Big(0),
fee: oldAccumulatedSymbol.fee.plus(fee),
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
@ -955,6 +1003,7 @@ export abstract class PortfolioCalculator {
};
} else {
currentTransactionPointItem = {
assetSubClass,
currency,
dataSource,
fee,

205
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts

@ -0,0 +1,205 @@
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 { Activity } from '@ghostfolio/common/interfaces';
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 {
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
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 }
]);
});
});
});

5
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts

@ -1,4 +1,3 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
@ -14,13 +13,13 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
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 { Activity } from '@ghostfolio/common/interfaces';
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;
})
@ -31,7 +30,6 @@ 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;
})
@ -41,7 +39,6 @@ jest.mock(
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;
})

5
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts

@ -1,4 +1,3 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
@ -14,13 +13,13 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
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 { Activity } from '@ghostfolio/common/interfaces';
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;
})
@ -31,7 +30,6 @@ 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;
})
@ -41,7 +39,6 @@ jest.mock(
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;
})

15
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts

@ -1,4 +1,3 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
@ -14,13 +13,13 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
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 { Activity } from '@ghostfolio/common/interfaces';
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;
})
@ -31,7 +30,6 @@ 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;
})
@ -41,7 +39,6 @@ jest.mock(
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;
})
@ -112,6 +109,12 @@ describe('PortfolioCalculator', () => {
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const historicalDataDates = portfolioSnapshot.historicalData.map(
({ date }) => {
return date;
}
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
@ -173,8 +176,12 @@ describe('PortfolioCalculator', () => {
totalLiabilitiesWithCurrencyEffect: new Big('0')
});
expect(historicalDataDates).not.toContain('2021-01-01');
expect(historicalDataDates).not.toContain('2021-12-31');
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({
date: '2021-12-18',
netPerformance: 23.05,
netPerformanceInPercentage: 0.08437042459736457,
netPerformanceInPercentageWithCurrencyEffect: 0.08437042459736457,

150
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur-in-base-currency-eur.spec.ts

@ -0,0 +1,150 @@
import {
activityDummyData,
loadExportFile,
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 { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
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 { Activity, ExportResponse } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
import { join } from 'node:path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
() => {
return {
ExchangeRateDataService: jest.fn().mockImplementation(() => {
return ExchangeRateDataServiceMock;
})
};
}
);
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let exportResponse: ExportResponse;
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeAll(() => {
exportResponse = loadExportFile(
join(__dirname, '../../../../../../../test/import/ok/btceur.json')
);
});
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 BTCUSD buy (in EUR)', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime());
const activities: Activity[] = exportResponse.activities.map(
(activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: 4.46,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: activity.dataSource,
name: 'Bitcoin',
symbol: activity.symbol
},
unitPriceInAssetProfileCurrency: 44558.42
})
);
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'EUR',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const historicalDataDates = portfolioSnapshot.historicalData.map(
({ date }) => {
return date;
}
);
expect(historicalDataDates).not.toContain('2021-01-01');
expect(historicalDataDates).toContain('2021-12-31');
expect(historicalDataDates).toContain('2022-01-01');
expect(historicalDataDates).not.toContain('2022-12-31');
expect(portfolioSnapshot.positions[0].fee).toEqual(new Big(4.46));
expect(
portfolioSnapshot.positions[0].feeInBaseCurrency.toNumber()
).toBeCloseTo(3.94, 1);
});
});
});

37
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts

@ -1,8 +1,6 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
loadActivityExportFile,
loadExportFile,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
@ -16,15 +14,14 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
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 { Activity, ExportResponse } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Tag } from '@prisma/client';
import { Big } from 'big.js';
import { join } from 'path';
import { join } from 'node:path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
@ -35,7 +32,6 @@ 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;
})
@ -45,7 +41,6 @@ jest.mock(
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;
})
@ -53,7 +48,7 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
});
describe('PortfolioCalculator', () => {
let activityDtos: CreateOrderDto[];
let exportResponse: ExportResponse;
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
@ -63,7 +58,7 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService;
beforeAll(() => {
activityDtos = loadActivityExportFile(
exportResponse = loadExportFile(
join(__dirname, '../../../../../../../test/import/ok/btceur.json')
);
});
@ -97,7 +92,8 @@ describe('PortfolioCalculator', () => {
it.only('with BTCUSD buy (in EUR)', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime());
const activities: Activity[] = activityDtos.map((activity) => ({
const activities: Activity[] = exportResponse.activities.map(
(activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
@ -109,21 +105,25 @@ describe('PortfolioCalculator', () => {
name: 'Bitcoin',
symbol: activity.symbol
},
tags: activity.tags?.map((id) => {
return { id } as Tag;
}),
unitPriceInAssetProfileCurrency: 44558.42
}));
})
);
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'USD',
currency: exportResponse.user.settings.currency,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const historicalDataDates = portfolioSnapshot.historicalData.map(
({ date }) => {
return date;
}
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
@ -231,6 +231,11 @@ describe('PortfolioCalculator', () => {
totalLiabilitiesWithCurrencyEffect: new Big('0')
});
expect(historicalDataDates).not.toContain('2021-01-01');
expect(historicalDataDates).toContain('2021-12-31');
expect(historicalDataDates).toContain('2022-01-01');
expect(historicalDataDates).not.toContain('2022-12-31');
expect(investments).toEqual([
{ date: '2021-12-12', investment: new Big('44558.42') }
]);

6
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts

@ -1,4 +1,3 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
@ -15,13 +14,13 @@ import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-r
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 { Activity } from '@ghostfolio/common/interfaces';
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;
})
@ -32,7 +31,6 @@ 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;
})
@ -42,7 +40,6 @@ jest.mock(
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;
})
@ -53,7 +50,6 @@ jest.mock(
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
ExchangeRateDataService: jest.fn().mockImplementation(() => {
return ExchangeRateDataServiceMock;
})

126
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts

@ -0,0 +1,126 @@
import {
activityDummyData,
loadExportFile,
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 { Activity, ExportResponse } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
import { join } from 'node:path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let exportResponse: ExportResponse;
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeAll(() => {
exportResponse = loadExportFile(
join(__dirname, '../../../../../../../test/import/ok/btcusd-short.json')
);
});
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 BTCUSD short sell (in USD)', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime());
const activities: Activity[] = exportResponse.activities.map(
(activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: activity.dataSource,
name: 'Bitcoin',
symbol: activity.symbol
},
unitPriceInAssetProfileCurrency: activity.unitPrice
})
);
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: exportResponse.user.settings.currency,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
expect(portfolioSnapshot.positions[0].averagePrice).toEqual(
Big(45647.95)
);
});
});
});

37
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts

@ -1,8 +1,6 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
loadActivityExportFile,
loadExportFile,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
@ -16,15 +14,14 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
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 { Activity, ExportResponse } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Tag } from '@prisma/client';
import { Big } from 'big.js';
import { join } from 'path';
import { join } from 'node:path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
@ -35,7 +32,6 @@ 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;
})
@ -45,7 +41,6 @@ jest.mock(
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;
})
@ -53,7 +48,7 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
});
describe('PortfolioCalculator', () => {
let activityDtos: CreateOrderDto[];
let exportResponse: ExportResponse;
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
@ -63,7 +58,7 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService;
beforeAll(() => {
activityDtos = loadActivityExportFile(
exportResponse = loadExportFile(
join(__dirname, '../../../../../../../test/import/ok/btcusd.json')
);
});
@ -97,7 +92,8 @@ describe('PortfolioCalculator', () => {
it.only('with BTCUSD buy (in USD)', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime());
const activities: Activity[] = activityDtos.map((activity) => ({
const activities: Activity[] = exportResponse.activities.map(
(activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
@ -109,21 +105,25 @@ describe('PortfolioCalculator', () => {
name: 'Bitcoin',
symbol: activity.symbol
},
tags: activity.tags?.map((id) => {
return { id } as Tag;
}),
unitPriceInAssetProfileCurrency: 44558.42
}));
})
);
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'USD',
currency: exportResponse.user.settings.currency,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const historicalDataDates = portfolioSnapshot.historicalData.map(
({ date }) => {
return date;
}
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
@ -231,6 +231,11 @@ describe('PortfolioCalculator', () => {
totalLiabilitiesWithCurrencyEffect: new Big('0')
});
expect(historicalDataDates).not.toContain('2021-01-01');
expect(historicalDataDates).toContain('2021-12-31');
expect(historicalDataDates).toContain('2022-01-01');
expect(historicalDataDates).not.toContain('2022-12-31');
expect(investments).toEqual([
{ date: '2021-12-12', investment: new Big('44558.42') }
]);

290
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts

@ -0,0 +1,290 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { 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 { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
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 { HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { DataSource } from '@prisma/client';
import { randomUUID } from 'node:crypto';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
() => {
return {
ExchangeRateDataService: jest.fn().mockImplementation(() => {
return ExchangeRateDataServiceMock;
})
};
}
);
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let accountBalanceService: AccountBalanceService;
let accountService: AccountService;
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let dataProviderService: DataProviderService;
let exchangeRateDataService: ExchangeRateDataService;
let orderService: OrderService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
accountBalanceService = new AccountBalanceService(
null,
exchangeRateDataService,
null
);
accountService = new AccountService(
accountBalanceService,
null,
exchangeRateDataService,
null
);
redisCacheService = new RedisCacheService(null, configurationService);
dataProviderService = new DataProviderService(
configurationService,
null,
null,
null,
null,
redisCacheService
);
currentRateService = new CurrentRateService(
dataProviderService,
null,
null,
null
);
orderService = new OrderService(
accountBalanceService,
accountService,
null,
dataProviderService,
null,
exchangeRateDataService,
null,
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
);
});
describe('Cash Performance', () => {
it('should calculate performance for cash assets in CHF default currency', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2025-01-01').getTime());
const accountId = randomUUID();
jest
.spyOn(accountBalanceService, 'getAccountBalances')
.mockResolvedValue({
balances: [
{
accountId,
id: randomUUID(),
date: parseDate('2023-12-31'),
value: 1000,
valueInBaseCurrency: 850
},
{
accountId,
id: randomUUID(),
date: parseDate('2024-12-31'),
value: 2000,
valueInBaseCurrency: 1800
}
]
});
jest.spyOn(accountService, 'getCashDetails').mockResolvedValue({
accounts: [
{
balance: 2000,
comment: null,
createdAt: parseDate('2023-12-31'),
currency: 'USD',
id: accountId,
isExcluded: false,
name: 'USD',
platformId: null,
updatedAt: parseDate('2023-12-31'),
userId: userDummyData.id
}
],
balanceInBaseCurrency: 1820
});
jest
.spyOn(dataProviderService, 'getDataSourceForExchangeRates')
.mockReturnValue(DataSource.YAHOO);
jest.spyOn(orderService, 'getOrders').mockResolvedValue({
activities: [],
count: 0
});
const { activities } = await orderService.getOrdersForPortfolioCalculator(
{
userCurrency: 'CHF',
userId: userDummyData.id
}
);
jest.spyOn(currentRateService, 'getValues').mockResolvedValue({
dataProviderInfos: [],
errors: [],
values: []
});
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});
const { historicalData } = await portfolioCalculator.computeSnapshot();
const historicalData20231231 = historicalData.find(({ date }) => {
return date === '2023-12-31';
});
const historicalData20240101 = historicalData.find(({ date }) => {
return date === '2024-01-01';
});
const historicalData20241231 = historicalData.find(({ date }) => {
return date === '2024-12-31';
});
/**
* Investment value with currency effect: 1000 USD * 0.85 = 850 CHF
* Total investment: 1000 USD * 0.91 = 910 CHF
* Value (current): 1000 USD * 0.91 = 910 CHF
* Value with currency effect: 1000 USD * 0.85 = 850 CHF
*/
expect(historicalData20231231).toMatchObject({
date: '2023-12-31',
investmentValueWithCurrencyEffect: 850,
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
netWorth: 850,
totalAccountBalance: 0,
totalInvestment: 910,
totalInvestmentValueWithCurrencyEffect: 850,
value: 910,
valueWithCurrencyEffect: 850
});
/**
* Net performance with currency effect: (1000 * 0.86) - (1000 * 0.85) = 10 CHF
* Total investment: 1000 USD * 0.91 = 910 CHF
* Total investment value with currency effect: 1000 USD * 0.85 = 850 CHF
* Value (current): 1000 USD * 0.91 = 910 CHF
* Value with currency effect: 1000 USD * 0.86 = 860 CHF
*/
expect(historicalData20240101).toMatchObject({
date: '2024-01-01',
investmentValueWithCurrencyEffect: 0,
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0.011764705882352941,
netPerformanceWithCurrencyEffect: 10,
netWorth: 860,
totalAccountBalance: 0,
totalInvestment: 910,
totalInvestmentValueWithCurrencyEffect: 850,
value: 910,
valueWithCurrencyEffect: 860
});
/**
* Investment value with currency effect: 1000 USD * 0.90 = 900 CHF
* Net performance: (1000 USD * 1.0) - (1000 USD * 1.0) = 0 CHF
* Net performance with currency effect: (1000 USD * 0.9) - (1000 USD * 0.85) = 50 CHF
* Total investment: 2000 USD * 0.91 = 1820 CHF
* Total investment value with currency effect: (1000 USD * 0.85) + (1000 USD * 0.90) = 1750 CHF
* Value (current): 2000 USD * 0.91 = 1820 CHF
* Value with currency effect: 2000 USD * 0.9 = 1800 CHF
*/
expect(historicalData20241231).toMatchObject<HistoricalDataItem>({
date: '2024-12-31',
investmentValueWithCurrencyEffect: 900,
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0.058823529411764705,
netPerformanceWithCurrencyEffect: 50,
netWorth: 1800,
totalAccountBalance: 0,
totalInvestment: 1820,
totalInvestmentValueWithCurrencyEffect: 1750,
value: 1820,
valueWithCurrencyEffect: 1800
});
});
});
});

5
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts

@ -1,4 +1,3 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
@ -14,13 +13,13 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
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 { Activity } from '@ghostfolio/common/interfaces';
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;
})
@ -31,7 +30,6 @@ 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;
})
@ -41,7 +39,6 @@ jest.mock(
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;
})

6
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts

@ -1,4 +1,3 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
@ -15,13 +14,13 @@ import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-r
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 { Activity } from '@ghostfolio/common/interfaces';
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;
})
@ -32,7 +31,6 @@ 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;
})
@ -42,7 +40,6 @@ jest.mock(
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;
})
@ -53,7 +50,6 @@ jest.mock(
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
ExchangeRateDataService: jest.fn().mockImplementation(() => {
return ExchangeRateDataServiceMock;
})

5
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts

@ -1,4 +1,3 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
@ -14,13 +13,13 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
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 { Activity } from '@ghostfolio/common/interfaces';
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;
})
@ -31,7 +30,6 @@ 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;
})
@ -41,7 +39,6 @@ jest.mock(
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;
})

144
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts

@ -0,0 +1,144 @@
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 { Activity } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
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 transaction point', () => {
it('with MSFT buy and sell with fractional quantities (multiples of 1/3)', () => {
jest.useFakeTimers().setSystemTime(parseDate('2024-04-01').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2024-03-08'),
feeInAssetProfileCurrency: 0,
quantity: 0.3333333333333333,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Microsoft Inc.',
symbol: 'MSFT'
},
type: 'BUY',
unitPriceInAssetProfileCurrency: 408
},
{
...activityDummyData,
date: new Date('2024-03-13'),
quantity: 0.6666666666666666,
feeInAssetProfileCurrency: 0,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Microsoft Inc.',
symbol: 'MSFT'
},
type: 'BUY',
unitPriceInAssetProfileCurrency: 400
},
{
...activityDummyData,
date: new Date('2024-03-14'),
quantity: 1,
feeInAssetProfileCurrency: 0,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Microsoft Inc.',
symbol: 'MSFT'
},
type: 'SELL',
unitPriceInAssetProfileCurrency: 411
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'USD',
userId: userDummyData.id
});
const transactionPoints = portfolioCalculator.getTransactionPoints();
const lastTransactionPoint =
transactionPoints[transactionPoints.length - 1];
const position = lastTransactionPoint.items.find(
(item) => item.symbol === 'MSFT'
);
expect(position.investment.toNumber()).toBe(0);
expect(position.quantity.toNumber()).toBe(0);
});
});
});

5
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts

@ -1,4 +1,3 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
@ -14,13 +13,13 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
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 { Activity } from '@ghostfolio/common/interfaces';
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;
})
@ -31,7 +30,6 @@ 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;
})
@ -41,7 +39,6 @@ jest.mock(
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;
})

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-orders.spec.ts

@ -15,7 +15,6 @@ 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;
})
@ -26,7 +25,6 @@ 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;
})
@ -36,7 +34,6 @@ jest.mock(
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;
})

26
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts

@ -1,8 +1,6 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
loadActivityExportFile,
loadExportFile,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
@ -16,15 +14,14 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
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 { Activity, ExportResponse } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Tag } from '@prisma/client';
import { Big } from 'big.js';
import { join } from 'path';
import { join } from 'node:path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
@ -35,7 +32,6 @@ 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;
})
@ -45,7 +41,6 @@ jest.mock(
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;
})
@ -53,7 +48,7 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
});
describe('PortfolioCalculator', () => {
let activityDtos: CreateOrderDto[];
let exportResponse: ExportResponse;
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
@ -63,7 +58,7 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService;
beforeAll(() => {
activityDtos = loadActivityExportFile(
exportResponse = loadExportFile(
join(
__dirname,
'../../../../../../../test/import/ok/novn-buy-and-sell-partially.json'
@ -100,7 +95,8 @@ describe('PortfolioCalculator', () => {
it.only('with NOVN.SW buy and sell partially', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime());
const activities: Activity[] = activityDtos.map((activity) => ({
const activities: Activity[] = exportResponse.activities.map(
(activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
@ -112,16 +108,14 @@ describe('PortfolioCalculator', () => {
name: 'Novartis AG',
symbol: activity.symbol
},
tags: activity.tags?.map((id) => {
return { id } as Tag;
}),
unitPriceInAssetProfileCurrency: activity.unitPrice
}));
})
);
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
currency: exportResponse.user.settings.currency,
userId: userDummyData.id
});

26
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts

@ -1,8 +1,6 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
loadActivityExportFile,
loadExportFile,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
@ -16,15 +14,14 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
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 { Activity, ExportResponse } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Tag } from '@prisma/client';
import { Big } from 'big.js';
import { join } from 'path';
import { join } from 'node:path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
@ -35,7 +32,6 @@ 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;
})
@ -45,7 +41,6 @@ jest.mock(
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;
})
@ -53,7 +48,7 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
});
describe('PortfolioCalculator', () => {
let activityDtos: CreateOrderDto[];
let exportResponse: ExportResponse;
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
@ -63,7 +58,7 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService;
beforeAll(() => {
activityDtos = loadActivityExportFile(
exportResponse = loadExportFile(
join(
__dirname,
'../../../../../../../test/import/ok/novn-buy-and-sell.json'
@ -100,7 +95,8 @@ describe('PortfolioCalculator', () => {
it.only('with NOVN.SW buy and sell', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime());
const activities: Activity[] = activityDtos.map((activity) => ({
const activities: Activity[] = exportResponse.activities.map(
(activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
@ -112,16 +108,14 @@ describe('PortfolioCalculator', () => {
name: 'Novartis AG',
symbol: activity.symbol
},
tags: activity.tags?.map((id) => {
return { id } as Tag;
}),
unitPriceInAssetProfileCurrency: activity.unitPrice
}));
})
);
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
currency: exportResponse.user.settings.currency,
userId: userDummyData.id
});

5
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts

@ -1,4 +1,3 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
@ -14,13 +13,13 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
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 { Activity } from '@ghostfolio/common/interfaces';
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;
})
@ -31,7 +30,6 @@ 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;
})
@ -41,7 +39,6 @@ jest.mock(
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;
})

13
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts

@ -188,6 +188,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
})
);
const isCash = orders[0]?.SymbolProfile?.assetSubClass === 'CASH';
if (orders.length <= 0) {
return {
currentValues: {},
@ -244,6 +246,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
// For BUY / SELL activities with a MANUAL data source where no historical market price is available,
// the calculation should fall back to using the activity’s unit price.
unitPriceAtEndDate = latestActivity.unitPrice;
} else if (isCash) {
unitPriceAtEndDate = new Big(1);
}
if (
@ -295,7 +299,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
quantity: new Big(0),
SymbolProfile: {
dataSource,
symbol
symbol,
assetSubClass: isCash ? 'CASH' : undefined
},
type: 'BUY',
unitPrice: unitPriceAtStartDate
@ -308,7 +313,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
itemType: 'end',
SymbolProfile: {
dataSource,
symbol
symbol,
assetSubClass: isCash ? 'CASH' : undefined
},
quantity: new Big(0),
type: 'BUY',
@ -348,7 +354,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
quantity: new Big(0),
SymbolProfile: {
dataSource,
symbol
symbol,
assetSubClass: isCash ? 'CASH' : undefined
},
type: 'BUY',
unitPrice: marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice,

4
apps/api/src/app/portfolio/interfaces/get-values-params.interface.ts

@ -1,8 +1,8 @@
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { DateQuery } from './date-query.interface';
export interface GetValuesParams {
dataGatheringItems: IDataGatheringItem[];
dataGatheringItems: DataGatheringItem[];
dateQuery: DateQuery;
}

4
apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts

@ -1,4 +1,4 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { Activity } from '@ghostfolio/common/interfaces';
export interface PortfolioOrder extends Pick<Activity, 'tags' | 'type'> {
date: string;
@ -6,7 +6,7 @@ export interface PortfolioOrder extends Pick<Activity, 'tags' | 'type'> {
quantity: Big;
SymbolProfile: Pick<
Activity['SymbolProfile'],
'currency' | 'dataSource' | 'name' | 'symbol' | 'userId'
'assetSubClass' | 'currency' | 'dataSource' | 'name' | 'symbol' | 'userId'
>;
unitPrice: Big;
}

3
apps/api/src/app/portfolio/interfaces/transaction-point-symbol.interface.ts

@ -1,7 +1,8 @@
import { DataSource, Tag } from '@prisma/client';
import { AssetSubClass, DataSource, Tag } from '@prisma/client';
import { Big } from 'big.js';
export interface TransactionPointSymbol {
assetSubClass: AssetSubClass;
averagePrice: Big;
currency: string;
dataSource: DataSource;

81
apps/api/src/app/portfolio/portfolio.controller.ts

@ -19,10 +19,10 @@ import {
} from '@ghostfolio/common/config';
import {
PortfolioDetails,
PortfolioDividends,
PortfolioDividendsResponse,
PortfolioHoldingResponse,
PortfolioHoldingsResponse,
PortfolioInvestments,
PortfolioInvestmentsResponse,
PortfolioPerformanceResponse,
PortfolioReportResponse
} from '@ghostfolio/common/interfaces';
@ -195,10 +195,9 @@ export class PortfolioController {
'excludedAccountsAndActivities',
'fees',
'filteredValueInBaseCurrency',
'fireWealth',
'grossPerformance',
'grossPerformanceWithCurrencyEffect',
'interest',
'interestInBaseCurrency',
'items',
'liabilities',
'netPerformance',
@ -306,7 +305,7 @@ export class PortfolioController {
@Query('range') dateRange: DateRange = 'max',
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string
): Promise<PortfolioDividends> {
): Promise<PortfolioDividendsResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
@ -440,7 +439,7 @@ export class PortfolioController {
@Query('range') dateRange: DateRange = 'max',
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string
): Promise<PortfolioInvestments> {
): Promise<PortfolioInvestmentsResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
@ -611,36 +610,6 @@ export class PortfolioController {
return performanceInformation;
}
/**
* @deprecated
*/
@Get('position/:dataSource/:symbol')
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getPosition(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<PortfolioHoldingResponse> {
const holding = await this.portfolioService.getHolding({
dataSource,
impersonationId,
symbol,
userId: this.request.user.id
});
if (!holding) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
return holding;
}
@Get('report')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getReport(
@ -655,8 +624,8 @@ export class PortfolioController {
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
for (const rule in report.xRay.rules) {
report.xRay.rules[rule] = null;
for (const category of report.xRay.categories) {
category.rules = null;
}
report.xRay.statistics = {
@ -700,40 +669,4 @@ export class PortfolioController {
userId: this.request.user.id
});
}
/**
* @deprecated
*/
@HasPermission(permissions.updateOrder)
@Put('position/:dataSource/:symbol/tags')
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updatePositionTags(
@Body() data: UpdateHoldingTagsDto,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<void> {
const holding = await this.portfolioService.getHolding({
dataSource,
impersonationId,
symbol,
userId: this.request.user.id
});
if (!holding) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
await this.portfolioService.updateTags({
dataSource,
impersonationId,
symbol,
tags: data.tags,
userId: this.request.user.id
});
}
}

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

@ -1,7 +1,6 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
@ -40,16 +39,18 @@ import {
import { DATE_FORMAT, getSum, parseDate } from '@ghostfolio/common/helper';
import {
AccountsResponse,
Activity,
EnhancedSymbolProfile,
Filter,
HistoricalDataItem,
InvestmentItem,
PortfolioDetails,
PortfolioHoldingResponse,
PortfolioInvestments,
PortfolioInvestmentsResponse,
PortfolioPerformanceResponse,
PortfolioPosition,
PortfolioReportResponse,
PortfolioReportRule,
PortfolioSummary,
UserSettings
} from '@ghostfolio/common/interfaces';
@ -87,7 +88,6 @@ import {
parseISO,
set
} from 'date-fns';
import { isEmpty } from 'lodash';
import { PortfolioCalculator } from './calculator/portfolio-calculator';
import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory';
@ -396,7 +396,7 @@ export class PortfolioService {
impersonationId: string;
savingsRate: number;
userId: string;
}): Promise<PortfolioInvestments> {
}): Promise<PortfolioInvestmentsResponse> {
userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user);
@ -447,7 +447,7 @@ export class PortfolioService {
});
}
let streaks: PortfolioInvestments['streaks'];
let streaks: PortfolioInvestmentsResponse['streaks'];
if (savingsRate) {
streaks = this.getStreaks({
@ -522,10 +522,6 @@ export class PortfolioService {
return type === 'ACCOUNT';
}) ?? false;
const isFilteredByCash = filters?.some(({ id, type }) => {
return id === AssetClass.LIQUIDITY && type === 'ASSET_CLASS';
});
const isFilteredByClosedHoldings =
filters?.some(({ id, type }) => {
return id === 'CLOSED' && type === 'HOLDING_TYPE';
@ -557,6 +553,9 @@ export class PortfolioService {
assetProfileIdentifiers
);
const cashSymbolProfiles = this.getCashSymbolProfiles(cashDetails);
symbolProfiles.push(...cashSymbolProfiles);
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
for (const symbolProfile of symbolProfiles) {
symbolProfileMap[symbolProfile.symbol] = symbolProfile;
@ -661,18 +660,6 @@ export class PortfolioService {
};
}
if (filters?.length === 0 || isFilteredByAccount || isFilteredByCash) {
const cashPositions = this.getCashPositions({
cashDetails,
userCurrency,
value: filteredValueInBaseCurrency
});
for (const symbol of Object.keys(cashPositions)) {
holdings[symbol] = cashPositions[symbol];
}
}
const { accounts, platforms } = await this.getValueOfAccountsAndPlatforms({
activities,
filters,
@ -775,35 +762,7 @@ export class PortfolioService {
});
if (activities.length === 0) {
return {
activities: [],
averagePrice: undefined,
dataProviderInfo: undefined,
dividendInBaseCurrency: undefined,
dividendYieldPercent: undefined,
dividendYieldPercentWithCurrencyEffect: undefined,
feeInBaseCurrency: undefined,
firstBuyDate: undefined,
grossPerformance: undefined,
grossPerformancePercent: undefined,
grossPerformancePercentWithCurrencyEffect: undefined,
grossPerformanceWithCurrencyEffect: undefined,
historicalData: [],
investmentInBaseCurrencyWithCurrencyEffect: undefined,
marketPrice: undefined,
marketPriceMax: undefined,
marketPriceMin: undefined,
netPerformance: undefined,
netPerformancePercent: undefined,
netPerformancePercentWithCurrencyEffect: undefined,
netPerformanceWithCurrencyEffect: undefined,
performances: undefined,
quantity: undefined,
SymbolProfile: undefined,
tags: [],
transactionCount: undefined,
value: undefined
};
return undefined;
}
const [SymbolProfile] = await this.symbolProfileService.getSymbolProfiles([
@ -817,7 +776,6 @@ export class PortfolioService {
currency: userCurrency
});
const portfolioStart = portfolioCalculator.getStartDate();
const transactionPoints = portfolioCalculator.getTransactionPoints();
const { positions } = await portfolioCalculator.getSnapshot();
@ -826,7 +784,10 @@ export class PortfolioService {
return position.dataSource === dataSource && position.symbol === symbol;
});
if (holding) {
if (!holding) {
return undefined;
}
const {
averagePrice,
currency,
@ -867,13 +828,9 @@ export class PortfolioService {
const dividendYieldPercentWithCurrencyEffect =
getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
netPerformancePercentage: timeWeightedInvestmentWithCurrencyEffect.eq(
0
)
netPerformancePercentage: timeWeightedInvestmentWithCurrencyEffect.eq(0)
? new Big(0)
: dividendInBaseCurrency.div(
timeWeightedInvestmentWithCurrencyEffect
)
: dividendInBaseCurrency.div(timeWeightedInvestmentWithCurrencyEffect)
});
const historicalData = await this.dataProviderService.getHistorical(
@ -927,9 +884,7 @@ export class PortfolioService {
date,
averagePrice: currentAveragePrice,
marketPrice:
historicalDataArray.length > 0
? marketPrice
: currentAveragePrice,
historicalDataArray.length > 0 ? marketPrice : currentAveragePrice,
quantity: currentQuantity
});
@ -965,8 +920,8 @@ export class PortfolioService {
marketPriceMin,
SymbolProfile,
tags,
transactionCount,
activities: activitiesOfHolding,
activitiesCount: transactionCount,
averagePrice: averagePrice.toNumber(),
dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0],
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
@ -1006,98 +961,6 @@ export class PortfolioService {
userCurrency
)
};
} else {
const currentData = await this.dataProviderService.getQuotes({
user,
items: [{ symbol, dataSource: DataSource.YAHOO }]
});
const marketPrice = currentData[symbol]?.marketPrice;
let historicalData = await this.dataProviderService.getHistorical(
[{ symbol, dataSource: DataSource.YAHOO }],
'day',
portfolioStart,
new Date()
);
if (isEmpty(historicalData)) {
try {
historicalData = await this.dataProviderService.getHistoricalRaw({
assetProfileIdentifiers: [{ symbol, dataSource: DataSource.YAHOO }],
from: portfolioStart,
to: new Date()
});
} catch {
historicalData = {
[symbol]: {}
};
}
}
const historicalDataArray: HistoricalDataItem[] = [];
let marketPriceMax = marketPrice;
let marketPriceMaxDate = new Date();
let marketPriceMin = marketPrice;
for (const [date, { marketPrice }] of Object.entries(
historicalData[symbol]
)) {
historicalDataArray.push({
date,
value: marketPrice
});
if (marketPrice > marketPriceMax) {
marketPriceMax = marketPrice;
marketPriceMaxDate = parseISO(date);
}
marketPriceMin = Math.min(
marketPrice ?? Number.MAX_SAFE_INTEGER,
marketPriceMin
);
}
const performancePercent =
this.benchmarkService.calculateChangeInPercentage(
marketPriceMax,
marketPrice
);
return {
marketPrice,
marketPriceMax,
marketPriceMin,
SymbolProfile,
activities: [],
averagePrice: 0,
dataProviderInfo: undefined,
dividendInBaseCurrency: 0,
dividendYieldPercent: 0,
dividendYieldPercentWithCurrencyEffect: 0,
feeInBaseCurrency: 0,
firstBuyDate: undefined,
grossPerformance: undefined,
grossPerformancePercent: undefined,
grossPerformancePercentWithCurrencyEffect: undefined,
grossPerformanceWithCurrencyEffect: undefined,
historicalData: historicalDataArray,
investmentInBaseCurrencyWithCurrencyEffect: 0,
netPerformance: undefined,
netPerformancePercent: undefined,
netPerformancePercentWithCurrencyEffect: undefined,
netPerformanceWithCurrencyEffect: undefined,
performances: {
allTimeHigh: {
performancePercent,
date: marketPriceMaxDate
}
},
quantity: 0,
tags: [],
transactionCount: undefined,
value: 0
};
}
}
public async getPerformance({
@ -1231,48 +1094,54 @@ export class PortfolioService {
})
).toNumber();
const rules: PortfolioReportResponse['xRay']['rules'] = {
accountClusterRisk:
summary.activityCount > 0
? await this.rulesService.evaluate(
const categories: PortfolioReportResponse['xRay']['categories'] = [
{
key: 'liquidity',
name: this.i18nService.getTranslation({
id: 'rule.liquidity.category',
languageCode: userSettings.language
}),
rules: await this.rulesService.evaluate(
[
new AccountClusterRiskCurrentInvestment(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
accounts
),
new AccountClusterRiskSingleAccount(
new BuyingPower(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
accounts
summary.cash,
userSettings.language
)
],
userSettings
)
: undefined,
assetClassClusterRisk:
summary.activityCount > 0
? await this.rulesService.evaluate(
},
{
key: 'emergencyFund',
name: this.i18nService.getTranslation({
id: 'rule.emergencyFund.category',
languageCode: userSettings.language
}),
rules: await this.rulesService.evaluate(
[
new AssetClassClusterRiskEquity(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
Object.values(holdings)
),
new AssetClassClusterRiskFixedIncome(
new EmergencyFundSetup(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
Object.values(holdings)
this.getTotalEmergencyFund({
userSettings,
emergencyFundHoldingsValueInBaseCurrency:
this.getEmergencyFundHoldingsValueInBaseCurrency({ holdings })
}).toNumber()
)
],
userSettings
)
: undefined,
currencyClusterRisk:
},
{
key: 'currencyClusterRisk',
name: this.i18nService.getTranslation({
id: 'rule.currencyClusterRisk.category',
languageCode: userSettings.language
}),
rules:
summary.activityCount > 0
? await this.rulesService.evaluate(
[
@ -1291,68 +1160,98 @@ export class PortfolioService {
],
userSettings
)
: undefined,
economicMarketClusterRisk:
: undefined
},
{
key: 'assetClassClusterRisk',
name: this.i18nService.getTranslation({
id: 'rule.assetClassClusterRisk.category',
languageCode: userSettings.language
}),
rules:
summary.activityCount > 0
? await this.rulesService.evaluate(
[
new EconomicMarketClusterRiskDevelopedMarkets(
new AssetClassClusterRiskEquity(
this.exchangeRateDataService,
this.i18nService,
marketsTotalInBaseCurrency,
markets.developedMarkets.valueInBaseCurrency,
userSettings.language
userSettings.language,
Object.values(holdings)
),
new EconomicMarketClusterRiskEmergingMarkets(
new AssetClassClusterRiskFixedIncome(
this.exchangeRateDataService,
this.i18nService,
marketsTotalInBaseCurrency,
markets.emergingMarkets.valueInBaseCurrency,
userSettings.language
userSettings.language,
Object.values(holdings)
)
],
userSettings
)
: undefined,
emergencyFund: await this.rulesService.evaluate(
: undefined
},
{
key: 'accountClusterRisk',
name: this.i18nService.getTranslation({
id: 'rule.accountClusterRisk.category',
languageCode: userSettings.language
}),
rules:
summary.activityCount > 0
? await this.rulesService.evaluate(
[
new EmergencyFundSetup(
new AccountClusterRiskCurrentInvestment(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
this.getTotalEmergencyFund({
userSettings,
emergencyFundHoldingsValueInBaseCurrency:
this.getEmergencyFundHoldingsValueInBaseCurrency({ holdings })
}).toNumber()
)
],
userSettings
accounts
),
fees: await this.rulesService.evaluate(
[
new FeeRatioInitialInvestment(
new AccountClusterRiskSingleAccount(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
summary.committedFunds,
summary.fees
accounts
)
],
userSettings
),
liquidity: await this.rulesService.evaluate(
)
: undefined
},
{
key: 'economicMarketClusterRisk',
name: this.i18nService.getTranslation({
id: 'rule.economicMarketClusterRisk.category',
languageCode: userSettings.language
}),
rules:
summary.activityCount > 0
? await this.rulesService.evaluate(
[
new BuyingPower(
new EconomicMarketClusterRiskDevelopedMarkets(
this.exchangeRateDataService,
this.i18nService,
summary.cash,
marketsTotalInBaseCurrency,
markets.developedMarkets.valueInBaseCurrency,
userSettings.language
),
new EconomicMarketClusterRiskEmergingMarkets(
this.exchangeRateDataService,
this.i18nService,
marketsTotalInBaseCurrency,
markets.emergingMarkets.valueInBaseCurrency,
userSettings.language
)
],
userSettings
),
regionalMarketClusterRisk:
)
: undefined
},
{
key: 'regionalMarketClusterRisk',
name: this.i18nService.getTranslation({
id: 'rule.regionalMarketClusterRisk.category',
languageCode: userSettings.language
}),
rules:
summary.activityCount > 0
? await this.rulesService.evaluate(
[
@ -1395,12 +1294,36 @@ export class PortfolioService {
userSettings
)
: undefined
};
},
{
key: 'fees',
name: this.i18nService.getTranslation({
id: 'rule.fees.category',
languageCode: userSettings.language
}),
rules: await this.rulesService.evaluate(
[
new FeeRatioInitialInvestment(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
summary.committedFunds,
summary.fees
)
],
userSettings
)
}
];
return {
xRay: {
rules,
statistics: this.getReportStatistics(rules)
categories,
statistics: this.getReportStatistics(
categories.flatMap(({ rules }) => {
return rules ?? [];
})
)
}
};
}
@ -1612,6 +1535,37 @@ export class PortfolioService {
return cashPositions;
}
private getCashSymbolProfiles(cashDetails: CashDetails) {
const cashSymbols = [
...new Set(cashDetails.accounts.map(({ currency }) => currency))
];
return cashSymbols.map<EnhancedSymbolProfile>((currency) => {
const account = cashDetails.accounts.find(
({ currency: accountCurrency }) => {
return accountCurrency === currency;
}
);
return {
currency,
activitiesCount: 0,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
countries: [],
createdAt: account.createdAt,
dataSource: DataSource.MANUAL,
holdings: [],
id: currency,
isActive: true,
name: currency,
sectors: [],
symbol: currency,
updatedAt: account.updatedAt
};
});
}
private getDividendsByGroup({
dividends,
groupBy
@ -1822,7 +1776,7 @@ export class PortfolioService {
}
private getReportStatistics(
evaluatedRules: PortfolioReportResponse['xRay']['rules']
evaluatedRules: PortfolioReportRule[]
): PortfolioReportResponse['xRay']['statistics'] {
const rulesActiveCount = Object.values(evaluatedRules)
.flat()
@ -2018,6 +1972,7 @@ export class PortfolioService {
}).length,
committedFunds: committedFunds.toNumber(),
currentValueInBaseCurrency: currentValueInBaseCurrency.toNumber(),
dateOfFirstActivity: firstOrderDate,
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
emergencyFund: {
assets: emergencyFundHoldingsValueInBaseCurrency,
@ -2031,17 +1986,21 @@ export class PortfolioService {
filteredValueInPercentage: netWorth
? filteredValueInBaseCurrency.div(netWorth).toNumber()
: undefined,
fireWealth: new Big(currentValueInBaseCurrency)
fireWealth: {
today: {
valueInBaseCurrency: new Big(currentValueInBaseCurrency)
.minus(emergencyFundHoldingsValueInBaseCurrency)
.toNumber(),
.toNumber()
}
},
grossPerformance: new Big(netPerformance).plus(fees).toNumber(),
grossPerformanceWithCurrencyEffect: new Big(
netPerformanceWithCurrencyEffect
)
.plus(fees)
.toNumber(),
interest: interest.toNumber(),
liabilities: liabilities.toNumber(),
interestInBaseCurrency: interest.toNumber(),
liabilitiesInBaseCurrency: liabilities.toNumber(),
totalInvestment: totalInvestment.toNumber(),
totalValueInBaseCurrency: netWorth
};
@ -2061,11 +2020,11 @@ export class PortfolioService {
.filter(({ isDraft, type }) => {
return isDraft === false && type === activityType;
})
.map(({ quantity, SymbolProfile, unitPrice }) => {
.map(({ currency, quantity, SymbolProfile, unitPrice }) => {
return new Big(
this.exchangeRateDataService.toCurrency(
new Big(quantity).mul(unitPrice).toNumber(),
SymbolProfile.currency,
currency ?? SymbolProfile.currency,
userCurrency
)
);
@ -2217,7 +2176,7 @@ export class PortfolioService {
accounts[account?.id || UNKNOWN_KEY] = {
balance: 0,
currency: account?.currency,
name: account.name,
name: account?.name,
valueInBaseCurrency: currentValueOfSymbolInBaseCurrency
};
}
@ -2231,7 +2190,7 @@ export class PortfolioService {
platforms[account?.platformId || UNKNOWN_KEY] = {
balance: 0,
currency: account?.currency,
name: account.platform?.name,
name: account?.platform?.name,
valueInBaseCurrency: currentValueOfSymbolInBaseCurrency
};
}

4
apps/api/src/app/portfolio/rules.service.ts

@ -1,7 +1,7 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule';
import {
PortfolioReportRule,
RuleSettings,
UserSettings
} from '@ghostfolio/common/interfaces';
@ -22,7 +22,6 @@ export class RulesService {
return {
evaluation,
value,
categoryName: rule.getCategoryName(),
configuration: rule.getConfiguration(),
isActive: true,
key: rule.getKey(),
@ -30,7 +29,6 @@ export class RulesService {
};
} else {
return {
categoryName: rule.getCategoryName(),
isActive: false,
key: rule.getKey(),
name: rule.getName()

2
apps/api/src/app/redis-cache/redis-cache.service.ts

@ -4,9 +4,9 @@ import { AssetProfileIdentifier, Filter } from '@ghostfolio/common/interfaces';
import { CACHE_MANAGER, Cache } from '@nestjs/cache-manager';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { createHash } from 'crypto';
import Keyv from 'keyv';
import ms from 'ms';
import { createHash } from 'node:crypto';
@Injectable()
export class RedisCacheService {

11
apps/api/src/app/subscription/subscription.controller.ts

@ -5,7 +5,10 @@ import {
DEFAULT_LANGUAGE_CODE,
PROPERTY_COUPONS
} from '@ghostfolio/common/config';
import { Coupon } from '@ghostfolio/common/interfaces';
import {
Coupon,
CreateStripeCheckoutSessionResponse
} from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
@ -111,11 +114,11 @@ export class SubscriptionController {
@Post('stripe/checkout-session')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async createCheckoutSession(
public createStripeCheckoutSession(
@Body() { couponId, priceId }: { couponId?: string; priceId: string }
) {
): Promise<CreateStripeCheckoutSessionResponse> {
try {
return this.subscriptionService.createCheckoutSession({
return this.subscriptionService.createStripeCheckoutSession({
couponId,
priceId,
user: this.request.user

25
apps/api/src/app/subscription/subscription.service.ts

@ -5,13 +5,16 @@ import {
DEFAULT_LANGUAGE_CODE,
PROPERTY_STRIPE_CONFIG
} from '@ghostfolio/common/config';
import { SubscriptionType } from '@ghostfolio/common/enums';
import { parseDate } from '@ghostfolio/common/helper';
import { SubscriptionOffer } from '@ghostfolio/common/interfaces';
import {
CreateStripeCheckoutSessionResponse,
SubscriptionOffer
} from '@ghostfolio/common/interfaces';
import {
SubscriptionOfferKey,
UserWithSettings
} from '@ghostfolio/common/types';
import { SubscriptionType } from '@ghostfolio/common/types/subscription-type.type';
import { Injectable, Logger } from '@nestjs/common';
import { Subscription } from '@prisma/client';
@ -32,13 +35,13 @@ export class SubscriptionService {
this.stripe = new Stripe(
this.configurationService.get('STRIPE_SECRET_KEY'),
{
apiVersion: '2025-08-27.basil'
apiVersion: '2025-12-15.clover'
}
);
}
}
public async createCheckoutSession({
public async createStripeCheckoutSession({
couponId,
priceId,
user
@ -46,7 +49,7 @@ export class SubscriptionService {
couponId?: string;
priceId: string;
user: UserWithSettings;
}) {
}): Promise<CreateStripeCheckoutSessionResponse> {
const subscriptionOffers: {
[offer in SubscriptionOfferKey]: SubscriptionOffer;
} =
@ -58,7 +61,8 @@ export class SubscriptionService {
}
);
const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = {
const stripeCheckoutSessionCreateParams: Stripe.Checkout.SessionCreateParams =
{
cancel_url: `${this.configurationService.get('ROOT_URL')}/${
user.settings.settings.language
}/account`,
@ -84,7 +88,7 @@ export class SubscriptionService {
};
if (couponId) {
checkoutSessionCreateParams.discounts = [
stripeCheckoutSessionCreateParams.discounts = [
{
coupon: couponId
}
@ -92,11 +96,12 @@ export class SubscriptionService {
}
const session = await this.stripe.checkout.sessions.create(
checkoutSessionCreateParams
stripeCheckoutSessionCreateParams
);
return {
sessionId: session.id
sessionId: session.id,
sessionUrl: session.url
};
}
@ -175,6 +180,8 @@ export class SubscriptionService {
offerKey = 'renewal-early-bird-2023';
} else if (isBefore(createdAt, parseDate('2024-01-01'))) {
offerKey = 'renewal-early-bird-2024';
} else if (isBefore(createdAt, parseDate('2025-12-01'))) {
offerKey = 'renewal-early-bird-2025';
}
const offer = await this.getSubscriptionOffer({

10
apps/api/src/app/symbol/symbol.controller.ts

@ -1,8 +1,11 @@
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 { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import { LookupResponse } from '@ghostfolio/common/interfaces';
import {
DataProviderHistoricalResponse,
LookupResponse,
SymbolItem
} from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
@ -22,7 +25,6 @@ import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { isDate, isEmpty } from 'lodash';
import { SymbolItem } from './interfaces/symbol-item.interface';
import { SymbolService } from './symbol.service';
@Controller('symbol')
@ -97,7 +99,7 @@ export class SymbolController {
@Param('dataSource') dataSource: DataSource,
@Param('dateString') dateString: string,
@Param('symbol') symbol: string
): Promise<IDataProviderHistoricalResponse> {
): Promise<DataProviderHistoricalResponse> {
const date = parseISO(dateString);
if (!isDate(date)) {

17
apps/api/src/app/symbol/symbol.service.ts

@ -1,21 +1,18 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import {
IDataGatheringItem,
IDataProviderHistoricalResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import {
DataProviderHistoricalResponse,
HistoricalDataItem,
LookupResponse
LookupResponse,
SymbolItem
} from '@ghostfolio/common/interfaces';
import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { format, subDays } from 'date-fns';
import { SymbolItem } from './interfaces/symbol-item.interface';
@Injectable()
export class SymbolService {
public constructor(
@ -27,7 +24,7 @@ export class SymbolService {
dataGatheringItem,
includeHistoricalData
}: {
dataGatheringItem: IDataGatheringItem;
dataGatheringItem: DataGatheringItem;
includeHistoricalData?: number;
}): Promise<SymbolItem> {
const quotes = await this.dataProviderService.getQuotes({
@ -75,10 +72,10 @@ export class SymbolService {
dataSource,
date = new Date(),
symbol
}: IDataGatheringItem): Promise<IDataProviderHistoricalResponse> {
}: DataGatheringItem): Promise<DataProviderHistoricalResponse> {
let historicalData: {
[symbol: string]: {
[date: string]: IDataProviderHistoricalResponse;
[date: string]: DataProviderHistoricalResponse;
};
} = {
[symbol]: {}

16
apps/api/src/app/user/user.controller.ts

@ -3,9 +3,15 @@ import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
DeleteOwnUserDto,
UpdateOwnAccessTokenDto,
UpdateUserSettingDto
} from '@ghostfolio/common/dtos';
import {
AccessTokenResponse,
User,
UserItem,
UserSettings
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -31,10 +37,6 @@ import { User as UserModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { merge, size } from 'lodash';
import { DeleteOwnUserDto } from './delete-own-user.dto';
import { UserItem } from './interfaces/user-item.interface';
import { UpdateOwnAccessTokenDto } from './update-own-access-token.dto';
import { UpdateUserSettingDto } from './update-user-setting.dto';
import { UserService } from './user.service';
@Controller('user')
@ -126,11 +128,7 @@ export class UserController {
);
}
const hasAdmin = await this.userService.hasAdmin();
const { accessToken, id, role } = await this.userService.createUser({
data: { role: hasAdmin ? 'USER' : 'ADMIN' }
});
const { accessToken, id, role } = await this.userService.createUser();
return {
accessToken,

52
apps/api/src/app/user/user.service.ts

@ -48,9 +48,9 @@ import { PerformanceCalculationType } from '@ghostfolio/common/types/performance
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Prisma, Role, User } from '@prisma/client';
import { createHmac } from 'crypto';
import { differenceInDays, subDays } from 'date-fns';
import { sortBy, without } from 'lodash';
import { createHmac } from 'node:crypto';
@Injectable()
export class UserService {
@ -184,6 +184,7 @@ export class UserService {
userWhereUniqueInput: Prisma.UserWhereUniqueInput
): Promise<UserWithSettings | null> {
const {
_count,
accessesGet,
accessToken,
accounts,
@ -199,6 +200,11 @@ export class UserService {
updatedAt
} = await this.prismaService.user.findUnique({
include: {
_count: {
select: {
activities: true
}
},
accessesGet: true,
accounts: {
include: { platform: true }
@ -210,6 +216,8 @@ export class UserService {
where: userWhereUniqueInput
});
const activitiesCount = _count?.activities ?? 0;
const user: UserWithSettings = {
accessesGet,
accessToken,
@ -240,6 +248,11 @@ export class UserService {
};
}
// Set default value for annual interest rate
if (!(user.settings.settings as UserSettings)?.annualInterestRate) {
(user.settings.settings as UserSettings).annualInterestRate = 5;
}
// Set default value for base currency
if (!(user.settings.settings as UserSettings)?.baseCurrency) {
(user.settings.settings as UserSettings).baseCurrency = DEFAULT_CURRENCY;
@ -257,6 +270,21 @@ export class UserService {
PerformanceCalculationType.ROAI;
}
// Set default value for projected total amount
if (!(user.settings.settings as UserSettings)?.projectedTotalAmount) {
(user.settings.settings as UserSettings).projectedTotalAmount = 0;
}
// Set default value for safe withdrawal rate
if (!(user.settings.settings as UserSettings)?.safeWithdrawalRate) {
(user.settings.settings as UserSettings).safeWithdrawalRate = 0.04;
}
// Set default value for savings rate
if (!(user.settings.settings as UserSettings)?.savingsRate) {
(user.settings.settings as UserSettings).savingsRate = 0;
}
// Set default value for view mode
if (!(user.settings.settings as UserSettings).viewMode) {
(user.settings.settings as UserSettings).viewMode = 'DEFAULT';
@ -404,13 +432,13 @@ export class UserService {
);
let frequency = 7;
if (daysSinceRegistration > 720) {
if (activitiesCount > 1000 || daysSinceRegistration > 720) {
frequency = 1;
} else if (daysSinceRegistration > 360) {
} else if (activitiesCount > 750 || daysSinceRegistration > 360) {
frequency = 2;
} else if (daysSinceRegistration > 180) {
} else if (activitiesCount > 500 || daysSinceRegistration > 180) {
frequency = 3;
} else if (daysSinceRegistration > 60) {
} else if (activitiesCount > 250 || daysSinceRegistration > 60) {
frequency = 4;
} else if (daysSinceRegistration > 30) {
frequency = 5;
@ -513,15 +541,23 @@ export class UserService {
});
}
public async createUser({
public async createUser(
{
data
}: {
data: Prisma.UserCreateInput;
}): Promise<User> {
if (!data?.provider) {
} = { data: {} }
): Promise<User> {
if (!data.provider) {
data.provider = 'ANONYMOUS';
}
if (!data.role) {
const hasAdmin = await this.hasAdmin();
data.role = hasAdmin ? 'USER' : 'ADMIN';
}
const user = await this.prismaService.user.create({
data: {
...data,

1082
apps/api/src/assets/cryptocurrencies/cryptocurrencies.json

File diff suppressed because it is too large

1
apps/api/src/assets/sitemap.xml

@ -5,5 +5,6 @@
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
${publicRoutes}
${blogPosts}
${personalFinanceTools}
</urlset>

3
apps/api/src/dependencies.ts

@ -0,0 +1,3 @@
// Dependencies required by .config/prisma.ts in Docker container
import 'dotenv';
import 'dotenv-expand';

11
apps/api/src/events/asset-profile-changed.event.ts

@ -0,0 +1,11 @@
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
export class AssetProfileChangedEvent {
public constructor(
public readonly data: AssetProfileIdentifier & { currency: string }
) {}
public static getName(): string {
return 'assetProfile.changed';
}
}

61
apps/api/src/events/asset-profile-changed.listener.ts

@ -0,0 +1,61 @@
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { AssetProfileChangedEvent } from './asset-profile-changed.event';
@Injectable()
export class AssetProfileChangedListener {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly orderService: OrderService
) {}
@OnEvent(AssetProfileChangedEvent.getName())
public async handleAssetProfileChanged(event: AssetProfileChangedEvent) {
Logger.log(
`Asset profile of ${event.data.symbol} (${event.data.dataSource}) has changed`,
'AssetProfileChangedListener'
);
if (
this.configurationService.get(
'ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES'
) === false ||
event.data.currency === DEFAULT_CURRENCY
) {
return;
}
const existingCurrencies = this.exchangeRateDataService.getCurrencies();
if (!existingCurrencies.includes(event.data.currency)) {
Logger.log(
`New currency ${event.data.currency} has been detected`,
'AssetProfileChangedListener'
);
await this.exchangeRateDataService.initialize();
}
const { dateOfFirstActivity } =
await this.orderService.getStatisticsByCurrency(event.data.currency);
if (dateOfFirstActivity) {
await this.dataGatheringService.gatherSymbol({
dataSource: this.dataProviderService.getDataSourceForExchangeRates(),
date: dateOfFirstActivity,
symbol: `${DEFAULT_CURRENCY}${event.data.currency}`
});
}
}
}

17
apps/api/src/events/events.module.ts

@ -1,11 +1,24 @@
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.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 { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { Module } from '@nestjs/common';
import { AssetProfileChangedListener } from './asset-profile-changed.listener';
import { PortfolioChangedListener } from './portfolio-changed.listener';
@Module({
imports: [RedisCacheModule],
providers: [PortfolioChangedListener]
imports: [
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
OrderModule,
RedisCacheModule
],
providers: [AssetProfileChangedListener, PortfolioChangedListener]
})
export class EventsModule {}

26
apps/api/src/helper/object.helper.spec.ts

@ -1,4 +1,24 @@
import { redactAttributes } from './object.helper';
import { query, redactAttributes } from './object.helper';
describe('query', () => {
it('should get market price from stock API response', () => {
const object = {
currency: 'USD',
market: {
previousClose: 273.04,
price: 271.86
},
symbol: 'AAPL'
};
const result = query({
object,
pathExpression: '$.market.price'
})[0];
expect(result).toBe(271.86);
});
});
describe('redactAttributes', () => {
it('should redact provided attributes', () => {
@ -1536,7 +1556,7 @@ describe('redactAttributes', () => {
fireWealth: null,
grossPerformance: null,
grossPerformanceWithCurrencyEffect: null,
interest: null,
interestInBaseCurrency: null,
items: null,
liabilities: null,
totalInvestment: null,
@ -3039,7 +3059,7 @@ describe('redactAttributes', () => {
fireWealth: null,
grossPerformance: null,
grossPerformanceWithCurrencyEffect: null,
interest: null,
interestInBaseCurrency: null,
items: null,
liabilities: null,
totalInvestment: null,

11
apps/api/src/helper/object.helper.ts

@ -1,4 +1,5 @@
import { Big } from 'big.js';
import jsonpath from 'jsonpath';
import { cloneDeep, isArray, isObject } from 'lodash';
export function hasNotDefinedValuesInObject(aObject: Object): boolean {
@ -31,6 +32,16 @@ export function nullifyValuesInObjects<T>(aObjects: T[], keys: string[]): T[] {
});
}
export function query({
object,
pathExpression
}: {
object: object;
pathExpression: string;
}) {
return jsonpath.query(object, pathExpression);
}
export function redactAttributes({
isFirstRun = true,
object,

2
apps/api/src/helper/string.helper.ts

@ -1,4 +1,4 @@
import { randomBytes } from 'crypto';
import { randomBytes } from 'node:crypto';
export function getRandomString(length: number) {
const bytes = randomBytes(length);

8
apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts

@ -16,9 +16,10 @@ import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@Injectable()
export class RedactValuesInResponseInterceptor<T>
implements NestInterceptor<T, any>
{
export class RedactValuesInResponseInterceptor<T> implements NestInterceptor<
T,
any
> {
public intercept(
context: ExecutionContext,
next: CallHandler<T>
@ -61,6 +62,7 @@ export class RedactValuesInResponseInterceptor<T>
'totalInterestInBaseCurrency',
'totalValueInBaseCurrency',
'unitPrice',
'unitPriceInAssetProfileCurrency',
'value',
'valueInBaseCurrency'
].map((attribute) => {

33
apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts

@ -11,9 +11,9 @@ import { DataSource } from '@prisma/client';
import { Observable } from 'rxjs';
@Injectable()
export class TransformDataSourceInRequestInterceptor<T>
implements NestInterceptor<T, any>
{
export class TransformDataSourceInRequestInterceptor<
T
> implements NestInterceptor<T, any> {
public constructor(
private readonly configurationService: ConfigurationService
) {}
@ -27,9 +27,23 @@ export class TransformDataSourceInRequestInterceptor<T>
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
if (request.body?.activities) {
const dataSourceGhostfolioDataProvider = this.configurationService.get(
'DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER'
)?.[0];
request.body.activities = request.body.activities.map((activity) => {
if (DataSource[activity.dataSource]) {
if (
activity.dataSource === 'GHOSTFOLIO' &&
dataSourceGhostfolioDataProvider
) {
return {
...activity,
dataSource: dataSourceGhostfolioDataProvider
};
} else {
return activity;
}
} else {
return {
...activity,
@ -55,6 +69,19 @@ export class TransformDataSourceInRequestInterceptor<T>
});
}
}
} else {
if (request.body?.activities) {
request.body.activities = request.body.activities.map((activity) => {
if (DataSource[activity.dataSource]) {
return activity;
} else {
return {
...activity,
dataSource: decodeDataSource(activity.dataSource)
};
}
});
}
}
return next.handle();

60
apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts

@ -13,39 +13,59 @@ import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@Injectable()
export class TransformDataSourceInResponseInterceptor<T>
implements NestInterceptor<T, any>
{
export class TransformDataSourceInResponseInterceptor<
T
> implements NestInterceptor<T, any> {
private encodedDataSourceMap: {
[dataSource: string]: string;
} = {};
public constructor(
private readonly configurationService: ConfigurationService
) {}
) {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
this.encodedDataSourceMap = Object.keys(DataSource).reduce(
(encodedDataSourceMap, dataSource) => {
if (!['GHOSTFOLIO', 'MANUAL'].includes(dataSource)) {
encodedDataSourceMap[dataSource] = encodeDataSource(
DataSource[dataSource]
);
}
return encodedDataSourceMap;
},
{}
);
}
}
public intercept(
_context: ExecutionContext,
context: ExecutionContext,
next: CallHandler<T>
): Observable<any> {
const isExportMode = context.getClass().name === 'ExportController';
return next.handle().pipe(
map((data: any) => {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
const valueMap = this.encodedDataSourceMap;
if (isExportMode) {
for (const dataSource of this.configurationService.get(
'DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER'
)) {
valueMap[dataSource] = 'GHOSTFOLIO';
}
}
data = redactAttributes({
object: data,
options: [
{
attribute: 'dataSource',
valueMap: Object.keys(DataSource).reduce(
(valueMap, dataSource) => {
if (!['MANUAL'].includes(dataSource)) {
valueMap[dataSource] = encodeDataSource(
DataSource[dataSource]
);
}
return valueMap;
},
{}
)
valueMap,
attribute: 'dataSource'
}
],
object: data
]
});
}

16
apps/api/src/main.ts

@ -90,7 +90,21 @@ async function bootstrap() {
await app.listen(PORT, HOST, () => {
logLogo();
Logger.log(`Listening at http://${HOST}:${PORT}`);
let address = app.getHttpServer().address();
if (typeof address === 'object') {
const addressObject = address;
let host = addressObject.address;
if (addressObject.family === 'IPv6') {
host = `[${addressObject.address}]`;
}
address = `${host}:${addressObject.port}`;
}
Logger.log(`Listening at http://${address}`);
Logger.log('');
});
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save