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. 70
      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. 108
      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. 65
      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. 113
      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. 142
      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. 126
      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. 91
      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. 61
      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. 61
      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. 50
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  72. 50
      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. 893
      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. 71
      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. 58
      apps/api/src/app/user/user.service.ts
  88. 1084
      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. 35
      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 uses: peter-evans/create-pull-request@v7
with: with:
author: 'github-actions[bot] <github-actions[bot]@users.noreply.github.com>' author: 'github-actions[bot] <github-actions[bot]@users.noreply.github.com>'
branch: 'feature/update-locales' branch: 'task/update-locales'
commit-message: 'Update locales' commit-message: 'Update locales'
delete-branch: true delete-branch: true
title: 'Feature/update locales' title: 'Task/update locales'
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}

4
.vscode/extensions.json

@ -1,8 +1,8 @@
{ {
"recommendations": [ "recommendations": [
"angular.ng-template", "angular.ng-template",
"esbenp.prettier-vscode",
"firsttris.vscode-jest-runner", "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 "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 ## 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 ### Changed
- Localized the content of the about page - 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 - Refactored the world map chart component to standalone
- Enabled the trim option in the `extract-i18n` configuration
- Improved the language localization for German (`de`) - Improved the language localization for German (`de`)
- Upgraded the _Stripe_ dependencies - Upgraded the _Stripe_ dependencies
- Upgraded `ngx-device-detector` from version `10.0.2` to `10.1.0` - 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 `ngx-skeleton-loader` from version `11.2.1` to `11.3.0`
- Upgraded `yahoo-finance2` from version `3.6.4` to `3.8.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 ## 2.195.0 - 2025-08-29
### Changed ### Changed
@ -1804,7 +2375,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### Fixed
- Fixed an issue in the portfolio summary with the currency conversion of fees - 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 - Removed the show condition of the experimental features setting in the user settings
## 2.95.0 - 2024-07-12 ## 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 # Only add basic files without the application itself to avoid rebuilding
# layers when files (package.json etc.) have not changed # layers when files (package.json etc.) have not changed
COPY ./.config .config/
COPY ./CHANGELOG.md CHANGELOG.md COPY ./CHANGELOG.md CHANGELOG.md
COPY ./LICENSE LICENSE COPY ./LICENSE LICENSE
COPY ./package.json package.json COPY ./package.json package.json
COPY ./package-lock.json package-lock.json COPY ./package-lock.json package-lock.json
COPY ./prisma/schema.prisma prisma/schema.prisma COPY ./prisma/schema.prisma prisma/
RUN npm install RUN npm install
# See https://github.com/nrwl/nx/issues/6586 for further details COPY ./apps apps/
COPY ./decorate-angular-cli.js decorate-angular-cli.js COPY ./libs libs/
RUN node decorate-angular-cli.js
COPY ./apps apps
COPY ./libs libs
COPY ./jest.config.ts jest.config.ts COPY ./jest.config.ts jest.config.ts
COPY ./jest.preset.js jest.preset.js COPY ./jest.preset.js jest.preset.js
COPY ./nx.json nx.json COPY ./nx.json nx.json
@ -40,14 +37,15 @@ RUN npm run build:production
WORKDIR /ghostfolio/dist/apps/api WORKDIR /ghostfolio/dist/apps/api
# package.json was generated by the build process, however the original # package.json was generated by the build process, however the original
# package-lock.json needs to be used to ensure the same versions # 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 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 # Overwrite the generated package.json with the original one to ensure having
# all the scripts # all the scripts
COPY package.json /ghostfolio/dist/apps/api COPY package.json /ghostfolio/dist/apps/api/
RUN npm run database:generate-typings RUN npm run database:generate-typings
# Image to run, copy everything needed from builder # Image to run, copy everything needed from builder
@ -60,8 +58,8 @@ RUN apt-get update && apt-get install -y --no-install-suggests \
openssl \ openssl \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY --chown=node:node --from=builder /ghostfolio/dist/apps /ghostfolio/apps 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 ./docker/entrypoint.sh /ghostfolio/
WORKDIR /ghostfolio/apps/api WORKDIR /ghostfolio/apps/api
EXPOSE ${PORT:-3333} EXPOSE ${PORT:-3333}
USER node USER node

70
README.md

@ -85,24 +85,39 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
### Supported Environment Variables ### Supported Environment Variables
| Name | Type | Default Value | Description | | Name | Type | Default Value | Description |
| ------------------------ | --------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | ------------------------ | --------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens | | `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_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key |
| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key | | `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key |
| `DATABASE_URL` | `string` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` | | `DATABASE_URL` | `string` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on | | `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on |
| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) | | `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) |
| `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` | | `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` |
| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on | | `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database | | `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database | | `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database | | `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database |
| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ | | `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ |
| `REDIS_HOST` | `string` | | The host where _Redis_ is running | | `REDIS_HOST` | `string` | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | `string` | | The password of _Redis_ | | `REDIS_PASSWORD` | `string` | | The password of _Redis_ |
| `REDIS_PORT` | `number` | | The port where _Redis_ is running | | `REDIS_PORT` | `number` | | The port where _Redis_ is running |
| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds | | `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 ### 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 | | `accountId` | `string` (optional) | Id of the account |
| `comment` | `string` (optional) | Comment of the activity | | `comment` | `string` (optional) | Comment of the activity |
| `currency` | `string` | `CHF` \| `EUR` \| `USD` etc. | | `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` | | `date` | `string` | Date in the format `ISO-8601` |
| `fee` | `number` | Fee of the activity | | `fee` | `number` | Fee of the activity |
| `quantity` | `number` | Quantity 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. 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 ## Analytics
@ -305,6 +331,8 @@ If you like to support this project, get [**Ghostfolio Premium**](https://ghostf
## License ## 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). 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 { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos';
import { Access } from '@ghostfolio/common/interfaces'; import { Access } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
@ -14,6 +15,7 @@ import {
Inject, Inject,
Param, Param,
Post, Post,
Put,
UseGuards UseGuards
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
@ -22,7 +24,6 @@ import { Access as AccessModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccessService } from './access.service'; import { AccessService } from './access.service';
import { CreateAccessDto } from './create-access.dto';
@Controller('access') @Controller('access')
export class AccessController { export class AccessController {
@ -39,7 +40,7 @@ export class AccessController {
include: { include: {
granteeUser: true granteeUser: true
}, },
orderBy: { granteeUserId: 'asc' }, orderBy: [{ granteeUserId: 'desc' }, { createdAt: 'asc' }],
where: { userId: this.request.user.id } where: { userId: this.request.user.id }
}); });
@ -103,9 +104,12 @@ export class AccessController {
@HasPermission(permissions.deleteAccess) @HasPermission(permissions.deleteAccess)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteAccess(@Param('id') id: string): Promise<AccessModel> { 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( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
@ -116,4 +120,52 @@ export class AccessController {
id 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: { public async accesses(params: {
cursor?: Prisma.AccessWhereUniqueInput;
include?: Prisma.AccessInclude; include?: Prisma.AccessInclude;
orderBy?: Prisma.Enumerable<Prisma.AccessOrderByWithRelationInput>;
skip?: number; skip?: number;
take?: number; take?: number;
cursor?: Prisma.AccessWhereUniqueInput;
where?: Prisma.AccessWhereInput; where?: Prisma.AccessWhereInput;
orderBy?: Prisma.AccessOrderByWithRelationInput;
}): Promise<AccessWithGranteeUser[]> { }): Promise<AccessWithGranteeUser[]> {
const { include, skip, take, cursor, where, orderBy } = params; const { cursor, include, orderBy, skip, take, where } = params;
return this.prismaService.access.findMany({ return this.prismaService.access.findMany({
cursor, cursor,
@ -52,4 +52,17 @@ export class AccessService {
where 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 { AccountService } from '@ghostfolio/api/app/account/account.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { CreateAccountBalanceDto } from '@ghostfolio/common/dtos';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
@ -20,7 +21,6 @@ import { AccountBalance } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccountBalanceService } from './account-balance.service'; import { AccountBalanceService } from './account-balance.service';
import { CreateAccountBalanceDto } from './create-account-balance.dto';
@Controller('account-balance') @Controller('account-balance')
export class AccountBalanceController { 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 { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.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 { DATE_FORMAT, getSum, resetHours } from '@ghostfolio/common/helper';
import { import {
AccountBalancesResponse, AccountBalancesResponse,
@ -15,8 +16,6 @@ import { AccountBalance, Prisma } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
import { CreateAccountBalanceDto } from './create-account-balance.dto';
@Injectable() @Injectable()
export class AccountBalanceService { export class AccountBalanceService {
public constructor( 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 { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import {
CreateAccountDto,
TransferBalanceDto,
UpdateAccountDto
} from '@ghostfolio/common/dtos';
import { import {
AccountBalancesResponse, AccountBalancesResponse,
AccountResponse,
AccountsResponse AccountsResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { import type { RequestWithUser } from '@ghostfolio/common/types';
AccountWithValue,
RequestWithUser
} from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -38,9 +41,6 @@ import { Account as AccountModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccountService } from './account.service'; 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') @Controller('account')
export class AccountController { export class AccountController {
@ -114,7 +114,7 @@ export class AccountController {
public async getAccountById( public async getAccountById(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('id') id: string @Param('id') id: string
): Promise<AccountWithValue> { ): Promise<AccountResponse> {
const impersonationUserId = const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId); 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 { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service'; import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
import { DemoService } from '@ghostfolio/api/services/demo/demo.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 { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH, DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM, DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
GATHER_ASSET_PROFILE_PROCESS_JOB_NAME, GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import {
UpdateAssetProfileDto,
UpdatePropertyDto
} from '@ghostfolio/common/dtos';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
AdminUsers, AdminUserResponse,
AdminUsersResponse,
EnhancedSymbolProfile, EnhancedSymbolProfile,
ScraperConfiguration ScraperConfiguration
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { import type {
DateRange,
MarketDataPreset, MarketDataPreset,
RequestWithUser RequestWithUser
} from '@ghostfolio/common/types'; } from '@ghostfolio/common/types';
@ -49,7 +55,6 @@ import { isDate, parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AdminService } from './admin.service'; import { AdminService } from './admin.service';
import { UpdateAssetProfileDto } from './update-asset-profile.dto';
@Controller('admin') @Controller('admin')
export class AdminController { export class AdminController {
@ -88,7 +93,7 @@ export class AdminController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherMax(): Promise<void> { public async gatherMax(): Promise<void> {
const assetProfileIdentifiers = const assetProfileIdentifiers =
await this.dataGatheringService.getAllActiveAssetProfileIdentifiers(); await this.dataGatheringService.getActiveAssetProfileIdentifiers();
await this.dataGatheringService.addJobsToQueue( await this.dataGatheringService.addJobsToQueue(
assetProfileIdentifiers.map(({ dataSource, symbol }) => { assetProfileIdentifiers.map(({ dataSource, symbol }) => {
@ -115,7 +120,7 @@ export class AdminController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherProfileData(): Promise<void> { public async gatherProfileData(): Promise<void> {
const assetProfileIdentifiers = const assetProfileIdentifiers =
await this.dataGatheringService.getAllActiveAssetProfileIdentifiers(); await this.dataGatheringService.getActiveAssetProfileIdentifiers();
await this.dataGatheringService.addJobsToQueue( await this.dataGatheringService.addJobsToQueue(
assetProfileIdentifiers.map(({ dataSource, symbol }) => { assetProfileIdentifiers.map(({ dataSource, symbol }) => {
@ -161,9 +166,21 @@ export class AdminController {
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
public async gatherSymbol( public async gatherSymbol(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string,
@Query('range') dateRange: DateRange
): Promise<void> { ): 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; return;
} }
@ -290,7 +307,7 @@ export class AdminController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateProperty( public async updateProperty(
@Param('key') key: string, @Param('key') key: string,
@Body() data: PropertyDto @Body() data: UpdatePropertyDto
) { ) {
return this.adminService.putSetting(key, data.value); return this.adminService.putSetting(key, data.value);
} }
@ -301,10 +318,17 @@ export class AdminController {
public async getUsers( public async getUsers(
@Query('skip') skip?: number, @Query('skip') skip?: number,
@Query('take') take?: number @Query('take') take?: number
): Promise<AdminUsers> { ): Promise<AdminUsersResponse> {
return this.adminService.getUsers({ return this.adminService.getUsers({
skip: isNaN(skip) ? undefined : skip, skip: isNaN(skip) ? undefined : skip,
take: isNaN(take) ? undefined : take 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);
}
} }

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

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

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

@ -71,7 +71,7 @@ export class QueueService {
.slice(0, limit) .slice(0, limit)
.map(async (job) => { .map(async (job) => {
return { return {
attemptsMade: job.attemptsMade + 1, attemptsMade: job.attemptsMade,
data: job.data, data: job.data,
finishedOn: job.finishedOn, finishedOn: job.finishedOn,
id: job.id, 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 { ScheduleModule } from '@nestjs/schedule';
import { ServeStaticModule } from '@nestjs/serve-static'; import { ServeStaticModule } from '@nestjs/serve-static';
import { StatusCodes } from 'http-status-codes'; import { StatusCodes } from 'http-status-codes';
import { join } from 'path'; import { join } from 'node:path';
import { AccessModule } from './access/access.module'; import { AccessModule } from './access/access.module';
import { AccountModule } from './account/account.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 { BenchmarksModule } from './endpoints/benchmarks/benchmarks.module';
import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module'; import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module';
import { MarketDataModule } from './endpoints/market-data/market-data.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 { PublicModule } from './endpoints/public/public.module';
import { SitemapModule } from './endpoints/sitemap/sitemap.module'; import { SitemapModule } from './endpoints/sitemap/sitemap.module';
import { TagsModule } from './endpoints/tags/tags.module'; import { TagsModule } from './endpoints/tags/tags.module';
@ -72,8 +73,8 @@ import { UserModule } from './user/user.module';
redis: { redis: {
db: parseInt(process.env.REDIS_DB ?? '0', 10), db: parseInt(process.env.REDIS_DB ?? '0', 10),
host: process.env.REDIS_HOST, 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, CacheModule,
@ -95,6 +96,7 @@ import { UserModule } from './user/user.module';
MarketDataModule, MarketDataModule,
OrderModule, OrderModule,
PlatformModule, PlatformModule,
PlatformsModule,
PortfolioModule, PortfolioModule,
PortfolioSnapshotQueueModule, PortfolioSnapshotQueueModule,
PrismaModule, PrismaModule,

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

@ -1,7 +1,7 @@
import { AdminService } from '@ghostfolio/api/app/admin/admin.service'; 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 { 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 { 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 { Controller, Get, Param, UseInterceptors } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
@ -17,7 +17,7 @@ export class AssetController {
public async getAsset( public async getAsset(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<AdminMarketDataDetails> { ): Promise<AssetResponse> {
const { assetProfile, marketData } = const { assetProfile, marketData } =
await this.adminService.getMarketDataBySymbol({ dataSource, symbol }); 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 { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { OAuthResponse } from '@ghostfolio/common/interfaces'; import {
AssertionCredentialJSON,
AttestationCredentialJSON,
OAuthResponse
} from '@ghostfolio/common/interfaces';
import { import {
Body, Body,
@ -22,10 +26,6 @@ import { Request, Response } from 'express';
import { getReasonPhrase, StatusCodes } from 'http-status-codes'; import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import {
AssertionCredentialJSON,
AttestationCredentialJSON
} from './interfaces/simplewebauthn';
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
@ -84,7 +84,6 @@ export class AuthController {
@Req() request: Request, @Req() request: Request,
@Res() response: Response @Res() response: Response
) { ) {
// Handles the Google OAuth2 callback
const jwt: string = (request.user as any).jwt; const jwt: string = (request.user as any).jwt;
if (jwt) { if (jwt) {
@ -102,16 +101,11 @@ export class AuthController {
} }
} }
@Post('internet-identity') @Get('oidc')
public async internetIdentityLogin( @UseGuards(AuthGuard('oidc'))
@Body() body: { principalId: string } @Version(VERSION_NEUTRAL)
): Promise<OAuthResponse> { public oidcLogin() {
try { if (!this.configurationService.get('ENABLE_FEATURE_AUTH_OIDC')) {
const authToken = await this.authService.validateInternetIdentityLogin(
body.principalId
);
return { authToken };
} catch {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
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') @Get('webauthn/generate-registration-options')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async generateRegistrationOptions() { public async generateRegistrationOptions() {
@ -133,13 +155,6 @@ export class AuthController {
return this.webAuthService.verifyAttestation(body.credential); 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') @Post('webauthn/verify-authentication')
public async verifyAuthentication( public async verifyAuthentication(
@Body() body: { deviceId: string; credential: AssertionCredentialJSON } @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 { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service'; import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; 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 { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.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 { JwtModule } from '@nestjs/jwt';
import type { StrategyOptions } from 'passport-openidconnect';
import { ApiKeyStrategy } from './api-key.strategy'; import { ApiKeyStrategy } from './api-key.strategy';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { GoogleStrategy } from './google.strategy'; import { GoogleStrategy } from './google.strategy';
import { JwtStrategy } from './jwt.strategy'; import { JwtStrategy } from './jwt.strategy';
import { OidcStrategy } from './oidc.strategy';
@Module({ @Module({
controllers: [AuthController], controllers: [AuthController],
@ -36,6 +39,83 @@ import { JwtStrategy } from './jwt.strategy';
AuthService, AuthService,
GoogleStrategy, GoogleStrategy,
JwtStrategy, 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 WebAuthService
] ]
}) })

65
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 { Injectable, InternalServerErrorException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { Provider } from '@prisma/client';
import { ValidateOAuthLoginParams } from './interfaces/interfaces'; import { ValidateOAuthLoginParams } from './interfaces/interfaces';
@ -18,66 +17,22 @@ export class AuthService {
) {} ) {}
public async validateAnonymousLogin(accessToken: string): Promise<string> { public async validateAnonymousLogin(accessToken: string): Promise<string> {
return new Promise(async (resolve, reject) => { const hashedAccessToken = this.userService.createAccessToken({
try { password: accessToken,
const hashedAccessToken = this.userService.createAccessToken({ salt: this.configurationService.get('ACCESS_TOKEN_SALT')
password: accessToken,
salt: this.configurationService.get('ACCESS_TOKEN_SALT')
});
const [user] = await this.userService.users({
where: { accessToken: hashedAccessToken }
});
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 const [user] = await this.userService.users({
user = await this.userService.createUser({ where: { accessToken: hashedAccessToken }
data: { });
provider,
thirdPartyId: principalId
}
});
}
if (user) {
return this.jwtService.sign({ return this.jwtService.sign({
id: user.id id: user.id
}); });
} catch (error) {
throw new InternalServerErrorException(
'validateInternetIdentityLogin',
error.message
);
} }
throw new Error();
} }
public async validateOAuthLogin({ public async validateOAuthLogin({
@ -112,7 +67,7 @@ export class AuthService {
} catch (error) { } catch (error) {
throw new InternalServerErrorException( throw new InternalServerErrorException(
'validateOAuthLogin', '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 { Injectable, Logger } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { Provider } from '@prisma/client'; import { Provider } from '@prisma/client';
import { DoneCallback } from 'passport';
import { Profile, Strategy } from 'passport-google-oauth20'; import { Profile, Strategy } from 'passport-google-oauth20';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
@ -29,7 +30,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
_token: string, _token: string,
_refreshToken: string, _refreshToken: string,
profile: Profile, profile: Profile,
done: Function done: DoneCallback
) { ) {
try { try {
const jwt = await this.authService.validateOAuthLogin({ 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'; import { Provider } from '@prisma/client';
@ -6,6 +6,25 @@ export interface AuthDeviceDialogParams {
authDevice: AuthDeviceDto; 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 { export interface ValidateOAuthLoginParams {
provider: Provider; provider: Provider;
thirdPartyId: string; 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 { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.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 type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
@ -27,11 +31,6 @@ import {
import { isoBase64URL, isoUint8Array } from '@simplewebauthn/server/helpers'; import { isoBase64URL, isoUint8Array } from '@simplewebauthn/server/helpers';
import ms from 'ms'; import ms from 'ms';
import {
AssertionCredentialJSON,
AttestationCredentialJSON
} from './interfaces/simplewebauthn';
@Injectable() @Injectable()
export class WebAuthService { export class WebAuthService {
public constructor( public constructor(

113
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 { Injectable } from '@nestjs/common';
import { createOpenRouter } from '@openrouter/ai-sdk-provider'; import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import { generateText } from 'ai'; import { generateText } from 'ai';
import type { ColumnDescriptor } from 'tablemark';
@Injectable() @Injectable()
export class AiService { 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( public constructor(
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
private readonly propertyService: PropertyService private readonly propertyService: PropertyService
@ -58,34 +80,81 @@ export class AiService {
userId userId
}); });
const holdingsTable = [ const holdingsTableColumns: ColumnDescriptor[] =
'| Name | Symbol | Currency | Asset Class | Asset Sub Class | Allocation in Percentage |', AiService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.map(({ align, name }) => {
'| --- | --- | --- | --- | --- | --- |', return { name, align: align ?? 'left' };
...Object.values(holdings) });
.sort((a, b) => {
return b.allocationInPercentage - a.allocationInPercentage; const holdingsTableRows = Object.values(holdings)
}) .sort((a, b) => {
.map( return b.allocationInPercentage - a.allocationInPercentage;
({ })
allocationInPercentage, .map(
assetClass, ({
assetSubClass, allocationInPercentage,
currency, assetClass,
name, assetSubClass,
symbol currency,
}) => { name: label,
return `| ${name} | ${symbol} | ${currency} | ${assetClass} | ${assetSubClass} | ${(allocationInPercentage * 100).toFixed(3)}% |`; symbol
} }) => {
) 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') { if (mode === 'portfolio') {
return holdingsTable.join('\n'); return holdingsTableString;
} }
return [ return [
`You are a neutral financial assistant. Please analyze the following investment portfolio (base currency being ${userCurrency}) in simple words.`, `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:', 'Structure your answer with these sections:',
'Overview: Briefly summarize the portfolio’s composition and allocation rationale.', 'Overview: Briefly summarize the portfolio’s composition and allocation rationale.',
'Risk Assessment: Identify potential risks, including market volatility, concentration, and sectoral imbalances.', '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 VERSION_NEUTRAL
} from '@nestjs/common'; } from '@nestjs/common';
import { Response } from 'express'; import { Response } from 'express';
import { readFileSync } from 'fs'; import { readFileSync } from 'node:fs';
import { join } from 'path'; import { join } from 'node:path';
@Controller('assets') @Controller('assets')
export class AssetsController { 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 { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import type { import type {
AssetProfileIdentifier, AssetProfileIdentifier,
BenchmarkMarketDataDetails, BenchmarkMarketDataDetailsResponse,
BenchmarkResponse BenchmarkResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
@ -125,7 +125,7 @@ export class BenchmarksController {
@Query('symbol') filterBySymbol?: string, @Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string, @Query('tags') filterByTags?: string,
@Query('withExcludedAccounts') withExcludedAccountsParam = 'false' @Query('withExcludedAccounts') withExcludedAccountsParam = 'false'
): Promise<BenchmarkMarketDataDetails> { ): Promise<BenchmarkMarketDataDetailsResponse> {
const { endDate, startDate } = getIntervalFromDateRange( const { endDate, startDate } = getIntervalFromDateRange(
dateRange, dateRange,
new Date(startDateString) 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 { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
BenchmarkMarketDataDetails, BenchmarkMarketDataDetailsResponse,
Filter Filter
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { DateRange, UserWithSettings } from '@ghostfolio/common/types'; import { DateRange, UserWithSettings } from '@ghostfolio/common/types';
@ -43,7 +43,7 @@ export class BenchmarksService {
startDate: Date; startDate: Date;
user: UserWithSettings; user: UserWithSettings;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
} & AssetProfileIdentifier): Promise<BenchmarkMarketDataDetails> { } & AssetProfileIdentifier): Promise<BenchmarkMarketDataDetailsResponse> {
const marketData: { date: string; value: number }[] = []; const marketData: { date: string; value: number }[] = [];
const userCurrency = user.settings.settings.baseCurrency; const userCurrency = user.settings.settings.baseCurrency;
const userId = user.id; 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 { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; 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 { parseDate } from '@ghostfolio/common/helper';
import { import {
DataProviderGhostfolioAssetProfileResponse, DataProviderGhostfolioAssetProfileResponse,
@ -66,7 +67,14 @@ export class GhostfolioController {
}); });
return assetProfile; return assetProfile;
} catch { } catch (error) {
if (error instanceof AssetProfileInvalidError) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
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, GetQuotesParams,
GetSearchParams GetSearchParams
} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; } 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 { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { import {
@ -18,6 +17,7 @@ import {
import { PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS } from '@ghostfolio/common/config'; import { PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS } from '@ghostfolio/common/config';
import { import {
DataProviderGhostfolioAssetProfileResponse, DataProviderGhostfolioAssetProfileResponse,
DataProviderHistoricalResponse,
DataProviderInfo, DataProviderInfo,
DividendsResponse, DividendsResponse,
HistoricalResponse, HistoricalResponse,
@ -40,10 +40,7 @@ export class GhostfolioService {
private readonly propertyService: PropertyService private readonly propertyService: PropertyService
) {} ) {}
public async getAssetProfile({ public async getAssetProfile({ symbol }: GetAssetProfileParams) {
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbol
}: GetAssetProfileParams) {
let result: DataProviderGhostfolioAssetProfileResponse = {}; let result: DataProviderGhostfolioAssetProfileResponse = {};
try { try {
@ -51,16 +48,44 @@ export class GhostfolioService {
for (const dataProviderService of this.getDataProviderServices()) { for (const dataProviderService of this.getDataProviderServices()) {
promises.push( promises.push(
dataProviderService this.dataProviderService
.getAssetProfile({ .getAssetProfiles([
requestTimeout, {
symbol symbol,
}) dataSource: dataProviderService.getName()
.then((assetProfile) => { }
])
.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 = {
...result, ...result,
...assetProfile, ...assetProfile,
dataSource: DataSource.GHOSTFOLIO dataSource: dataSourceOrigin
}; };
return assetProfile; return assetProfile;
@ -89,7 +114,7 @@ export class GhostfolioService {
try { try {
const promises: Promise<{ const promises: Promise<{
[date: string]: IDataProviderHistoricalResponse; [date: string]: DataProviderHistoricalResponse;
}>[] = []; }>[] = [];
for (const dataProviderService of this.getDataProviderServices()) { for (const dataProviderService of this.getDataProviderServices()) {
@ -131,7 +156,7 @@ export class GhostfolioService {
try { try {
const promises: Promise<{ const promises: Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: DataProviderHistoricalResponse };
}>[] = []; }>[] = [];
for (const dataProviderService of this.getDataProviderServices()) { for (const dataProviderService of this.getDataProviderServices()) {

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

@ -10,6 +10,7 @@ import {
ghostfolioFearAndGreedIndexSymbolCryptocurrencies, ghostfolioFearAndGreedIndexSymbolCryptocurrencies,
ghostfolioFearAndGreedIndexSymbolStocks ghostfolioFearAndGreedIndexSymbolStocks
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { UpdateBulkMarketDataDto } from '@ghostfolio/common/dtos';
import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper'; import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper';
import { import {
MarketDataDetailsResponse, MarketDataDetailsResponse,
@ -35,8 +36,6 @@ import { DataSource, Prisma } from '@prisma/client';
import { parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
import { getReasonPhrase, StatusCodes } from 'http-status-codes'; import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { UpdateBulkMarketDataDto } from './update-bulk-market-data.dto';
@Controller('market-data') @Controller('market-data')
export class MarketDataController { export class MarketDataController {
public constructor( 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 { 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 { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { UserService } from '@ghostfolio/api/app/user/user.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 { 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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
@ -18,6 +20,7 @@ import {
UseInterceptors UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { Type as ActivityType } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -27,15 +30,17 @@ export class PublicController {
private readonly accessService: AccessService, private readonly accessService: AccessService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly orderService: OrderService,
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService private readonly userService: UserService
) {} ) {}
@Get(':accessId/portfolio') @Get(':accessId/portfolio')
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPublicPortfolio( public async getPublicPortfolio(
@Param('accessId') accessId @Param('accessId') accessId: string
): Promise<PublicPortfolioResponse> { ): Promise<PublicPortfolioResponse> {
const access = await this.accessService.access({ id: accessId }); 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) => { Object.values(markets ?? {}).forEach((market) => {
delete market.valueInBaseCurrency; delete market.valueInBaseCurrency;
}); });
@ -83,6 +129,7 @@ export class PublicController {
const publicPortfolioResponse: PublicPortfolioResponse = { const publicPortfolioResponse: PublicPortfolioResponse = {
createdAt, createdAt,
hasDetails, hasDetails,
latestActivities,
markets, markets,
alias: access.alias, alias: access.alias,
holdings: {}, 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 { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { Response } from 'express'; import { Response } from 'express';
import { readFileSync } from 'fs'; import { readFileSync } from 'node:fs';
import { join } from 'path'; import { join } from 'node:path';
import { SitemapService } from './sitemap.service'; import { SitemapService } from './sitemap.service';
@ -37,6 +37,7 @@ export class SitemapController {
response.setHeader('content-type', 'application/xml'); response.setHeader('content-type', 'application/xml');
response.send( response.send(
interpolate(this.sitemapXml, { interpolate(this.sitemapXml, {
blogPosts: this.sitemapService.getBlogPosts({ currentDate }),
personalFinanceTools: this.configurationService.get( personalFinanceTools: this.configurationService.get(
'ENABLE_FEATURE_SUBSCRIPTION' 'ENABLE_FEATURE_SUBSCRIPTION'
) )

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

@ -17,6 +17,125 @@ export class SitemapService {
private readonly i18nService: I18nService 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 }) { public getPersonalFinanceTools({ currentDate }: { currentDate: string }) {
const rootUrl = this.configurationService.get('ROOT_URL'); const rootUrl = this.configurationService.get('ROOT_URL');
@ -43,20 +162,21 @@ export class SitemapService {
}); });
return personalFinanceTools.map(({ alias, key }) => { return personalFinanceTools.map(({ alias, key }) => {
const location = [ const routerLink = [
rootUrl,
languageCode,
resourcesPath, resourcesPath,
personalFinanceToolsPath, personalFinanceToolsPath,
`${productPath}-${alias ?? key}` `${productPath}-${alias ?? key}`
].join('/'); ];
return [ return this.createRouteSitemapUrl({
' <url>', currentDate,
` <loc>${location}</loc>`, languageCode,
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`, rootUrl,
' </url>' route: {
].join('\n'); routerLink,
path: undefined
}
});
}); });
}).join('\n'); }).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 { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { CreateTagDto, UpdateTagDto } from '@ghostfolio/common/dtos';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import { RequestWithUser } from '@ghostfolio/common/types';
@ -21,9 +22,6 @@ import { AuthGuard } from '@nestjs/passport';
import { Tag } from '@prisma/client'; import { Tag } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreateTagDto } from './create-tag.dto';
import { UpdateTagDto } from './update-tag.dto';
@Controller('tags') @Controller('tags')
export class TagsController { export class TagsController {
public constructor( 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 { 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 { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { CreateWatchlistItemDto } from '@ghostfolio/common/dtos';
import { WatchlistResponse } from '@ghostfolio/common/interfaces'; import { WatchlistResponse } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import { RequestWithUser } from '@ghostfolio/common/types';
@ -26,7 +27,6 @@ import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreateWatchlistItemDto } from './create-watchlist-item.dto';
import { WatchlistService } from './watchlist.service'; import { WatchlistService } from './watchlist.service';
@Controller('watchlist') @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 { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; import { DataProviderHistoricalResponse } from '@ghostfolio/common/interfaces';
import { import {
Controller, Controller,
@ -25,7 +25,7 @@ export class ExchangeRateController {
public async getExchangeRate( public async getExchangeRate(
@Param('dateString') dateString: string, @Param('dateString') dateString: string,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<IDataProviderHistoricalResponse> { ): Promise<DataProviderHistoricalResponse> {
const date = parseISO(dateString); const date = parseISO(dateString);
const exchangeRate = await this.exchangeRateService.getExchangeRate({ 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 { 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 { 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 { 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 type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
@ -28,6 +29,7 @@ export class ExportController {
@Get() @Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async export( public async export(
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('activityIds') filterByActivityIds?: string, @Query('activityIds') filterByActivityIds?: string,
@ -35,7 +37,7 @@ export class ExportController {
@Query('dataSource') filterByDataSource?: string, @Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string, @Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string @Query('tags') filterByTags?: string
): Promise<Export> { ): Promise<ExportResponse> {
const activityIds = filterByActivityIds?.split(',') ?? []; const activityIds = filterByActivityIds?.split(',') ?? [];
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
@ -48,8 +50,8 @@ export class ExportController {
return this.exportService.export({ return this.exportService.export({
activityIds, activityIds,
filters, 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 { environment } from '@ghostfolio/api/environments/environment';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.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 { Injectable } from '@nestjs/common';
import { Platform, Prisma } from '@prisma/client'; import { Platform, Prisma } from '@prisma/client';
@ -21,14 +25,14 @@ export class ExportService {
public async export({ public async export({
activityIds, activityIds,
filters, filters,
userCurrency, userId,
userId userSettings
}: { }: {
activityIds?: string[]; activityIds?: string[];
filters?: Filter[]; filters?: Filter[];
userCurrency: string;
userId: string; userId: string;
}): Promise<Export> { userSettings: UserSettings;
}): Promise<ExportResponse> {
const { ACCOUNT: filtersByAccount } = groupBy(filters, ({ type }) => { const { ACCOUNT: filtersByAccount } = groupBy(filters, ({ type }) => {
return type; return type;
}); });
@ -36,11 +40,11 @@ export class ExportService {
let { activities } = await this.orderService.getOrders({ let { activities } = await this.orderService.getOrders({
filters, filters,
userCurrency,
userId, userId,
includeDrafts: true, includeDrafts: true,
sortColumn: 'date', sortColumn: 'date',
sortDirection: 'asc', sortDirection: 'asc',
userCurrency: userSettings?.baseCurrency,
withExcludedAccountsAndActivities: true withExcludedAccountsAndActivities: true
}); });
@ -244,7 +248,10 @@ export class ExportService {
} }
), ),
user: { 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 { Type } from 'class-transformer';
import { IsArray, IsOptional, ValidateNested } from 'class-validator'; import { IsArray, IsOptional, ValidateNested } from 'class-validator';
import { CreateTagDto } from '../endpoints/tags/create-tag.dto';
import { CreateAccountWithBalancesDto } from './create-account-with-balances.dto';
import { CreateAssetProfileWithMarketDataDto } from './create-asset-profile-with-market-data.dto';
export class ImportDataDto { export class ImportDataDto {
@IsArray() @IsArray()
@IsOptional() @IsOptional()

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

@ -1,10 +1,4 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service'; 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 { OrderService } from '@ghostfolio/api/app/order/order.service';
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service'; import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.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 { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config'; import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config';
import {
CreateAssetProfileDto,
CreateAccountDto,
CreateOrderDto
} from '@ghostfolio/common/dtos';
import { import {
getAssetProfileIdentifier, getAssetProfileIdentifier,
parseDate parseDate
} from '@ghostfolio/common/helper'; } 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 { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { import {
AccountWithPlatform, AccountWithPlatform,
@ -32,9 +35,8 @@ import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { endOfToday, isAfter, isSameSecond, parseISO } from 'date-fns'; import { endOfToday, isAfter, isSameSecond, parseISO } from 'date-fns';
import { omit, uniqBy } from 'lodash'; 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'; import { ImportDataDto } from './import-data.dto';
@Injectable() @Injectable()
@ -58,13 +60,18 @@ export class ImportService {
userId userId
}: AssetProfileIdentifier & { userId: string }): Promise<Activity[]> { }: AssetProfileIdentifier & { userId: string }): Promise<Activity[]> {
try { try {
const { activities, firstBuyDate, historicalData } = const holding = await this.portfolioService.getHolding({
await this.portfolioService.getHolding({ dataSource,
dataSource, symbol,
symbol, userId,
userId, impersonationId: undefined
impersonationId: undefined });
});
if (!holding) {
return [];
}
const { activities, firstBuyDate, historicalData } = holding;
const [[assetProfile], dividends] = await Promise.all([ const [[assetProfile], dividends] = await Promise.all([
this.symbolProfileService.getSymbolProfiles([ this.symbolProfileService.getSymbolProfiles([
@ -270,7 +277,7 @@ export class ImportService {
// Asset profile belongs to a different user // Asset profile belongs to a different user
if (existingAssetProfile) { if (existingAssetProfile) {
const symbol = uuidv4(); const symbol = randomUUID();
assetProfileSymbolMapping[assetProfile.symbol] = symbol; assetProfileSymbolMapping[assetProfile.symbol] = symbol;
assetProfile.symbol = symbol; assetProfile.symbol = symbol;
} }
@ -373,6 +380,7 @@ export class ImportService {
const assetProfiles = await this.validateActivities({ const assetProfiles = await this.validateActivities({
activitiesDto, activitiesDto,
assetProfilesWithMarketDataDto,
maxActivitiesToImport, maxActivitiesToImport,
user user
}); });
@ -488,7 +496,7 @@ export class ImportService {
accountId: validatedAccount?.id, accountId: validatedAccount?.id,
accountUserId: undefined, accountUserId: undefined,
createdAt: new Date(), createdAt: new Date(),
id: uuidv4(), id: randomUUID(),
isDraft: isAfter(date, endOfToday()), isDraft: isAfter(date, endOfToday()),
SymbolProfile: { SymbolProfile: {
assetClass, assetClass,
@ -538,6 +546,7 @@ export class ImportService {
connectOrCreate: { connectOrCreate: {
create: { create: {
dataSource, dataSource,
name,
symbol, symbol,
currency: assetProfile.currency, currency: assetProfile.currency,
userId: dataSource === 'MANUAL' ? user.id : undefined userId: dataSource === 'MANUAL' ? user.id : undefined
@ -698,10 +707,12 @@ export class ImportService {
private async validateActivities({ private async validateActivities({
activitiesDto, activitiesDto,
assetProfilesWithMarketDataDto,
maxActivitiesToImport, maxActivitiesToImport,
user user
}: { }: {
activitiesDto: Partial<CreateOrderDto>[]; activitiesDto: Partial<CreateOrderDto>[];
assetProfilesWithMarketDataDto: ImportDataDto['assetProfiles'];
maxActivitiesToImport: number; maxActivitiesToImport: number;
user: UserWithSettings; user: UserWithSettings;
}) { }) {
@ -718,12 +729,6 @@ export class ImportService {
index, index,
{ currency, dataSource, symbol, type } { currency, dataSource, symbol, type }
] of activitiesDto.entries()) { ] of activitiesDto.entries()) {
if (type === 'ITEM') {
throw new Error(
`activities.${index}.type ("${type}") is deprecated, please use "BUY" instead`
);
}
if (!dataSources.includes(dataSource)) { if (!dataSources.includes(dataSource)) {
throw new Error( throw new Error(
`activities.${index}.dataSource ("${dataSource}") is not valid` `activities.${index}.dataSource ("${dataSource}") is not valid`
@ -746,20 +751,73 @@ export class ImportService {
} }
if (!assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })]) { if (!assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })]) {
const assetProfile = { if (['FEE', 'INTEREST', 'LIABILITY'].includes(type)) {
currency, // 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([ await this.dataProviderService.getAssetProfiles([
{ dataSource, symbol } { dataSource, symbol }
]) ])
)?.[symbol] )?.[symbol];
}; } catch {}
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 ( if (!['FEE', 'INTEREST', 'LIABILITY'].includes(type)) {
(dataSource !== 'MANUAL' && type === 'BUY') ||
type === 'DIVIDEND' ||
type === 'SELL'
) {
if (!assetProfile?.name) { if (!assetProfile?.name) {
throw new Error( throw new Error(
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")` `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 { 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'; import { Controller, Get, UseInterceptors } from '@nestjs/common';
@ -11,7 +11,7 @@ export class InfoController {
@Get() @Get()
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getInfo(): Promise<InfoItem> { public async getInfo(): Promise<InfoResponse> {
return this.infoService.get(); return this.infoService.get();
} }
} }

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

@ -51,6 +51,18 @@ export class InfoService {
const globalPermissions: string[] = []; 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_FEAR_AND_GREED_INDEX')) {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
info.fearAndGreedDataSource = encodeDataSource( 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')) { if (this.configurationService.get('ENABLE_FEATURE_STATISTICS')) {
globalPermissions.push(permissions.enableStatistics); globalPermissions.push(permissions.enableStatistics);
} }
@ -85,7 +93,6 @@ export class InfoService {
(await this.propertyService.getByKey<string[]>( (await this.propertyService.getByKey<string[]>(
PROPERTY_COUNTRIES_OF_SUBSCRIBERS PROPERTY_COUNTRIES_OF_SUBSCRIBERS
)) ?? []; )) ?? [];
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
} }
if (this.configurationService.get('ENABLE_FEATURE_SYSTEM_MESSAGE')) { 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, DATA_GATHERING_QUEUE_PRIORITY_HIGH,
HEADER_KEY_IMPERSONATION HEADER_KEY_IMPERSONATION
} from '@ghostfolio/common/config'; } 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 { permissions } from '@ghostfolio/common/permissions';
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types'; 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 { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; 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 { OrderService } from './order.service';
import { UpdateOrderDto } from './update-order.dto';
@Controller('order') @Controller('order')
export class OrderController { export class OrderController {
@ -113,7 +115,7 @@ export class OrderController {
@Query('symbol') filterBySymbol?: string, @Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string, @Query('tags') filterByTags?: string,
@Query('take') take?: number @Query('take') take?: number
): Promise<Activities> { ): Promise<ActivitiesResponse> {
let endDate: Date; let endDate: Date;
let startDate: Date; let startDate: Date;
@ -157,13 +159,14 @@ export class OrderController {
public async getOrderById( public async getOrderById(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('id') id: string @Param('id') id: string
): Promise<Activity> { ): Promise<ActivityResponse> {
const impersonationUserId = const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId); await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.settings.settings.baseCurrency; const userCurrency = this.request.user.settings.settings.baseCurrency;
const { activities } = await this.orderService.getOrders({ const { activities } = await this.orderService.getOrders({
userCurrency, userCurrency,
includeDrafts: true,
userId: impersonationUserId || this.request.user.id, userId: impersonationUserId || this.request.user.id,
withExcludedAccountsAndActivities: true 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 { 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 { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; 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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
@ -14,6 +18,8 @@ import {
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { import {
ActivitiesResponse,
Activity,
AssetProfileIdentifier, AssetProfileIdentifier,
EnhancedSymbolProfile, EnhancedSymbolProfile,
Filter Filter
@ -35,15 +41,15 @@ import { Big } from 'big.js';
import { isUUID } from 'class-validator'; import { isUUID } from 'class-validator';
import { endOfToday, isAfter } from 'date-fns'; import { endOfToday, isAfter } from 'date-fns';
import { groupBy, uniqBy } from 'lodash'; import { groupBy, uniqBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid'; import { randomUUID } from 'node:crypto';
import { Activities } from './interfaces/activities.interface';
@Injectable() @Injectable()
export class OrderService { export class OrderService {
public constructor( public constructor(
private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService,
private readonly eventEmitter: EventEmitter2, private readonly eventEmitter: EventEmitter2,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
@ -129,7 +135,7 @@ export class OrderService {
const assetSubClass = data.assetSubClass; const assetSubClass = data.assetSubClass;
const dataSource: DataSource = 'MANUAL'; const dataSource: DataSource = 'MANUAL';
let name: string; let name = data.SymbolProfile.connectOrCreate.create.name;
let symbol: string; let symbol: string;
if ( if (
@ -142,8 +148,8 @@ export class OrderService {
symbol = data.SymbolProfile.connectOrCreate.create.symbol; symbol = data.SymbolProfile.connectOrCreate.create.symbol;
} else { } else {
// Create custom asset profile // Create custom asset profile
name = data.SymbolProfile.connectOrCreate.create.symbol; name = name ?? data.SymbolProfile.connectOrCreate.create.symbol;
symbol = uuidv4(); symbol = randomUUID();
} }
data.SymbolProfile.connectOrCreate.create.assetClass = assetClass; 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( this.eventEmitter.emit(
PortfolioChangedEvent.getName(), PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({ new PortfolioChangedEvent({
@ -308,6 +323,111 @@ export class OrderService {
return count; 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) { public async getLatestOrder({ dataSource, symbol }: AssetProfileIdentifier) {
return this.prismaService.order.findFirst({ return this.prismaService.order.findFirst({
orderBy: { orderBy: {
@ -325,7 +445,7 @@ export class OrderService {
includeDrafts = false, includeDrafts = false,
skip, skip,
sortColumn, sortColumn,
sortDirection, sortDirection = 'asc',
startDate, startDate,
take = Number.MAX_SAFE_INTEGER, take = Number.MAX_SAFE_INTEGER,
types, types,
@ -345,11 +465,11 @@ export class OrderService {
userCurrency: string; userCurrency: string;
userId: string; userId: string;
withExcludedAccountsAndActivities?: boolean; withExcludedAccountsAndActivities?: boolean;
}): Promise<Activities> { }): Promise<ActivitiesResponse> {
let orderBy: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput> = [ let orderBy: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput> = [
{ date: 'asc' }, { date: 'asc' }
{ id: 'asc' }
]; ];
const where: Prisma.OrderWhereInput = { userId }; const where: Prisma.OrderWhereInput = { userId };
if (endDate || startDate) { if (endDate || startDate) {
@ -483,7 +603,7 @@ export class OrderService {
} }
if (sortColumn) { if (sortColumn) {
orderBy = [{ [sortColumn]: sortDirection }, { id: sortDirection }]; orderBy = [{ [sortColumn]: sortDirection }];
} }
if (types) { if (types) {
@ -506,7 +626,6 @@ export class OrderService {
const [orders, count] = await Promise.all([ const [orders, count] = await Promise.all([
this.orders({ this.orders({
orderBy,
skip, skip,
take, take,
where, where,
@ -519,7 +638,8 @@ export class OrderService {
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
SymbolProfile: true, SymbolProfile: true,
tags: true tags: true
} },
orderBy: [...orderBy, { id: sortDirection }]
}), }),
this.prismaService.order.count({ where }) this.prismaService.order.count({ where })
]); ]);
@ -601,6 +721,15 @@ export class OrderService {
return { activities, count }; 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 @LogPerformance
public async getOrdersForPortfolioCalculator({ public async getOrdersForPortfolioCalculator({
filters, filters,
@ -611,12 +740,29 @@ export class OrderService {
userCurrency: string; userCurrency: string;
userId: string; userId: string;
}) { }) {
return this.getOrders({ const nonCashOrders = await this.getOrders({
filters, filters,
userCurrency, userCurrency,
userId, userId,
withExcludedAccountsAndActivities: false // TODO 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( 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 { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { CreatePlatformDto, UpdatePlatformDto } from '@ghostfolio/common/dtos';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import { import {
@ -17,16 +18,14 @@ import { AuthGuard } from '@nestjs/passport';
import { Platform } from '@prisma/client'; import { Platform } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreatePlatformDto } from './create-platform.dto';
import { PlatformService } from './platform.service'; import { PlatformService } from './platform.service';
import { UpdatePlatformDto } from './update-platform.dto';
@Controller('platform') @Controller('platform')
export class PlatformController { export class PlatformController {
public constructor(private readonly platformService: PlatformService) {} public constructor(private readonly platformService: PlatformService) {}
@Get() @Get()
@HasPermission(permissions.readPlatforms) @HasPermission(permissions.readPlatformsWithAccountCount)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getPlatforms() { public async getPlatforms() {
return this.platformService.getPlatformsWithAccountCount(); 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 = { export const activityDummyData = {
accountId: undefined, accountId: undefined,
@ -37,6 +39,6 @@ export const userDummyData = {
id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
}; };
export function loadActivityExportFile(filePath: string) { export function loadExportFile(filePath: string): ExportResponse {
return JSON.parse(readFileSync(filePath, 'utf8')).activities; 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 { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.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 { 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 { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';

91
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 { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface'; import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface';
import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.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 { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.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 { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { import {
@ -26,6 +25,7 @@ import {
resetHours resetHours
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
Activity,
AssetProfileIdentifier, AssetProfileIdentifier,
DataProviderInfo, DataProviderInfo,
Filter, Filter,
@ -44,11 +44,15 @@ import { plainToClass } from 'class-transformer';
import { import {
differenceInDays, differenceInDays,
eachDayOfInterval, eachDayOfInterval,
eachYearOfInterval,
endOfDay, endOfDay,
endOfYear,
format, format,
isAfter, isAfter,
isBefore, isBefore,
isWithinInterval,
min, min,
startOfYear,
subDays subDays
} from 'date-fns'; } from 'date-fns';
import { isNumber, sortBy, sum, uniqBy } from 'lodash'; import { isNumber, sortBy, sum, uniqBy } from 'lodash';
@ -193,19 +197,25 @@ export abstract class PortfolioCalculator {
} }
const currencies: { [symbol: string]: string } = {}; const currencies: { [symbol: string]: string } = {};
const dataGatheringItems: IDataGatheringItem[] = []; const dataGatheringItems: DataGatheringItem[] = [];
let firstIndex = transactionPoints.length; let firstIndex = transactionPoints.length;
let firstTransactionPoint: TransactionPoint = null; let firstTransactionPoint: TransactionPoint = null;
let totalInterestWithCurrencyEffect = new Big(0); let totalInterestWithCurrencyEffect = new Big(0);
let totalLiabilitiesWithCurrencyEffect = new Big(0); let totalLiabilitiesWithCurrencyEffect = new Big(0);
for (const { currency, dataSource, symbol } of transactionPoints[ for (const {
firstIndex - 1 assetSubClass,
].items) { currency,
dataGatheringItems.push({ dataSource,
dataSource, symbol
symbol } of transactionPoints[firstIndex - 1].items) {
}); // Gather data for all assets except CASH
if (assetSubClass !== 'CASH') {
dataGatheringItems.push({
dataSource,
symbol
});
}
currencies[symbol] = currency; currencies[symbol] = currency;
} }
@ -336,7 +346,7 @@ export abstract class PortfolioCalculator {
).mul( ).mul(
exchangeRatesByCurrency[`${item.currency}${this.currency}`]?.[ exchangeRatesByCurrency[`${item.currency}${this.currency}`]?.[
endDateString endDateString
] ] ?? 1
); );
const { 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; return chartDateMap;
} }
@ -911,6 +939,7 @@ export abstract class PortfolioCalculator {
} of this.activities) { } of this.activities) {
let currentTransactionPointItem: TransactionPointSymbol; let currentTransactionPointItem: TransactionPointSymbol;
const assetSubClass = SymbolProfile.assetSubClass;
const currency = SymbolProfile.currency; const currency = SymbolProfile.currency;
const dataSource = SymbolProfile.dataSource; const dataSource = SymbolProfile.dataSource;
const factor = getFactor(type); const factor = getFactor(type);
@ -922,29 +951,48 @@ export abstract class PortfolioCalculator {
if (oldAccumulatedSymbol) { if (oldAccumulatedSymbol) {
let investment = oldAccumulatedSymbol.investment; let investment = oldAccumulatedSymbol.investment;
const newQuantity = quantity let newQuantity = quantity
.mul(factor) .mul(factor)
.plus(oldAccumulatedSymbol.quantity); .plus(oldAccumulatedSymbol.quantity);
if (type === 'BUY') { if (type === 'BUY') {
investment = oldAccumulatedSymbol.investment.plus( if (oldAccumulatedSymbol.investment.gte(0)) {
quantity.mul(unitPrice) investment = oldAccumulatedSymbol.investment.plus(
); quantity.mul(unitPrice)
);
} else {
investment = oldAccumulatedSymbol.investment.plus(
quantity.mul(oldAccumulatedSymbol.averagePrice)
);
}
} else if (type === 'SELL') { } else if (type === 'SELL') {
investment = oldAccumulatedSymbol.investment.minus( if (oldAccumulatedSymbol.investment.gt(0)) {
quantity.mul(oldAccumulatedSymbol.averagePrice) 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 = { currentTransactionPointItem = {
assetSubClass,
currency, currency,
dataSource, dataSource,
investment, investment,
skipErrors, skipErrors,
symbol, symbol,
averagePrice: newQuantity.gt(0) averagePrice: newQuantity.eq(0)
? investment.div(newQuantity) ? new Big(0)
: new Big(0), : investment.div(newQuantity).abs(),
dividend: new Big(0), dividend: new Big(0),
fee: oldAccumulatedSymbol.fee.plus(fee), fee: oldAccumulatedSymbol.fee.plus(fee),
firstBuyDate: oldAccumulatedSymbol.firstBuyDate, firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
@ -955,6 +1003,7 @@ export abstract class PortfolioCalculator {
}; };
} else { } else {
currentTransactionPointItem = { currentTransactionPointItem = {
assetSubClass,
currency, currency,
dataSource, dataSource,
fee, 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 { import {
activityDummyData, activityDummyData,
symbolProfileDummyData, 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 { 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 { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { Activity } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js'; import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => { CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock; return CurrentRateServiceMock;
}) })
@ -31,7 +30,6 @@ jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => { () => {
return { return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => { PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock; return PortfolioSnapshotServiceMock;
}) })
@ -41,7 +39,6 @@ jest.mock(
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return { return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => { RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock; 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 { import {
activityDummyData, activityDummyData,
symbolProfileDummyData, 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 { 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 { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { Activity } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js'; import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => { CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock; return CurrentRateServiceMock;
}) })
@ -31,7 +30,6 @@ jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => { () => {
return { return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => { PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock; return PortfolioSnapshotServiceMock;
}) })
@ -41,7 +39,6 @@ jest.mock(
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return { return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => { RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock; 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 { import {
activityDummyData, activityDummyData,
symbolProfileDummyData, 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 { 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 { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { Activity } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js'; import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => { CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock; return CurrentRateServiceMock;
}) })
@ -31,7 +30,6 @@ jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => { () => {
return { return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => { PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock; return PortfolioSnapshotServiceMock;
}) })
@ -41,7 +39,6 @@ jest.mock(
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return { return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => { RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock; return RedisCacheServiceMock;
}) })
@ -112,6 +109,12 @@ describe('PortfolioCalculator', () => {
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const historicalDataDates = portfolioSnapshot.historicalData.map(
({ date }) => {
return date;
}
);
const investments = portfolioCalculator.getInvestments(); const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
@ -173,8 +176,12 @@ describe('PortfolioCalculator', () => {
totalLiabilitiesWithCurrencyEffect: new Big('0') 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(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({ expect.objectContaining({
date: '2021-12-18',
netPerformance: 23.05, netPerformance: 23.05,
netPerformanceInPercentage: 0.08437042459736457, netPerformanceInPercentage: 0.08437042459736457,
netPerformanceInPercentageWithCurrencyEffect: 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);
});
});
});

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

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

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

50
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 { import {
activityDummyData, activityDummyData,
loadActivityExportFile, loadExportFile,
symbolProfileDummyData, symbolProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } 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 { 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 { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { Activity, ExportResponse } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Tag } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { join } from 'path'; import { join } from 'node:path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => { CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock; return CurrentRateServiceMock;
}) })
@ -35,7 +32,6 @@ jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => { () => {
return { return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => { PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock; return PortfolioSnapshotServiceMock;
}) })
@ -45,7 +41,6 @@ jest.mock(
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return { return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => { RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock; return RedisCacheServiceMock;
}) })
@ -53,7 +48,7 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
}); });
describe('PortfolioCalculator', () => { describe('PortfolioCalculator', () => {
let activityDtos: CreateOrderDto[]; let exportResponse: ExportResponse;
let configurationService: ConfigurationService; let configurationService: ConfigurationService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
@ -63,7 +58,7 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeAll(() => { beforeAll(() => {
activityDtos = loadActivityExportFile( exportResponse = loadExportFile(
join( join(
__dirname, __dirname,
'../../../../../../../test/import/ok/novn-buy-and-sell.json' '../../../../../../../test/import/ok/novn-buy-and-sell.json'
@ -100,28 +95,27 @@ describe('PortfolioCalculator', () => {
it.only('with NOVN.SW buy and sell', async () => { it.only('with NOVN.SW buy and sell', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime()); jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime());
const activities: Activity[] = activityDtos.map((activity) => ({ const activities: Activity[] = exportResponse.activities.map(
...activityDummyData, (activity) => ({
...activity, ...activityDummyData,
date: parseDate(activity.date), ...activity,
feeInAssetProfileCurrency: activity.fee, date: parseDate(activity.date),
SymbolProfile: { feeInAssetProfileCurrency: activity.fee,
...symbolProfileDummyData, SymbolProfile: {
currency: activity.currency, ...symbolProfileDummyData,
dataSource: activity.dataSource, currency: activity.currency,
name: 'Novartis AG', dataSource: activity.dataSource,
symbol: activity.symbol name: 'Novartis AG',
}, symbol: activity.symbol
tags: activity.tags?.map((id) => { },
return { id } as Tag; unitPriceInAssetProfileCurrency: activity.unitPrice
}), })
unitPriceInAssetProfileCurrency: activity.unitPrice );
}));
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.ROAI, calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF', currency: exportResponse.user.settings.currency,
userId: userDummyData.id 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 { import {
activityDummyData, activityDummyData,
symbolProfileDummyData, 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 { 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 { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { Activity } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js'; import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => { CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock; return CurrentRateServiceMock;
}) })
@ -31,7 +30,6 @@ jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => { () => {
return { return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => { PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock; return PortfolioSnapshotServiceMock;
}) })
@ -41,7 +39,6 @@ jest.mock(
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return { return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => { RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock; 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) { if (orders.length <= 0) {
return { return {
currentValues: {}, 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, // 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. // the calculation should fall back to using the activity’s unit price.
unitPriceAtEndDate = latestActivity.unitPrice; unitPriceAtEndDate = latestActivity.unitPrice;
} else if (isCash) {
unitPriceAtEndDate = new Big(1);
} }
if ( if (
@ -295,7 +299,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
quantity: new Big(0), quantity: new Big(0),
SymbolProfile: { SymbolProfile: {
dataSource, dataSource,
symbol symbol,
assetSubClass: isCash ? 'CASH' : undefined
}, },
type: 'BUY', type: 'BUY',
unitPrice: unitPriceAtStartDate unitPrice: unitPriceAtStartDate
@ -308,7 +313,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
itemType: 'end', itemType: 'end',
SymbolProfile: { SymbolProfile: {
dataSource, dataSource,
symbol symbol,
assetSubClass: isCash ? 'CASH' : undefined
}, },
quantity: new Big(0), quantity: new Big(0),
type: 'BUY', type: 'BUY',
@ -348,7 +354,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
quantity: new Big(0), quantity: new Big(0),
SymbolProfile: { SymbolProfile: {
dataSource, dataSource,
symbol symbol,
assetSubClass: isCash ? 'CASH' : undefined
}, },
type: 'BUY', type: 'BUY',
unitPrice: marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice, 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'; import { DateQuery } from './date-query.interface';
export interface GetValuesParams { export interface GetValuesParams {
dataGatheringItems: IDataGatheringItem[]; dataGatheringItems: DataGatheringItem[];
dateQuery: DateQuery; 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'> { export interface PortfolioOrder extends Pick<Activity, 'tags' | 'type'> {
date: string; date: string;
@ -6,7 +6,7 @@ export interface PortfolioOrder extends Pick<Activity, 'tags' | 'type'> {
quantity: Big; quantity: Big;
SymbolProfile: Pick< SymbolProfile: Pick<
Activity['SymbolProfile'], Activity['SymbolProfile'],
'currency' | 'dataSource' | 'name' | 'symbol' | 'userId' 'assetSubClass' | 'currency' | 'dataSource' | 'name' | 'symbol' | 'userId'
>; >;
unitPrice: Big; 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'; import { Big } from 'big.js';
export interface TransactionPointSymbol { export interface TransactionPointSymbol {
assetSubClass: AssetSubClass;
averagePrice: Big; averagePrice: Big;
currency: string; currency: string;
dataSource: DataSource; dataSource: DataSource;

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

@ -19,10 +19,10 @@ import {
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
PortfolioDetails, PortfolioDetails,
PortfolioDividends, PortfolioDividendsResponse,
PortfolioHoldingResponse, PortfolioHoldingResponse,
PortfolioHoldingsResponse, PortfolioHoldingsResponse,
PortfolioInvestments, PortfolioInvestmentsResponse,
PortfolioPerformanceResponse, PortfolioPerformanceResponse,
PortfolioReportResponse PortfolioReportResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
@ -195,10 +195,9 @@ export class PortfolioController {
'excludedAccountsAndActivities', 'excludedAccountsAndActivities',
'fees', 'fees',
'filteredValueInBaseCurrency', 'filteredValueInBaseCurrency',
'fireWealth',
'grossPerformance', 'grossPerformance',
'grossPerformanceWithCurrencyEffect', 'grossPerformanceWithCurrencyEffect',
'interest', 'interestInBaseCurrency',
'items', 'items',
'liabilities', 'liabilities',
'netPerformance', 'netPerformance',
@ -306,7 +305,7 @@ export class PortfolioController {
@Query('range') dateRange: DateRange = 'max', @Query('range') dateRange: DateRange = 'max',
@Query('symbol') filterBySymbol?: string, @Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string @Query('tags') filterByTags?: string
): Promise<PortfolioDividends> { ): Promise<PortfolioDividendsResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
@ -440,7 +439,7 @@ export class PortfolioController {
@Query('range') dateRange: DateRange = 'max', @Query('range') dateRange: DateRange = 'max',
@Query('symbol') filterBySymbol?: string, @Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string @Query('tags') filterByTags?: string
): Promise<PortfolioInvestments> { ): Promise<PortfolioInvestmentsResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
@ -611,36 +610,6 @@ export class PortfolioController {
return performanceInformation; 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') @Get('report')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getReport( public async getReport(
@ -655,8 +624,8 @@ export class PortfolioController {
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic' this.request.user.subscription.type === 'Basic'
) { ) {
for (const rule in report.xRay.rules) { for (const category of report.xRay.categories) {
report.xRay.rules[rule] = null; category.rules = null;
} }
report.xRay.statistics = { report.xRay.statistics = {
@ -700,40 +669,4 @@ export class PortfolioController {
userId: this.request.user.id 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
});
}
} }

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

File diff suppressed because it is too large

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 { Rule } from '@ghostfolio/api/models/rule';
import { import {
PortfolioReportRule, PortfolioReportRule,
RuleSettings,
UserSettings UserSettings
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
@ -22,7 +22,6 @@ export class RulesService {
return { return {
evaluation, evaluation,
value, value,
categoryName: rule.getCategoryName(),
configuration: rule.getConfiguration(), configuration: rule.getConfiguration(),
isActive: true, isActive: true,
key: rule.getKey(), key: rule.getKey(),
@ -30,7 +29,6 @@ export class RulesService {
}; };
} else { } else {
return { return {
categoryName: rule.getCategoryName(),
isActive: false, isActive: false,
key: rule.getKey(), key: rule.getKey(),
name: rule.getName() 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 { CACHE_MANAGER, Cache } from '@nestjs/cache-manager';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { createHash } from 'crypto';
import Keyv from 'keyv'; import Keyv from 'keyv';
import ms from 'ms'; import ms from 'ms';
import { createHash } from 'node:crypto';
@Injectable() @Injectable()
export class RedisCacheService { export class RedisCacheService {

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

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

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

@ -5,13 +5,16 @@ import {
DEFAULT_LANGUAGE_CODE, DEFAULT_LANGUAGE_CODE,
PROPERTY_STRIPE_CONFIG PROPERTY_STRIPE_CONFIG
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { SubscriptionType } from '@ghostfolio/common/enums';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { SubscriptionOffer } from '@ghostfolio/common/interfaces'; import {
CreateStripeCheckoutSessionResponse,
SubscriptionOffer
} from '@ghostfolio/common/interfaces';
import { import {
SubscriptionOfferKey, SubscriptionOfferKey,
UserWithSettings UserWithSettings
} from '@ghostfolio/common/types'; } from '@ghostfolio/common/types';
import { SubscriptionType } from '@ghostfolio/common/types/subscription-type.type';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Subscription } from '@prisma/client'; import { Subscription } from '@prisma/client';
@ -32,13 +35,13 @@ export class SubscriptionService {
this.stripe = new Stripe( this.stripe = new Stripe(
this.configurationService.get('STRIPE_SECRET_KEY'), this.configurationService.get('STRIPE_SECRET_KEY'),
{ {
apiVersion: '2025-08-27.basil' apiVersion: '2025-12-15.clover'
} }
); );
} }
} }
public async createCheckoutSession({ public async createStripeCheckoutSession({
couponId, couponId,
priceId, priceId,
user user
@ -46,7 +49,7 @@ export class SubscriptionService {
couponId?: string; couponId?: string;
priceId: string; priceId: string;
user: UserWithSettings; user: UserWithSettings;
}) { }): Promise<CreateStripeCheckoutSessionResponse> {
const subscriptionOffers: { const subscriptionOffers: {
[offer in SubscriptionOfferKey]: SubscriptionOffer; [offer in SubscriptionOfferKey]: SubscriptionOffer;
} = } =
@ -58,33 +61,34 @@ export class SubscriptionService {
} }
); );
const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = { const stripeCheckoutSessionCreateParams: Stripe.Checkout.SessionCreateParams =
cancel_url: `${this.configurationService.get('ROOT_URL')}/${ {
user.settings.settings.language cancel_url: `${this.configurationService.get('ROOT_URL')}/${
}/account`, user.settings.settings.language
client_reference_id: user.id, }/account`,
line_items: [ client_reference_id: user.id,
{ line_items: [
price: priceId, {
quantity: 1 price: priceId,
} quantity: 1
], }
locale: ],
(user.settings?.settings locale:
?.language as Stripe.Checkout.SessionCreateParams.Locale) ?? (user.settings?.settings
DEFAULT_LANGUAGE_CODE, ?.language as Stripe.Checkout.SessionCreateParams.Locale) ??
metadata: subscriptionOffer DEFAULT_LANGUAGE_CODE,
? { subscriptionOffer: JSON.stringify(subscriptionOffer) } metadata: subscriptionOffer
: {}, ? { subscriptionOffer: JSON.stringify(subscriptionOffer) }
mode: 'payment', : {},
payment_method_types: ['card'], mode: 'payment',
success_url: `${this.configurationService.get( payment_method_types: ['card'],
'ROOT_URL' success_url: `${this.configurationService.get(
)}/api/v1/subscription/stripe/callback?checkoutSessionId={CHECKOUT_SESSION_ID}` 'ROOT_URL'
}; )}/api/v1/subscription/stripe/callback?checkoutSessionId={CHECKOUT_SESSION_ID}`
};
if (couponId) { if (couponId) {
checkoutSessionCreateParams.discounts = [ stripeCheckoutSessionCreateParams.discounts = [
{ {
coupon: couponId coupon: couponId
} }
@ -92,11 +96,12 @@ export class SubscriptionService {
} }
const session = await this.stripe.checkout.sessions.create( const session = await this.stripe.checkout.sessions.create(
checkoutSessionCreateParams stripeCheckoutSessionCreateParams
); );
return { return {
sessionId: session.id sessionId: session.id,
sessionUrl: session.url
}; };
} }
@ -175,6 +180,8 @@ export class SubscriptionService {
offerKey = 'renewal-early-bird-2023'; offerKey = 'renewal-early-bird-2023';
} else if (isBefore(createdAt, parseDate('2024-01-01'))) { } else if (isBefore(createdAt, parseDate('2024-01-01'))) {
offerKey = 'renewal-early-bird-2024'; offerKey = 'renewal-early-bird-2024';
} else if (isBefore(createdAt, parseDate('2025-12-01'))) {
offerKey = 'renewal-early-bird-2025';
} }
const offer = await this.getSubscriptionOffer({ 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 { 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 { 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 { 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 {
import { LookupResponse } from '@ghostfolio/common/interfaces'; DataProviderHistoricalResponse,
LookupResponse,
SymbolItem
} from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
@ -22,7 +25,6 @@ import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { isDate, isEmpty } from 'lodash'; import { isDate, isEmpty } from 'lodash';
import { SymbolItem } from './interfaces/symbol-item.interface';
import { SymbolService } from './symbol.service'; import { SymbolService } from './symbol.service';
@Controller('symbol') @Controller('symbol')
@ -97,7 +99,7 @@ export class SymbolController {
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('dateString') dateString: string, @Param('dateString') dateString: string,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<IDataProviderHistoricalResponse> { ): Promise<DataProviderHistoricalResponse> {
const date = parseISO(dateString); const date = parseISO(dateString);
if (!isDate(date)) { 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 { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
IDataGatheringItem,
IDataProviderHistoricalResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { import {
DataProviderHistoricalResponse,
HistoricalDataItem, HistoricalDataItem,
LookupResponse LookupResponse,
SymbolItem
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { UserWithSettings } from '@ghostfolio/common/types'; import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { format, subDays } from 'date-fns'; import { format, subDays } from 'date-fns';
import { SymbolItem } from './interfaces/symbol-item.interface';
@Injectable() @Injectable()
export class SymbolService { export class SymbolService {
public constructor( public constructor(
@ -27,7 +24,7 @@ export class SymbolService {
dataGatheringItem, dataGatheringItem,
includeHistoricalData includeHistoricalData
}: { }: {
dataGatheringItem: IDataGatheringItem; dataGatheringItem: DataGatheringItem;
includeHistoricalData?: number; includeHistoricalData?: number;
}): Promise<SymbolItem> { }): Promise<SymbolItem> {
const quotes = await this.dataProviderService.getQuotes({ const quotes = await this.dataProviderService.getQuotes({
@ -75,10 +72,10 @@ export class SymbolService {
dataSource, dataSource,
date = new Date(), date = new Date(),
symbol symbol
}: IDataGatheringItem): Promise<IDataProviderHistoricalResponse> { }: DataGatheringItem): Promise<DataProviderHistoricalResponse> {
let historicalData: { let historicalData: {
[symbol: string]: { [symbol: string]: {
[date: string]: IDataProviderHistoricalResponse; [date: string]: DataProviderHistoricalResponse;
}; };
} = { } = {
[symbol]: {} [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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
DeleteOwnUserDto,
UpdateOwnAccessTokenDto,
UpdateUserSettingDto
} from '@ghostfolio/common/dtos';
import { import {
AccessTokenResponse, AccessTokenResponse,
User, User,
UserItem,
UserSettings UserSettings
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; 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 { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { merge, size } from 'lodash'; 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'; import { UserService } from './user.service';
@Controller('user') @Controller('user')
@ -126,11 +128,7 @@ export class UserController {
); );
} }
const hasAdmin = await this.userService.hasAdmin(); const { accessToken, id, role } = await this.userService.createUser();
const { accessToken, id, role } = await this.userService.createUser({
data: { role: hasAdmin ? 'USER' : 'ADMIN' }
});
return { return {
accessToken, accessToken,

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

1084
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 xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"> http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
${publicRoutes} ${publicRoutes}
${blogPosts}
${personalFinanceTools} ${personalFinanceTools}
</urlset> </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 { 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 { Module } from '@nestjs/common';
import { AssetProfileChangedListener } from './asset-profile-changed.listener';
import { PortfolioChangedListener } from './portfolio-changed.listener'; import { PortfolioChangedListener } from './portfolio-changed.listener';
@Module({ @Module({
imports: [RedisCacheModule], imports: [
providers: [PortfolioChangedListener] ConfigurationModule,
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
OrderModule,
RedisCacheModule
],
providers: [AssetProfileChangedListener, PortfolioChangedListener]
}) })
export class EventsModule {} 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', () => { describe('redactAttributes', () => {
it('should redact provided attributes', () => { it('should redact provided attributes', () => {
@ -1536,7 +1556,7 @@ describe('redactAttributes', () => {
fireWealth: null, fireWealth: null,
grossPerformance: null, grossPerformance: null,
grossPerformanceWithCurrencyEffect: null, grossPerformanceWithCurrencyEffect: null,
interest: null, interestInBaseCurrency: null,
items: null, items: null,
liabilities: null, liabilities: null,
totalInvestment: null, totalInvestment: null,
@ -3039,7 +3059,7 @@ describe('redactAttributes', () => {
fireWealth: null, fireWealth: null,
grossPerformance: null, grossPerformance: null,
grossPerformanceWithCurrencyEffect: null, grossPerformanceWithCurrencyEffect: null,
interest: null, interestInBaseCurrency: null,
items: null, items: null,
liabilities: null, liabilities: null,
totalInvestment: null, totalInvestment: null,

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

@ -1,4 +1,5 @@
import { Big } from 'big.js'; import { Big } from 'big.js';
import jsonpath from 'jsonpath';
import { cloneDeep, isArray, isObject } from 'lodash'; import { cloneDeep, isArray, isObject } from 'lodash';
export function hasNotDefinedValuesInObject(aObject: Object): boolean { 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({ export function redactAttributes({
isFirstRun = true, isFirstRun = true,
object, 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) { export function getRandomString(length: number) {
const bytes = randomBytes(length); 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'; import { map } from 'rxjs/operators';
@Injectable() @Injectable()
export class RedactValuesInResponseInterceptor<T> export class RedactValuesInResponseInterceptor<T> implements NestInterceptor<
implements NestInterceptor<T, any> T,
{ any
> {
public intercept( public intercept(
context: ExecutionContext, context: ExecutionContext,
next: CallHandler<T> next: CallHandler<T>
@ -61,6 +62,7 @@ export class RedactValuesInResponseInterceptor<T>
'totalInterestInBaseCurrency', 'totalInterestInBaseCurrency',
'totalValueInBaseCurrency', 'totalValueInBaseCurrency',
'unitPrice', 'unitPrice',
'unitPriceInAssetProfileCurrency',
'value', 'value',
'valueInBaseCurrency' 'valueInBaseCurrency'
].map((attribute) => { ].map((attribute) => {

35
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'; import { Observable } from 'rxjs';
@Injectable() @Injectable()
export class TransformDataSourceInRequestInterceptor<T> export class TransformDataSourceInRequestInterceptor<
implements NestInterceptor<T, any> T
{ > implements NestInterceptor<T, any> {
public constructor( public constructor(
private readonly configurationService: ConfigurationService private readonly configurationService: ConfigurationService
) {} ) {}
@ -27,9 +27,23 @@ export class TransformDataSourceInRequestInterceptor<T>
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
if (request.body?.activities) { if (request.body?.activities) {
const dataSourceGhostfolioDataProvider = this.configurationService.get(
'DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER'
)?.[0];
request.body.activities = request.body.activities.map((activity) => { request.body.activities = request.body.activities.map((activity) => {
if (DataSource[activity.dataSource]) { if (DataSource[activity.dataSource]) {
return activity; if (
activity.dataSource === 'GHOSTFOLIO' &&
dataSourceGhostfolioDataProvider
) {
return {
...activity,
dataSource: dataSourceGhostfolioDataProvider
};
} else {
return activity;
}
} else { } else {
return { return {
...activity, ...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(); 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'; import { map } from 'rxjs/operators';
@Injectable() @Injectable()
export class TransformDataSourceInResponseInterceptor<T> export class TransformDataSourceInResponseInterceptor<
implements NestInterceptor<T, any> T
{ > implements NestInterceptor<T, any> {
private encodedDataSourceMap: {
[dataSource: string]: string;
} = {};
public constructor( public constructor(
private readonly configurationService: ConfigurationService 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( public intercept(
_context: ExecutionContext, context: ExecutionContext,
next: CallHandler<T> next: CallHandler<T>
): Observable<any> { ): Observable<any> {
const isExportMode = context.getClass().name === 'ExportController';
return next.handle().pipe( return next.handle().pipe(
map((data: any) => { map((data: any) => {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { 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({ data = redactAttributes({
object: data,
options: [ options: [
{ {
attribute: 'dataSource', valueMap,
valueMap: Object.keys(DataSource).reduce( attribute: 'dataSource'
(valueMap, dataSource) => {
if (!['MANUAL'].includes(dataSource)) {
valueMap[dataSource] = encodeDataSource(
DataSource[dataSource]
);
}
return valueMap;
},
{}
)
} }
], ]
object: data
}); });
} }

16
apps/api/src/main.ts

@ -90,7 +90,21 @@ async function bootstrap() {
await app.listen(PORT, HOST, () => { await app.listen(PORT, HOST, () => {
logLogo(); 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(''); Logger.log('');
}); });
} }

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

Loading…
Cancel
Save