Compare commits

...

567 Commits

Author SHA1 Message Date
325556188f Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 14m33s
2025-07-02 00:05:30 -07:00
8b9b1c7f11 Feature/refactor getByKey() in PropertyService (#5065)
* Refactor getByKey() in PropertyService

* Update changelog
2025-07-01 18:28:18 +02:00
2f9023dadd Feature/update locales (#5067)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-07-01 17:51:57 +02:00
a3c4f84822 Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 19m45s
2025-06-30 12:00:26 -07:00
f501c7aeae Release 2.176.0 (#5068) 2025-06-30 20:12:07 +02:00
aca2fe1654 Feature/restructure holding detail dialog (#5058)
* Restructure holding detail dialog

* Update changelog
2025-06-30 20:08:40 +02:00
dcf40367c1 Feature/upgrade prettier to version 3.6.2 (#5057)
* Upgrade prettier to version 3.6.2

* Update changelog
2025-06-30 19:59:37 +02:00
dcedaa4d59 Feature/update locales (#5066)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-06-30 18:50:24 +02:00
28b11b979d Feature/update locales (#5060)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-06-30 18:44:36 +02:00
7a97ec75f4 Feature/refactor portfolio service (#5063)
* Refactor portfolio service
2025-06-30 18:43:11 +02:00
91a6d14e24 Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 12m42s
2025-06-29 18:00:24 -07:00
d9fb159c6a Feature/integrate Fuse.js in GET holdings endpoint (#5062)
* Integrate Fuse.js in GET holdings endpoint

* Update changelog

---------

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
2025-06-29 21:21:39 +02:00
7b88f7978f Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 12m10s
2025-06-29 12:00:24 -07:00
bd43ea9b6c Feature/dynamically compose public routes in sitemap (#5035)
* Dynamically compose public routes in sitemap

* Update changelog
2025-06-29 17:46:50 +02:00
655601c6a3 Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 13m42s
2025-06-29 06:00:24 -07:00
e28ab59673 Feature/introduce fuzzy search for quick links of assistant (#5055)
* Introduce fuzzy search for quick links of assistant

* Update changelog
2025-06-29 09:42:55 +02:00
c8291578b8 update workflow
Some checks failed
Docker image CD / build_and_push (push) Failing after 19m0s
2025-06-29 00:17:32 -07:00
57abe53daf update workflow 2025-06-29 00:16:15 -07:00
0b26f2a624 update workflow
Some checks failed
Docker image CD / build_and_push (push) Failing after 0s
2025-06-29 00:14:12 -07:00
e8c1185812 update
Some checks failed
Docker image CD / build_and_push (push) Failing after 0s
2025-06-29 00:12:04 -07:00
c197622880 Merge branch 'main' of github.com:ghostfolio/ghostfolio 2025-06-29 00:11:28 -07:00
3dc287cfd7 Feature/update locales (#5056)
* 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>
2025-06-29 09:10:05 +02:00
9b4730b731 Feature/improve search results of assistant to only display categories with content (#5053)
* Improve search results

* Update changelog
2025-06-29 08:44:11 +02:00
c437bc2534 Feature/allow user to rotate Security Token (#5016)
* Allow user to rotate Security Token

* Update changelog
2025-06-29 08:43:29 +02:00
433f1686e3 Feature/rename Account to account in Order database schema (#5052)
* Rename Account to account in Order database schema

* Update changelog
2025-06-28 22:30:39 +02:00
842a564bb5 Release 2.175.0 (#5051) 2025-06-28 17:19:25 +02:00
559cac31bd Feature/extend AI service by OpenRouter access (#5025)
* Extend AI service by OpenRouter access

* Update changelog
2025-06-28 17:03:11 +02:00
77d3121f0d Feature/rename Account to account in AccountBalance database schema (#5049)
* Rename Account to account in AccountBalance database schema

* Update changelog
2025-06-28 16:52:16 +02:00
f936c69a7f Feature/refactor scraper in manual service (#5048)
* Refactoring
2025-06-28 16:12:00 +02:00
3fcd611e29 Feature/extend selector handling of scraper for more use cases (#5047)
* Extend selector handling for more use cases

* Update changelog
2025-06-28 15:50:50 +02:00
4983b7ca12 Feature/simplify badge style in admin settings (#5046)
* Simplify badge style
2025-06-28 15:28:24 +02:00
a45ad41459 Feature/update locales (#5044)
* 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>
2025-06-28 08:34:15 +02:00
854a08c7a5 Bugfix/fix various routes (#5042)
* Fix various routes
2025-06-27 23:13:55 +02:00
1ec81d352b Feature/restructure import test files (#4977)
* Restructure import test files
2025-06-27 20:54:01 +02:00
5dfe9b54fe Feature/rename Analytics to analytics in User database schema (#5032)
* Rename Analytics to analytics in User database schema
2025-06-27 20:53:29 +02:00
b34ef31812 Feature/improve language localization for CA (#5041)
* Improve language localization for CA
2025-06-27 18:57:29 +02:00
bd04b4a60a Feature/improve language localization for NL (#5040)
* Improve language localization for NL
2025-06-27 18:56:45 +02:00
3de2c9a81c Feature/update locales (#5039)
* 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>
2025-06-27 18:56:07 +02:00
351fd8a1d4 Feature/language localization for static portfolio analysis rule: Currency Cluster Risks (#5038)
* Language localization for static portfolio analysis rule: Currency Cluster Risks

* Update changelog
2025-06-27 17:27:46 +02:00
a919f3bb3b Feature/improve language localization for NL (#5037)
* Improve language localization for NL

* Update changelog
2025-06-27 17:17:29 +02:00
36741dedf0 Feature/update locales (#5034)
* 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>
2025-06-27 17:14:42 +02:00
f0eb0dc7fc Feature/localize asset class cluster risks (Equity and Fixed Income) (#5033)
* Localize asset class cluster risks

* Update changelog
2025-06-26 16:27:57 +02:00
ded0385da7 Feature/change entrypoint.sh to run app with main PID (#5019)
* Change entrypoint.sh to run app with main PID

* Update changelog
2025-06-25 20:06:40 +02:00
0fca29199d Feature/improve language localization for TR 20250625 (#5030)
* Improve language localization for TR

* Update changelog
2025-06-25 18:51:55 +02:00
2fa16480a5 Feature/update locales (#5031)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-06-25 18:16:16 +02:00
c40993ce33 Feature/improve language localization for ES 20250625 (#5029)
* Improve language localization for ES

* Update changelog
2025-06-25 18:14:27 +02:00
a7a33eeb56 Bugfix/fix locale in scraper configuration (#5011)
* Fix locale in scraper configuration

* Update changelog
2025-06-25 18:08:45 +02:00
a0c5958f81 Feature/improve language localization for CA 20250625 (#5028)
* Improve language localization for CA

* Update changelog
2025-06-25 17:56:28 +02:00
d609b75254 Release 2.174.0 (#5023) 2025-06-24 20:36:27 +02:00
197aea1bec Feature/update locales (#5022)
* 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>
2025-06-24 20:34:27 +02:00
bad3f87957 Feature/improve style in admin settings component (#5014)
* Improve style
2025-06-24 19:58:31 +02:00
84c4d79a74 Feature/Set up account cluster risks localization (Current Investment) (#5012)
* Set up localization

* Update changelog
2025-06-24 19:57:59 +02:00
960b871aa5 Feature/add new badge to settings of admin page navigation (#5020)
* Add new badge
2025-06-24 19:56:47 +02:00
24f1aeb4c6 Feature/refresh cryptocurrencies list 20250624 (#5013)
* Update cryptocurrencies.json

* Update changelog
2025-06-24 18:58:49 +02:00
0334eabdad Feature/improve language localization for FR 20250624 (#5015)
* Improve language localization for FR

* Update changelog
2025-06-24 18:57:35 +02:00
d1351b4665 Feature/update locales (#5007)
* 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>
2025-06-23 21:18:55 +02:00
99cab25fe0 Feature/improve labels in data provider status component (#5008)
* Improve labels
2025-06-23 17:38:49 +02:00
738f7490f6 Feature/rename Platform to platform in Account database schema (#4999)
* Rename Platform to platform in Account database schema

* Update changelog
2025-06-23 15:15:04 +02:00
b6854f30e1 Feature/add status column to data providers table (#4998)
* Add status column to data providers table

* Update changelog
2025-06-23 15:12:46 +02:00
be3a9b8e83 Feature/refactor health check endpoints (#5005)
* Refactor health check endpoints

* Update changelog
2025-06-22 20:17:51 +02:00
f33d902560 Feature/migrate value component to control flow (part 2) (#5000)
* Migrate to control flow

* Update changelog
2025-06-22 20:15:51 +02:00
7619772bf6 Feature/update locales (#5004)
* 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>
2025-06-22 20:14:15 +02:00
9bd7f795ac Feature/improve line height on pricing page (#5002)
* Improve line height
2025-06-22 19:52:34 +02:00
4964a8675b Feature/improve localization on pricing page (#5003)
* Improve localization
2025-06-22 19:50:01 +02:00
39fbb3a27c Feature/update locales (#4996)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-06-21 20:10:23 +02:00
3c427a0005 Feature/update locales (#4995)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-06-21 19:54:15 +02:00
f5aae11a4c Release 2.173.0 (#4994) 2025-06-21 19:52:15 +02:00
69371bd42f Feature/update locales (#4993)
* 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>
2025-06-21 19:50:12 +02:00
38ae224ee1 Feature/upgrade prisma to version 6.10.1 (#4982)
* Upgrade prisma to version 6.10.1

* Update changelog
2025-06-21 19:26:27 +02:00
ee7bb911d7 Feature/simplify data providers management of admin control panel (#4991)
* Set up open color for CSS variables usage

* Simplify data providers management

* Update changelog
2025-06-21 19:26:06 +02:00
dca821e75c Bugfix/resolve LegacyRouteConverter warning on startup (#4980)
* Resolve LegacyRouteConverter warning on startup

* Update changelog
2025-06-21 19:22:36 +02:00
56128d8fe8 Feature/reuse root url in sitemap service (#4989) 2025-06-21 13:57:48 +02:00
e45b1e9b1f Feature/update locales (#4988)
* 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>
2025-06-21 13:33:24 +02:00
c8f46f64fb Feature/extend public routes by i18n ids in paths (#4987)
* Add i18n ids
2025-06-21 13:28:22 +02:00
1f50a9bf7b Feature/update locales (#4986)
* 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>
2025-06-21 12:56:23 +02:00
314b121584 Feature/refactor sitemap module (part 2) (#4985)
* Refactor sitemap module
2025-06-21 12:50:32 +02:00
ff447b6de6 Feature/update locales (#4984)
* 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>
2025-06-21 12:12:32 +02:00
29ecfc1137 Feature/migrate assistant and value component to control flow (part 3) (#4981)
* Migrate to control flow

* Update changelog
2025-06-21 11:56:56 +02:00
029504d843 Feature/refactor sitemap module (#4983)
* Refactor sitemap module
2025-06-21 11:56:35 +02:00
d5d74eb4db Feature/rename GranteeUser to granteeUser in Access database schema (#4979)
* Rename GranteeUser to granteeUser in Access database schema

* Update changelog
2025-06-21 09:42:41 +02:00
14b213c571 Bugfix/fix variable resolution in HtmlTemplateMiddleware (#4975)
* Fix variable resolution in HtmlTemplateMiddleware

* Update changelog
2025-06-21 09:27:28 +02:00
463dc3b295 Feature/upgrade class-validator to version 0.14.2 (#4950)
* Upgrade class-validator to version 0.14.2

* Update changelog
2025-06-20 19:40:17 +02:00
28d9bb81be Feature/improve language localization for FR 20250620 (#4972)
* Improve language localization for FR

* Update changelog
2025-06-20 17:22:33 +02:00
26cb9ca11a Release 2.172.0 (#4970) 2025-06-19 22:28:19 +02:00
3aae0aa40c Bugfix/fix migration to form control in value component (#4969)
* Fix migration to form control
2025-06-19 18:52:29 +02:00
f541832666 Feature/update locales (#4968)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
2025-06-19 18:51:51 +02:00
d858aa53ec Feature/update locales (#4966)
* 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>
2025-06-19 17:53:20 +02:00
f351212600 Bugfix/fix routing for user account page (#4965)
* Fix routing
2025-06-19 17:52:51 +02:00
7a251ef749 Feature/set up language localization for X-ray rule Account Cluster Risks (Single Account) (#4959)
* Set up language localization

* Update changelog
2025-06-19 17:30:36 +02:00
0b143088a2 Feature/update locales (#4962)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-06-19 11:00:29 +02:00
6e3cf1460e Feature/improve language localization for PT 20250618 (#4963)
* Improve language localization for PT
2025-06-19 10:53:43 +02:00
46aa2ebc7a Feature/improve language localization for PT (#4961)
* Improve language localization for PT
2025-06-18 21:21:38 +02:00
d8626ffab7 Feature/include admin control panel in quick links of assistant (#4955)
* Include admin control panel in quick links of assistant

* Update changelog
2025-06-18 20:29:56 +02:00
a31f71d7ee Feature/improve language localization for ES 20250614 (#4924)
* Improve language localization for ES

* Update changelog
2025-06-18 20:22:20 +02:00
b92eff5b72 Feature/switch data provider service to OnModuleInit ensuring (currency) quotes are fetched only once (#4944)
* Switch data provider service to OnModuleInit ensuring (currency) quotes are fetched only once

* Update changelog
2025-06-18 20:21:46 +02:00
6f4e0f11cf Feature/extend development guide to start client in other languages (#4881)
* Extend start client guide
2025-06-18 19:47:47 +02:00
a56016ee03 Bugfix/add missing import of entity logo component (#4960)
* Add missing import
2025-06-18 19:45:11 +02:00
33ef272243 Feature/update locales (#4954)
* Update locales

* Clean up

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
2025-06-16 22:06:55 +02:00
78d8a1c8d4 Feature/update locales (#4952)
* 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>
2025-06-16 20:51:34 +02:00
b0e1065f79 Feature/conditionally display date range options based on first user activity (#4945)
* Conditionally display date range options based on first user activity

* Update changelog
2025-06-16 20:32:25 +02:00
153b162927 Feature/refactor various routes (part 3) (#4951)
* Refactor various routes
2025-06-16 20:20:23 +02:00
32919f96a9 Feature/update locales (#4947)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-06-16 08:07:41 +02:00
5fde161eed Feature/clean up space in AI service (#4942)
* Clean up space
2025-06-16 08:03:38 +02:00
18f94a1ba1 Feature/refactor various routes (part 2) (#4949)
* Refactor various routes
2025-06-16 08:02:11 +02:00
a7319844fd Feature/migrate assistant and value component to control flow (part 2) (#4948)
* Migrate to control flow

* Update changelog
2025-06-15 22:05:32 +02:00
8ae91961d9 Feature/refactor various routes (#4946)
* Refactor various routes
2025-06-15 21:40:25 +02:00
0cdad51226 Feature/improve language localization for PT 20250614 (#4925)
* Improve language localization for PT

* Update changelog
2025-06-15 17:59:38 +02:00
b63a57cf9e Feature/improve language localization for ZH (#4927)
* Improve language localization for ZH

* Update changelog
2025-06-15 17:47:06 +02:00
ec5b2693c0 Release 2.171.0 (#4941) 2025-06-15 16:48:22 +02:00
da1a6445b1 Feature/update locales (#4939)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-06-15 15:27:08 +02:00
ed649fe37e Release 2.171.0-beta.4 (#4940) 2025-06-15 15:23:57 +02:00
3ea72af5a0 Bugfix/prevent date offset in cash balance records (#4906)
* Prevent date offset in cash balance records

* Update changelog
2025-06-15 15:19:12 +02:00
ab00ebee1e Bugfix/fix missing assetlinks.json for TWA (part 2) (#4938)
* Fix missing assetlinks.json

* Update changelog
2025-06-15 14:26:54 +02:00
ca8525a90c Bugfix/Google Sign-in hangs on redirect (#4926)
* Exclude routes with language codes
2025-06-15 14:20:28 +02:00
ffd6f51289 Feature/update locales (#4929)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-06-15 11:05:03 +02:00
4b556dfdc4 Feature/refactor public routes: resources (#4937)
* Refactor public routes: resources
2025-06-15 10:57:10 +02:00
c4cde4eef6 Feature/refactor public sub routes: about (#4935)
* Refactor public sub routes: about
2025-06-15 10:19:17 +02:00
072d700b82 Feature/refactor public routes: markets (#4934)
* Refactor public routes: markets
2025-06-15 10:09:38 +02:00
908a731989 Feature/refactor public routes: faq (#4933)
* Refactor public routes: faq
2025-06-15 09:48:32 +02:00
60c9b5300d Feature/refactor public routes: blog (#4932)
* Refactor public routes: blog
2025-06-15 09:11:41 +02:00
74b51fe1dd Feature/refactor public routes: pricing (#4931)
* Refactor public routes: pricing
2025-06-15 09:04:18 +02:00
1400660664 Feature/refactor tab configuration (#4930)
* Refactor path to routerLink
2025-06-15 08:40:19 +02:00
38756b853d Feature/refactor public routes: oss-friends (#4928)
* Refactor public routes: oss-friends
2025-06-15 08:14:37 +02:00
853a3b12ad Feature/update locales (#4920)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-06-14 12:58:06 +02:00
c67e351ccc Feature/revert icon on pricing page (#4923)
* Revert icon
2025-06-14 12:52:13 +02:00
1674944596 Feature/improve language localization for PL 20250614 (#4922)
* Improve language localization for PL
2025-06-14 12:44:54 +02:00
c453a8cd07 Feature/improve language localization for ES 20250614 (#4921)
* Improve language localization for ES

* Update changelog
2025-06-14 12:43:20 +02:00
2f7425d0a2 Feature/refactor public routes (#4919)
* Refactor public routes: features
2025-06-14 10:46:55 +02:00
8d4a21c096 Feature/restructure pricing page (#4916)
* Restructure content

* Update changelog
2025-06-14 10:46:26 +02:00
fb3f7517e2 Feature/improve language localization for PT 20250614 (#4918)
* Improve language localization for PT

* Update changelog
2025-06-14 10:45:56 +02:00
279757fab3 Feature/update locales (#4917)
* 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>
2025-06-14 10:27:20 +02:00
b0cf5f7f80 Feature/improve language localization for TR 20250614 (#4914)
* Improve language localization for TR

* Update changelog
2025-06-14 10:07:52 +02:00
be3be20604 Feature/set market state of exchange rate symbols to open in FMP service (#4915)
* Set market state of exchange rate symbols to open

* Update changelog
2025-06-14 10:07:06 +02:00
5af53eb97e Feature/improve language localization for CA 20250614 (#4913)
* Improve language localization for CA
2025-06-14 09:45:28 +02:00
8cedd91561 Feature/upgrade Stripe dependencies 20250613 (#4911)
* Upgrade Stripe dependencies

* Update changelog
2025-06-14 08:51:59 +02:00
9c4fe5fa6b Feature/clean up sitemap.xml (#4909)
* Clean up
2025-06-14 08:51:31 +02:00
9c27c1772f Feature/update locales (#4910)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-06-13 22:18:26 +02:00
d84e28e058 Feature/replace asset profile count with value component in admin settings (#4825)
* Replace asset profile count with value component in admin settings

* Update changelog

---------

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
2025-06-13 20:12:37 +02:00
54249c708b Feature/update locales (#4908)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-06-13 19:42:19 +02:00
66ef2c2fec Feature/migrate assistant and value component to control flow (#4905)
* Migrate to control flow

* Update changelog
2025-06-13 19:38:20 +02:00
72a3b8709b Feature/update locales (#4898)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-06-13 13:38:51 +02:00
3b54fcac60 Feature/improve language localization for IT 20250612 (#4904)
* Improve language localization for IT

* Update changelog
2025-06-13 11:03:09 +02:00
ac4536abb5 Feature/improve language localization for CA 20250612 (#4900)
* Improve language localization for CA

* Update changelog
2025-06-13 10:37:12 +02:00
1a72ef40c2 Feature/improve language localization for DE 20250612 (#4903)
* Update translations

* Update changelog
2025-06-12 22:14:47 +02:00
b80c186c97 Feature/improve styles of assistant (#4901)
* Improve styles

* Update changelog
2025-06-12 20:11:42 +02:00
20070d76bc Feature/rename User to user in database schema (#4899)
* Rename User to user in database schema

* Update changelog
2025-06-12 20:02:16 +02:00
17ef147da7 Feature/add current holdings as default options of symbol search in create or update activity dialog (#4892)
* Add current holdings as default options of symbol search in create or update activity dialog

* Update changelog
2025-06-12 18:40:26 +02:00
076ac1a32f Feature/Rewrite HtmlTemplateMiddleware to use Dependency Injection (#4889)
* Rewrite HtmlTemplateMiddleware to use Dependency Injection

* Update changelog
2025-06-12 18:06:08 +02:00
4e985e4796 Feature/improve language localization for PL (#4897)
* Improve language localization for PL

* Update changelog
2025-06-12 14:41:29 +02:00
c6f25e0fb6 Feature/improve language localization for FR 20250611 (#4896)
* Improve language localization for FR

* Update changelog
2025-06-12 08:02:00 +02:00
f3a598a58e Release 2.170.0 (#4895) 2025-06-11 20:26:30 +02:00
8d3e12c646 Feature/add a skeleton loader to changelog page (#4891)
* Add a skeleton loader to changelog page

* Update changelog
2025-06-11 20:24:07 +02:00
a541ccee1b Feature/upgrade prisma to version 6.9.0 (#4887)
* Upgrade prisma to version 6.9.0

* Update changelog
2025-06-11 20:23:19 +02:00
78e81ec36a Feature/improve language localization for FR 20250611 (#4894)
* Improve language localization for FR

* Update changelog
2025-06-11 14:58:43 +02:00
ef64292cd6 Feature/improve language localization for PT 20250611 (#4893)
* Improve language localization for PT
2025-06-11 14:17:00 +02:00
0f537adf3e Feature/rename ApiKey to apiKeys in User database schema (#4890)
* Rename ApiKey to apiKeys in User database schema

* Update changelog
2025-06-11 12:06:59 +02:00
8bc6b51405 Feature/upgrade @types/lodash to version 4.17.17 (#4818)
* Upgrade @types/lodash to version 4.17.17
2025-06-10 15:38:54 +02:00
8e3480c0ce Feature/extend self-hosting FAQ by additional data providers question (#4885)
* Extend FAQ by additional data providers question

* Update changelog
2025-06-10 15:38:06 +02:00
aba42307da Feature/update locales (#4883)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-06-09 20:34:07 +02:00
4993d81c80 Feature/restructure paths to routes (part 2) (#4884)
* Restructure paths to routes
2025-06-09 20:30:20 +02:00
ced7f1f206 Feature/extend search in assistant by quick links (#4870)
* Extend search in assistant by quick links

* Update changelog
2025-06-09 19:51:27 +02:00
d44eba9617 Feature/improve language localization for PT 20250527 (#4772)
* Improve language localization for PT

* Update changelog
2025-06-09 14:39:37 +02:00
f9aea95a73 Feature/upgrade zone.js to version 0.15.1 (#4869)
* Upgrade zone.js to version 0.15.1

* Update changelog
2025-06-09 09:52:13 +02:00
6056cf9a6a Feature/upgrade @keyv/redis to version 4.4.0 (#4821)
* Upgrade @keyv/redis to version 4.4.0

* Update changelog
2025-06-09 08:44:30 +02:00
d77295f64c Feature/reuse routes in app component (#4878)
* Reuse routes
2025-06-09 08:43:56 +02:00
376b1416bb Bugfix/restrict date range change permission in Zen mode (#4877)
* Restrict date range change permission in Zen Mode

* Update changelog
2025-06-09 08:03:42 +02:00
2e377044ed Feature/update locales (#4874)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-06-08 21:15:20 +02:00
0d3a99dd39 Release 2.169.0 (#4873) 2025-06-08 21:08:41 +02:00
c604268507 Bugfix/fix issue in header after paths to routes restructuring (#4872)
* Fix issue in header
2025-06-08 21:06:33 +02:00
3af558c580 Bugfix/handle exception in getKeys() of Redis cache service (#4871)
* Handle exception in getKeys()

* Update changelog
2025-06-08 20:58:47 +02:00
85aa323e84 Feature/improve cache verification in health check endpoint (#4868)
* Improve implementation of isHealthy() without using getKeys()

* Update changelog
2025-06-08 17:27:15 +02:00
c42ffcb2a1 Feature/improve language localization for FR 20250607 (#4860)
* Improve language localization for FR

* Update changelog
2025-06-08 17:26:22 +02:00
fec223070f Feature/update locales (#4866)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-06-08 13:55:08 +02:00
766d792b10 Feature/restructure paths to routes (#4863)
* Restructure paths to routes
2025-06-08 13:50:52 +02:00
a40c726843 Feature/improve language localization for CA 20250607 (#4856)
* Improve language localization for CA

* Update changelog
2025-06-08 13:50:17 +02:00
ffce1c7208 Feature/improve language localization for PL 20250607 (#4857)
* Improve language localization for PL

* Update changelog
2025-06-08 12:31:35 +02:00
7a0b82b5f0 Feature/update locales (#4864)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-06-08 09:48:00 +02:00
fa99876dca Feature/rename asset profile icon component to entity logo component (#4861)
* Rename asset profile icon component to entity logo component

* Update changelog
2025-06-08 09:37:23 +02:00
25a755d1bf Feature/rename Account to accounts in User database schema (#4859)
* Rename Account to accounts

* Update changelog
2025-06-08 08:50:06 +02:00
71d56ad789 Feature/move asset profile icon component to @ghostfolio/ui (#4858)
* Move asset profile icon component to @ghostfolio/ui

* Update changelog
2025-06-08 08:39:13 +02:00
edba53be29 Bugfix/fix missing assetlinks.json for TWA (#4855)
* Fix command for copying assetlinks.json

* Update changelog
2025-06-07 21:54:09 +02:00
9b233da4bf Release 2.168.0 (#4854) 2025-06-07 16:55:52 +02:00
3bcb19af14 Feature/migrate i18n service to injectable service (#4829)
* Migrate i18n service to injectable service

* Update changelog
2025-06-07 16:52:48 +02:00
21baabc7f2 Feature/upgrade nestjs to version 11.1.3 (#4852)
* Upgrade nestjs to version 11.1.3

* Update changelog
2025-06-07 16:38:18 +02:00
66e430ab9a Feature/add background gradient to sidebar navigation (#4850)
* Add background gradient to sidebar navigation

* Update changelog
2025-06-07 16:25:20 +02:00
042112faa6 Feature/improve language localization for de 20250607 (#4848)
* Update translation

* Update changelog
2025-06-07 13:43:33 +02:00
b3ec353074 Feature/change interpolation syntax in i18n service (#4849)
* Change interpolation syntax from {var} to ${var}
2025-06-07 13:43:06 +02:00
19b8300a4d Release 2.167.0 (#4847) 2025-06-07 10:52:36 +02:00
1d29005736 Feature/update locales (#4834)
* 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>
2025-06-07 10:50:25 +02:00
e38ce2852c Feature/rename Tag to tags in User database schema (#4846)
* Rename Tag to tags

* Update changelog
2025-06-07 10:37:21 +02:00
31f5c0de88 Bugfix/fix import of empty account balances (#4677)
* Fix import of empty account balances

* Update changelog

---------

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
2025-06-07 10:37:01 +02:00
840805f9df Feature/enable column sorting in benchmark component (#4842)
* Enable column sorting in benchmark component

* Update changelog
2025-06-07 09:04:19 +02:00
e8f185e484 Bugfix/fix issue in annualized performance calculation (#4840)
* Fix issue in annualized performance calculation

* Update changelog
2025-06-07 08:31:55 +02:00
1dd3857618 Refactor getCashPositions() in portfolio service (#4800)
* Refactor getCashPositions()
2025-06-07 08:12:23 +02:00
c318e0019e Feature/improve language localization for TR 20250606 (#4844)
* Improve language localization for TR

* Update changelog
2025-06-06 20:51:33 +02:00
b5abdaae07 Feature/extend GfSymbolAutocompleteComponent by default options (#4563)
* Extend GfSymbolAutocompleteComponent by default options

* Update changelog
2025-06-06 20:50:12 +02:00
f87dc437fd Feature/improve language localization for ES 20250605 (#4833)
* Improve language localization for ES

* Updated Changlog
2025-06-06 20:28:19 +02:00
e2ec635bbf Feature/localize X-ray rule EmergencyFundSetup (#4835)
* Set up language localization for static portfolio analysis rule: Emergency Fund (Set up)

* Update changelog
2025-06-06 07:41:35 +02:00
1f01668a52 Feature/upgrade ng-extract-i18n-merge to version 2.15.1 (#4838)
* Upgrade ng-extract-i18n-merge to version 2.15.1

* Update changelog
2025-06-06 07:38:54 +02:00
7d2bb2116f Bugfix/enable import button in import activities dialog (#4791)
* Enable import button in import activities dialog

* Update changelog
2025-06-05 23:11:52 +02:00
fde8ff4bb6 Feature/localize X-ray rule FeeRatioInitialInvestment (#4779)
* Localize X-ray rule FeeRatioInitialInvestment

* Update changelog

---------

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
2025-06-05 19:31:45 +02:00
8cee03f16a Feature/upgrade Nx to version 21.1.2 (#4809)
* Upgrade Nx to version 21.1.2

* Update changelog
2025-06-05 17:38:15 +02:00
5fdd27eca0 Release 2.166.0 (#4830) 2025-06-05 07:59:24 +02:00
85af3db0d8 Bugfix/respect filter by holding when exporting activities (#4824)
* Respect filter by holding when exporting activities

* Update changelog
2025-06-04 20:09:06 +02:00
ccee5b1337 Bugfix/respect filter by holding when deleting activities (#4823)
* Respect filter by holding when deleting activities

* Update changelog
2025-06-04 08:53:17 +02:00
9366aea9d5 Feature/improve style of card components (#4812)
* Improve style of title

* Update changelog
2025-06-03 15:55:16 +02:00
5c617f761a Feature/clean up Dockerfile (#4811)
* Clean up
2025-06-03 13:11:55 +02:00
de56a35197 Feature/update locales (#4819)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-06-03 08:39:43 +02:00
3efe09d3eb Feature/improve language localization for ES 20250602 (#4814)
* Improve language localization for ES

* Update changelog
2025-06-03 07:52:23 +02:00
a11fef5e23 Bugfix/fix exception with currencies in historical market data editor of admin control panel (#4813)
* Fix exception with currencies

* Update changelog
2025-06-03 07:51:38 +02:00
15deed3032 Feature/upgrade Stripe dependencies 20250529 (#4662)
* Upgrade Stripe dependencies

* Upgrade ngx-stripe to version 19.7.0

* Update changelog
2025-06-02 17:32:43 +02:00
8b8d194383 Feature/clean up legacy demo user id property (#4808)
* Clean up
2025-06-02 14:24:19 +02:00
c59d2701f0 Feature/update locales (#4807)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-06-01 09:52:52 +02:00
90f2e8101c Feature/improve style of system message (#4806)
* Improve style of system message

* Update changelog
2025-06-01 09:50:08 +02:00
7342e7e5ec Feature/support creating custom tags in create or update activity dialog (#4801)
* Support creating custom tags in create or update activity dialog

* Update changelog
2025-06-01 09:49:37 +02:00
ef8bbf1ae8 Feature/improve language localization for TR 20250531 (#4803)
* Improve language localization for TR

* Update changelog
2025-06-01 09:48:24 +02:00
9757df859f Feature/improve data providers management style of admin control panel (#4804)
* Various style improvements
2025-06-01 09:01:23 +02:00
32baf1946c Feature/improve language localization for UK 20250531 (#4802)
* Improve language localization for UK

* Update changelog
2025-05-31 22:23:53 +02:00
b66e60e9f4 Feature/update locales (#4798)
* 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>
2025-05-31 14:23:59 +02:00
cb7326edb3 Release 2.165.0 (#4799) 2025-05-31 11:00:18 +02:00
cb7434a8b2 Feature/sync demo account activities based on tags (#4797)
* Sync demo account activities based on tags

* Update changelog
2025-05-31 10:57:13 +02:00
cf76f54c1c Feature/improve language localization for CA 20250531 (#4796)
* Improve language localization for CA

* Update changelog
2025-05-31 10:56:31 +02:00
fda9cc71f7 Feature/modularize cron service (#4795)
* Modularize cron service

* Update changelog
2025-05-31 10:22:41 +02:00
c553fdf6d4 Bugfix/change investment value to take currency effects into account in holding detail dialog (#4789)
* Change investment value to take currency effects into account

* Update changelog
2025-05-31 10:22:02 +02:00
03b04ac7f0 Feature/update locales (#4792)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-05-30 19:06:44 +02:00
c6e38cd4ac Feature/refresh cryptocurrencies list 20250529 (#4790)
* Update cryptocurrencies.json

* Update changelog
2025-05-30 19:03:51 +02:00
daedfd6ad6 Feature/various style improvements (#4788)
* Various style improvements
2025-05-30 19:01:06 +02:00
3bfbb31191 Feature/upgrade ng-extract-i18n-merge to version 2.15.0 (#4787)
* Upgrade ng-extract-i18n-merge to version 2.15.0

* Update changelog
2025-05-30 14:25:00 +02:00
824985b464 Feature/improve language localization for PL 20250529 (#4786)
* Improve language localization for PL

* Update changelog
2025-05-30 08:24:29 +02:00
62e304490d Feature/improve language localization for ES 20250529 (#4785)
* Improve language localization for ES

* Update changelog
2025-05-29 19:27:09 +02:00
5fa97a9a40 Feature/upgrade big.js to version 7.0.1 (#4784)
* Upgrade big.js to version 7.0.1

* Update changelog
2025-05-29 17:45:59 +02:00
5db5fd903e Feature/extend FAQ section by performance calculation method (#4773)
* Extend FAQ by performance calculation method

* Update changelog
2025-05-29 17:45:32 +02:00
9e74eec04d Feature/rename orders to activities in Tag database schema (#4783)
* Rename orders to activities in Tag database schema

* Update changelog
2025-05-29 17:44:50 +02:00
9f3079716b Feature/improve language localization for NL 20250528 (#4776)
* Improve language localization for NL

* Update sitemap.xml

* Update changelog
2025-05-29 07:35:17 +02:00
bd2a8e2444 Release 2.164.0 (#4778) 2025-05-28 15:28:30 +02:00
a8e48be9da Feature/improve language localization for NL 20250528 (#4775)
* Improve language localization for NL

* Update changelog
2025-05-28 15:25:05 +02:00
bbb55dbec9 Feature/upgrade yahoo-finance2 to version 3.3.5 (#4777)
* Upgrade yahoo-finance2 to version 3.3.5

* Update changelog
2025-05-28 15:21:39 +02:00
0ab7b98077 Feature/refactor publicly accessible page paths (#4768)
* Refactoring
2025-05-28 12:01:54 +02:00
316d118111 Feature/improve language localization for FR 20250527 (#4766)
* Improve language localization for FR

* Update sitemap.xml

* Update changelog
2025-05-27 21:55:24 +02:00
6c1273dc8a Feature/update locales (#4774)
* Update locales

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-05-27 21:36:11 +02:00
a594b58d61 Feature/improve language localization for ES 20250526 (#4763)
* Improve language localization for ES

* Update changelog
2025-05-27 21:31:36 +02:00
59abe473f0 Feature/improve language localization PL 20250527 (#4767)
* Improve language localization PL

* Update changelog
2025-05-27 08:25:18 +02:00
8c2f8033d7 Feature/upgrade Node.js from version 20 to 22 (#4680)
* Upgrade to Node.js 22

* Update changelog
2025-05-27 07:38:34 +02:00
7e1f4fd11f Release 2.163.0 (#4760) 2025-05-26 11:58:44 +02:00
9122b674ca Feature/upgrade yahoo-finance2 to version 3.3.4 (#4755)
* Upgrade yahoo-finance2 to version 3.3.4

* Update changelog
2025-05-26 11:56:09 +02:00
f20791d5ad Feature/improve language localization for IT 20250525 (#4750)
* Improve language localization for IT

* Update changelog
2025-05-26 07:47:01 +02:00
502f1c9244 Feature/improve language localization for TR 20250523 (#4738)
* Improve language localization for TR

* Update changelog
2025-05-25 21:04:51 +02:00
1f2d2f8d8a Feature/improve language localization 20250524 (#4747)
* Improve language localization
2025-05-25 21:02:51 +02:00
ffe1021d69 Feature/update locales (#4753)
* 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>
2025-05-25 21:01:27 +02:00
afb35f7036 Feature/expose docker repository in GitHub workflow (#4716)
* Expose docker repository in GitHub workflow
2025-05-25 20:29:12 +02:00
13b544c67d Feature/refactor router links (#4752)
* Refactor router links
2025-05-25 20:14:04 +02:00
b8d356a949 Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 22m17s
2025-05-24 16:55:12 -07:00
692147f629 Release 2.162.1 (#4749) 2025-05-24 22:00:12 +02:00
9a75e7d257 Bugfix/fix Redis cache health check (#4748)
* Fix Redis cache health check
2025-05-24 21:58:51 +02:00
d96a346b70 Release 2.162.0 (#4746) 2025-05-24 21:06:50 +02:00
1a247d6e97 Feature/improve handling of schema validation errors in search of Yahoo Finance service (#4744)
* Improve handling of schema validation errors

* Update changelog
2025-05-24 20:51:45 +02:00
90385157d7 Feature/improve Ghostfolio data provider integration (#4743)
* Improve Ghostfolio data provider integration
2025-05-24 20:18:46 +02:00
a8a31c141d Bugfix/fix TransformDataSourceInRequestInterceptor after upgrade to NestJS 11 (#4741)
* Fix TransformDataSourceInRequestInterceptor for Express 5
2025-05-24 16:15:51 +02:00
a1786c95a1 Feature/upgrade prisma to version 6.8.2 (#4740)
* Upgrade prisma to version 6.8.2

* Update changelog
2025-05-24 13:29:05 +02:00
6bdf7b185f Bugfix/text alignment in top holdings component (#4734)
* Fix text alignment of allocation column

* Update changelog
2025-05-24 13:23:14 +02:00
b5bd2bd997 Bugfix/rename snake-case hint to kebab-case in paths (#4737)
* Rename snake-case to kebab-case
2025-05-24 10:36:47 +02:00
71b8121b48 Merge branch 'main' of github.com:ghostfolio/ghostfolio 2025-05-23 23:43:23 -07:00
df11615b2f Feature/improve language localization for PL 20250522 (#4732)
* Improve language localization for PL

* Update changelog
2025-05-23 22:08:28 +02:00
7c378d88af Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 21m21s
2025-05-23 12:00:31 -07:00
4bffb3107d Bugfix/fix page navigation (#4711)
* Fix page navigation and use paths references
2025-05-23 16:54:09 +02:00
8f3fecc5aa Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 17m4s
2025-05-23 06:00:32 -07:00
ba6503636e Feature/upgrade yahoo-finance2 to version 3.3.3 (#4736)
* Upgrade yahoo-finance2 to version 3.3.3

* Update changelog
2025-05-23 14:31:41 +02:00
6e67520b68 Feature/improve Ghostfolio data provider status check (#4735)
* Improve Ghostfolio data provider status check
2025-05-23 14:31:22 +02:00
0ea588315a Feature/improve symbol lookup results by removing currency from name of cryptocurrencies (#4702)
* Improve symbol lookup results by removing currency from name of cryptocurrencies

* Update changelog
2025-05-23 14:23:42 +02:00
c33aa82bd7 Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 21m14s
2025-05-22 12:00:31 -07:00
3d94b1a873 Bugfix/fix exclude route with wildcard of serve static module (#4733)
* Fix route with wildcard

https://docs.nestjs.com/migration-guide#express-v5
2025-05-22 18:23:21 +02:00
11ca51024a Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 22m4s
2025-05-21 19:10:30 -07:00
bd2d05b143 Feature/improve language localization for PT 20250512 (#4712)
* Improve language localization for PT

* Update changelog
2025-05-21 20:44:49 +02:00
d4666f778d Feature/remove deprecated endpoints in Ghostfolio controller (#4692)
* Remove deprecated endpoints
2025-05-21 20:04:56 +02:00
243ef2206c Feature/improve language localization for ES 20250517 (#4723)
* Improve language localization for ES

* Update changelog
2025-05-21 20:02:52 +02:00
1cfc691a3e Feature/update locales (#4730)
* 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>
2025-05-21 19:40:40 +02:00
fe5d6f702b Feature/add hint about delayed market data to markets overview (#4699)
* Add hint about delayed market data

* Update changelog
2025-05-21 16:48:43 +02:00
24160366b9 Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 21m19s
2025-05-20 18:00:44 -07:00
eed9d157f0 Feature/update locales (#4729)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-05-20 23:57:37 +02:00
a06872b657 Bugfix/improve show condition of button to fetch current market price (#4700)
* Improve show condition of button to fetch current market price

* Update changelog
2025-05-20 23:53:32 +02:00
f4ef91e3be Feature/upgrade twitter-api-v2 to version 1.23.0 (#4693)
* Upgrade twitter-api-v2 to version 1.23.0

* Update changelog
2025-05-20 23:49:54 +02:00
f361ecc732 Feature/improve language localization for FR 20250520 (#4728)
* Improve language localization for FR

* Update changelog
2025-05-20 23:48:34 +02:00
294a1834b6 Feature/improve language localization for CA 20250513 (#4719)
* Improve language localization for CA
2025-05-20 21:05:02 +02:00
f63ede46b0 Feature/improve language localization for IT 20250513 (#4717)
* Improve language localization for IT
2025-05-20 21:02:37 +02:00
3da83fc42b Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 21m7s
2025-05-20 06:00:44 -07:00
70a4697f54 Feature/remove deprecated endpoints in admin controller (#4687)
* Remove deprecated endpoints

* Update changelog
2025-05-20 09:22:49 +02:00
d395b195ff Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 21m18s
2025-05-19 18:00:47 -07:00
c2c628e77c Feature/update locales (#4726)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-05-19 22:46:41 +01:00
a0f377e8eb Feature/refactor ordersCount to activityCount (#4688)
* Refactor ordersCount to activityCount
2025-05-19 22:27:52 +01:00
85d07c27b6 Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 21m23s
2025-05-18 12:00:44 -07:00
57c43e5815 Feature/upgrade yahoo-finance2 to version 3.3.2 (#4721)
* Upgrade yahoo-finance2 to version 3.3.2

* Update changelog
2025-05-18 20:45:00 +02:00
273ec92316 Feature/update locales (#4724)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-05-18 20:14:36 +02:00
dcc016633e Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 21m16s
2025-05-18 06:00:45 -07:00
af79888cd6 Feature/add asset profile count to data providers management of admin control (#4707)
* Extend admin settings columns

* assetProfileCount
* status

* Update changelog
2025-05-18 10:54:53 +01:00
2a934a75ec Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 22m15s
2025-05-17 12:00:45 -07:00
698d71fb3a Feature/restrict permissions of demo user (#4697)
* Restrict permissions of demo user

* Update changelog
2025-05-17 19:39:48 +02:00
e7bfcabac2 Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 21m52s
2025-05-16 12:00:44 -07:00
ccbf958aa6 Feature/upgrade countup.js to version 2.8.2 (#4708)
* Upgrade countup.js to version 2.8.2

* Update changelog
2025-05-16 20:16:24 +02:00
8abc039fac Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 22m45s
2025-05-15 12:00:45 -07:00
1697b7e1e0 Feature/remove unused Order model (#4690)
* Remove unused Order model
2025-05-15 15:56:05 +01:00
8358928aaf Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 23m8s
2025-05-14 18:00:45 -07:00
895a6214c7 Feature/update locales (#4718)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-05-14 23:29:32 +02:00
e97757631b Feature/add missing types of impersonationId in controllers (#4691)
* Add missing types
2025-05-14 22:52:11 +02:00
722da69987 Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 22m10s
2025-05-13 12:00:49 -07:00
a8937fbf04 Feature/improve language localization for NL 20250512 (#4714)
* Improve language localization for NL

* Update changelog
2025-05-13 18:49:46 +01:00
e045811232 Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 21m31s
2025-05-12 00:00:44 -07:00
365318e6e0 Feature/improve localization (#4709)
* Update translations

* Update changelog

---------

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
2025-05-12 08:40:33 +02:00
66eb7adbdb Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 21m31s
2025-05-10 12:00:45 -07:00
6c322522d9 Feature/update locales (#4706)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-05-10 20:54:48 +02:00
0b7fc7a3b2 Feature/migrate data providers overview to Angular Material table (#4704)
* Migrate data providers overview to Angular Material table

* Update changelog
2025-05-10 20:23:06 +02:00
4adc9dc9b1 Feature/upgrade to node-yahoo-finance2 version 3 (#4695)
* Upgrade node-yahoo-finance2 from version 2.11.3 to 3.3.1

* Update changelog
2025-05-10 18:07:32 +02:00
7aab1c08b0 Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 21m38s
2025-05-10 06:00:44 -07:00
7bf87352c9 Feature/update locales (#4703)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-05-10 14:53:46 +02:00
2932d57c1e Feature/improve language localization for ZH 20250510 (#4701)
* Improve language localization for ZH 20250510

* Update changelog
2025-05-10 14:50:04 +02:00
3fe5a762eb Feature/extend personal finance tools 20250510 (#4698)
* Extend personal finance tools

* Add Balance Pro
* Add PinkLion
2025-05-10 14:48:33 +02:00
12ebce03af Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 21m42s
2025-05-09 12:00:45 -07:00
755d85a54b Feature/update locales (#4689)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-05-09 17:43:33 +02:00
d99217a434 Feature/refactor @Input in portfolio proportion chart component (#4684)
* Refactor @Input in GfPortfolioProportionChartComponent
2025-05-09 17:31:35 +02:00
480709c32a Bugfix/add missing permission guard in create watchlist item endpoint (#4686)
* Add missing permission guard

* Update changelog
2025-05-09 17:30:44 +02:00
b6e8431c53 Feature/clean up unused @Input in top holdings component (#4683)
* Clean up unused input
2025-05-09 16:24:03 +02:00
11629ffd26 Feature/clean up unused interfaces (#4685)
* Clean up unused interfaces
2025-05-09 16:20:02 +02:00
037d3b1a60 Feature/rename Order to activities in User database schema (#4669)
* Rename Order to activities in User database schema

* Update changelog
2025-05-09 16:19:14 +02:00
7af153a161 Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 21m36s
2025-05-09 06:00:45 -07:00
72ccf47526 Feature/update locales (#4678)
* Update locales

* Clean up

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
2025-05-09 12:32:23 +02:00
c81d3ced75 Feature/improve language localization for IT 20250509 (#4681)
* Improve language localization for IT 20250509

* Update changelog
2025-05-09 12:03:47 +02:00
1215803a40 Bugfix/fix ApiKeyStrategy error (#4682)
* Fix ApiKeyStrategy error
2025-05-09 10:48:43 +02:00
92fbf33032 Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 22m41s
2025-05-08 12:00:44 -07:00
aadd9f56a0 Feature/extend admin endpoint by asset profile count per data provider (#4676)
* Extend admin endpoint by asset profile count per data provider

* Update changelog
2025-05-08 17:08:19 +02:00
4237ded93c Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 21m50s
2025-05-08 00:00:44 -07:00
8e76bd82eb Feature/improve language localization for Catalan (#4675)
* Improve language localization for Catalan

* Update changelog
2025-05-08 08:30:44 +02:00
c393aeeb9a Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 21m57s
2025-05-07 12:00:44 -07:00
828bd5f172 Feature/upgrade to NestJS 11 (#4270)
* Upgrade to NestJS 11

* Update changelog
2025-05-07 20:34:31 +02:00
edbcd0e225 Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 21m29s
2025-05-06 12:00:45 -07:00
03e27dd233 Feature/update locales (#4670)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-05-06 18:45:02 +02:00
096699d83b Release 2.161.0 (#4671) 2025-05-06 18:08:25 +02:00
19eff6e814 Bugfix/fix horizontal overflow in table of benchmark component (#4668)
* Fix horizontal overflow

* Update changelog
2025-05-06 18:05:30 +02:00
67db1b0de4 Feature/rename Order to activities in SymbolProfile database schema (#4661)
* Rename Order to activities

* Update changelog
2025-05-06 17:43:46 +02:00
c38dab5ab0 Feature/extend holding endpoint by performances (#4660)
* Extend holding endpoint by performances

* Update changelog
2025-05-06 17:43:03 +02:00
40d3eaa023 Bugfix/fix performance calculation on date of activity when unit price differs from market price (#4650)
* Fix performance calculation on date of activity when unit price differs from market price

* Update changelog
2025-05-06 17:41:04 +02:00
3ec2460bfe Feature/update locales (#4664)
* Update locales

* Clean up

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
2025-05-06 17:38:21 +02:00
aaf507b276 Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 21m41s
2025-05-06 06:00:45 -07:00
307621e103 Feature/improve language localization for TR 20250506 (#4663)
* Improve language localization for TR

* Update changelog
2025-05-06 09:42:33 +02:00
4594f7339b Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 21m19s
2025-05-04 20:32:35 -07:00
6e9622a2cf Release 2.160.0 (#4659) 2025-05-04 17:16:41 +02:00
28d2fd3877 Bugfix/restore incorrect fee currency conversion (#4645)
* Restore incorrect fee currency conversion

* Update changelog
2025-05-04 17:14:46 +02:00
5b6447b60d Feature/update locales (#4658)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-05-04 15:58:24 +02:00
d661cdc78f Feature/rename account to accounts in platform database schema (#4656)
* Rename Account to accounts in platform database schema

* Update changelog
2025-05-04 15:55:31 +02:00
ecffb53f07 Feature/extend faq pages (#4655)
* Extend FAQ pages

* Update changelog
2025-05-04 15:54:54 +02:00
b93671c740 Feature/update locales (#4654)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-05-04 12:03:30 +02:00
620ae023d9 Feature/move watchlist to general availability (#4653)
* Add watchlist to features page

* Move watchlist to general availability

* Update changelog
2025-05-04 11:17:15 +02:00
308dfaa58d Feature/upgrade prisma to version 6.7.0 (#4647)
* Upgrade prisma to version 6.7.0

* Update changelog
2025-05-04 11:16:32 +02:00
e4073608e5 Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 21m26s
2025-05-04 01:46:12 -07:00
3646fb7f77 Feature/refactor portfolio holding response (#4649)
* Refactor portfolio holding response

* maxPrice -> marketPriceMax
* minPrice -> marketPriceMin
* orders -> activities
2025-05-04 09:48:43 +02:00
1bced96460 Feature/deprecate portfolio position endpoints (#4648)
* Deprecate api/v1/portfolio/position endpoints

* Update changelog
2025-05-03 20:47:02 +02:00
3e963228d6 Feature/refactor accounts response interface (#4644)
* Refactor accounts response interface
2025-05-02 19:15:11 +02:00
c3887e2f8e Release 2.159.0 (#4643) 2025-05-02 17:01:39 +02:00
72188374ca Feature/upgrade bootstrap to version 4.6.2 (#4631)
* Upgrade bootstrap to version 4.6.2

* Update changelog
2025-05-02 16:47:18 +02:00
f70d71d5bd Feature/improve watchlist for impersonation mode (#4632)
* Improve watchlist for impersonation mode

* Update changelog
2025-05-02 16:46:43 +02:00
770b322137 Feature/extend watchlist endpoint by name, performances and market condition (#4634)
* Extend watchlist endpoint by name, performances and market condition

* Update changelog
2025-05-02 16:11:24 +02:00
6bb85c4fb8 Bugfix/allow GBp in currency code validation (#4640)
* Allow GBp in currency code validation

* Update changelog
2025-05-02 16:09:27 +02:00
e314efb2e1 Feature/improve language localization for FR 20250501 (#4637)
* Improve french translation

* Update changelog
2025-05-02 08:26:04 +02:00
575615b972 Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 21m24s
2025-04-30 13:16:56 -07:00
2306bc597b Release 2.158.0 (#4633) 2025-04-30 17:53:42 +02:00
ca992db14e Bugfix/save activities with type INTEREST, ITEM and LIABILITY (#4630)
* Save activities with type INTEREST, ITEM and LIABILITY

* Update changelog
2025-04-30 17:51:32 +02:00
8fbdcac66c Feature/rename Order to activities in account database schema (#4577)
* Rename Order to activities

* Update changelog
2025-04-30 17:31:06 +02:00
8df9667979 Feature/update locales (#4629)
* 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>
2025-04-30 16:59:50 +02:00
73f009e43b Feature/extend GUI to delete watchlist item (#4624)
* Extend GUI to delete watchlist item

* Update changelog
2025-04-30 11:00:03 +02:00
d9d8eadbd3 Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 22m43s
2025-04-29 18:24:02 -07:00
afac9daeab Release 2.157.1 (#4627) 2025-04-29 20:58:28 +02:00
d919622932 Bugfix/fix create watchlist item for new asset profile (#4625)
* Fix create watchlist item for new asset profile
2025-04-29 20:56:28 +02:00
a5fe259761 Feature/update locales (#4623)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-04-29 20:31:53 +02:00
db7d45ecb9 Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 21m34s
2025-04-28 12:00:43 -07:00
82cf4afd7d Release 2.157.0 (#4622) 2025-04-28 19:44:44 +02:00
b90bfc3d6e Feature/extend data providers management of admin control panel (#4615)
* Extend data providers management

* Update changelog
2025-04-28 19:42:39 +02:00
1b5a65d391 Feature/update locales (#4621)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-04-28 19:28:12 +02:00
34f191ef7a Feature/change column label in benchmark component (#4616)
* Generalize column label

* Update changelog
2025-04-28 18:53:55 +02:00
fe1df8095a Feature/update locales (#4620)
* 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>
2025-04-28 18:53:33 +02:00
7d0af34034 Feature/update locales (#4619)
* 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>
2025-04-28 18:26:26 +02:00
398833a0e3 Feature/improve wording of data providers management (#4617)
* Improve wording
2025-04-28 18:26:07 +02:00
c671ea4022 Feature/add frontend for watchlist (#4604)
* Add frontend for watchlist

* Update changelog

---------

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
2025-04-28 17:17:35 +02:00
909d56ab10 Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 21m20s
2025-04-27 17:44:10 -07:00
456327d199 Release 2.156.0 (#4614) 2025-04-27 14:29:34 +02:00
f209519d95 Bugfix/investment calculation for activities in custom currency (#4597)
* Investment calculation for activities in custom currency

* Update changelog
2025-04-27 14:26:14 +02:00
c34996fdd6 Feature/update locales (#4613)
* 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>
2025-04-27 14:22:52 +02:00
a5e13d30ae Bugfix/fix missing localization for alias fallback on public page (#4610)
* Fix missing localization

* Update changelog
2025-04-27 14:11:37 +02:00
b2dcb1e7ac Feature/improve subscription interstitial (#4612)
* Improve algorithm

* Set up skip button delay
2025-04-27 14:10:57 +02:00
2774dd7b2e Feature/update locales (#4611)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-04-27 09:50:24 +02:00
862de91e7b Feature/respect watcher count in delete asset profile checkbox (#4609)
* Respect watchedByCount in delete asset profile checkbox

* Update changelog
2025-04-27 09:46:25 +02:00
07fa345457 Feature/upgrade ngx-skeleton-loader to version 11.0.0 (#4602)
* Upgrade ngx-skeleton-loader to version 11.0.0

* Update changelog
2025-04-27 09:44:58 +02:00
68fb5c8b66 Feature/upgrade Nx to version 20.8.1 (#4601)
* Upgrade Nx to version 20.8.1

* Update changelog
2025-04-26 21:47:31 +02:00
d2452791cc Feature/update create watchlist item permission (#4608)
* Remove createWatchlistItem permission
2025-04-26 21:47:03 +02:00
e86801dfe9 Feature/improve language localization for FR 20250426 (#4603)
* Improve french translation

* Update changelog
2025-04-26 17:36:49 +02:00
7fb0f9b6e8 Feature/refactor import service (#4599)
* Refactoring
2025-04-26 07:02:31 +02:00
28dd26be97 Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 21m51s
2025-04-25 08:42:17 -07:00
3b59d7989a Feature/improve currency code validation (#4598)
* Improve currency code validation

* Update changelog
2025-04-25 08:24:16 +02:00
3023d79311 Merge branch 'main' of github.com:ghostfolio/ghostfolio
Some checks failed
Docker image CD / build_and_push (push) Failing after 34m1s
2025-04-24 12:00:44 -07:00
447fe1806f Bugfix/fix activities import of files with extension in uppercase (#4596)
* Fix activities import of files with extension in uppercase

* Update changelog
2025-04-24 20:40:12 +02:00
9f5cc6a4cb Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 21m21s
2025-04-23 12:00:43 -07:00
4c63e08e3c Release 2.155.0 (#4595) 2025-04-23 20:29:27 +02:00
8dcf04019d Feature/update locales (#4594)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-04-23 20:27:06 +02:00
ac37974fd6 Feature/update locales (#4593)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-04-23 20:26:35 +02:00
50e7e3d3c7 Feature/simplify data source checks in DTOs (#4581)
* Simplify DataSource checks in DTOs

* Add test case

* Update changelog
2025-04-23 20:24:08 +02:00
10580e22d1 Feature/migrate value component to control flow (#4592)
* Migrate to control flow

* Update changelog
2025-04-23 20:23:20 +02:00
53a81b3c2b Feature/migrate assistant component to control flow (#4591)
* Migrate to control flow

* Update changelog
2025-04-23 20:15:31 +02:00
56fcafaa12 Feature/improve premium data provider handling in getQuotes() (#4590)
* Improve premium data provider handling in getQuotes()
2025-04-23 20:15:11 +02:00
dfa940c1b4 Bugfix/add missing common module import in rule settings dialog (#4586)
* Add missing import

* Update changelog
2025-04-23 19:46:21 +02:00
d9acd3ace9 Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 21m54s
2025-04-22 12:00:43 -07:00
e67ccea673 Feature/rename User to user in subscription database schema (#4576)
* Rename User to user

* Update changelog
2025-04-22 19:38:03 +02:00
fa2739bbb4 Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 21m54s
2025-04-21 11:24:27 -07:00
416fa44cf0 Feature/create watchlist API endpoints (#4570)
* Create watchlist API endpoints

* Update changelog
2025-04-21 20:19:59 +02:00
b77afed38c Release 2.154.0 (#4583) 2025-04-21 19:49:15 +02:00
ad29221e79 Feature/update locales (#4582)
* 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>
2025-04-21 19:46:58 +02:00
71fc3906c7 Feature/add performance calculation type to user settings (#4567)
* Add performance calculation type to user settings

* Update changelog
2025-04-21 19:28:31 +02:00
26b705cfea Feature/update locales (#4580)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-04-21 19:08:22 +02:00
3091c3f080 Feature/rename Subscription to subscriptions in user database schema (#4575)
* Rename Subscription to subscriptions

* Update changelog
2025-04-21 16:30:01 +02:00
d6e0b499d9 Feature/migrate lookup by ISIN in Financial Modeling Prep service to stable API version (#4573)
* Migrate lookup by ISIN to stable API version

* Update changelog
2025-04-21 16:29:05 +02:00
1ae5ba7f8a Feature/parallelize asset profile requests in get quotes functionality of Financial Modeling Prep service (#4569)
* Parallelize asset profile requests

* Update changelog
2025-04-21 07:42:32 +02:00
e8e499be26 Merge branch 'main' of github.com:ghostfolio/ghostfolio
Some checks failed
Docker image CD / build_and_push (push) Failing after 35m3s
2025-04-20 18:22:54 -07:00
de6c416639 Feature/update locales (#4574)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-04-20 16:10:10 +02:00
a41032d0af Feature/add expansion panel for historical market data editor (#4550)
* Add expansion panel for historical market data editor

* Update changelog
2025-04-20 16:06:24 +02:00
b287257a29 Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 21m16s
2025-04-20 00:00:43 -07:00
6b2966b7dc Feature/extend benchmark detail dialog by market price (#4565)
* Add current market price

* Update changelog
2025-04-20 08:01:03 +02:00
c16352b76c Bugfix/fix typos in permissions (#4572)
* Fix typos in permissions
2025-04-20 07:54:34 +02:00
255c3ec5f4 Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 22m26s
2025-04-19 22:11:55 -07:00
e498746c4c Feature/update locales (#4568)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-04-19 10:22:32 +02:00
8163966212 Bugfix/fix word wrap in menu of historical market data table of admin control panel (#4562)
* Fix word wrap

* Update changelog
2025-04-19 10:18:37 +02:00
1b45ce8619 Feature/add watchlist to user database schema (#4560)
* Add watchlist to user database schema

* Update changelog
2025-04-18 19:25:59 +02:00
f3022ca1f4 Release 2.153.0 (#4561) 2025-04-18 14:44:45 +02:00
b6f87e46a7 Bugfix/fix asset class parsing in Financial Modeling Prep service for exchange rates (#4559)
* Fix the asset class parsing

* Update changelog
2025-04-18 14:41:55 +02:00
f29f201a4f Bugfix/add missing isActive flag in asset profile of custom currency (#4557)
* Add missing isActive flag

* Update changelog
2025-04-18 14:04:39 +02:00
7cebfbc9c2 Feature/update locales (#4556)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-04-18 11:18:50 +02:00
db38806812 Feature/refresh cryptocurrencies list 20250417 (#4548)
* Update cryptocurrencies.json

* Update changelog
2025-04-18 11:15:51 +02:00
13643f578a Feature/upgrade uuid to version 11.1.0 (#4552)
* Upgrade uuid to version 11.1.0

* Update changelog
2025-04-18 11:14:26 +02:00
edf03d1cd6 Feature/upgrade chart.js to version 4.4.9 (#4547)
* Upgrade chart.js to version 4.4.9

* Update changelog
2025-04-18 11:13:12 +02:00
3361666f63 Feature/activity in custom currency (#4486)
* Activity in custom currency

* Update changelog

---------

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
2025-04-18 11:11:49 +02:00
53122b09ab Feature/update locales (#4554)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-04-17 20:39:30 +02:00
073e60055d Release 2.152.1 (#4553) 2025-04-17 20:35:12 +02:00
07ad7f2817 Bugfix/fix upgrade vs. renew subscription button labels (#4549)
* Fix upgrade vs. renew subscription button labels
2025-04-17 20:33:11 +02:00
5e64de6650 Feature/extend personal finance tools 20250417 (#4551)
* Add Finvest
* Add Money Peak
* Add Peek
* Add Tresor One
2025-04-17 20:32:40 +02:00
d23dfadbd0 Bugfix/fix active subscription in user table of admin control (#4544)
* Fix active subscription

* Update changelog
2025-04-17 17:47:36 +02:00
c6083ec7c9 Feature/update locales (#4543)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-04-17 07:49:50 +02:00
535e9b654d Release 2.152.0 (#4542) 2025-04-16 20:59:46 +02:00
94e53c7d4a Feature/move subscription offer from info to user service (#4533)
* Move subscription offer from info to user service

* Update changelog
2025-04-16 20:57:28 +02:00
5072ba09aa Feature/update locales (#4541)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-04-16 20:51:40 +02:00
6122da3f14 Feature/optimize get range query in market data service (#4527)
* Optimize query in getRange()

* Update changelog
2025-04-16 20:48:43 +02:00
cca1637aec Feature/move API key generation from experimental to general availability (#4540)
* Move API key generation from experimental to general availability
2025-04-16 20:47:30 +02:00
f34fba97ba Feature/upgrade Nx to version 20.8.0 (#4534)
* Upgrade Nx to version 20.8.1

* Update changelog
2025-04-16 20:46:57 +02:00
55791303a0 Feature/upgrade prisma to version 6.6.0 (#4531)
* Upgrade prisma to version 6.6.0

* Update changelog
2025-04-16 19:51:07 +02:00
c86033a0b5 Feature/update locales (#4538)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-04-16 15:09:50 +02:00
48a0a28d23 Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 14m0s
2025-04-16 00:52:36 -07:00
42734dff75 Feature/deactivate asset profile on delisting (Yahoo Finance) (#4524)
* Deactivate asset profile on delisting (Yahoo Finance)

* Update changelog

---------

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
2025-04-16 07:44:42 +02:00
95325aad14 Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 29m50s
2025-04-12 00:47:00 -07:00
b2634db99f Update locales (#4529)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-04-11 19:11:26 +02:00
1856d40ad0 Release 2.151.0 (#4530) 2025-04-11 19:08:56 +02:00
267dfc572a Bugfix/fix pricing link in premium indicator component (#4525)
* Fix link to pricing page

* Update changelog
2025-04-11 19:04:33 +02:00
d1a4cb5037 Feature/improve financial modeling prep service (#4528)
* Improve service

* Set maximum number of symbols per request
* Migrate getQuotes to stable API version

* Update changelog
2025-04-11 19:04:03 +02:00
e6b7073195 Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 31m6s
2025-04-10 19:41:41 -07:00
bb4e034300 Feature/upgrade Nx to version 20.7.1 (#4514)
* Upgrade Nx to version 20.7.1

* Update changelog
2025-04-10 17:45:09 +02:00
6defc02c28 Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 36m0s
2025-04-09 19:28:18 -07:00
38e8284aef Feature/update locales (#4526)
* 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>
2025-04-09 21:37:31 +02:00
0f557f431e Feature/add status column to market data table in admin control panel (#4520)
* Add status column to market data table in admin control panel

* Update changelog
2025-04-09 19:33:00 +02:00
587c735288 Merge branch 'main' of github.com:ghostfolio/ghostfolio
Some checks failed
Docker image CD / build_and_push (push) Failing after 47m42s
2025-04-08 19:25:00 -07:00
2c07b7f058 Feature/update locales (#4519)
* 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>
2025-04-08 21:53:20 +02:00
2deca58928 Bugfix/fix action label in asset profile dialog (#4517)
* Fix label

* Update changelog
2025-04-08 08:56:19 +02:00
ed07610602 Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 31m25s
2025-04-06 20:36:56 -07:00
ad47bedcb5 Feature/upgrade eslint dependencies 20250405 (#4513)
* Upgrade eslint dependencies

* Update changelog
2025-04-06 09:33:00 +02:00
830720620f Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 29m7s
2025-04-05 06:10:04 -07:00
3ec0fe81b4 Release 2.150.0 (#4511) 2025-04-05 11:15:43 +02:00
f7f057f2e8 Feature/update locales (#4509)
* Update locales

* Update translations

* 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>
2025-04-05 11:13:21 +02:00
57748a18ef Feature/add data gathering toggle to asset profile details dialog (#4497)
* Add data gathering toggle to asset profile details dialog

* Update changelog
2025-04-05 10:52:31 +02:00
d3ecbc0a96 Feature/update locales (#4507)
* 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>
2025-04-05 10:36:50 +02:00
b6fc5566d4 Feature/update messages.pl.xlf (#4499)
* Update messages.pl.xlf

* Update changelog
2025-04-04 22:11:08 +02:00
5754f01819 Feature/improve check for duplicates in preview step of activities import (comments) (#4498)
* Improve check for duplicates in preview step of activities import (comments)

* Update changelog
2025-04-04 22:10:26 +02:00
6ae22d13ec Feature/extend API keys for Ghostfolio data provider (#4501)
* Extend API keys for Ghostfolio data provider
2025-04-04 21:53:35 +02:00
f1d292330a Feature/update locales (#4506)
* 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>
2025-04-04 21:51:39 +02:00
2894274b92 Feature/improve language localization for fr (#4502)
* Improve language localization for fr

* Update changelog
2025-04-04 19:37:19 +02:00
29739b27ee Feature/upgrade ng-extract-i18n-merge to version 2.14.3 (#4500)
* Upgrade ng-extract-i18n-merge to version 2.14.3

* Update changelog
2025-04-03 17:20:21 +02:00
a216ba98b6 Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 31m35s
2025-04-02 23:38:10 -07:00
4df575e34f Feature/migrate ion-icon usage to self-closing tags (#4492)
* Migrate to self-closing tags
2025-04-02 16:47:57 +02:00
9c7778983d Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 31m28s
2025-04-01 11:02:05 -07:00
476b287ef3 Feature/upgrade @types/lodash to version 4.17.16 (#4489)
* Upgrade @types/lodash to version 4.17.16
2025-03-31 12:03:17 +02:00
c58b4c1cda Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 32m44s
2025-03-31 01:56:44 -07:00
1202d15522 Release 2.149.0 (#4495) 2025-03-30 10:35:31 +02:00
8cd7679760 Feature/update locales (#4494)
* 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>
2025-03-30 10:33:27 +02:00
91394160b9 Feature/Allow to edit identifier in asset profile dialog (#4469)
* Allow to edit identifier in asset profile dialog

* Update changelog
2025-03-30 10:26:57 +02:00
64cbd276ce Feature/update locales (#4493)
* 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>
2025-03-30 10:15:09 +02:00
b720a8dd96 Feature/set up terms of service (#4490)
* Set up terms of service

* Update changelog
2025-03-30 09:59:56 +02:00
ff563ddfea Feature/upgrade Nx to version 20.6.4 (#4491)
* Upgrade Nx to version 20.6.4

* Update changelog
2025-03-29 21:52:02 +01:00
e46c25aaeb Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 31m13s
2025-03-27 10:16:26 -07:00
70d56c6c6c Feature/add LinkedIn page to README.md (#4476)
* Add LinkedIn page
2025-03-27 14:23:12 +01:00
a09d3e5beb Feature/harmonize stepper and tab group options (#4455)
* Harmonize stepper options

* Harmonize tab group options
2025-03-26 16:29:42 +01:00
2f8767578c Merge branch 'main' of github.com:ghostfolio/ghostfolio
Some checks failed
Docker image CD / build_and_push (push) Failing after 3h7m58s
2025-03-25 20:06:51 -07:00
a13d6140cf Feature/extend emergency fund X-ray rule to support assets (#4485)
* Extend emergency fund X-ray rule to support assets

* Update changelog

---------

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
2025-03-25 20:54:56 +01:00
31be526c11 Feature/restrict historical market data gathering to active asset profiles (#4483)
* Restrict historical market data gathering to active asset profiles

* Update changelog
2025-03-25 19:53:19 +01:00
f28c452be0 Release 2.148.0 (#4482) 2025-03-24 20:59:18 +01:00
f08ed2dc68 Feature/add isActive flag to asset profile model (#4479)
* Add isActive to SymbolProfile model

* Update changelog
2025-03-24 20:55:41 +01:00
c9e8eb401a Feature/update locales (#4481)
* 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>
2025-03-24 20:55:11 +01:00
f22c5ee16b Feature/upgrade ngx-skeleton-loader to version 10.0.0 (#4480)
* Upgrade ngx-skeleton-loader to version 10.0.0

* Update changelog
2025-03-24 20:49:52 +01:00
44f6efd427 Feature/extend personal finance tools 20250323 (#4478)
* Add Asseta

* Add Danti
2025-03-24 20:49:24 +01:00
8920b60a72 Bugfix/fix apostrophes in locales (#4475)
* Fix apostrophes
2025-03-23 08:44:08 +01:00
7183db17b0 Release 2.147.0 (#4474) 2025-03-22 17:55:14 +01:00
c467c946d4 Bugfix/fix symbol validation in Yahoo Finance service (#4472)
* Fix symbol validation

* Add test cases
2025-03-22 17:52:16 +01:00
a6952a0e37 Feature/extend get data sources in data provider service (#4473)
* Extend data sources
2025-03-22 17:51:43 +01:00
5247cc3c97 Feature/update locales (#4468)
* 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>
2025-03-22 17:18:40 +01:00
4842c347a9 Feature/generate new security token for user via admin control panel (#4458)
* Generate new security token for user via admin control panel

* Update changelog

---------

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
2025-03-22 10:56:01 +01:00
6c624fefc9 Feature/eliminate firstOrderDate in favor of dateOfFirstActivity in portfolio summary component (#4462)
* Eliminate firstOrderDate in favor of dateOfFirstActivity in portfolio summary component

* Update changelog
2025-03-22 10:21:28 +01:00
5ecdb5fb7a Feature/improve language localization for tr 20250322 (#4467)
* Update translations

* Update changelog
2025-03-22 10:20:30 +01:00
a9c3224856 Feature/add endpoint to localize site.webmanifest (#4450)
* Add endpoint to localize site.webmanifest

* Refactor rootUrl

* Update changelog

---------

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
2025-03-22 09:36:45 +01:00
b5db651ec8 Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 33m37s
2025-03-21 21:23:08 -07:00
198f73db00 Feature/improve export by applying filters on accounts and tags (#4425)
* Improve export by applying filters on accounts and tags

* Update changelog

---------

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
2025-03-21 20:58:47 +01:00
536b000ff9 Feature/improve Storybook story of fire calculator (#4451)
* Add default value for fire wealth
2025-03-21 20:41:37 +01:00
150d97bd42 Feature/update locales (#4463)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-03-21 20:40:59 +01:00
d8b305a343 Feature/add Storybook to sitemap.xml (#4452)
* Add Storybook

* Update changelog
2025-03-20 08:04:02 +01:00
1aa1960d45 Feature/rename TWR to ROAI (#4464)
* Rename TWR to ROAI
2025-03-20 08:03:32 +01:00
22cde840ea Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 31m18s
2025-03-19 19:08:43 -07:00
795e4582a8 Feature/replace lodash.uniq with Array.from + Set (#4387)
* Replace lodash.uniq with Array.from + Set

* Update chagnelog
2025-03-19 21:10:19 +01:00
ddc7989280 Feature/update locales (#4460)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-03-19 20:37:30 +01:00
82fe2590bf Feature/add Storybook link to development guide (#4448)
* Add Storybook
2025-03-19 17:26:00 +01:00
ee361bf669 Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 34m46s
2025-03-18 20:08:39 -07:00
efc0b1bf5a Feature/support filters in AI prompt API (#4431)
* Support filters in AI prompt API

* Update changelog
2025-03-18 20:13:24 +01:00
235db72ade Bugfix/change client-side dates to be sent in UTC format (#4402)
* Change client-side dates to be sent in UTC format

* Update changelog
2025-03-18 20:06:00 +01:00
170b10dbde Merge branch 'main' of github.com:ghostfolio/ghostfolio
Some checks failed
Docker image CD / build_and_push (push) Failing after 42m5s
2025-03-17 21:13:13 -07:00
4db8c007f0 Bugfix/fix activities import with account balances (#4446)
* Fix import with account balances

* Update changelog
2025-03-17 22:27:22 +01:00
51d55f74e9 Feature/add missing lifecycle hook in historical market data editor dialog (#4456)
* Add OnInit
2025-03-17 21:21:46 +01:00
6036547cf5 Feature/refactor portfolio calculator factory (#4454)
* Refactor portfolio calculator factory
2025-03-17 21:21:09 +01:00
f17a95eb48 Feature/refresh cryptocurrencies list 20250316 (#4449)
* Update cryptocurrencies.json

* Update changelog
2025-03-16 20:59:49 +01:00
6cd79fab31 Feature/rename TWR to ROAI (#4453)
* Rename TWR to ROAI
2025-03-16 20:59:21 +01:00
5783497a66 Feature/improve symbol validation in Yahoo Finance service (#4445)
* Improve symbol validation

* Update changelog
2025-03-16 10:42:35 +01:00
29b8d63951 Release 2.146.0 (#4444) 2025-03-15 19:39:07 +01:00
3efe06409e Feature/improve language localization for de 20250315 (#4442)
* Update translations
2025-03-15 19:36:10 +01:00
0cc3674780 Feature/upgrade prisma to version 6.5.0 (#4440)
* Upgrade prisma to version 6.5.0

* Update changelog
2025-03-15 18:47:33 +01:00
755ab15755 Feature/format name in financial modeling prep service (#4441)
* Format name

* Update changelog
2025-03-15 18:47:07 +01:00
b90e3076d0 Feature/remove exchange rates from admin control overview (#4439)
* Remove exchange rates

* Update changelog
2025-03-15 18:25:15 +01:00
6319661b0c Feature/upgrade prettier to version 3.5.3 (#4435)
* Upgrade prettier to version 3.5.3

* Update changelog
2025-03-15 18:24:05 +01:00
2f868b8902 Feature/add guard to TransformDataSourceInRequestInterceptor (#4438)
* Add guard
2025-03-15 12:22:24 +01:00
1917c17cf9 Bugfix/fix issue with serving Storybook related to contentSecurityPolicy (#4437)
* Fix issue with serving Storybook related to contentSecurityPolicy

* Update changelog
2025-03-15 11:57:01 +01:00
9e44023f86 Feature/improve usability of AI prompt actions (#4426)
* Improve usability of AI prompt actions

* Update changelog
2025-03-15 11:55:48 +01:00
d8dc02fc4a Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 20m41s
2025-03-14 00:00:53 -07:00
a4c78739bb Feature/improve width of user account registration dialog on mobile (#4434)
* Improve width on mobile
2025-03-14 08:00:07 +01:00
7535f3f2b4 Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 31m40s
2025-03-13 18:00:53 -07:00
f528c45c68 Feature/update locales 20250313 (#4433)
* 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>
2025-03-13 20:42:29 +01:00
9a35f1a611 Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 22m29s
2025-03-12 12:00:53 -07:00
97037e9481 Feature/upgrade to Nx 20.5 (#4430)
* Upgrade to Nx 20.5

* Update changelog
2025-03-12 14:27:11 +01:00
1eaea2618a Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 32m15s
2025-03-12 06:00:54 -07:00
a137bbbdca Feature/restructure user account registration flow (#4393)
* Restructure user account registration flow

* Update changelog
2025-03-12 13:42:25 +01:00
1cb33a407d Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 34m44s
2025-03-10 18:00:52 -07:00
9abc4af203 Release 2.145.1 (#4422) 2025-03-10 20:47:27 +01:00
70cd1a89b5 Feature/extend Ghostfolio data provider (#4420)
* Extend Ghostfolio data provider
2025-03-10 20:45:41 +01:00
27f5536f23 Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 20m22s
2025-03-10 12:00:53 -07:00
feea2ced82 Feature/update certificate for development (#4418)
* Update certificate for development

* Extend documentation
2025-03-10 17:40:38 +01:00
4368beebff Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 21m2s
2025-03-09 06:00:52 -07:00
7ca0720244 Release 2.145.0 (#4417) 2025-03-09 12:31:11 +01:00
6bdfd8984f Bugfix/fix fetching dividend and historical market data in Financial Modeling Prep service (#4416)
* Add default values

* Update changelog
2025-03-09 12:28:33 +01:00
52081d6741 Bugfix/exclude Storybook from html template middleware (#4414)
* Exclude storybook

* Update changelog
2025-03-09 12:07:23 +01:00
cb1f488eb4 Feature/extend export by account balances (#4390)
* Extend export by account balances

* Update changelog
2025-03-09 10:56:39 +01:00
25d0c1c8a0 Bugfix/fix symbols in API page (#4408)
* Fix symbols
2025-03-09 10:22:32 +01:00
443ff55295 Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 31m48s
2025-03-08 18:00:52 -08:00
d346f6b9fa Bugfix/fix changelog (#4412)
* Update changelog
2025-03-08 21:36:13 +01:00
bdde9dd314 Task/upgrade to simplewebauthn version 13.1 (#4407)
* Upgrade to simplewebauthn version 13.1

* Update changelog
2025-03-08 21:33:47 +01:00
f7185676b0 Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 34m15s
2025-03-08 00:00:53 -08:00
9438931849 Update locales (#4411)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-03-08 08:36:27 +01:00
cd2b26c1c2 Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 31m46s
2025-03-07 18:00:53 -08:00
ff73e7e0ee Feature/adapt style of X-ray rule to summary (#4394)
* Adapt style of X-ray rule to summary

* Update changelog
2025-03-07 21:34:29 +01:00
9278adc6ba Feature/update locales 20250307 (#4409)
* 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>
2025-03-07 21:12:55 +01:00
9db8c5ccef Merge branch 'main' of github.com:ghostfolio/ghostfolio
All checks were successful
Docker image CD / build_and_push (push) Successful in 31m35s
2025-03-07 12:00:54 -08:00
589eefaa76 Feature/extend AI prompt API by mode (#4395)
* Extend AI prompt API by mode

* Update changelog
2025-03-07 19:48:52 +01:00
b260c4f450 Feature/extend personal finance tools 20250306 (#4406)
* Extend personal finance tools

* CoinStats
* Fincake
* Koinly
* Nansen
* Simply Wall St
* Tradervue
2025-03-07 19:48:03 +01:00
513 changed files with 43732 additions and 31070 deletions

View File

@ -22,4 +22,3 @@ JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
# For more info, see: https://nx.dev/concepts/inferred-tasks
NX_ADD_PLUGINS=false
NX_NATIVE_COMMAND_RUNNER=false

42
.github/workflows/build-code.yml vendored Normal file
View File

@ -0,0 +1,42 @@
name: Build code
on:
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node_version:
- 22
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Use Node.js ${{ matrix.node_version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node_version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Check code style
run: npm run lint
- name: Check formatting
run: npm run format:check
- name: Execute tests
run: npm test
- name: Build application
run: npm run build:production

View File

@ -67,3 +67,4 @@ jobs:
webhook_auth: ${{ secrets.WEBHOOK_AUTH }}
webhook_secret: ${{ secrets.WEBHOOK_SECRET }}
webhook_auth_type: bearer

2
.gitignore vendored
View File

@ -27,8 +27,10 @@ npm-debug.log
# misc
/.angular/cache
.cursor/rules/nx-rules.mdc
.env
.env.prod
.github/instructions/nx.instructions.md
.nx/cache
.nx/workspace-data
/.sass-cache

2
.nvmrc
View File

@ -1 +1 @@
v20
v22

View File

@ -5,6 +5,598 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Changed
- Made the `getByKey()` function generic in the property service
## 2.176.0 - 2025-06-30
### Added
- Added support for generating a new _Security Token_ via the users account access panel
### Changed
- Moved the main content of the holding detail dialog to a new overview tab
- Introduced fuzzy search for the holdings of the assistant
- Introduced fuzzy search for the quick links of the assistant
- Improved the search results of the assistant to only display categories with content
- Enhanced the sitemap to dynamically compose public routes
- Renamed `Account` to `account` in the `Order` database schema
- Improved the language localization for German (`de`)
- Upgraded `prettier` from version `3.5.3` to `3.6.2`
## 2.175.0 - 2025-06-28
### Added
- Set up the language localization for the static portfolio analysis rule: _Asset Class Cluster Risks_ (Equity)
- Set up the language localization for the static portfolio analysis rule: _Asset Class Cluster Risks_ (Fixed Income)
- Set up the language localization for the static portfolio analysis rule: _Currency Cluster Risks_ (Investment)
- Set up the language localization for the static portfolio analysis rule: _Currency Cluster Risks_ (Investment: Base Currency)
### Changed
- Extended the selector handling of the scraper configuration for more use cases
- Extended the _AI_ service by an access to _OpenRouter_ (experimental)
- Changed `node main` to `exec node main` in the `entrypoint.sh` file to improve the container signal handling
- Renamed `Account` to `account` in the `AccountBalance` database schema
- Improved the language localization for Catalan (`ca`)
- Improved the language localization for Dutch (`nl`)
- Improved the language localization for Español (`es`)
- Improved the language localization for German (`de`)
- Improved the language localization for Turkish (`tr`)
### Fixed
- Fixed an issue with the locale in the scraper configuration
## 2.174.0 - 2025-06-24
### Added
- Set up the language localization for the static portfolio analysis rule: _Account Cluster Risks_ (Current Investment)
- Extended the data providers management of the admin control panel by the online status
### Changed
- Migrated the `@ghostfolio/ui/value` component to control flow
- Renamed `Platform` to `platform` in the `Account` database schema
- Refactored the health check endpoint for data enhancers
- Refactored the health check endpoint for data providers
- Improved the language localization for French (`fr`)
- Improved the language localization for German (`de`)
- Refreshed the cryptocurrencies list
## 2.173.0 - 2025-06-21
### Added
- Set up `open-color` for CSS variable usage
### Changed
- Simplified the data providers management of the admin control panel
- Migrated the `@ghostfolio/ui/assistant` component to control flow
- Migrated the `@ghostfolio/ui/value` component to control flow
- Renamed `GranteeUser` to `granteeUser` in the `Access` database schema
- Improved the language localization for French (`fr`)
- Improved the language localization for German (`de`)
- Upgraded `class-validator` from version `0.14.1` to `0.14.2`
- Upgraded `prisma` from version `6.9.0` to `6.10.1`
### Fixed
- Fixed an issue in the `HtmlTemplateMiddleware` related to incorrect variable resolution
- Eliminated the _Unsupported route path_ warning of the `LegacyRouteConverter` on startup
## 2.172.0 - 2025-06-19
### Added
- Set up the language localization for the static portfolio analysis rule: _Account Cluster Risks_ (Single Account)
- Included the admin control panel in the quick links of the assistant
### Changed
- Adapted the options of the date range selector in the assistant dynamically based on the users first activity
- Switched the data provider service to `OnModuleInit`, ensuring (currency) quotes are fetched only once
- Migrated the `@ghostfolio/ui/assistant` component to control flow
- Migrated the `@ghostfolio/ui/value` component to control flow
- Improved the language localization for Chinese (`zh`)
- Improved the language localization for Español (`es`)
- Improved the language localization for German (`de`)
- Improved the language localization for Portuguese (`pt`)
## 2.171.0 - 2025-06-15
### Added
- Added the current holdings as default options of the symbol search in the create or update activity dialog
### Changed
- Improved the style of the assistant
- Reused the value component in the data providers management of the admin control panel
- Set the market state of exchange rate symbols to `open` in the _Financial Modeling Prep_ service
- Restructured the content of the pricing page
- Migrated the `@ghostfolio/ui/assistant` component to control flow
- Migrated the `@ghostfolio/ui/value` component to control flow
- Migrated the `HtmlTemplateMiddleware` to use `@Injectable()`
- Renamed `User` to `user` in the database schema
- Improved the language localization for Catalan (`ca`)
- Improved the language localization for Español (`es`)
- Improved the language localization for French (`fr`)
- Improved the language localization for German (`de`)
- Improved the language localization for Italian (`it`)
- Improved the language localization for Polish (`pl`)
- Improved the language localization for Portuguese (`pt`)
- Improved the language localization for Turkish (`tr`)
- Upgraded the _Stripe_ dependencies
### Fixed
- Fixed a date offset issue with account balances
- Fixed missing `/.well-known/assetlinks.json` for TWA
## 2.170.0 - 2025-06-11
### Added
- Included quick links in the search results of the assistant
- Added a skeleton loader to the changelog page
- Extended the content of the _Self-Hosting_ section by information about additional data providers on the Frequently Asked Questions (FAQ) page
### Changed
- Renamed `ApiKey` to `apiKeys` in the `User` database schema
- Improved the language localization for French (`fr`)
- Improved the language localization for Portuguese (`pt`)
- Upgraded `@keyv/redis` from version `4.3.4` to `4.4.0`
- Upgraded `prisma` from version `6.8.2` to `6.9.0`
- Upgraded `zone.js` from version `0.15.0` to `0.15.1`
### Fixed
- Restricted the date range change permission in the _Zen Mode_
## 2.169.0 - 2025-06-08
### Changed
- Renamed the asset profile icon component to entity logo component and moved to `@ghostfolio/ui`
- Renamed `Account` to `accounts` in the `User` database schema
- Improved the cache verification in the health check endpoint (experimental)
- Improved the language localization for Catalan (`ca`)
- Improved the language localization for French (`fr`)
- Improved the language localization for Polish (`pl`)
### Fixed
- Handled an exception in the get keys function of the _Redis_ cache service
- Fixed missing `/.well-known/assetlinks.json` for TWA
## 2.168.0 - 2025-06-07
### Added
- Added a background gradient to the sidebar navigation
### Changed
- Migrated the `i18n` service to use `@Injectable()`
- Improved the language localization for German (`de`)
- Upgraded `nestjs` from version `11.1.0` to `11.1.3`
## 2.167.0 - 2025-06-07
### Added
- Added support for column sorting to the markets overview
- Added support for column sorting to the watchlist
- Set up the language localization for the static portfolio analysis rule: _Emergency Fund_ (Setup)
- Set up the language localization for the static portfolio analysis rule: _Fees_ (Fee Ratio)
### Changed
- Extended the symbol search component by default options
- Renamed `Tag` to `tags` in the `User` database schema
- Improved the language localization for German (`de`)
- Improved the language localization for Spanish (`es`)
- Improved the language localization for Turkish (`tr`)
- Upgraded `ng-extract-i18n-merge` from version `2.15.0` to `2.15.1`
- Upgraded `Nx` from version `20.8.1` to `21.1.2`
### Fixed
- Fixed an issue where the import button was not correctly enabled in the import activities dialog
- Fixed an issue with empty account balances in the import activities dialog
- Fixed an issue in the annualized performance calculation
## 2.166.0 - 2025-06-05
### Added
- Added support to create custom tags in the create or update activity dialog (experimental)
### Changed
- Improved the style of the card components
- Improved the style of the system message
- Improved the language localization for German (`de`)
- Improved the language localization for Spanish (`es`)
- Improved the language localization for Turkish (`tr`)
- Improved the language localization for Ukrainian (`uk`)
- Upgraded the _Stripe_ dependencies
- Upgraded `ngx-stripe` from version `19.0.0` to `19.7.0`
### Fixed
- Respected the filter by holding when deleting activities on the portfolio activities page
- Respected the filter by holding when exporting activities on the portfolio activities page
- Fixed an exception with currencies in the historical market data editor of the admin control panel
## 2.165.0 - 2025-05-31
### Added
- Extended the content of the _General_ section by the performance calculation method on the Frequently Asked Questions (FAQ) page
### Changed
- Improved the _Live Demo_ setup by syncing activities based on tags
- Renamed `orders` to `activities` in the `Tag` database schema
- Modularized the cron service
- Refreshed the cryptocurrencies list
- Improved the language localization for Catalan (`ca`)
- Improved the language localization for Dutch (`nl`)
- Improved the language localization for Polish (`pl`)
- Improved the language localization for Spanish (`es`)
- Upgraded `big.js` from version `6.2.2` to `7.0.1`
- Upgraded `ng-extract-i18n-merge` from version `2.14.3` to `2.15.0`
### Fixed
- Changed the investment value to take the currency effects into account in the holding detail dialog
## 2.164.0 - 2025-05-28
### Changed
- Improved the language localization for Dutch (`nl`)
- Improved the language localization for French (`fr`)
- Improved the language localization for Polish (`pl`)
- Improved the language localization for Spanish (`es`)
- Upgraded `Node.js` from version `20` to `22` (`Dockerfile`)
- Upgraded `yahoo-finance2` from version `3.3.4` to `3.3.5`
## 2.163.0 - 2025-05-26
### Changed
- Improved the language localization for Italian (`it`)
- Improved the language localization for Turkish (`tr`)
- Upgraded `yahoo-finance2` from version `3.3.3` to `3.3.4`
## 2.162.1 - 2025-05-24
### Added
- Added a hint about delayed market data to the markets overview
- Added the asset profile count per data provider to the endpoint `GET api/v1/admin`
### Changed
- Increased the robustness of the search in the _Yahoo Finance_ service by catching schema validation errors
- Improved the symbol lookup results by removing the currency from the name of cryptocurrencies (experimental)
- Harmonized the data providers management style of the admin control panel
- Extended the data providers management of the admin control panel by the asset profile count
- Restricted the permissions of the demo user
- Renamed `Order` to `activities` in the `User` database schema
- Removed the deprecated endpoint `GET api/v1/admin/market-data/:dataSource/:symbol`
- Removed the deprecated endpoint `POST api/v1/admin/market-data/:dataSource/:symbol`
- Removed the deprecated endpoint `PUT api/v1/admin/market-data/:dataSource/:symbol/:dateString`
- Improved the language localization for Catalan (`ca`)
- Improved the language localization for Chinese (`zh`)
- Improved the language localization for Dutch (`nl`)
- Improved the language localization for French (`fr`)
- Improved the language localization for German (`de`)
- Improved the language localization for Italian (`it`)
- Improved the language localization for Polish (`pl`)
- Improved the language localization for Portuguese (`pt`)
- Improved the language localization for Spanish (`es`)
- Upgraded `countup.js` from version `2.8.0` to `2.8.2`
- Upgraded `nestjs` from version `10.4.15` to `11.0.12`
- Upgraded `prisma` from version `6.7.0` to `6.8.2`
- Upgraded `twitter-api-v2` from version `1.14.2` to `1.23.0`
- Upgraded `yahoo-finance2` from version `2.11.3` to `3.3.3`
### Fixed
- Displayed the button to fetch the current market price only if the activity is not in a custom currency
- Fixed an issue in the watchlist endpoint (`POST`) related to the `HasPermissionGuard`
- Improved the text alignment of the allocations by ETF holding on the allocations page (experimental)
## 2.161.0 - 2025-05-06
### Added
- Extended the endpoint to get a holding by the date of the last all time high and the current change to the all time high
### Changed
- Renamed `Order` to `activities` in the `SymbolProfile` database schema
- Improved the language localization for Turkish (`tr`)
### Fixed
- Fixed an issue in the performance calculation on the date of an activity when the unit price differs from the market price
- Fixed the horizontal overflow in the table of the benchmark component
## 2.160.0 - 2025-05-04
### Added
- Added the watchlist to the features page
- Extended the content of the Frequently Asked Questions (FAQ) pages
### Changed
- Moved the watchlist from experimental to general availability
- Deprecated the endpoint to get a portfolio position in favor of get a holding
- Deprecated the endpoint to update portfolio position tags in favor of update holding tags
- Renamed `Account` to `accounts` in the `Platform` database schema
- Upgraded `prisma` from version `6.6.0` to `6.7.0`
### Fixed
- Fixed an issue with the fee calculations related to activities in a custom currency
## 2.159.0 - 2025-05-02
### Added
- Extended the watchlist by the date of the last all time high, the current change to the all time high and the current market condition (experimental)
- Added support for the impersonation mode in the watchlist (experimental)
### Changed
- Improved the language localization for French (`fr`)
- Upgraded `bootstrap` from version `4.6.0` to `4.6.2`
### Fixed
- Fixed the currency code validation by allowing `GBp`
## 2.158.0 - 2025-04-30
### Added
- Added support to delete an asset from the watchlist (experimental)
### Changed
- Renamed `Order` to `activities` in the `Account` database schema
- Improved the language localization for German (`de`)
### Fixed
- Fixed an issue with the saving of activities with type `INTEREST`, `ITEM` and `LIABILITY`
## 2.157.1 - 2025-04-29
### Added
- Introduced a watchlist to follow assets (experimental)
### Changed
- Changed the column label from _Index_ to _Name_ in the benchmark component
- Extended the data providers management of the admin control panel
- Improved the language localization for German (`de`)
## 2.156.0 - 2025-04-27
### Changed
- Improved the error message of the currency code validation
- Tightened the currency code validation by requiring uppercase letters
- Respected the watcher count for the delete asset profiles checkbox in the historical market data table of the admin control panel
- Improved the language localization for French (`fr`)
- Upgraded `ngx-skeleton-loader` from version `10.0.0` to `11.0.0`
- Upgraded `Nx` from version `20.8.0` to `20.8.1`
### Fixed
- Fixed an issue with the investment calculation for activities in a custom currency
- Improved the file selector of the activities import functionality to accept case-insensitive file extensions (`.CSV` and `.JSON`)
- Fixed the missing localization for "someone" on the public page
## 2.155.0 - 2025-04-23
### Added
- Added the endpoints (`DELETE`, `GET` and `POST`) for the watchlist
### Changed
- Simplified the data source check in the DTO of the activity creation
- Simplified the data source check in the DTO of the asset profile update
- Renamed `User` to `user` in the `Subscription` database schema
- Migrated the `@ghostfolio/ui/assistant` component to control flow
- Migrated the `@ghostfolio/ui/value` component to control flow
### Fixed
- Fixed an issue in the settings dialog to customize the rule thresholds of the _X-ray_ page (experimental)
## 2.154.0 - 2025-04-21
### Added
- Extended the benchmark detail dialog by the current market price
- Added the performance calculation type to the user settings (experimental)
- Added `watchlist` to the `User` database schema as a preparation for watching assets
### Changed
- Made the historical market data editor expandable in the admin control panel
- Renamed `Subscription` to `subscriptions` in the `User` database schema
- Parallelized the requests in the get quotes functionality of the _Financial Modeling Prep_ service
- Migrated the lookup functionality by `isin` of the _Financial Modeling Prep_ service to its stable API version
- Improved the language localization for German (`de`)
### Fixed
- Fixed the word wrap in the menu of the historical market data table in the admin control panel
## 2.153.0 - 2025-04-18
### Changed
- Added support for activities in a custom currency
- Refreshed the cryptocurrencies list
- Upgraded `chart.js` from version `4.4.7` to `4.4.9`
- Upgraded `uuid` from version `11.0.5` to `11.1.0`
### Fixed
- Fixed the functionality to open an asset profile of a custom currency in the admin control panel
- Fixed the asset class parsing in the _Financial Modeling Prep_ service for exchange rates
## 2.152.1 - 2025-04-17
### Changed
- Deactivated asset profiles automatically on delisting in the _Yahoo Finance_ service
- Optimized the query of the data range functionality (`getRange()`) in the market data service
- Moved the subscription offer from the info to the user service
- Upgraded `Nx` from version `20.7.1` to `20.8.0`
- Upgraded `prisma` from version `6.5.0` to `6.6.0`
- Upgraded `storybook` from version `8.4.7` to `8.6.12`
## 2.151.0 - 2025-04-11
### Added
- Added the data gathering status column to the historical market data table of the admin control
### Changed
- Set the maximum number of symbols per request in the _Financial Modeling Prep_ service
- Migrated the get quotes functionality of the _Financial Modeling Prep_ service to its stable API version
- Improved the language localization for Enlish (`en`)
- Upgraded `eslint` dependencies
- Upgraded `Nx` from version `20.6.4` to `20.7.1`
### Fixed
- Fixed the link to the pricing page in the premium indicator component
## 2.150.0 - 2025-04-05
### Added
- Added support to toggle the data gathering for individual asset profiles in the asset profile details dialog of the admin control panel
### Changed
- Improved the check for duplicates in the preview step of the activities import (allow different comments)
- Improved the language localization for French (`fr`)
- Improved the language localization for German (`de`)
- Improved the language localization for Polish (`pl`)
- Upgraded `ng-extract-i18n-merge` from version `2.14.1` to `2.14.3`
## 2.149.0 - 2025-03-30
### Added
- Added support for changing the asset profile identifier (`dataSource` and `symbol`) in the asset profile details dialog of the admin control panel (experimental)
- Set up the terms of service for the _Ghostfolio_ SaaS (cloud)
### Changed
- Improved the static portfolio analysis rule: Emergency fund setup by supporting assets
- Restricted the historical market data gathering to active asset profiles
- Improved the language localization for German (`de`)
- Upgraded `Nx` from version `20.5.0` to `20.6.4`
## 2.148.0 - 2025-03-24
### Added
- Added the `isActive` flag to the asset profile model
### Changed
- Improved the language localization for German (`de`)
- Upgraded `ngx-skeleton-loader` from version `9.0.0` to `10.0.0`
## 2.147.0 - 2025-03-22
### Added
- Added support for filtering in the _Copy AI prompt to clipboard_ actions on the analysis page (experimental)
- Added support for generating a new _Security Token_ via the users table of the admin control panel
- Added an endpoint to localize the `site.webmanifest`
- Added the _Storybook_ path to the `sitemap.xml` file
### Changed
- Improved the export functionality by applying filters on accounts and tags
- Improved the symbol validation in the _Yahoo Finance_ service (get asset profiles)
- Eliminated `firstOrderDate` from the summary of the portfolio details endpoint in favor of using `dateOfFirstActivity` from the user endpoint
- Refactored `lodash.uniq` with `Array.from(new Set(...))`
- Refreshed the cryptocurrencies list
- Improved the language localization for German (`de`)
- Improved the language localization for Turkish (`tr`)
### Fixed
- Fixed an issue in the activities import functionality related to the account balances
- Changed client-side dates to be sent in UTC format to ensure date consistency
- Benchmark endpoint
- Exchange rate endpoint
## 2.146.0 - 2025-03-15
### Changed
- Improved the usability of the user account registration
- Improved the usability of the _Copy AI prompt to clipboard_ actions on the analysis page (experimental)
- Formatted the name in the _Financial Modeling Prep_ service
- Removed the exchange rates from the overview of the admin control panel
- Improved the language localization for German (`de`)
- Upgraded `angular` from version `19.0.5` to `19.2.1`
- Upgraded `Nx` from version `20.3.2` to `20.5.0`
- Upgraded `prettier` from version `3.5.1` to `3.5.3`
- Upgraded `prisma` from version `6.4.1` to `6.5.0`
### Fixed
- Fixed an issue with serving _Storybook_ related to the `contentSecurityPolicy`
## 2.145.1 - 2025-03-10
### Added
- Extended the export functionality by the account balances
- Added a _Copy portfolio data to clipboard for AI prompt_ action to the analysis page (experimental)
### Changed
- Improved the style of the summary on the _X-ray_ page
- Improved the language localization for German (`de`)
- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `9.0` to `13.1`
### Fixed
- Fixed an issue to get dividends in the _Financial Modeling Prep_ service
- Fixed an issue to get historical market data in the _Financial Modeling Prep_ service
- Fixed an issue with serving _Storybook_
## 2.144.0 - 2025-03-06
### Fixed
@ -3367,7 +3959,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added the language localization for Français (`fr`)
- Added the language localization for French (`fr`)
- Extended the landing page by a global heat map of subscribers
- Added support for the thousand separator in the global heat map component
@ -3396,7 +3988,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added support for the dividend timeline grouped by year
- Added support for the investment timeline grouped by year
- Set up the language localization for Français (`fr`)
- Set up the language localization for French (`fr`)
### Changed
@ -3505,7 +4097,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Improved the value redaction interceptor (including `comment`)
- Improved the language localization for Español (`es`)
- Improved the language localization for Spanish (`es`)
- Upgraded `cheerio` from version `1.0.0-rc.6` to `1.0.0-rc.12`
- Upgraded `prisma` from version `4.6.1` to `4.7.1`
@ -3734,7 +4326,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Improved the usage of the value component in the admin control panel
- Improved the language localization for Español (`es`)
- Improved the language localization for Spanish (`es`)
### Fixed
@ -3756,7 +4348,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Set up the language localization for Español (`es`)
- Set up the language localization for Spanish (`es`)
- Added support for sectors in mutual funds
## 1.198.0 - 25.09.2022
@ -5539,7 +6131,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Changed the navigation to always show the portfolio page
- Migrated the data type of currencies from `enum` to `string` in the database
- Supported unlimited currencies (instead of `CHF`, `EUR`, `GBP` and `USD`)
- Respected the accounts' currencies in the exchange rate service
- Respected the accounts currencies in the exchange rate service
### Fixed

View File

@ -5,7 +5,7 @@
### Prerequisites
- [Docker](https://www.docker.com/products/docker-desktop)
- [Node.js](https://nodejs.org/en/download) (version 20+)
- [Node.js](https://nodejs.org/en/download) (version 22+)
- Create a local copy of this Git repository (clone)
- Copy the file `.env.dev` to `.env` and populate it with your data (`cp .env.dev .env`)
@ -30,7 +30,13 @@ Run `npm run start:server`
### Start Client
Run `npm run start:client` and open https://localhost:4200/en in your browser
#### English (Default)
Run `npm run start:client` and open https://localhost:4200/en in your browser.
#### Other Languages
To start the client in a different language, such as German (`de`), adapt the `start:client` script in the `package.json` file by changing `--configuration=development-en` to `--configuration=development-de`. Then, run `npm run start:client` and open https://localhost:4200/de in your browser.
### Start _Storybook_
@ -60,6 +66,10 @@ Remove permission in `UserService` using `without()`
Use `@if (user?.settings?.isExperimentalFeatures) {}` in HTML template
## Component Library (_Storybook_)
https://ghostfol.io/development/storybook
## Git
### Rebase
@ -101,3 +111,12 @@ https://www.prisma.io/docs/concepts/components/prisma-migrate/db-push
Run `npm run prisma migrate dev --name added_job_title`
https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate
## SSL
Generate `localhost.cert` and `localhost.pem` files.
```
openssl req -x509 -newkey rsa:2048 -nodes -keyout apps/client/localhost.pem -out apps/client/localhost.cert -days 365 \
-subj "/C=CH/ST=State/L=City/O=Organization/OU=Unit/CN=localhost"
```

View File

@ -1,4 +1,4 @@
FROM --platform=$BUILDPLATFORM node:20-slim AS builder
FROM --platform=$BUILDPLATFORM node:22-slim AS builder
# Build application and add additional files
WORKDIR /ghostfolio
@ -33,24 +33,25 @@ COPY ./nx.json nx.json
COPY ./replace.build.mjs replace.build.mjs
COPY ./tsconfig.base.json tsconfig.base.json
ENV NX_DAEMON=false
RUN npm run build:production
# Prepare the dist image with additional node_modules
WORKDIR /ghostfolio/dist/apps/api
# package.json was generated by the build process, however the original
# package-lock.json needs to be used to ensure the same versions
# package-lock.json needs to be used to ensure the same versions
COPY ./package-lock.json /ghostfolio/dist/apps/api/package-lock.json
RUN npm install
COPY prisma /ghostfolio/dist/apps/api/prisma
# 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
RUN npm run database:generate-typings
# Image to run, copy everything needed from builder
FROM node:20-slim
FROM node:22-slim
LABEL org.opencontainers.image.source="https://github.com/ghostfolio/ghostfolio"
ENV NODE_ENV=production

View File

@ -7,7 +7,7 @@
**Open Source Wealth Management Software**
[**Ghostfol.io**](https://ghostfol.io) | [**Live Demo**](https://ghostfol.io/en/demo) | [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) | [**FAQ**](https://ghostfol.io/en/faq) |
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**X**](https://x.com/ghostfolio_)
[**Blog**](https://ghostfol.io/en/blog) | [**LinkedIn**](https://www.linkedin.com/company/ghostfolio) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**X**](https://x.com/ghostfolio_)
[![Shield: Buy me a coffee](https://img.shields.io/badge/Buy%20me%20a%20coffee-Support-yellow?logo=buymeacoffee)](https://www.buymeacoffee.com/ghostfolio)
[![Shield: Contributions Welcome](https://img.shields.io/badge/Contributions-Welcome-limegreen.svg)](#contributing) [![Shield: Docker Pulls](https://img.shields.io/docker/pulls/ghostfolio/ghostfolio?label=Docker%20Pulls)](https://hub.docker.com/r/ghostfolio/ghostfolio)
@ -47,7 +47,7 @@ Ghostfolio is for you if you are...
- ✅ Create, update and delete transactions
- ✅ Multi account management
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `WTD`, `MTD`, `YTD`, `1Y`, `5Y`, `Max`
- ✅ Portfolio performance: Return on Average Investment (ROAI) for `Today`, `WTD`, `MTD`, `YTD`, `1Y`, `5Y`, `Max`
- ✅ Various charts
- ✅ Static analysis to identify potential risks in your portfolio
- ✅ Import and export transactions

View File

@ -37,20 +37,20 @@ export class AccessController {
public async getAllAccesses(): Promise<Access[]> {
const accessesWithGranteeUser = await this.accessService.accesses({
include: {
GranteeUser: true
granteeUser: true
},
orderBy: { granteeUserId: 'asc' },
where: { userId: this.request.user.id }
});
return accessesWithGranteeUser.map(
({ alias, GranteeUser, id, permissions }) => {
if (GranteeUser) {
({ alias, granteeUser, id, permissions }) => {
if (granteeUser) {
return {
alias,
id,
permissions,
grantee: GranteeUser?.id,
grantee: granteeUser?.id,
type: 'PRIVATE'
};
}
@ -85,11 +85,11 @@ export class AccessController {
try {
return this.accessService.createAccess({
alias: data.alias || undefined,
GranteeUser: data.granteeUserId
granteeUser: data.granteeUserId
? { connect: { id: data.granteeUserId } }
: undefined,
permissions: data.permissions,
User: { connect: { id: this.request.user.id } }
user: { connect: { id: this.request.user.id } }
});
} catch {
throw new HttpException(

View File

@ -13,7 +13,7 @@ export class AccessService {
): Promise<AccessWithGranteeUser | null> {
return this.prismaService.access.findFirst({
include: {
GranteeUser: true
granteeUser: true
},
where: accessWhereInput
});

View File

@ -30,7 +30,7 @@ export class AccountBalanceService {
): Promise<AccountBalance | null> {
return this.prismaService.accountBalance.findFirst({
include: {
Account: true
account: true
},
where: accountBalanceWhereInput
});
@ -46,7 +46,7 @@ export class AccountBalanceService {
}): Promise<AccountBalance> {
const accountBalance = await this.prismaService.accountBalance.upsert({
create: {
Account: {
account: {
connect: {
id_userId: {
userId,
@ -154,7 +154,7 @@ export class AccountBalanceService {
}
if (withExcludedAccounts === false) {
where.Account = { isExcluded: false };
where.account = { isExcluded: false };
}
const balances = await this.prismaService.accountBalance.findMany({
@ -163,7 +163,7 @@ export class AccountBalanceService {
date: 'asc'
},
select: {
Account: true,
account: true,
date: true,
id: true,
value: true
@ -174,10 +174,10 @@ export class AccountBalanceService {
balances: balances.map((balance) => {
return {
...balance,
accountId: balance.Account.id,
accountId: balance.account.id,
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
balance.value,
balance.Account.currency,
balance.account.currency,
userCurrency
)
};

View File

@ -9,7 +9,7 @@ import { ImpersonationService } from '@ghostfolio/api/services/impersonation/imp
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import {
AccountBalancesResponse,
Accounts
AccountsResponse
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import type {
@ -57,17 +57,17 @@ export class AccountController {
@HasPermission(permissions.deleteAccount)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteAccount(@Param('id') id: string): Promise<AccountModel> {
const account = await this.accountService.accountWithOrders(
const account = await this.accountService.accountWithActivities(
{
id_userId: {
id,
userId: this.request.user.id
}
},
{ Order: true }
{ activities: true }
);
if (!account || account?.Order.length > 0) {
if (!account || account?.activities.length > 0) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
@ -87,10 +87,10 @@ export class AccountController {
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getAllAccounts(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string
): Promise<Accounts> {
): Promise<AccountsResponse> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
@ -110,7 +110,7 @@ export class AccountController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor)
public async getAccountById(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('id') id: string
): Promise<AccountWithValue> {
const impersonationUserId =
@ -152,8 +152,8 @@ export class AccountController {
return this.accountService.createAccount(
{
...data,
Platform: { connect: { id: platformId } },
User: { connect: { id: this.request.user.id } }
platform: { connect: { id: platformId } },
user: { connect: { id: this.request.user.id } }
},
this.request.user.id
);
@ -163,7 +163,7 @@ export class AccountController {
return this.accountService.createAccount(
{
...data,
User: { connect: { id: this.request.user.id } }
user: { connect: { id: this.request.user.id } }
},
this.request.user.id
);
@ -250,8 +250,8 @@ export class AccountController {
{
data: {
...data,
Platform: { connect: { id: platformId } },
User: { connect: { id: this.request.user.id } }
platform: { connect: { id: platformId } },
user: { connect: { id: this.request.user.id } }
},
where: {
id_userId: {
@ -270,10 +270,10 @@ export class AccountController {
{
data: {
...data,
Platform: originalAccount.platformId
platform: originalAccount.platformId
? { disconnect: true }
: undefined,
User: { connect: { id: this.request.user.id } }
user: { connect: { id: this.request.user.id } }
},
where: {
id_userId: {

View File

@ -7,7 +7,13 @@ import { Filter } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Account, Order, Platform, Prisma } from '@prisma/client';
import {
Account,
AccountBalance,
Order,
Platform,
Prisma
} from '@prisma/client';
import { Big } from 'big.js';
import { format } from 'date-fns';
import { groupBy } from 'lodash';
@ -33,12 +39,12 @@ export class AccountService {
return account;
}
public async accountWithOrders(
public async accountWithActivities(
accountWhereUniqueInput: Prisma.AccountWhereUniqueInput,
accountInclude: Prisma.AccountInclude
): Promise<
Account & {
Order?: Order[];
activities?: Order[];
}
> {
return this.prismaService.account.findUnique({
@ -56,13 +62,19 @@ export class AccountService {
orderBy?: Prisma.AccountOrderByWithRelationInput;
}): Promise<
(Account & {
Order?: Order[];
Platform?: Platform;
activities?: Order[];
balances?: AccountBalance[];
platform?: Platform;
})[]
> {
const { include = {}, skip, take, cursor, where, orderBy } = params;
include.balances = { orderBy: { date: 'desc' }, take: 1 };
const isBalancesIncluded = !!include.balances;
include.balances = {
orderBy: { date: 'desc' },
...(isBalancesIncluded ? {} : { take: 1 })
};
const accounts = await this.prismaService.account.findMany({
cursor,
@ -76,7 +88,9 @@ export class AccountService {
return accounts.map((account) => {
account = { ...account, balance: account.balances[0]?.value ?? 0 };
delete account.balances;
if (!isBalancesIncluded) {
delete account.balances;
}
return account;
});
@ -126,7 +140,10 @@ export class AccountService {
public async getAccounts(aUserId: string): Promise<Account[]> {
const accounts = await this.accounts({
include: { Order: true, Platform: true },
include: {
activities: true,
platform: true
},
orderBy: { name: 'asc' },
where: { userId: aUserId }
});
@ -134,15 +151,15 @@ export class AccountService {
return accounts.map((account) => {
let transactionCount = 0;
for (const order of account.Order) {
if (!order.isDraft) {
for (const { isDraft } of account.activities) {
if (!isDraft) {
transactionCount += 1;
}
}
const result = { ...account, transactionCount };
delete result.Order;
delete result.activities;
return result;
});

View File

@ -3,22 +3,22 @@ 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 { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.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 {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS
} from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import {
AdminData,
AdminMarketData,
AdminMarketDataDetails,
AdminUsers,
EnhancedSymbolProfile
EnhancedSymbolProfile,
ScraperConfiguration
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import type {
@ -50,8 +50,6 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AdminService } from './admin.service';
import { UpdateAssetProfileDto } from './update-asset-profile.dto';
import { UpdateBulkMarketDataDto } from './update-bulk-market-data.dto';
import { UpdateMarketDataDto } from './update-market-data.dto';
@Controller('admin')
export class AdminController {
@ -59,8 +57,8 @@ export class AdminController {
private readonly adminService: AdminService,
private readonly apiService: ApiService,
private readonly dataGatheringService: DataGatheringService,
private readonly demoService: DemoService,
private readonly manualService: ManualService,
private readonly marketDataService: MarketDataService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@ -68,7 +66,14 @@ export class AdminController {
@HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getAdminData(): Promise<AdminData> {
return this.adminService.get();
return this.adminService.get({ user: this.request.user });
}
@Get('demo-user/sync')
@HasPermission(permissions.syncDemoUserAccount)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async syncDemoUserAccount(): Promise<Prisma.BatchPayload> {
return this.demoService.syncDemoUserAccount();
}
@HasPermission(permissions.accessAdminControl)
@ -83,7 +88,7 @@ export class AdminController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherMax(): Promise<void> {
const assetProfileIdentifiers =
await this.dataGatheringService.getAllAssetProfileIdentifiers();
await this.dataGatheringService.getAllActiveAssetProfileIdentifiers();
await this.dataGatheringService.addJobsToQueue(
assetProfileIdentifiers.map(({ dataSource, symbol }) => {
@ -92,9 +97,9 @@ export class AdminController {
dataSource,
symbol
},
name: GATHER_ASSET_PROFILE_PROCESS,
name: GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
...GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
jobId: getAssetProfileIdentifier({ dataSource, symbol }),
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
}
@ -110,7 +115,7 @@ export class AdminController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherProfileData(): Promise<void> {
const assetProfileIdentifiers =
await this.dataGatheringService.getAllAssetProfileIdentifiers();
await this.dataGatheringService.getAllActiveAssetProfileIdentifiers();
await this.dataGatheringService.addJobsToQueue(
assetProfileIdentifiers.map(({ dataSource, symbol }) => {
@ -119,9 +124,9 @@ export class AdminController {
dataSource,
symbol
},
name: GATHER_ASSET_PROFILE_PROCESS,
name: GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
...GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
jobId: getAssetProfileIdentifier({ dataSource, symbol }),
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
}
@ -142,9 +147,9 @@ export class AdminController {
dataSource,
symbol
},
name: GATHER_ASSET_PROFILE_PROCESS,
name: GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
...GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
jobId: getAssetProfileIdentifier({ dataSource, symbol }),
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
}
@ -214,30 +219,16 @@ export class AdminController {
});
}
/**
* @deprecated
*/
@Get('market-data/:dataSource/:symbol')
@HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getMarketDataBySymbol(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<AdminMarketDataDetails> {
return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
}
@HasPermission(permissions.accessAdminControl)
@Post('market-data/:dataSource/:symbol/test')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async testMarketData(
@Body() data: { scraperConfiguration: string },
@Body() data: { scraperConfiguration: ScraperConfiguration },
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<{ price: number }> {
try {
const scraperConfiguration = JSON.parse(data.scraperConfiguration);
const price = await this.manualService.test(scraperConfiguration);
const price = await this.manualService.test(data.scraperConfiguration);
if (price) {
return { price };
@ -253,58 +244,6 @@ export class AdminController {
}
}
/**
* @deprecated
*/
@HasPermission(permissions.accessAdminControl)
@Post('market-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateMarketData(
@Body() data: UpdateBulkMarketDataDto,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
) {
const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map(
({ date, marketPrice }) => ({
dataSource,
marketPrice,
symbol,
date: parseISO(date),
state: 'CLOSE'
})
);
return this.marketDataService.updateMany({
data: dataBulkUpdate
});
}
/**
* @deprecated
*/
@HasPermission(permissions.accessAdminControl)
@Put('market-data/:dataSource/:symbol/:dateString')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async update(
@Param('dataSource') dataSource: DataSource,
@Param('dateString') dateString: string,
@Param('symbol') symbol: string,
@Body() data: UpdateMarketDataDto
) {
const date = parseISO(dateString);
return this.marketDataService.updateMarketData({
data: { marketPrice: data.marketPrice, state: 'CLOSE' },
where: {
dataSource_date_symbol: {
dataSource,
date,
symbol
}
}
});
}
@HasPermission(permissions.accessAdminControl)
@Post('profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@ -334,15 +273,14 @@ export class AdminController {
@Patch('profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async patchAssetProfileData(
@Body() assetProfileData: UpdateAssetProfileDto,
@Body() assetProfile: UpdateAssetProfileDto,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<EnhancedSymbolProfile> {
return this.adminService.patchAssetProfileData({
...assetProfileData,
dataSource,
symbol
});
return this.adminService.patchAssetProfileData(
{ dataSource, symbol },
assetProfile
);
}
@HasPermission(permissions.accessAdminControl)

View File

@ -1,10 +1,10 @@
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { DemoModule } from '@ghostfolio/api/services/demo/demo.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
@ -25,13 +25,13 @@ import { QueueModule } from './queue/queue.module';
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
DemoModule,
ExchangeRateDataModule,
MarketDataModule,
OrderModule,
PrismaModule,
PropertyModule,
QueueModule,
SubscriptionModule,
SymbolProfileModule,
TransformDataSourceInRequestModule
],

View File

@ -1,5 +1,4 @@
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { environment } from '@ghostfolio/api/environments/environment';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
@ -10,7 +9,6 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import {
DEFAULT_CURRENCY,
PROPERTY_CURRENCIES,
PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_IS_USER_SIGNUP_ENABLED
@ -31,9 +29,14 @@ import {
Filter
} from '@ghostfolio/common/interfaces';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { MarketDataPreset } from '@ghostfolio/common/types';
import { MarketDataPreset, UserWithSettings } from '@ghostfolio/common/types';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import {
BadRequestException,
HttpException,
Injectable,
Logger
} from '@nestjs/common';
import {
AssetClass,
AssetSubClass,
@ -44,6 +47,7 @@ import {
SymbolProfile
} from '@prisma/client';
import { differenceInDays } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { groupBy } from 'lodash';
@Injectable()
@ -57,7 +61,6 @@ export class AdminService {
private readonly orderService: OrderService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService,
private readonly symbolProfileService: SymbolProfileService
) {}
@ -111,9 +114,8 @@ export class AdminService {
await this.marketDataService.deleteMany({ dataSource, symbol });
const currency = getCurrencyFromSymbol(symbol);
const customCurrencies = (await this.propertyService.getByKey(
PROPERTY_CURRENCIES
)) as string[];
const customCurrencies =
await this.propertyService.getByKey<string[]>(PROPERTY_CURRENCIES);
if (customCurrencies.includes(currency)) {
const updatedCustomCurrencies = customCurrencies.filter(
@ -131,31 +133,11 @@ export class AdminService {
}
}
public async get(): Promise<AdminData> {
const exchangeRates = this.exchangeRateDataService
.getCurrencies()
.filter((currency) => {
return currency !== DEFAULT_CURRENCY;
})
.map((currency) => {
const label1 = DEFAULT_CURRENCY;
const label2 = currency;
return {
label1,
label2,
dataSource:
DataSource[
this.configurationService.get('DATA_SOURCE_EXCHANGE_RATES')
],
symbol: `${label1}${label2}`,
value: this.exchangeRateDataService.toCurrency(
1,
DEFAULT_CURRENCY,
currency
)
};
});
public async get({ user }: { user: UserWithSettings }): Promise<AdminData> {
const dataSources = await this.dataProviderService.getDataSources({
user,
includeGhostfolio: true
});
const [settings, transactionCount, userCount] = await Promise.all([
this.propertyService.get(),
@ -163,8 +145,27 @@ export class AdminService {
this.countUsersWithAnalytics()
]);
const dataProviders = await Promise.all(
dataSources.map(async (dataSource) => {
const dataProviderInfo = this.dataProviderService
.getDataProvider(dataSource)
.getDataProviderInfo();
const assetProfileCount = await this.prismaService.symbolProfile.count({
where: {
dataSource
}
});
return {
...dataProviderInfo,
assetProfileCount
};
})
);
return {
exchangeRates,
dataProviders,
settings,
transactionCount,
userCount,
@ -243,7 +244,7 @@ export class AdminService {
if (sortColumn === 'activitiesCount') {
orderBy = {
Order: {
activities: {
_count: sortDirection
}
};
@ -261,7 +262,15 @@ export class AdminService {
where,
select: {
_count: {
select: { Order: true }
select: {
activities: true,
watchedBy: true
}
},
activities: {
orderBy: [{ date: 'asc' }],
select: { date: true },
take: 1
},
assetClass: true,
assetSubClass: true,
@ -270,13 +279,9 @@ export class AdminService {
currency: true,
dataSource: true,
id: true,
isActive: true,
isUsedByUsersWithSubscription: true,
name: true,
Order: {
orderBy: [{ date: 'asc' }],
select: { date: true },
take: 1
},
scraperConfiguration: true,
sectors: true,
symbol: true,
@ -323,6 +328,7 @@ export class AdminService {
assetProfiles.map(
async ({
_count,
activities,
assetClass,
assetSubClass,
comment,
@ -330,9 +336,9 @@ export class AdminService {
currency,
dataSource,
id,
isActive,
isUsedByUsersWithSubscription,
name,
Order,
sectors,
symbol,
SymbolProfileOverrides
@ -388,14 +394,17 @@ export class AdminService {
countriesCount,
dataSource,
id,
isActive,
lastMarketPrice,
name,
symbol,
marketDataItemCount,
sectorsCount,
activitiesCount: _count.Order,
date: Order?.[0]?.date,
isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription
activitiesCount: _count.activities,
date: activities?.[0]?.date,
isUsedByUsersWithSubscription:
await isUsedByUsersWithSubscription,
watchedByCount: _count.watchedBy
};
}
)
@ -471,7 +480,8 @@ export class AdminService {
currency,
dataSource,
dateOfFirstActivity,
symbol
symbol,
isActive: true
}
};
}
@ -491,61 +501,126 @@ export class AdminService {
return { count, users };
}
public async patchAssetProfileData({
assetClass,
assetSubClass,
comment,
countries,
currency,
dataSource,
holdings,
name,
scraperConfiguration,
sectors,
symbol,
symbolMapping,
url
}: AssetProfileIdentifier & Prisma.SymbolProfileUpdateInput) {
const symbolProfileOverrides = {
assetClass: assetClass as AssetClass,
assetSubClass: assetSubClass as AssetSubClass,
name: name as string,
url: url as string
};
const updatedSymbolProfile: AssetProfileIdentifier &
Prisma.SymbolProfileUpdateInput = {
public async patchAssetProfileData(
{ dataSource, symbol }: AssetProfileIdentifier,
{
assetClass,
assetSubClass,
comment,
countries,
currency,
dataSource,
dataSource: newDataSource,
holdings,
isActive,
name,
scraperConfiguration,
sectors,
symbol,
symbol: newSymbol,
symbolMapping,
...(dataSource === 'MANUAL'
? { assetClass, assetSubClass, name, url }
: {
SymbolProfileOverrides: {
upsert: {
create: symbolProfileOverrides,
update: symbolProfileOverrides
}
}
})
};
url
}: Prisma.SymbolProfileUpdateInput
) {
if (
newSymbol &&
newDataSource &&
(newSymbol !== symbol || newDataSource !== dataSource)
) {
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
{
dataSource: DataSource[newDataSource.toString()],
symbol: newSymbol as string
}
]);
await this.symbolProfileService.updateSymbolProfile(updatedSymbolProfile);
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
{
dataSource,
symbol
if (assetProfile) {
throw new HttpException(
getReasonPhrase(StatusCodes.CONFLICT),
StatusCodes.CONFLICT
);
}
]);
return symbolProfile;
try {
Promise.all([
await this.symbolProfileService.updateAssetProfileIdentifier(
{
dataSource,
symbol
},
{
dataSource: DataSource[newDataSource.toString()],
symbol: newSymbol as string
}
),
await this.marketDataService.updateAssetProfileIdentifier(
{
dataSource,
symbol
},
{
dataSource: DataSource[newDataSource.toString()],
symbol: newSymbol as string
}
)
]);
return this.symbolProfileService.getSymbolProfiles([
{
dataSource: DataSource[newDataSource.toString()],
symbol: newSymbol as string
}
])?.[0];
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
} else {
const symbolProfileOverrides = {
assetClass: assetClass as AssetClass,
assetSubClass: assetSubClass as AssetSubClass,
name: name as string,
url: url as string
};
const updatedSymbolProfile: Prisma.SymbolProfileUpdateInput = {
comment,
countries,
currency,
dataSource,
holdings,
isActive,
scraperConfiguration,
sectors,
symbol,
symbolMapping,
...(dataSource === 'MANUAL'
? { assetClass, assetSubClass, name, url }
: {
SymbolProfileOverrides: {
upsert: {
create: symbolProfileOverrides,
update: symbolProfileOverrides
}
}
})
};
await this.symbolProfileService.updateSymbolProfile(
{
dataSource,
symbol
},
updatedSymbolProfile
);
return this.symbolProfileService.getSymbolProfiles([
{
dataSource: dataSource as DataSource,
symbol: symbol as string
}
])?.[0];
}
}
public async putSetting(key: string, value: string) {
@ -572,7 +647,7 @@ export class AdminService {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
where = {
NOT: {
Analytics: null
analytics: null
}
};
}
@ -596,10 +671,10 @@ export class AdminService {
select: {
_count: {
select: {
Order: {
activities: {
where: {
User: {
Subscription: {
user: {
subscriptions: {
some: {
expiresAt: {
gt: new Date()
@ -617,7 +692,7 @@ export class AdminService {
}
});
return _count.Order > 0;
return _count.activities > 0;
}
}
}
@ -704,8 +779,10 @@ export class AdminService {
countriesCount: 0,
date: dateOfFirstActivity,
id: undefined,
isActive: true,
name: symbol,
sectorsCount: 0
sectorsCount: 0,
watchedByCount: 0
};
}
);
@ -728,13 +805,13 @@ export class AdminService {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
orderBy = {
Analytics: {
analytics: {
lastRequestAt: 'desc'
}
};
where = {
NOT: {
Analytics: null
analytics: null
}
};
}
@ -746,9 +823,9 @@ export class AdminService {
where,
select: {
_count: {
select: { Account: true, Order: true }
select: { accounts: true, activities: true }
},
Analytics: {
analytics: {
select: {
activityCount: true,
country: true,
@ -759,26 +836,33 @@ export class AdminService {
createdAt: true,
id: true,
role: true,
Subscription: true
subscriptions: {
orderBy: {
expiresAt: 'desc'
},
take: 1,
where: {
expiresAt: {
gt: new Date()
}
}
}
}
});
return usersWithAnalytics.map(
({ _count, Analytics, createdAt, id, role, Subscription }) => {
({ _count, analytics, createdAt, id, role, subscriptions }) => {
const daysSinceRegistration =
differenceInDays(new Date(), createdAt) + 1;
const engagement = Analytics
? Analytics.activityCount / daysSinceRegistration
const engagement = analytics
? analytics.activityCount / daysSinceRegistration
: undefined;
const subscription = this.configurationService.get(
'ENABLE_FEATURE_SUBSCRIPTION'
)
? this.subscriptionService.getSubscription({
createdAt,
subscriptions: Subscription
})
: undefined;
const subscription =
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
subscriptions?.length > 0
? subscriptions[0]
: undefined;
return {
createdAt,
@ -786,11 +870,11 @@ export class AdminService {
id,
role,
subscription,
accountCount: _count.Account || 0,
country: Analytics?.country,
dailyApiRequests: Analytics?.dataProviderGhostfolioDailyRequests || 0,
lastActivity: Analytics?.updatedAt,
transactionCount: _count.Order || 0
accountCount: _count.accounts || 0,
activityCount: _count.activities || 0,
country: analytics?.country,
dailyApiRequests: analytics?.dataProviderGhostfolioDailyRequests || 0,
lastActivity: analytics?.updatedAt
};
}
);

View File

@ -1,8 +1,9 @@
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
import { AssetClass, AssetSubClass, Prisma } from '@prisma/client';
import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client';
import {
IsArray,
IsBoolean,
IsEnum,
IsObject,
IsOptional,
@ -19,8 +20,8 @@ export class UpdateAssetProfileDto {
@IsOptional()
assetSubClass?: AssetSubClass;
@IsString()
@IsOptional()
@IsString()
comment?: string;
@IsArray()
@ -31,8 +32,16 @@ export class UpdateAssetProfileDto {
@IsOptional()
currency?: string;
@IsString()
@IsEnum(DataSource)
@IsOptional()
dataSource?: DataSource;
@IsBoolean()
@IsOptional()
isActive?: boolean;
@IsOptional()
@IsString()
name?: string;
@IsObject()
@ -43,6 +52,10 @@ export class UpdateAssetProfileDto {
@IsOptional()
sectors?: Prisma.InputJsonArray;
@IsOptional()
@IsString()
symbol?: string;
@IsObject()
@IsOptional()
symbolMapping?: {

View File

@ -1,20 +1,21 @@
import { EventsModule } from '@ghostfolio/api/events/events.module';
import { HtmlTemplateMiddleware } from '@ghostfolio/api/middlewares/html-template.middleware';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { CronService } from '@ghostfolio/api/services/cron.service';
import { CronModule } from '@ghostfolio/api/services/cron/cron.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 { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
import {
DEFAULT_LANGUAGE_CODE,
SUPPORTED_LANGUAGE_CODES
} from '@ghostfolio/common/config';
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common';
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule } from '@nestjs/schedule';
@ -32,11 +33,14 @@ import { AuthModule } from './auth/auth.module';
import { CacheModule } from './cache/cache.module';
import { AiModule } from './endpoints/ai/ai.module';
import { ApiKeysModule } from './endpoints/api-keys/api-keys.module';
import { AssetsModule } from './endpoints/assets/assets.module';
import { BenchmarksModule } from './endpoints/benchmarks/benchmarks.module';
import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module';
import { MarketDataModule } from './endpoints/market-data/market-data.module';
import { PublicModule } from './endpoints/public/public.module';
import { SitemapModule } from './endpoints/sitemap/sitemap.module';
import { TagsModule } from './endpoints/tags/tags.module';
import { WatchlistModule } from './endpoints/watchlist/watchlist.module';
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
import { ExportModule } from './export/export.module';
import { HealthModule } from './health/health.module';
@ -47,7 +51,6 @@ import { OrderModule } from './order/order.module';
import { PlatformModule } from './platform/platform.module';
import { PortfolioModule } from './portfolio/portfolio.module';
import { RedisCacheModule } from './redis-cache/redis-cache.module';
import { SitemapModule } from './sitemap/sitemap.module';
import { SubscriptionModule } from './subscription/subscription.module';
import { SymbolModule } from './symbol/symbol.module';
import { UserModule } from './user/user.module';
@ -61,6 +64,7 @@ import { UserModule } from './user/user.module';
AiModule,
ApiKeysModule,
AssetModule,
AssetsModule,
AuthDeviceModule,
AuthModule,
BenchmarksModule,
@ -75,6 +79,7 @@ import { UserModule } from './user/user.module';
CacheModule,
ConfigModule.forRoot(),
ConfigurationModule,
CronModule,
DataGatheringModule,
DataProviderModule,
EventEmitterModule.forRoot(),
@ -98,7 +103,7 @@ import { UserModule } from './user/user.module';
RedisCacheModule,
ScheduleModule.forRoot(),
ServeStaticModule.forRoot({
exclude: ['/api*', '/sitemap.xml'],
exclude: ['/.well-known/*wildcard', '/api/*wildcard', '/sitemap.xml'],
rootPath: join(__dirname, '..', 'client'),
serveStaticOptions: {
setHeaders: (res) => {
@ -121,13 +126,21 @@ import { UserModule } from './user/user.module';
}
}
}),
ServeStaticModule.forRoot({
rootPath: join(__dirname, '..', 'client', '.well-known'),
serveRoot: '/.well-known'
}),
SitemapModule,
SubscriptionModule,
SymbolModule,
TagsModule,
TwitterBotModule,
UserModule
UserModule,
WatchlistModule
],
providers: [CronService]
providers: [I18nService]
})
export class AppModule {}
export class AppModule implements NestModule {
public configure(consumer: MiddlewareConsumer) {
consumer.apply(HtmlTemplateMiddleware).forRoutes('*wildcard');
}
}

View File

@ -21,37 +21,31 @@ export class ApiKeyStrategy extends PassportStrategy(
private readonly prismaService: PrismaService,
private readonly userService: UserService
) {
super(
{ header: HEADER_KEY_TOKEN, prefix: 'Api-Key ' },
true,
async (apiKey: string, done: (error: any, user?: any) => void) => {
try {
const user = await this.validateApiKey(apiKey);
super({ header: HEADER_KEY_TOKEN, prefix: 'Api-Key ' }, false);
}
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
if (hasRole(user, 'INACTIVE')) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
public async validate(apiKey: string) {
const user = await this.validateApiKey(apiKey);
await this.prismaService.analytics.upsert({
create: { User: { connect: { id: user.id } } },
update: {
activityCount: { increment: 1 },
lastRequestAt: new Date()
},
where: { userId: user.id }
});
}
done(null, user);
} catch (error) {
done(error, null);
}
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
if (hasRole(user, 'INACTIVE')) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
);
await this.prismaService.analytics.upsert({
create: { user: { connect: { id: user.id } } },
update: {
activityCount: { increment: 1 },
lastRequestAt: new Date()
},
where: { userId: user.id }
});
}
return user;
}
private async validateApiKey(apiKey: string) {

View File

@ -20,10 +20,10 @@ export class AuthService {
public async validateAnonymousLogin(accessToken: string): Promise<string> {
return new Promise(async (resolve, reject) => {
try {
const hashedAccessToken = this.userService.createAccessToken(
accessToken,
this.configurationService.get('ACCESS_TOKEN_SALT')
);
const hashedAccessToken = this.userService.createAccessToken({
password: accessToken,
salt: this.configurationService.get('ACCESS_TOKEN_SALT')
});
const [user] = await this.userService.users({
where: { accessToken: hashedAccessToken }

View File

@ -1,7 +1,11 @@
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config';
import {
DEFAULT_CURRENCY,
DEFAULT_LANGUAGE_CODE,
HEADER_KEY_TIMEZONE
} from '@ghostfolio/common/config';
import { hasRole } from '@ghostfolio/common/permissions';
import { HttpException, Injectable } from '@nestjs/common';
@ -42,7 +46,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
countriesAndTimezones.getCountryForTimezone(timezone)?.id;
await this.prismaService.analytics.upsert({
create: { country, User: { connect: { id: user.id } } },
create: { country, user: { connect: { id: user.id } } },
update: {
country,
activityCount: { increment: 1 },
@ -52,6 +56,14 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
});
}
if (!user.Settings.settings.baseCurrency) {
user.Settings.settings.baseCurrency = DEFAULT_CURRENCY;
}
if (!user.Settings.settings.language) {
user.Settings.settings.language = DEFAULT_LANGUAGE_CODE;
}
return user;
} else {
throw new HttpException(

View File

@ -24,6 +24,7 @@ import {
verifyRegistrationResponse,
VerifyRegistrationResponseOpts
} from '@simplewebauthn/server';
import { isoBase64URL, isoUint8Array } from '@simplewebauthn/server/helpers';
import {
AssertionCredentialJSON,
@ -54,10 +55,9 @@ export class WebAuthService {
const opts: GenerateRegistrationOptionsOpts = {
rpName: 'Ghostfolio',
rpID: this.rpID,
userID: user.id,
userID: isoUint8Array.fromUTF8String(user.id),
userName: '',
timeout: 60000,
attestationType: 'indirect',
authenticatorSelection: {
authenticatorAttachment: 'platform',
requireResidentKey: false,
@ -111,11 +111,17 @@ export class WebAuthService {
where: { userId: user.id }
});
if (registrationInfo && verified) {
const { counter, credentialID, credentialPublicKey } = registrationInfo;
const {
credential: {
counter,
id: credentialId,
publicKey: credentialPublicKey
}
} = registrationInfo;
let existingDevice = devices.find(
(device) => device.credentialId === credentialID
);
let existingDevice = devices.find((device) => {
return isoBase64URL.fromBuffer(device.credentialId) === credentialId;
});
if (!existingDevice) {
/**
@ -123,9 +129,9 @@ export class WebAuthService {
*/
existingDevice = await this.deviceService.createAuthDevice({
counter,
credentialId: Buffer.from(credentialID),
credentialId: Buffer.from(credentialId),
credentialPublicKey: Buffer.from(credentialPublicKey),
User: { connect: { id: user.id } }
user: { connect: { id: user.id } }
});
}
@ -148,9 +154,8 @@ export class WebAuthService {
const opts: GenerateAuthenticationOptionsOpts = {
allowCredentials: [
{
id: device.credentialId,
transports: ['internal'],
type: 'public-key'
id: isoBase64URL.fromBuffer(device.credentialId),
transports: ['internal']
}
],
rpID: this.rpID,
@ -187,10 +192,10 @@ export class WebAuthService {
let verification: VerifiedAuthenticationResponse;
try {
const opts: VerifyAuthenticationResponseOpts = {
authenticator: {
credentialID: device.credentialId,
credentialPublicKey: device.credentialPublicKey,
counter: device.counter
credential: {
counter: device.counter,
id: isoBase64URL.fromBuffer(device.credentialId),
publicKey: device.credentialPublicKey
},
expectedChallenge: `${user.authChallenge}`,
expectedOrigin: this.expectedOrigin,

View File

@ -1,14 +1,18 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import {
DEFAULT_CURRENCY,
DEFAULT_LANGUAGE_CODE
} from '@ghostfolio/common/config';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { AiPromptResponse } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import type { AiPromptMode, RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
import {
Controller,
Get,
Inject,
Param,
Query,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
@ -18,19 +22,35 @@ import { AiService } from './ai.service';
export class AiController {
public constructor(
private readonly aiService: AiService,
private readonly apiService: ApiService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Get('prompt')
@Get('prompt/:mode')
@HasPermission(permissions.readAiPrompt)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getPrompt(): Promise<AiPromptResponse> {
public async getPrompt(
@Param('mode') mode: AiPromptMode,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string
): Promise<AiPromptResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags
});
const prompt = await this.aiService.getPrompt({
filters,
mode,
impersonationId: undefined,
languageCode:
this.request.user.Settings.settings.language ?? DEFAULT_LANGUAGE_CODE,
userCurrency:
this.request.user.Settings.settings.baseCurrency ?? DEFAULT_CURRENCY,
languageCode: this.request.user.Settings.settings.language,
userCurrency: this.request.user.Settings.settings.baseCurrency,
userId: this.request.user.id
});

View File

@ -7,13 +7,17 @@ import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.servic
import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
@ -25,14 +29,18 @@ import { AiService } from './ai.service';
@Module({
controllers: [AiController],
imports: [
ApiModule,
BenchmarkModule,
ConfigurationModule,
DataProviderModule,
ExchangeRateDataModule,
I18nModule,
ImpersonationModule,
MarketDataModule,
OrderModule,
PortfolioSnapshotQueueModule,
PrismaModule,
PropertyModule,
RedisCacheModule,
SymbolProfileModule,
UserModule

View File

@ -1,29 +1,65 @@
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
PROPERTY_API_KEY_OPENROUTER,
PROPERTY_OPENROUTER_MODEL
} from '@ghostfolio/common/config';
import { Filter } from '@ghostfolio/common/interfaces';
import type { AiPromptMode } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import { generateText } from 'ai';
@Injectable()
export class AiService {
public constructor(private readonly portfolioService: PortfolioService) {}
public constructor(
private readonly portfolioService: PortfolioService,
private readonly propertyService: PropertyService
) {}
public async generateText({ prompt }: { prompt: string }) {
const openRouterApiKey = await this.propertyService.getByKey<string>(
PROPERTY_API_KEY_OPENROUTER
);
const openRouterModel = await this.propertyService.getByKey<string>(
PROPERTY_OPENROUTER_MODEL
);
const openRouterService = createOpenRouter({
apiKey: openRouterApiKey
});
return generateText({
prompt,
model: openRouterService.chat(openRouterModel)
});
}
public async getPrompt({
filters,
impersonationId,
languageCode,
mode,
userCurrency,
userId
}: {
filters?: Filter[];
impersonationId: string;
languageCode: string;
mode: AiPromptMode;
userCurrency: string;
userId: string;
}) {
const { holdings } = await this.portfolioService.getDetails({
filters,
impersonationId,
userId
});
const holdingsTable = [
'| Name | Symbol | Currency | Asset Class | Asset Sub Class | Allocation in Percentage |',
'| Name | Symbol | Currency | Asset Class | Asset Sub Class | Allocation in Percentage |',
'| --- | --- | --- | --- | --- | --- |',
...Object.values(holdings)
.sort((a, b) => {
@ -43,6 +79,10 @@ export class AiService {
)
];
if (mode === 'portfolio') {
return holdingsTable.join('\n');
}
return [
`You are a neutral financial assistant. Please analyze the following investment portfolio (base currency being ${userCurrency}) in simple words.`,
...holdingsTable,

View File

@ -0,0 +1,46 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { interpolate } from '@ghostfolio/common/helper';
import {
Controller,
Get,
Param,
Res,
Version,
VERSION_NEUTRAL
} from '@nestjs/common';
import { Response } from 'express';
import { readFileSync } from 'fs';
import { join } from 'path';
@Controller('assets')
export class AssetsController {
private webManifest = '';
public constructor(
public readonly configurationService: ConfigurationService
) {
try {
this.webManifest = readFileSync(
join(__dirname, 'assets', 'site.webmanifest'),
'utf8'
);
} catch {}
}
@Get('/:languageCode/site.webmanifest')
@Version(VERSION_NEUTRAL)
public getWebManifest(
@Param('languageCode') languageCode: string,
@Res() response: Response
): void {
const rootUrl = this.configurationService.get('ROOT_URL');
const webManifest = interpolate(this.webManifest, {
languageCode,
rootUrl
});
response.setHeader('Content-Type', 'application/json');
response.send(webManifest);
}
}

View File

@ -0,0 +1,11 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { Module } from '@nestjs/common';
import { AssetsController } from './assets.controller';
@Module({
controllers: [AssetsController],
providers: [ConfigurationService]
})
export class AssetsModule {}

View File

@ -15,6 +15,7 @@ import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.s
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
@ -35,6 +36,7 @@ import { BenchmarksService } from './benchmarks.service';
ConfigurationModule,
DataProviderModule,
ExchangeRateDataModule,
I18nModule,
ImpersonationModule,
MarketDataModule,
OrderModule,

View File

@ -2,6 +2,7 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { parseDate } from '@ghostfolio/common/helper';
import {
DataProviderGhostfolioAssetProfileResponse,
DataProviderGhostfolioStatusResponse,
DividendsResponse,
HistoricalResponse,
@ -23,6 +24,7 @@ import {
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { isISIN } from 'class-validator';
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { GetDividendsDto } from './get-dividends.dto';
@ -37,16 +39,12 @@ export class GhostfolioController {
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
/**
* @deprecated
*/
@Get('dividends/:symbol')
@Get('asset-profile/:symbol')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getDividendsV1(
@Param('symbol') symbol: string,
@Query() query: GetDividendsDto
): Promise<DividendsResponse> {
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
public async getAssetProfile(
@Param('symbol') symbol: string
): Promise<DataProviderGhostfolioAssetProfileResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
@ -59,18 +57,15 @@ export class GhostfolioController {
}
try {
const dividends = await this.ghostfolioService.getDividends({
symbol,
from: parseDate(query.from),
granularity: query.granularity,
to: parseDate(query.to)
const assetProfile = await this.ghostfolioService.getAssetProfile({
symbol
});
await this.ghostfolioService.incrementDailyRequests({
userId: this.request.user.id
});
return dividends;
return assetProfile;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
@ -119,48 +114,6 @@ export class GhostfolioController {
}
}
/**
* @deprecated
*/
@Get('historical/:symbol')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getHistoricalV1(
@Param('symbol') symbol: string,
@Query() query: GetHistoricalDto
): Promise<HistoricalResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
try {
const historicalData = await this.ghostfolioService.getHistorical({
symbol,
from: parseDate(query.from),
granularity: query.granularity,
to: parseDate(query.to)
});
await this.ghostfolioService.incrementDailyRequests({
userId: this.request.user.id
});
return historicalData;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
@Get('historical/:symbol')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
@ -201,47 +154,6 @@ export class GhostfolioController {
}
}
/**
* @deprecated
*/
@Get('lookup')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async lookupSymbolV1(
@Query('includeIndices') includeIndicesParam = 'false',
@Query('query') query = ''
): Promise<LookupResponse> {
const includeIndices = includeIndicesParam === 'true';
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
try {
const result = await this.ghostfolioService.lookup({
includeIndices,
query: query.toLowerCase()
});
await this.ghostfolioService.incrementDailyRequests({
userId: this.request.user.id
});
return result;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
@Get('lookup')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
@ -265,7 +177,9 @@ export class GhostfolioController {
try {
const result = await this.ghostfolioService.lookup({
includeIndices,
query: query.toLowerCase()
query: isISIN(query.toUpperCase())
? query.toUpperCase()
: query.toLowerCase()
});
await this.ghostfolioService.incrementDailyRequests({
@ -281,44 +195,6 @@ export class GhostfolioController {
}
}
/**
* @deprecated
*/
@Get('quotes')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getQuotesV1(
@Query() query: GetQuotesDto
): Promise<QuotesResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
try {
const quotes = await this.ghostfolioService.getQuotes({
symbols: query.symbols
});
await this.ghostfolioService.incrementDailyRequests({
userId: this.request.user.id
});
return quotes;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
@Get('quotes')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
@ -355,16 +231,6 @@ export class GhostfolioController {
}
}
/**
* @deprecated
*/
@Get('status')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getStatusV1(): Promise<DataProviderGhostfolioStatusResponse> {
return this.ghostfolioService.getStatus({ user: this.request.user });
}
@Get('status')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)

View File

@ -1,6 +1,8 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { GhostfolioService as GhostfolioDataProviderService } from '@ghostfolio/api/services/data-provider/ghostfolio/ghostfolio.service';
import {
GetAssetProfileParams,
GetDividendsParams,
GetHistoricalParams,
GetQuotesParams,
@ -15,6 +17,7 @@ import {
} from '@ghostfolio/common/config';
import { PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS } from '@ghostfolio/common/config';
import {
DataProviderGhostfolioAssetProfileResponse,
DataProviderInfo,
DividendsResponse,
HistoricalResponse,
@ -25,7 +28,7 @@ import {
import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { DataSource, SymbolProfile } from '@prisma/client';
import { Big } from 'big.js';
@Injectable()
@ -37,6 +40,44 @@ export class GhostfolioService {
private readonly propertyService: PropertyService
) {}
public async getAssetProfile({
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbol
}: GetAssetProfileParams) {
let result: DataProviderGhostfolioAssetProfileResponse = {};
try {
const promises: Promise<Partial<SymbolProfile>>[] = [];
for (const dataProviderService of this.getDataProviderServices()) {
promises.push(
dataProviderService
.getAssetProfile({
requestTimeout,
symbol
})
.then((assetProfile) => {
result = {
...result,
...assetProfile,
dataSource: DataSource.GHOSTFOLIO
};
return assetProfile;
})
);
}
await Promise.all(promises);
return result;
} catch (error) {
Logger.error(error, 'GhostfolioService');
throw error;
}
}
public async getDividends({
from,
granularity,
@ -123,9 +164,9 @@ export class GhostfolioService {
public async getMaxDailyRequests() {
return parseInt(
((await this.propertyService.getByKey(
(await this.propertyService.getByKey<string>(
PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS
)) as string) || '0',
)) || '0',
10
);
}
@ -277,6 +318,7 @@ export class GhostfolioService {
});
results.items = filteredItems;
return results;
} catch (error) {
Logger.error(error, 'GhostfolioService');
@ -286,10 +328,15 @@ export class GhostfolioService {
}
private getDataProviderInfo(): DataProviderInfo {
const ghostfolioDataProviderService = new GhostfolioDataProviderService(
this.configurationService,
this.propertyService
);
return {
...ghostfolioDataProviderService.getDataProviderInfo(),
isPremium: false,
name: 'Ghostfolio Premium',
url: 'https://ghostfol.io'
name: 'Ghostfolio Premium'
};
}

View File

@ -85,7 +85,7 @@ export class MarketDataController {
{ dataSource, symbol }
]);
if (!assetProfile) {
if (!assetProfile && !isCurrency(getCurrencyFromSymbol(symbol))) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
@ -103,7 +103,7 @@ export class MarketDataController {
);
const canUpsertOwnAssetProfile =
assetProfile.userId === this.request.user.id &&
assetProfile?.userId === this.request.user.id &&
hasPermission(
this.request.user.permissions,
permissions.createMarketDataOfOwnAssetProfile

View File

@ -9,8 +9,10 @@ import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
@ -25,8 +27,10 @@ import { PublicController } from './public.controller';
controllers: [PublicController],
imports: [
AccessModule,
BenchmarkModule,
DataProviderModule,
ExchangeRateDataModule,
I18nModule,
ImpersonationModule,
MarketDataModule,
OrderModule,

View File

@ -0,0 +1,51 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import {
DATE_FORMAT,
getYesterday,
interpolate
} from '@ghostfolio/common/helper';
import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common';
import { format } from 'date-fns';
import { Response } from 'express';
import { readFileSync } from 'fs';
import { join } from 'path';
import { SitemapService } from './sitemap.service';
@Controller('sitemap.xml')
export class SitemapController {
public sitemapXml = '';
public constructor(
private readonly configurationService: ConfigurationService,
private readonly sitemapService: SitemapService
) {
try {
this.sitemapXml = readFileSync(
join(__dirname, 'assets', 'sitemap.xml'),
'utf8'
);
} catch {}
}
@Get()
@Version(VERSION_NEUTRAL)
public getSitemapXml(@Res() response: Response) {
const currentDate = format(getYesterday(), DATE_FORMAT);
response.setHeader('content-type', 'application/xml');
response.send(
interpolate(this.sitemapXml, {
personalFinanceTools: this.configurationService.get(
'ENABLE_FEATURE_SUBSCRIPTION'
)
? this.sitemapService.getPersonalFinanceTools({ currentDate })
: '',
publicRoutes: this.sitemapService.getPublicRoutes({
currentDate
})
})
);
}
}

View File

@ -1,11 +1,14 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
import { Module } from '@nestjs/common';
import { SitemapController } from './sitemap.controller';
import { SitemapService } from './sitemap.service';
@Module({
controllers: [SitemapController],
imports: [ConfigurationModule]
imports: [ConfigurationModule, I18nModule],
providers: [SitemapService]
})
export class SitemapModule {}

View File

@ -0,0 +1,116 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { SUPPORTED_LANGUAGE_CODES } from '@ghostfolio/common/config';
import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools';
import { PublicRoute } from '@ghostfolio/common/routes/interfaces/public-route.interface';
import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { Injectable } from '@nestjs/common';
@Injectable()
export class SitemapService {
private static readonly TRANSLATION_TAGGED_MESSAGE_REGEX =
/:.*@@(?<id>[a-zA-Z0-9.]+):(?<message>.+)/;
public constructor(
private readonly configurationService: ConfigurationService,
private readonly i18nService: I18nService
) {}
public getPersonalFinanceTools({ currentDate }: { currentDate: string }) {
const rootUrl = this.configurationService.get('ROOT_URL');
return SUPPORTED_LANGUAGE_CODES.flatMap((languageCode) => {
return personalFinanceTools.map(({ alias, key }) => {
const route =
publicRoutes.resources.subRoutes.personalFinanceTools.subRoutes
.product;
const params = {
currentDate,
languageCode,
rootUrl,
urlPostfix: alias ?? key
};
return this.createRouteSitemapUrl({ ...params, route });
});
}).join('\n');
}
public getPublicRoutes({ currentDate }: { currentDate: string }) {
const rootUrl = this.configurationService.get('ROOT_URL');
return SUPPORTED_LANGUAGE_CODES.flatMap((languageCode) => {
const params = {
currentDate,
languageCode,
rootUrl
};
return [
this.createRouteSitemapUrl(params),
...this.createSitemapUrls(params, publicRoutes)
];
}).join('\n');
}
private createRouteSitemapUrl({
currentDate,
languageCode,
rootUrl,
route,
urlPostfix
}: {
currentDate: string;
languageCode: string;
rootUrl: string;
route?: PublicRoute;
urlPostfix?: string;
}): string {
const segments =
route?.routerLink.map((link) => {
const match = link.match(
SitemapService.TRANSLATION_TAGGED_MESSAGE_REGEX
);
const segment = match
? (this.i18nService.getTranslation({
languageCode,
id: match.groups.id
}) ?? match.groups.message)
: link;
return segment.replace(/^\/+|\/+$/, '');
}) ?? [];
const location =
[rootUrl, languageCode, ...segments].join('/') +
(urlPostfix ? `-${urlPostfix}` : '');
return [
' <url>',
` <loc>${location}</loc>`,
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
' </url>'
].join('\n');
}
private createSitemapUrls(
params: { currentDate: string; languageCode: string; rootUrl: string },
routes: Record<string, PublicRoute>
): string[] {
return Object.values(routes).flatMap((route) => {
if (route.excludeFromSitemap) {
return [];
}
const urls = [this.createRouteSitemapUrl({ ...params, route })];
if (route.subRoutes) {
urls.push(...this.createSitemapUrls(params, route.subRoutes));
}
return urls;
});
}
}

View File

@ -0,0 +1,10 @@
import { DataSource } from '@prisma/client';
import { IsEnum, IsString } from 'class-validator';
export class CreateWatchlistItemDto {
@IsEnum(DataSource)
dataSource: DataSource;
@IsString()
symbol: string;
}

View File

@ -0,0 +1,100 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { WatchlistResponse } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Delete,
Get,
Headers,
HttpException,
Inject,
Param,
Post,
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreateWatchlistItemDto } from './create-watchlist-item.dto';
import { WatchlistService } from './watchlist.service';
@Controller('watchlist')
export class WatchlistController {
public constructor(
private readonly impersonationService: ImpersonationService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly watchlistService: WatchlistService
) {}
@Post()
@HasPermission(permissions.createWatchlistItem)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async createWatchlistItem(@Body() data: CreateWatchlistItemDto) {
return this.watchlistService.createWatchlistItem({
dataSource: data.dataSource,
symbol: data.symbol,
userId: this.request.user.id
});
}
@Delete(':dataSource/:symbol')
@HasPermission(permissions.deleteWatchlistItem)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async deleteWatchlistItem(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
) {
const watchlistItems = await this.watchlistService.getWatchlistItems(
this.request.user.id
);
const watchlistItem = watchlistItems.find((item) => {
return item.dataSource === dataSource && item.symbol === symbol;
});
if (!watchlistItem) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
return this.watchlistService.deleteWatchlistItem({
dataSource,
symbol,
userId: this.request.user.id
});
}
@Get()
@HasPermission(permissions.readWatchlist)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getWatchlistItems(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string
): Promise<WatchlistResponse> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
const watchlist = await this.watchlistService.getWatchlistItems(
impersonationUserId || this.request.user.id
);
return {
watchlist
};
}
}

View File

@ -0,0 +1,31 @@
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';
import { WatchlistController } from './watchlist.controller';
import { WatchlistService } from './watchlist.service';
@Module({
controllers: [WatchlistController],
imports: [
BenchmarkModule,
DataGatheringModule,
DataProviderModule,
ImpersonationModule,
MarketDataModule,
PrismaModule,
SymbolProfileModule,
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule
],
providers: [WatchlistService]
})
export class WatchlistModule {}

View File

@ -0,0 +1,150 @@
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { WatchlistResponse } from '@ghostfolio/common/interfaces';
import { BadRequestException, Injectable } from '@nestjs/common';
import { DataSource, Prisma } from '@prisma/client';
@Injectable()
export class WatchlistService {
public constructor(
private readonly benchmarkService: BenchmarkService,
private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService
) {}
public async createWatchlistItem({
dataSource,
symbol,
userId
}: {
dataSource: DataSource;
symbol: string;
userId: string;
}): Promise<void> {
const symbolProfile = await this.prismaService.symbolProfile.findUnique({
where: {
dataSource_symbol: { dataSource, symbol }
}
});
if (!symbolProfile) {
const assetProfiles = await this.dataProviderService.getAssetProfiles([
{ dataSource, symbol }
]);
if (!assetProfiles[symbol]?.currency) {
throw new BadRequestException(
`Asset profile not found for ${symbol} (${dataSource})`
);
}
await this.symbolProfileService.add(
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
);
}
await this.dataGatheringService.gatherSymbol({
dataSource,
symbol
});
await this.prismaService.user.update({
data: {
watchlist: {
connect: {
dataSource_symbol: { dataSource, symbol }
}
}
},
where: { id: userId }
});
}
public async deleteWatchlistItem({
dataSource,
symbol,
userId
}: {
dataSource: DataSource;
symbol: string;
userId: string;
}) {
await this.prismaService.user.update({
data: {
watchlist: {
disconnect: {
dataSource_symbol: { dataSource, symbol }
}
}
},
where: { id: userId }
});
}
public async getWatchlistItems(
userId: string
): Promise<WatchlistResponse['watchlist']> {
const user = await this.prismaService.user.findUnique({
select: {
watchlist: {
select: { dataSource: true, symbol: true }
}
},
where: { id: userId }
});
const [assetProfiles, quotes] = await Promise.all([
this.symbolProfileService.getSymbolProfiles(user.watchlist),
this.dataProviderService.getQuotes({
items: user.watchlist.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
})
})
]);
const watchlist = await Promise.all(
user.watchlist.map(async ({ dataSource, symbol }) => {
const assetProfile = assetProfiles.find((profile) => {
return profile.dataSource === dataSource && profile.symbol === symbol;
});
const allTimeHigh = await this.marketDataService.getMax({
dataSource,
symbol
});
const performancePercent =
this.benchmarkService.calculateChangeInPercentage(
allTimeHigh?.marketPrice,
quotes[symbol]?.marketPrice
);
return {
dataSource,
symbol,
marketCondition:
this.benchmarkService.getMarketCondition(performancePercent),
name: assetProfile?.name,
performances: {
allTimeHigh: {
performancePercent,
date: allTimeHigh?.date
}
}
};
})
);
return watchlist.sort((a, b) => {
return a.name.localeCompare(b.name);
});
}
}

View File

@ -1,9 +1,17 @@
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 { ApiService } from '@ghostfolio/api/services/api/api.service';
import { Export } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Get, Inject, Query, UseGuards } from '@nestjs/common';
import {
Controller,
Get,
Inject,
Query,
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
@ -19,15 +27,21 @@ export class ExportController {
@Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async export(
@Query('accounts') filterByAccounts?: string,
@Query('activityIds') activityIds?: string[],
@Query('activityIds') filterByActivityIds?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string
): Promise<Export> {
const activityIds = filterByActivityIds?.split(',') ?? [];
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags
});

View File

@ -1,5 +1,6 @@
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
@ -9,8 +10,14 @@ import { ExportController } from './export.controller';
import { ExportService } from './export.service';
@Module({
imports: [AccountModule, ApiModule, OrderModule, TagModule],
controllers: [ExportController],
imports: [
AccountModule,
ApiModule,
OrderModule,
TagModule,
TransformDataSourceInRequestModule
],
providers: [ExportService]
})
export class ExportModule {}

View File

@ -28,43 +28,6 @@ export class ExportService {
}): Promise<Export> {
const platformsMap: { [platformId: string]: Platform } = {};
const accounts = (
await this.accountService.accounts({
include: {
Platform: true
},
orderBy: {
name: 'asc'
},
where: { userId }
})
).map(
({
balance,
comment,
currency,
id,
isExcluded,
name,
Platform: platform,
platformId
}) => {
if (platformId) {
platformsMap[platformId] = platform;
}
return {
balance,
comment,
currency,
id,
isExcluded,
name,
platformId
};
}
);
let { activities } = await this.orderService.getOrders({
filters,
userCurrency,
@ -75,16 +38,72 @@ export class ExportService {
withExcludedAccounts: true
});
if (activityIds) {
activities = activities.filter((activity) => {
return activityIds.includes(activity.id);
if (activityIds?.length > 0) {
activities = activities.filter(({ id }) => {
return activityIds.includes(id);
});
}
const tags = (await this.tagService.getTagsForUser(userId))
.filter(({ isUsed }) => {
return isUsed;
const accounts = (
await this.accountService.accounts({
include: {
balances: true,
platform: true
},
orderBy: {
name: 'asc'
},
where: { userId }
})
)
.filter(({ id }) => {
return activities.length > 0
? activities.some(({ accountId }) => {
return accountId === id;
})
: true;
})
.map(
({
balance,
balances,
comment,
currency,
id,
isExcluded,
name,
platform,
platformId
}) => {
if (platformId) {
platformsMap[platformId] = platform;
}
return {
balance,
balances: balances.map(({ date, value }) => {
return { date: date.toISOString(), value };
}),
comment,
currency,
id,
isExcluded,
name,
platformId
};
}
);
const tags = (await this.tagService.getTagsForUser(userId))
.filter(
({ id, isUsed }) =>
isUsed &&
activities.some((activity) => {
return activity.tags.some(({ id: tagId }) => {
return tagId === id;
});
})
)
.map(({ id, name }) => {
return {
id,
@ -101,6 +120,7 @@ export class ExportService {
({
accountId,
comment,
currency,
date,
fee,
id,
@ -118,7 +138,7 @@ export class ExportService {
quantity,
type,
unitPrice,
currency: SymbolProfile.currency,
currency: currency ?? SymbolProfile.currency,
dataSource: SymbolProfile.dataSource,
date: date.toISOString(),
symbol: ['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(type)

View File

@ -1,4 +1,8 @@
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import {
DataEnhancerHealthResponse,
DataProviderHealthResponse
} from '@ghostfolio/common/interfaces';
import {
Controller,
@ -37,23 +41,30 @@ export class HealthController {
}
@Get('data-enhancer/:name')
public async getHealthOfDataEnhancer(@Param('name') name: string) {
public async getHealthOfDataEnhancer(
@Param('name') name: string,
@Res() response: Response
): Promise<Response<DataEnhancerHealthResponse>> {
const hasResponse =
await this.healthService.hasResponseFromDataEnhancer(name);
if (hasResponse !== true) {
throw new HttpException(
getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE),
StatusCodes.SERVICE_UNAVAILABLE
);
if (hasResponse) {
return response.status(HttpStatus.OK).json({
status: getReasonPhrase(StatusCodes.OK)
});
} else {
return response
.status(HttpStatus.SERVICE_UNAVAILABLE)
.json({ status: getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE) });
}
}
@Get('data-provider/:dataSource')
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getHealthOfDataProvider(
@Param('dataSource') dataSource: DataSource
) {
@Param('dataSource') dataSource: DataSource,
@Res() response: Response
): Promise<Response<DataProviderHealthResponse>> {
if (!DataSource[dataSource]) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
@ -64,11 +75,14 @@ export class HealthController {
const hasResponse =
await this.healthService.hasResponseFromDataProvider(dataSource);
if (hasResponse !== true) {
throw new HttpException(
getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE),
StatusCodes.SERVICE_UNAVAILABLE
);
if (hasResponse) {
return response
.status(HttpStatus.OK)
.json({ status: getReasonPhrase(StatusCodes.OK) });
} else {
return response
.status(HttpStatus.SERVICE_UNAVAILABLE)
.json({ status: getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE) });
}
}
}

View File

@ -0,0 +1,10 @@
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { AccountBalance } from '@ghostfolio/common/interfaces';
import { IsArray, IsOptional } from 'class-validator';
export class CreateAccountWithBalancesDto extends CreateAccountDto {
@IsArray()
@IsOptional()
balances?: AccountBalance[];
}

View File

@ -1,15 +1,16 @@
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Type } from 'class-transformer';
import { IsArray, IsOptional, ValidateNested } from 'class-validator';
import { CreateAccountWithBalancesDto } from './create-account-with-balances.dto';
export class ImportDataDto {
@IsOptional()
@IsArray()
@Type(() => CreateAccountDto)
@Type(() => CreateAccountWithBalancesDto)
@ValidateNested({ each: true })
accounts: CreateAccountDto[];
accounts: CreateAccountWithBalancesDto[];
@IsArray()
@Type(() => CreateOrderDto)

View File

@ -71,7 +71,7 @@ export class ImportController {
const activities = await this.importService.import({
isDryRun,
maxActivitiesToImport,
accountsDto: importData.accounts ?? [],
accountsWithBalancesDto: importData.accounts ?? [],
activitiesDto: importData.activities,
user: this.request.user
});
@ -98,12 +98,10 @@ export class ImportController {
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<ImportResponse> {
const userCurrency = this.request.user.Settings.settings.baseCurrency;
const activities = await this.importService.getDividends({
dataSource,
symbol,
userCurrency
userId: this.request.user.id
});
return { activities };

View File

@ -10,12 +10,10 @@ import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.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 { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config';
import {
DATE_FORMAT,
getAssetProfileIdentifier,
parseDate
} from '@ghostfolio/common/helper';
@ -29,10 +27,12 @@ import {
import { Injectable } from '@nestjs/common';
import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
import { Big } from 'big.js';
import { endOfToday, format, isAfter, isSameSecond, parseISO } from 'date-fns';
import { isNumber, uniqBy } from 'lodash';
import { endOfToday, isAfter, isSameSecond, parseISO } from 'date-fns';
import { omit, uniqBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid';
import { ImportDataDto } from './import-data.dto';
@Injectable()
export class ImportService {
public constructor(
@ -40,7 +40,6 @@ export class ImportService {
private readonly configurationService: ConfigurationService,
private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly orderService: OrderService,
private readonly platformService: PlatformService,
private readonly portfolioService: PortfolioService,
@ -50,11 +49,16 @@ export class ImportService {
public async getDividends({
dataSource,
symbol,
userCurrency
}: AssetProfileIdentifier & { userCurrency: string }): Promise<Activity[]> {
userId
}: AssetProfileIdentifier & { userId: string }): Promise<Activity[]> {
try {
const { firstBuyDate, historicalData, orders } =
await this.portfolioService.getPosition(dataSource, undefined, symbol);
const { activities, firstBuyDate, historicalData } =
await this.portfolioService.getHolding({
dataSource,
symbol,
userId,
impersonationId: undefined
});
const [[assetProfile], dividends] = await Promise.all([
this.symbolProfileService.getSymbolProfiles([
@ -72,15 +76,15 @@ export class ImportService {
})
]);
const accounts = orders
.filter(({ Account }) => {
return !!Account;
const accounts = activities
.filter(({ account }) => {
return !!account;
})
.map(({ Account }) => {
return Account;
.map(({ account }) => {
return account;
});
const Account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;
const account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;
return await Promise.all(
Object.entries(dividends).map(async ([dateString, { marketPrice }]) => {
@ -92,9 +96,9 @@ export class ImportService {
const value = new Big(quantity).mul(marketPrice).toNumber();
const date = parseDate(dateString);
const isDuplicate = orders.some((activity) => {
const isDuplicate = activities.some((activity) => {
return (
activity.accountId === Account?.id &&
activity.accountId === account?.id &&
activity.SymbolProfile.currency === assetProfile.currency &&
activity.SymbolProfile.dataSource === assetProfile.dataSource &&
isSameSecond(activity.date, date) &&
@ -110,17 +114,18 @@ export class ImportService {
: undefined;
return {
Account,
account,
date,
error,
quantity,
value,
accountId: Account?.id,
accountId: account?.id,
accountUserId: undefined,
comment: undefined,
currency: undefined,
createdAt: undefined,
fee: 0,
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
id: assetProfile.id,
isDraft: false,
@ -128,15 +133,10 @@ export class ImportService {
symbolProfileId: assetProfile.id,
type: 'DIVIDEND',
unitPrice: marketPrice,
unitPriceInAssetProfileCurrency: marketPrice,
updatedAt: undefined,
userId: Account?.userId,
valueInBaseCurrency:
await this.exchangeRateDataService.toCurrencyAtDate(
value,
assetProfile.currency,
userCurrency,
date
)
userId: account?.userId,
valueInBaseCurrency: value
};
})
);
@ -146,14 +146,14 @@ export class ImportService {
}
public async import({
accountsDto,
accountsWithBalancesDto,
activitiesDto,
isDryRun = false,
maxActivitiesToImport,
user
}: {
accountsDto: Partial<CreateAccountDto>[];
activitiesDto: Partial<CreateOrderDto>[];
accountsWithBalancesDto: ImportDataDto['accounts'];
activitiesDto: ImportDataDto['activities'];
isDryRun?: boolean;
maxActivitiesToImport: number;
user: UserWithSettings;
@ -161,12 +161,12 @@ export class ImportService {
const accountIdMapping: { [oldAccountId: string]: string } = {};
const userCurrency = user.Settings.settings.baseCurrency;
if (!isDryRun && accountsDto?.length) {
if (!isDryRun && accountsWithBalancesDto?.length) {
const [existingAccounts, existingPlatforms] = await Promise.all([
this.accountService.accounts({
where: {
id: {
in: accountsDto.map(({ id }) => {
in: accountsWithBalancesDto.map(({ id }) => {
return id;
})
}
@ -175,14 +175,19 @@ export class ImportService {
this.platformService.getPlatforms()
]);
for (const account of accountsDto) {
for (const accountWithBalances of accountsWithBalancesDto) {
// Check if there is any existing account with the same ID
const accountWithSameId = existingAccounts.find(
(existingAccount) => existingAccount.id === account.id
);
const accountWithSameId = existingAccounts.find((existingAccount) => {
return existingAccount.id === accountWithBalances.id;
});
// If there is no account or if the account belongs to a different user then create a new account
if (!accountWithSameId || accountWithSameId.userId !== user.id) {
const account: CreateAccountDto = omit(
accountWithBalances,
'balances'
);
let oldAccountId: string;
const platformId = account.platformId;
@ -195,7 +200,10 @@ export class ImportService {
let accountObject: Prisma.AccountCreateInput = {
...account,
User: { connect: { id: user.id } }
balances: {
create: accountWithBalances.balances ?? []
},
user: { connect: { id: user.id } }
};
if (
@ -205,7 +213,7 @@ export class ImportService {
) {
accountObject = {
...accountObject,
Platform: { connect: { id: platformId } }
platform: { connect: { id: platformId } }
};
}
@ -259,24 +267,24 @@ export class ImportService {
);
if (isDryRun) {
accountsDto.forEach(({ id, name }) => {
accountsWithBalancesDto.forEach(({ id, name }) => {
accounts.push({ id, name });
});
}
const activities: Activity[] = [];
for (const [index, activity] of activitiesExtendedWithErrors.entries()) {
for (const activity of activitiesExtendedWithErrors) {
const accountId = activity.accountId;
const comment = activity.comment;
const currency = activity.currency;
const date = activity.date;
const error = activity.error;
let fee = activity.fee;
const fee = activity.fee;
const quantity = activity.quantity;
const SymbolProfile = activity.SymbolProfile;
const type = activity.type;
let unitPrice = activity.unitPrice;
const unitPrice = activity.unitPrice;
const assetProfile = assetProfiles[
getAssetProfileIdentifier({
@ -284,7 +292,6 @@ export class ImportService {
symbol: SymbolProfile.symbol
})
] ?? {
currency: SymbolProfile.currency,
dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol
};
@ -300,6 +307,7 @@ export class ImportService {
figiShareClass,
holdings,
id,
isActive,
isin,
name,
scraperConfiguration,
@ -319,35 +327,6 @@ export class ImportService {
Account?: { id: string; name: string };
});
if (SymbolProfile.currency !== assetProfile.currency) {
// Convert the unit price and fee to the asset currency if the imported
// activity is in a different currency
unitPrice = await this.exchangeRateDataService.toCurrencyAtDate(
unitPrice,
SymbolProfile.currency,
assetProfile.currency,
date
);
if (!isNumber(unitPrice)) {
throw new Error(
`activities.${index} historical exchange rate at ${format(
date,
DATE_FORMAT
)} is not available from "${SymbolProfile.currency}" to "${
assetProfile.currency
}"`
);
}
fee = await this.exchangeRateDataService.toCurrencyAtDate(
fee,
SymbolProfile.currency,
assetProfile.currency,
date
);
}
if (isDryRun) {
order = {
comment,
@ -375,6 +354,7 @@ export class ImportService {
figiShareClass,
holdings,
id,
isActive,
isin,
name,
scraperConfiguration,
@ -398,6 +378,7 @@ export class ImportService {
order = await this.orderService.createOrder({
comment,
currency,
date,
fee,
quantity,
@ -421,7 +402,7 @@ export class ImportService {
}
},
updateAccountBalance: false,
User: { connect: { id: user.id } },
user: { connect: { id: user.id } },
userId: user.id
});
@ -437,21 +418,8 @@ export class ImportService {
...order,
error,
value,
feeInBaseCurrency: await this.exchangeRateDataService.toCurrencyAtDate(
fee,
assetProfile.currency,
userCurrency,
date
),
// @ts-ignore
SymbolProfile: assetProfile,
valueInBaseCurrency:
await this.exchangeRateDataService.toCurrencyAtDate(
value,
assetProfile.currency,
userCurrency,
date
)
SymbolProfile: assetProfile
});
}
@ -517,7 +485,9 @@ export class ImportService {
const isDuplicate = existingActivities.some((activity) => {
return (
activity.accountId === accountId &&
activity.SymbolProfile.currency === currency &&
activity.comment === comment &&
(activity.currency === currency ||
activity.SymbolProfile.currency === currency) &&
activity.SymbolProfile.dataSource === dataSource &&
isSameSecond(activity.date, date) &&
activity.fee === fee &&
@ -535,6 +505,7 @@ export class ImportService {
return {
accountId,
comment,
currency,
date,
error,
fee,
@ -542,7 +513,6 @@ export class ImportService {
type,
unitPrice,
SymbolProfile: {
currency,
dataSource,
symbol,
activitiesCount: undefined,
@ -550,8 +520,10 @@ export class ImportService {
assetSubClass: undefined,
countries: undefined,
createdAt: undefined,
currency: undefined,
holdings: undefined,
id: undefined,
isActive: true,
sectors: undefined,
updatedAt: undefined
}
@ -586,7 +558,7 @@ export class ImportService {
const assetProfiles: {
[assetProfileIdentifier: string]: Partial<SymbolProfile>;
} = {};
const dataSources = await this.dataProviderService.getDataSources();
const dataSources = await this.dataProviderService.getDataSources({ user });
for (const [
index,
@ -629,12 +601,6 @@ export class ImportService {
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
);
}
if (assetProfile.currency !== currency) {
throw new Error(
`activities.${index}.currency ("${currency}") does not match with currency of ${assetProfile.symbol} ("${assetProfile.currency}")`
);
}
}
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] =

View File

@ -1,5 +1,6 @@
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
@ -31,6 +32,7 @@ import { InfoService } from './info.service';
PlatformModule,
PropertyModule,
RedisCacheModule,
SubscriptionModule,
SymbolProfileModule,
TransformDataSourceInResponseModule,
UserModule

View File

@ -1,5 +1,6 @@
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
@ -13,7 +14,6 @@ import {
PROPERTY_DEMO_USER_ID,
PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_SLACK_COMMUNITY_USERS,
PROPERTY_STRIPE_CONFIG,
ghostfolioFearAndGreedIndexDataSource
} from '@ghostfolio/common/config';
import {
@ -21,13 +21,8 @@ import {
encodeDataSource,
extractNumberFromString
} from '@ghostfolio/common/helper';
import {
InfoItem,
Statistics,
SubscriptionOffer
} from '@ghostfolio/common/interfaces';
import { InfoItem, Statistics } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import { SubscriptionOfferKey } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
@ -46,6 +41,7 @@ export class InfoService {
private readonly platformService: PlatformService,
private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService,
private readonly subscriptionService: SubscriptionService,
private readonly userService: UserService
) {}
@ -68,9 +64,9 @@ export class InfoService {
}
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
isReadOnlyMode = (await this.propertyService.getByKey(
isReadOnlyMode = await this.propertyService.getByKey<boolean>(
PROPERTY_IS_READ_ONLY_MODE
)) as boolean;
);
}
if (this.configurationService.get('ENABLE_FEATURE_SOCIAL_LOGIN')) {
@ -85,9 +81,9 @@ export class InfoService {
globalPermissions.push(permissions.enableSubscription);
info.countriesOfSubscribers =
((await this.propertyService.getByKey(
(await this.propertyService.getByKey<string[]>(
PROPERTY_COUNTRIES_OF_SUBSCRIBERS
)) as string[]) ?? [];
)) ?? [];
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
}
@ -101,7 +97,7 @@ export class InfoService {
isUserSignupEnabled,
platforms,
statistics,
subscriptionOffers
subscriptionOffer
] = await Promise.all([
this.benchmarkService.getBenchmarkAssetProfiles(),
this.getDemoAuthToken(),
@ -110,7 +106,7 @@ export class InfoService {
orderBy: { name: 'asc' }
}),
this.getStatistics(),
this.getSubscriptionOffers()
this.subscriptionService.getSubscriptionOffer({ key: 'default' })
]);
if (isUserSignupEnabled) {
@ -125,7 +121,7 @@ export class InfoService {
isReadOnlyMode,
platforms,
statistics,
subscriptionOffers,
subscriptionOffer,
baseCurrency: DEFAULT_CURRENCY,
currencies: this.exchangeRateDataService.getCurrencies()
};
@ -137,11 +133,11 @@ export class InfoService {
AND: [
{
NOT: {
Analytics: null
analytics: null
}
},
{
Analytics: {
analytics: {
lastRequestAt: {
gt: subDays(new Date(), aDays)
}
@ -220,7 +216,7 @@ export class InfoService {
AND: [
{
NOT: {
Analytics: null
analytics: null
}
},
{
@ -234,15 +230,15 @@ export class InfoService {
}
private async countSlackCommunityUsers() {
return (await this.propertyService.getByKey(
return await this.propertyService.getByKey<string>(
PROPERTY_SLACK_COMMUNITY_USERS
)) as string;
);
}
private async getDemoAuthToken() {
const demoUserId = (await this.propertyService.getByKey(
const demoUserId = await this.propertyService.getByKey<string>(
PROPERTY_DEMO_USER_ID
)) as string;
);
if (demoUserId) {
return this.jwtService.sign({
@ -299,25 +295,12 @@ export class InfoService {
return statistics;
}
private async getSubscriptionOffers(): Promise<{
[offer in SubscriptionOfferKey]: SubscriptionOffer;
}> {
if (!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
return undefined;
}
return (
((await this.propertyService.getByKey(PROPERTY_STRIPE_CONFIG)) as any) ??
{}
);
}
private async getUptime(): Promise<number> {
{
try {
const monitorId = (await this.propertyService.getByKey(
const monitorId = await this.propertyService.getByKey<string>(
PROPERTY_BETTER_UPTIME_MONITOR_ID
)) as string;
);
const { data } = await fetch(
`https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format(

View File

@ -27,12 +27,12 @@ export class CreateOrderDto {
@IsString()
accountId?: string;
@IsOptional()
@IsEnum(AssetClass, { each: true })
@IsOptional()
assetClass?: AssetClass;
@IsOptional()
@IsEnum(AssetSubClass, { each: true })
@IsOptional()
assetSubClass?: AssetSubClass;
@IsOptional()
@ -49,8 +49,8 @@ export class CreateOrderDto {
@IsOptional()
customCurrency?: string;
@IsEnum(DataSource)
@IsOptional()
@IsEnum(DataSource, { each: true })
dataSource?: DataSource;
@IsISO8601()

View File

@ -9,11 +9,13 @@ export interface Activities {
}
export interface Activity extends Order {
Account?: AccountWithPlatform;
account?: AccountWithPlatform;
error?: ActivityError;
feeInAssetProfileCurrency: number;
feeInBaseCurrency: number;
SymbolProfile?: EnhancedSymbolProfile;
tags?: Tag[];
unitPriceInAssetProfileCurrency: number;
updateAccountBalance?: boolean;
value: number;
valueInBaseCurrency: number;

View File

@ -53,14 +53,19 @@ export class OrderController {
@Delete()
@HasPermission(permissions.deleteOrder)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async deleteOrders(
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string
): Promise<number> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags
});
@ -97,7 +102,7 @@ export class OrderController {
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAllOrders(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@ -150,7 +155,7 @@ export class OrderController {
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getOrderById(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('id') id: string
): Promise<Activity> {
const impersonationUserId =
@ -209,7 +214,7 @@ export class OrderController {
}
}
},
User: { connect: { id: this.request.user.id } },
user: { connect: { id: this.request.user.id } },
userId: this.request.user.id
});
@ -264,7 +269,7 @@ export class OrderController {
data: {
...data,
date,
Account: {
account: {
connect: {
id_userId: { id: accountId, userId: this.request.user.id }
}
@ -282,7 +287,7 @@ export class OrderController {
name: data.symbol
}
},
User: { connect: { id: this.request.user.id } }
user: { connect: { id: this.request.user.id } }
},
where: {
id

View File

@ -7,8 +7,8 @@ import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathe
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS
} from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import {
@ -100,10 +100,10 @@ export class OrderService {
userId: string;
}
): Promise<Order> {
let Account: Prisma.AccountCreateNestedOneWithoutOrderInput;
let account: Prisma.AccountCreateNestedOneWithoutActivitiesInput;
if (data.accountId) {
Account = {
account = {
connect: {
id_userId: {
userId: data.userId,
@ -144,9 +144,9 @@ export class OrderService {
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
symbol: data.SymbolProfile.connectOrCreate.create.symbol
},
name: GATHER_ASSET_PROFILE_PROCESS,
name: GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
...GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
jobId: getAssetProfileIdentifier({
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
symbol: data.SymbolProfile.connectOrCreate.create.symbol
@ -179,7 +179,7 @@ export class OrderService {
const order = await this.prismaService.order.create({
data: {
...orderData,
Account,
account,
isDraft,
tags: {
connect: tags.map(({ id }) => {
@ -475,8 +475,8 @@ export class OrderService {
if (withExcludedAccounts === false) {
where.OR = [
{ Account: null },
{ Account: { NOT: { isExcluded: true } } }
{ account: null },
{ account: { NOT: { isExcluded: true } } }
];
}
@ -487,10 +487,9 @@ export class OrderService {
take,
where,
include: {
// eslint-disable-next-line @typescript-eslint/naming-convention
Account: {
account: {
include: {
Platform: true
platform: true
}
},
// eslint-disable-next-line @typescript-eslint/naming-convention
@ -531,24 +530,46 @@ export class OrderService {
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
const [
feeInAssetProfileCurrency,
feeInBaseCurrency,
unitPriceInAssetProfileCurrency,
valueInBaseCurrency
] = await Promise.all([
this.exchangeRateDataService.toCurrencyAtDate(
order.fee,
order.currency ?? order.SymbolProfile.currency,
order.SymbolProfile.currency,
order.date
),
this.exchangeRateDataService.toCurrencyAtDate(
order.fee,
order.currency ?? order.SymbolProfile.currency,
userCurrency,
order.date
),
this.exchangeRateDataService.toCurrencyAtDate(
order.unitPrice,
order.currency ?? order.SymbolProfile.currency,
order.SymbolProfile.currency,
order.date
),
this.exchangeRateDataService.toCurrencyAtDate(
value,
order.currency ?? order.SymbolProfile.currency,
userCurrency,
order.date
)
]);
return {
...order,
feeInAssetProfileCurrency,
feeInBaseCurrency,
unitPriceInAssetProfileCurrency,
value,
feeInBaseCurrency:
await this.exchangeRateDataService.toCurrencyAtDate(
order.fee,
order.SymbolProfile.currency,
userCurrency,
order.date
),
SymbolProfile: assetProfile,
valueInBaseCurrency:
await this.exchangeRateDataService.toCurrencyAtDate(
value,
order.SymbolProfile.currency,
userCurrency,
order.date
)
valueInBaseCurrency,
SymbolProfile: assetProfile
};
})
);
@ -628,8 +649,8 @@ export class OrderService {
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)) {
delete data.SymbolProfile.connect;
if (data.Account?.connect?.id_userId?.id === null) {
data.Account = { disconnect: true };
if (data.account?.connect?.id_userId?.id === null) {
data.account = { disconnect: true };
}
} else {
delete data.SymbolProfile.update;

View File

@ -54,7 +54,7 @@ export class PlatformService {
await this.prismaService.platform.findMany({
include: {
_count: {
select: { Account: true }
select: { accounts: true }
}
}
});
@ -64,7 +64,7 @@ export class PlatformService {
id,
name,
url,
accountCount: _count.Account
accountCount: _count.accounts
};
});
}

View File

@ -4,12 +4,17 @@ import {
SymbolMetrics
} from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot } from '@ghostfolio/common/models';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
export class MWRPortfolioCalculator extends PortfolioCalculator {
export class MwrPortfolioCalculator extends PortfolioCalculator {
protected calculateOverallPerformance(): PortfolioSnapshot {
throw new Error('Method not implemented.');
}
protected getPerformanceCalculationType() {
return PerformanceCalculationType.MWR;
}
protected getSymbolMetrics({}: {
end: Date;
exchangeRates: { [dateString: string]: number };

View File

@ -6,10 +6,14 @@ export const activityDummyData = {
comment: undefined,
createdAt: new Date(),
currency: undefined,
fee: undefined,
feeInAssetProfileCurrency: undefined,
feeInBaseCurrency: undefined,
id: undefined,
isDraft: false,
symbolProfileId: undefined,
unitPrice: undefined,
unitPriceInAssetProfileCurrency: undefined,
updatedAt: new Date(),
userId: undefined,
value: undefined,
@ -24,6 +28,7 @@ export const symbolProfileDummyData = {
createdAt: undefined,
holdings: [],
id: undefined,
isActive: true,
sectors: [],
updatedAt: undefined
};

View File

@ -5,17 +5,15 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { Filter, HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Injectable } from '@nestjs/common';
import { MWRPortfolioCalculator } from './mwr/portfolio-calculator';
import { MwrPortfolioCalculator } from './mwr/portfolio-calculator';
import { PortfolioCalculator } from './portfolio-calculator';
import { TWRPortfolioCalculator } from './twr/portfolio-calculator';
export enum PerformanceCalculationType {
MWR = 'MWR', // Money-Weighted Rate of Return
TWR = 'TWR' // Time-Weighted Rate of Return
}
import { RoaiPortfolioCalculator } from './roai/portfolio-calculator';
import { RoiPortfolioCalculator } from './roi/portfolio-calculator';
import { TwrPortfolioCalculator } from './twr/portfolio-calculator';
@Injectable()
export class PortfolioCalculatorFactory {
@ -44,7 +42,7 @@ export class PortfolioCalculatorFactory {
}): PortfolioCalculator {
switch (calculationType) {
case PerformanceCalculationType.MWR:
return new MWRPortfolioCalculator({
return new MwrPortfolioCalculator({
accountBalanceItems,
activities,
currency,
@ -56,19 +54,49 @@ export class PortfolioCalculatorFactory {
portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService
});
case PerformanceCalculationType.ROAI:
return new RoaiPortfolioCalculator({
accountBalanceItems,
activities,
currency,
filters,
userId,
configurationService: this.configurationService,
currentRateService: this.currentRateService,
exchangeRateDataService: this.exchangeRateDataService,
portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService
});
case PerformanceCalculationType.ROI:
return new RoiPortfolioCalculator({
accountBalanceItems,
activities,
currency,
filters,
userId,
configurationService: this.configurationService,
currentRateService: this.currentRateService,
exchangeRateDataService: this.exchangeRateDataService,
portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService
});
case PerformanceCalculationType.TWR:
return new TWRPortfolioCalculator({
return new TwrPortfolioCalculator({
accountBalanceItems,
activities,
currency,
currentRateService: this.currentRateService,
filters,
userId,
configurationService: this.configurationService,
currentRateService: this.currentRateService,
exchangeRateDataService: this.exchangeRateDataService,
portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService
});
default:
throw new Error('Invalid calculation type');
}

View File

@ -35,6 +35,7 @@ import {
} from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
import { GroupBy } from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Logger } from '@nestjs/common';
import { Big } from 'big.js';
@ -49,7 +50,7 @@ import {
min,
subDays
} from 'date-fns';
import { isNumber, sortBy, sum, uniq, uniqBy } from 'lodash';
import { isNumber, sortBy, sum, uniqBy } from 'lodash';
export abstract class PortfolioCalculator {
protected static readonly ENABLE_LOGGING = false;
@ -112,12 +113,12 @@ export abstract class PortfolioCalculator {
.map(
({
date,
fee,
feeInAssetProfileCurrency,
quantity,
SymbolProfile,
tags = [],
type,
unitPrice
unitPriceInAssetProfileCurrency
}) => {
if (isBefore(date, dateOfFirstActivity)) {
dateOfFirstActivity = date;
@ -134,9 +135,9 @@ export abstract class PortfolioCalculator {
tags,
type,
date: format(date, DATE_FORMAT),
fee: new Big(fee),
fee: new Big(feeInAssetProfileCurrency),
quantity: new Big(quantity),
unitPrice: new Big(unitPrice)
unitPrice: new Big(unitPriceInAssetProfileCurrency)
};
}
)
@ -222,7 +223,7 @@ export abstract class PortfolioCalculator {
const exchangeRatesByCurrency =
await this.exchangeRateDataService.getExchangeRatesByCurrency({
currencies: uniq(Object.values(currencies)),
currencies: Array.from(new Set(Object.values(currencies))),
endDate: endOfDay(this.endDate),
startDate: this.startDate,
targetCurrency: this.currency
@ -623,6 +624,8 @@ export abstract class PortfolioCalculator {
};
}
protected abstract getPerformanceCalculationType(): PerformanceCalculationType;
public getDataProviderInfos() {
return this.dataProviderInfos;
}
@ -899,8 +902,8 @@ export abstract class PortfolioCalculator {
let lastTransactionPoint: TransactionPoint = null;
for (const {
fee,
date,
fee,
quantity,
SymbolProfile,
tags,
@ -1073,6 +1076,7 @@ export abstract class PortfolioCalculator {
// Compute in the background
this.portfolioSnapshotService.addJobToQueue({
data: {
calculationType: this.getPerformanceCalculationType(),
filters: this.filters,
userCurrency: this.currency,
userId: this.userId
@ -1089,6 +1093,7 @@ export abstract class PortfolioCalculator {
// Wait for computation
await this.portfolioSnapshotService.addJobToQueue({
data: {
calculationType: this.getPerformanceCalculationType(),
filters: this.filters,
userCurrency: this.currency,
userId: this.userId

View File

@ -4,10 +4,7 @@ import {
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
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';
@ -17,6 +14,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
@ -91,7 +89,7 @@ describe('PortfolioCalculator', () => {
{
...activityDummyData,
date: new Date('2021-11-22'),
fee: 1.55,
feeInAssetProfileCurrency: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
@ -101,12 +99,12 @@ describe('PortfolioCalculator', () => {
symbol: 'BALN.SW'
},
type: 'BUY',
unitPrice: 142.9
unitPriceInAssetProfileCurrency: 142.9
},
{
...activityDummyData,
date: new Date('2021-11-30'),
fee: 1.65,
feeInAssetProfileCurrency: 1.65,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
@ -116,12 +114,12 @@ describe('PortfolioCalculator', () => {
symbol: 'BALN.SW'
},
type: 'SELL',
unitPrice: 136.6
unitPriceInAssetProfileCurrency: 136.6
},
{
...activityDummyData,
date: new Date('2021-11-30'),
fee: 0,
feeInAssetProfileCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
@ -131,13 +129,13 @@ describe('PortfolioCalculator', () => {
symbol: 'BALN.SW'
},
type: 'SELL',
unitPrice: 136.6
unitPriceInAssetProfileCurrency: 136.6
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});

View File

@ -4,10 +4,7 @@ import {
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PerformanceCalculationType,
PortfolioCalculatorFactory
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
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';
@ -17,6 +14,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
@ -91,7 +89,7 @@ describe('PortfolioCalculator', () => {
{
...activityDummyData,
date: new Date('2021-11-22'),
fee: 1.55,
feeInAssetProfileCurrency: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
@ -101,12 +99,12 @@ describe('PortfolioCalculator', () => {
symbol: 'BALN.SW'
},
type: 'BUY',
unitPrice: 142.9
unitPriceInAssetProfileCurrency: 142.9
},
{
...activityDummyData,
date: new Date('2021-11-30'),
fee: 1.65,
feeInAssetProfileCurrency: 1.65,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
@ -116,13 +114,13 @@ describe('PortfolioCalculator', () => {
symbol: 'BALN.SW'
},
type: 'SELL',
unitPrice: 136.6
unitPriceInAssetProfileCurrency: 136.6
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});

View File

@ -4,10 +4,7 @@ import {
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
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';
@ -17,6 +14,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
@ -91,7 +89,7 @@ describe('PortfolioCalculator', () => {
{
...activityDummyData,
date: new Date('2021-11-30'),
fee: 1.55,
feeInAssetProfileCurrency: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
@ -101,13 +99,13 @@ describe('PortfolioCalculator', () => {
symbol: 'BALN.SW'
},
type: 'BUY',
unitPrice: 136.6
unitPriceInAssetProfileCurrency: 136.6
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});
@ -195,5 +193,83 @@ describe('PortfolioCalculator', () => {
{ date: '2021-12-01', investment: 0 }
]);
});
it.only('with BALN.SW buy (with unit price lower than closing price)', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
type: 'BUY',
unitPriceInAssetProfileCurrency: 135.0
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const snapshotOnBuyDate = portfolioSnapshot.historicalData.find(
({ date }) => {
return date === '2021-11-30';
}
);
// Closing price on 2021-11-30: 136.6
expect(snapshotOnBuyDate?.netPerformanceWithCurrencyEffect).toEqual(1.65); // 2 * (136.6 - 135.0) - 1.55 = 1.65
});
it.only('with BALN.SW buy (with unit price lower than closing price), calculated on buy date', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2021-11-30').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
type: 'BUY',
unitPriceInAssetProfileCurrency: 135.0
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const snapshotOnBuyDate = portfolioSnapshot.historicalData.find(
({ date }) => {
return date === '2021-11-30';
}
);
// Closing price on 2021-11-30: 136.6
expect(snapshotOnBuyDate?.netPerformanceWithCurrencyEffect).toEqual(1.65); // 2 * (136.6 - 135.0) - 1.55 = 1.65
});
});
});

View File

@ -0,0 +1,241 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
loadActivityExportFile,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
import { join } from 'path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let activityDtos: CreateOrderDto[];
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeAll(() => {
activityDtos = loadActivityExportFile(
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[] = activityDtos.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: 'USD',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
expect(portfolioSnapshot.historicalData[0]).toEqual({
date: '2021-12-11',
investmentValueWithCurrencyEffect: 0,
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
netWorth: 0,
totalAccountBalance: 0,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0,
value: 0,
valueWithCurrencyEffect: 0
});
/**
* Closing price on 2021-12-12: 50098.3
*/
expect(portfolioSnapshot.historicalData[1]).toEqual({
date: '2021-12-12',
investmentValueWithCurrencyEffect: 44558.42,
netPerformance: 5535.42, // 1 * (50098.3 - 44558.42) - 4.46 = 5535.42
netPerformanceInPercentage: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412
netPerformanceInPercentageWithCurrencyEffect: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412
netPerformanceWithCurrencyEffect: 5535.42,
netWorth: 50098.3, // 1 * 50098.3 = 50098.3
totalAccountBalance: 0,
totalInvestment: 44558.42,
totalInvestmentValueWithCurrencyEffect: 44558.42,
value: 50098.3, // 1 * 50098.3 = 50098.3
valueWithCurrencyEffect: 50098.3
});
expect(
portfolioSnapshot.historicalData[
portfolioSnapshot.historicalData.length - 1
]
).toEqual({
date: '2022-01-14',
investmentValueWithCurrencyEffect: 0,
netPerformance: -1463.18,
netPerformanceInPercentage: -0.032837340282712,
netPerformanceInPercentageWithCurrencyEffect: -0.032837340282712,
netPerformanceWithCurrencyEffect: -1463.18,
netWorth: 43099.7,
totalAccountBalance: 0,
totalInvestment: 44558.42,
totalInvestmentValueWithCurrencyEffect: 44558.42,
value: 43099.7,
valueWithCurrencyEffect: 43099.7
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('43099.7'),
errors: [],
hasErrors: false,
positions: [
{
averagePrice: new Big('44558.42'),
currency: 'USD',
dataSource: 'YAHOO',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('4.46'),
feeInBaseCurrency: new Big('4.46'),
firstBuyDate: '2021-12-12',
grossPerformance: new Big('-1458.72'),
grossPerformancePercentage: new Big('-0.03273724696701543726'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'-0.03273724696701543726'
),
grossPerformanceWithCurrencyEffect: new Big('-1458.72'),
investment: new Big('44558.42'),
investmentWithCurrencyEffect: new Big('44558.42'),
netPerformance: new Big('-1463.18'),
netPerformancePercentage: new Big('-0.03283734028271199921'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('-0.03283734028271199921')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('-1463.18')
},
marketPrice: 43099.7,
marketPriceInBaseCurrency: 43099.7,
quantity: new Big('1'),
symbol: 'BTCUSD',
tags: [],
timeWeightedInvestment: new Big('44558.42'),
timeWeightedInvestmentWithCurrencyEffect: new Big('44558.42'),
transactionCount: 1,
valueInBaseCurrency: new Big('43099.7')
}
],
totalFeesWithCurrencyEffect: new Big('4.46'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('44558.42'),
totalInvestmentWithCurrencyEffect: new Big('44558.42'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
expect(investments).toEqual([
{ date: '2021-12-12', investment: new Big('44558.42') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2021-12-01', investment: 44558.42 },
{ date: '2022-01-01', investment: 0 }
]);
});
});
});

View File

@ -4,10 +4,7 @@ import {
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
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';
@ -18,6 +15,7 @@ import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-r
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
@ -105,7 +103,7 @@ describe('PortfolioCalculator', () => {
{
...activityDummyData,
date: new Date('2015-01-01'),
fee: 0,
feeInAssetProfileCurrency: 0,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
@ -115,12 +113,12 @@ describe('PortfolioCalculator', () => {
symbol: 'BTCUSD'
},
type: 'BUY',
unitPrice: 320.43
unitPriceInAssetProfileCurrency: 320.43
},
{
...activityDummyData,
date: new Date('2017-12-31'),
fee: 0,
feeInAssetProfileCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
@ -130,13 +128,13 @@ describe('PortfolioCalculator', () => {
symbol: 'BTCUSD'
},
type: 'SELL',
unitPrice: 14156.4
unitPriceInAssetProfileCurrency: 14156.4
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});

View File

@ -0,0 +1,241 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
loadActivityExportFile,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
import { join } from 'path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let activityDtos: CreateOrderDto[];
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeAll(() => {
activityDtos = loadActivityExportFile(
join(__dirname, '../../../../../../../test/import/ok/btcusd.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 USD)', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime());
const activities: Activity[] = activityDtos.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: 'USD',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
expect(portfolioSnapshot.historicalData[0]).toEqual({
date: '2021-12-11',
investmentValueWithCurrencyEffect: 0,
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
netWorth: 0,
totalAccountBalance: 0,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0,
value: 0,
valueWithCurrencyEffect: 0
});
/**
* Closing price on 2021-12-12: 50098.3
*/
expect(portfolioSnapshot.historicalData[1]).toEqual({
date: '2021-12-12',
investmentValueWithCurrencyEffect: 44558.42,
netPerformance: 5535.42, // 1 * (50098.3 - 44558.42) - 4.46 = 5535.42
netPerformanceInPercentage: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412
netPerformanceInPercentageWithCurrencyEffect: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412
netPerformanceWithCurrencyEffect: 5535.42, // 1 * (50098.3 - 44558.42) - 4.46 = 5535.42
netWorth: 50098.3, // 1 * 50098.3 = 50098.3
totalAccountBalance: 0,
totalInvestment: 44558.42,
totalInvestmentValueWithCurrencyEffect: 44558.42,
value: 50098.3, // 1 * 50098.3 = 50098.3
valueWithCurrencyEffect: 50098.3
});
expect(
portfolioSnapshot.historicalData[
portfolioSnapshot.historicalData.length - 1
]
).toEqual({
date: '2022-01-14',
investmentValueWithCurrencyEffect: 0,
netPerformance: -1463.18,
netPerformanceInPercentage: -0.032837340282712,
netPerformanceInPercentageWithCurrencyEffect: -0.032837340282712,
netPerformanceWithCurrencyEffect: -1463.18,
netWorth: 43099.7,
totalAccountBalance: 0,
totalInvestment: 44558.42,
totalInvestmentValueWithCurrencyEffect: 44558.42,
value: 43099.7,
valueWithCurrencyEffect: 43099.7
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('43099.7'),
errors: [],
hasErrors: false,
positions: [
{
averagePrice: new Big('44558.42'),
currency: 'USD',
dataSource: 'YAHOO',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('4.46'),
feeInBaseCurrency: new Big('4.46'),
firstBuyDate: '2021-12-12',
grossPerformance: new Big('-1458.72'),
grossPerformancePercentage: new Big('-0.03273724696701543726'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'-0.03273724696701543726'
),
grossPerformanceWithCurrencyEffect: new Big('-1458.72'),
investment: new Big('44558.42'),
investmentWithCurrencyEffect: new Big('44558.42'),
netPerformance: new Big('-1463.18'),
netPerformancePercentage: new Big('-0.03283734028271199921'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('-0.03283734028271199921')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('-1463.18')
},
marketPrice: 43099.7,
marketPriceInBaseCurrency: 43099.7,
quantity: new Big('1'),
symbol: 'BTCUSD',
tags: [],
timeWeightedInvestment: new Big('44558.42'),
timeWeightedInvestmentWithCurrencyEffect: new Big('44558.42'),
transactionCount: 1,
valueInBaseCurrency: new Big('43099.7')
}
],
totalFeesWithCurrencyEffect: new Big('4.46'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('44558.42'),
totalInvestmentWithCurrencyEffect: new Big('44558.42'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
expect(investments).toEqual([
{ date: '2021-12-12', investment: new Big('44558.42') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2021-12-01', investment: 44558.42 },
{ date: '2022-01-01', investment: 0 }
]);
});
});
});

View File

@ -4,10 +4,7 @@ import {
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
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';
@ -17,6 +14,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
@ -91,7 +89,7 @@ describe('PortfolioCalculator', () => {
{
...activityDummyData,
date: new Date('2021-09-01'),
fee: 49,
feeInAssetProfileCurrency: 49,
quantity: 0,
SymbolProfile: {
...symbolProfileDummyData,
@ -101,13 +99,13 @@ describe('PortfolioCalculator', () => {
symbol: '2c463fb3-af07-486e-adb0-8301b3d72141'
},
type: 'FEE',
unitPrice: 0
unitPriceInAssetProfileCurrency: 0
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
calculationType: PerformanceCalculationType.ROAI,
currency: 'USD',
userId: userDummyData.id
});

View File

@ -4,10 +4,7 @@ import {
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
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';
@ -18,6 +15,7 @@ import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-r
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
@ -104,7 +102,7 @@ describe('PortfolioCalculator', () => {
{
...activityDummyData,
date: new Date('2023-01-03'),
fee: 1,
feeInAssetProfileCurrency: 1,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
@ -114,13 +112,13 @@ describe('PortfolioCalculator', () => {
symbol: 'GOOGL'
},
type: 'BUY',
unitPrice: 89.12
unitPriceInAssetProfileCurrency: 89.12
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});

View File

@ -4,10 +4,7 @@ import {
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
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';
@ -17,6 +14,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
@ -91,7 +89,7 @@ describe('PortfolioCalculator', () => {
{
...activityDummyData,
date: new Date('2022-01-01'),
fee: 0,
feeInAssetProfileCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
@ -101,13 +99,13 @@ describe('PortfolioCalculator', () => {
symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde'
},
type: 'ITEM',
unitPrice: 500000
unitPriceInAssetProfileCurrency: 500000
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
calculationType: PerformanceCalculationType.ROAI,
currency: 'USD',
userId: userDummyData.id
});

View File

@ -4,10 +4,7 @@ import {
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
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';
@ -17,6 +14,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
@ -91,7 +89,7 @@ describe('PortfolioCalculator', () => {
{
...activityDummyData,
date: new Date('2023-01-01'), // Date in future
fee: 0,
feeInAssetProfileCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
@ -101,13 +99,13 @@ describe('PortfolioCalculator', () => {
symbol: '55196015-1365-4560-aa60-8751ae6d18f8'
},
type: 'LIABILITY',
unitPrice: 3000
unitPriceInAssetProfileCurrency: 3000
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
calculationType: PerformanceCalculationType.ROAI,
currency: 'USD',
userId: userDummyData.id
});

View File

@ -4,20 +4,17 @@ import {
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PerformanceCalculationType,
PortfolioCalculatorFactory
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
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 { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
@ -51,18 +48,6 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
};
});
jest.mock(
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
ExchangeRateDataService: jest.fn().mockImplementation(() => {
return ExchangeRateDataServiceMock;
})
};
}
);
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
@ -104,7 +89,7 @@ describe('PortfolioCalculator', () => {
{
...activityDummyData,
date: new Date('2021-09-16'),
fee: 19,
feeInAssetProfileCurrency: 19,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
@ -114,12 +99,12 @@ describe('PortfolioCalculator', () => {
symbol: 'MSFT'
},
type: 'BUY',
unitPrice: 298.58
unitPriceInAssetProfileCurrency: 298.58
},
{
...activityDummyData,
date: new Date('2021-11-16'),
fee: 0,
feeInAssetProfileCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
@ -129,13 +114,13 @@ describe('PortfolioCalculator', () => {
symbol: 'MSFT'
},
type: 'DIVIDEND',
unitPrice: 0.62
unitPriceInAssetProfileCurrency: 0.62
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
calculationType: PerformanceCalculationType.ROAI,
currency: 'USD',
userId: userDummyData.id
});

View File

@ -1,8 +1,5 @@
import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PerformanceCalculationType,
PortfolioCalculatorFactory
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
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';
@ -12,6 +9,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
@ -84,7 +82,7 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities: [],
calculationType: PerformanceCalculationType.TWR,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});

View File

@ -6,10 +6,7 @@ import {
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PerformanceCalculationType,
PortfolioCalculatorFactory
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
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';
@ -19,6 +16,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
import { join } from 'path';
@ -67,7 +65,7 @@ describe('PortfolioCalculator', () => {
activityDtos = loadActivityExportFile(
join(
__dirname,
'../../../../../../../test/import/ok-novn-buy-and-sell-partially.json'
'../../../../../../../test/import/ok/novn-buy-and-sell-partially.json'
)
);
});
@ -105,18 +103,20 @@ describe('PortfolioCalculator', () => {
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: activity.currency,
dataSource: activity.dataSource,
name: 'Novartis AG',
symbol: activity.symbol
}
},
unitPriceInAssetProfileCurrency: activity.unitPrice
}));
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});

View File

@ -6,10 +6,7 @@ import {
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PerformanceCalculationType,
PortfolioCalculatorFactory
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
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';
@ -19,6 +16,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
import { join } from 'path';
@ -67,7 +65,7 @@ describe('PortfolioCalculator', () => {
activityDtos = loadActivityExportFile(
join(
__dirname,
'../../../../../../../test/import/ok-novn-buy-and-sell.json'
'../../../../../../../test/import/ok/novn-buy-and-sell.json'
)
);
});
@ -105,18 +103,20 @@ describe('PortfolioCalculator', () => {
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: activity.currency,
dataSource: activity.dataSource,
name: 'Novartis AG',
symbol: activity.symbol
}
},
unitPriceInAssetProfileCurrency: activity.unitPrice
}));
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});
@ -145,19 +145,23 @@ describe('PortfolioCalculator', () => {
valueWithCurrencyEffect: 0
});
/**
* Closing price on 2022-03-07 is unknown,
* hence it uses the last unit price (2022-04-11): 87.8
*/
expect(portfolioSnapshot.historicalData[1]).toEqual({
date: '2022-03-07',
investmentValueWithCurrencyEffect: 151.6,
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
netWorth: 151.6,
netPerformance: 24, // 2 * (87.8 - 75.8) = 24
netPerformanceInPercentage: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438
netPerformanceInPercentageWithCurrencyEffect: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438
netPerformanceWithCurrencyEffect: 24,
netWorth: 175.6, // 2 * 87.8 = 175.6
totalAccountBalance: 0,
totalInvestment: 151.6,
totalInvestmentValueWithCurrencyEffect: 151.6,
value: 151.6,
valueWithCurrencyEffect: 151.6
value: 175.6, // 2 * 87.8 = 175.6
valueWithCurrencyEffect: 175.6
});
expect(

View File

@ -0,0 +1,987 @@
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
import { PortfolioOrderItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order-item.interface';
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
SymbolMetrics
} from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
import { DateRange } from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Logger } from '@nestjs/common';
import { Big } from 'big.js';
import { addMilliseconds, differenceInDays, format, isBefore } from 'date-fns';
import { cloneDeep, sortBy } from 'lodash';
export class RoaiPortfolioCalculator extends PortfolioCalculator {
private chartDates: string[];
protected calculateOverallPerformance(
positions: TimelinePosition[]
): PortfolioSnapshot {
let currentValueInBaseCurrency = new Big(0);
let grossPerformance = new Big(0);
let grossPerformanceWithCurrencyEffect = new Big(0);
let hasErrors = false;
let netPerformance = new Big(0);
let totalFeesWithCurrencyEffect = new Big(0);
const totalInterestWithCurrencyEffect = new Big(0);
let totalInvestment = new Big(0);
let totalInvestmentWithCurrencyEffect = new Big(0);
let totalTimeWeightedInvestment = new Big(0);
let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0);
for (const currentPosition of positions) {
if (currentPosition.feeInBaseCurrency) {
totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus(
currentPosition.feeInBaseCurrency
);
}
if (currentPosition.valueInBaseCurrency) {
currentValueInBaseCurrency = currentValueInBaseCurrency.plus(
currentPosition.valueInBaseCurrency
);
} else {
hasErrors = true;
}
if (currentPosition.investment) {
totalInvestment = totalInvestment.plus(currentPosition.investment);
totalInvestmentWithCurrencyEffect =
totalInvestmentWithCurrencyEffect.plus(
currentPosition.investmentWithCurrencyEffect
);
} else {
hasErrors = true;
}
if (currentPosition.grossPerformance) {
grossPerformance = grossPerformance.plus(
currentPosition.grossPerformance
);
grossPerformanceWithCurrencyEffect =
grossPerformanceWithCurrencyEffect.plus(
currentPosition.grossPerformanceWithCurrencyEffect
);
netPerformance = netPerformance.plus(currentPosition.netPerformance);
} else if (!currentPosition.quantity.eq(0)) {
hasErrors = true;
}
if (currentPosition.timeWeightedInvestment) {
totalTimeWeightedInvestment = totalTimeWeightedInvestment.plus(
currentPosition.timeWeightedInvestment
);
totalTimeWeightedInvestmentWithCurrencyEffect =
totalTimeWeightedInvestmentWithCurrencyEffect.plus(
currentPosition.timeWeightedInvestmentWithCurrencyEffect
);
} else if (!currentPosition.quantity.eq(0)) {
Logger.warn(
`Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`,
'PortfolioCalculator'
);
hasErrors = true;
}
}
return {
currentValueInBaseCurrency,
hasErrors,
positions,
totalFeesWithCurrencyEffect,
totalInterestWithCurrencyEffect,
totalInvestment,
totalInvestmentWithCurrencyEffect,
activitiesCount: this.activities.filter(({ type }) => {
return ['BUY', 'SELL'].includes(type);
}).length,
createdAt: new Date(),
errors: [],
historicalData: [],
totalLiabilitiesWithCurrencyEffect: new Big(0),
totalValuablesWithCurrencyEffect: new Big(0)
};
}
protected getPerformanceCalculationType() {
return PerformanceCalculationType.ROAI;
}
protected getSymbolMetrics({
chartDateMap,
dataSource,
end,
exchangeRates,
marketSymbolMap,
start,
symbol
}: {
chartDateMap?: { [date: string]: boolean };
end: Date;
exchangeRates: { [dateString: string]: number };
marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
};
start: Date;
} & AssetProfileIdentifier): SymbolMetrics {
const currentExchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)];
const currentValues: { [date: string]: Big } = {};
const currentValuesWithCurrencyEffect: { [date: string]: Big } = {};
let fees = new Big(0);
let feesAtStartDate = new Big(0);
let feesAtStartDateWithCurrencyEffect = new Big(0);
let feesWithCurrencyEffect = new Big(0);
let grossPerformance = new Big(0);
let grossPerformanceWithCurrencyEffect = new Big(0);
let grossPerformanceAtStartDate = new Big(0);
let grossPerformanceAtStartDateWithCurrencyEffect = new Big(0);
let grossPerformanceFromSells = new Big(0);
let grossPerformanceFromSellsWithCurrencyEffect = new Big(0);
let initialValue: Big;
let initialValueWithCurrencyEffect: Big;
let investmentAtStartDate: Big;
let investmentAtStartDateWithCurrencyEffect: Big;
const investmentValuesAccumulated: { [date: string]: Big } = {};
const investmentValuesAccumulatedWithCurrencyEffect: {
[date: string]: Big;
} = {};
const investmentValuesWithCurrencyEffect: { [date: string]: Big } = {};
let lastAveragePrice = new Big(0);
let lastAveragePriceWithCurrencyEffect = new Big(0);
const netPerformanceValues: { [date: string]: Big } = {};
const netPerformanceValuesWithCurrencyEffect: { [date: string]: Big } = {};
const timeWeightedInvestmentValues: { [date: string]: Big } = {};
const timeWeightedInvestmentValuesWithCurrencyEffect: {
[date: string]: Big;
} = {};
const totalAccountBalanceInBaseCurrency = new Big(0);
let totalDividend = new Big(0);
let totalDividendInBaseCurrency = new Big(0);
let totalInterest = new Big(0);
let totalInterestInBaseCurrency = new Big(0);
let totalInvestment = new Big(0);
let totalInvestmentFromBuyTransactions = new Big(0);
let totalInvestmentFromBuyTransactionsWithCurrencyEffect = new Big(0);
let totalInvestmentWithCurrencyEffect = new Big(0);
let totalLiabilities = new Big(0);
let totalLiabilitiesInBaseCurrency = new Big(0);
let totalQuantityFromBuyTransactions = new Big(0);
let totalUnits = new Big(0);
let totalValuables = new Big(0);
let totalValuablesInBaseCurrency = new Big(0);
let valueAtStartDate: Big;
let valueAtStartDateWithCurrencyEffect: Big;
// Clone orders to keep the original values in this.orders
let orders: PortfolioOrderItem[] = cloneDeep(
this.activities.filter(({ SymbolProfile }) => {
return SymbolProfile.symbol === symbol;
})
);
if (orders.length <= 0) {
return {
currentValues: {},
currentValuesWithCurrencyEffect: {},
feesWithCurrencyEffect: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big(0),
grossPerformanceWithCurrencyEffect: new Big(0),
hasErrors: false,
initialValue: new Big(0),
initialValueWithCurrencyEffect: new Big(0),
investmentValuesAccumulated: {},
investmentValuesAccumulatedWithCurrencyEffect: {},
investmentValuesWithCurrencyEffect: {},
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
netPerformancePercentageWithCurrencyEffectMap: {},
netPerformanceValues: {},
netPerformanceValuesWithCurrencyEffect: {},
netPerformanceWithCurrencyEffectMap: {},
timeWeightedInvestment: new Big(0),
timeWeightedInvestmentValues: {},
timeWeightedInvestmentValuesWithCurrencyEffect: {},
timeWeightedInvestmentWithCurrencyEffect: new Big(0),
totalAccountBalanceInBaseCurrency: new Big(0),
totalDividend: new Big(0),
totalDividendInBaseCurrency: new Big(0),
totalInterest: new Big(0),
totalInterestInBaseCurrency: new Big(0),
totalInvestment: new Big(0),
totalInvestmentWithCurrencyEffect: new Big(0),
totalLiabilities: new Big(0),
totalLiabilitiesInBaseCurrency: new Big(0),
totalValuables: new Big(0),
totalValuablesInBaseCurrency: new Big(0)
};
}
const dateOfFirstTransaction = new Date(orders[0].date);
const endDateString = format(end, DATE_FORMAT);
const startDateString = format(start, DATE_FORMAT);
const unitPriceAtStartDate = marketSymbolMap[startDateString]?.[symbol];
const unitPriceAtEndDate = marketSymbolMap[endDateString]?.[symbol];
if (
!unitPriceAtEndDate ||
(!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start))
) {
return {
currentValues: {},
currentValuesWithCurrencyEffect: {},
feesWithCurrencyEffect: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big(0),
grossPerformanceWithCurrencyEffect: new Big(0),
hasErrors: true,
initialValue: new Big(0),
initialValueWithCurrencyEffect: new Big(0),
investmentValuesAccumulated: {},
investmentValuesAccumulatedWithCurrencyEffect: {},
investmentValuesWithCurrencyEffect: {},
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
netPerformancePercentageWithCurrencyEffectMap: {},
netPerformanceWithCurrencyEffectMap: {},
netPerformanceValues: {},
netPerformanceValuesWithCurrencyEffect: {},
timeWeightedInvestment: new Big(0),
timeWeightedInvestmentValues: {},
timeWeightedInvestmentValuesWithCurrencyEffect: {},
timeWeightedInvestmentWithCurrencyEffect: new Big(0),
totalAccountBalanceInBaseCurrency: new Big(0),
totalDividend: new Big(0),
totalDividendInBaseCurrency: new Big(0),
totalInterest: new Big(0),
totalInterestInBaseCurrency: new Big(0),
totalInvestment: new Big(0),
totalInvestmentWithCurrencyEffect: new Big(0),
totalLiabilities: new Big(0),
totalLiabilitiesInBaseCurrency: new Big(0),
totalValuables: new Big(0),
totalValuablesInBaseCurrency: new Big(0)
};
}
// Add a synthetic order at the start and the end date
orders.push({
date: startDateString,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
itemType: 'start',
quantity: new Big(0),
SymbolProfile: {
dataSource,
symbol
},
type: 'BUY',
unitPrice: unitPriceAtStartDate
});
orders.push({
date: endDateString,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
itemType: 'end',
SymbolProfile: {
dataSource,
symbol
},
quantity: new Big(0),
type: 'BUY',
unitPrice: unitPriceAtEndDate
});
let lastUnitPrice: Big;
const ordersByDate: { [date: string]: PortfolioOrderItem[] } = {};
for (const order of orders) {
ordersByDate[order.date] = ordersByDate[order.date] ?? [];
ordersByDate[order.date].push(order);
}
if (!this.chartDates) {
this.chartDates = Object.keys(chartDateMap).sort();
}
for (const dateString of this.chartDates) {
if (dateString < startDateString) {
continue;
} else if (dateString > endDateString) {
break;
}
if (ordersByDate[dateString]?.length > 0) {
for (const order of ordersByDate[dateString]) {
order.unitPriceFromMarketData =
marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice;
}
} else {
orders.push({
date: dateString,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
quantity: new Big(0),
SymbolProfile: {
dataSource,
symbol
},
type: 'BUY',
unitPrice: marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice,
unitPriceFromMarketData:
marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice
});
}
const lastOrder = orders.at(-1);
lastUnitPrice = lastOrder.unitPriceFromMarketData ?? lastOrder.unitPrice;
}
// Sort orders so that the start and end placeholder order are at the correct
// position
orders = sortBy(orders, ({ date, itemType }) => {
let sortIndex = new Date(date);
if (itemType === 'end') {
sortIndex = addMilliseconds(sortIndex, 1);
} else if (itemType === 'start') {
sortIndex = addMilliseconds(sortIndex, -1);
}
return sortIndex.getTime();
});
const indexOfStartOrder = orders.findIndex(({ itemType }) => {
return itemType === 'start';
});
const indexOfEndOrder = orders.findIndex(({ itemType }) => {
return itemType === 'end';
});
let totalInvestmentDays = 0;
let sumOfTimeWeightedInvestments = new Big(0);
let sumOfTimeWeightedInvestmentsWithCurrencyEffect = new Big(0);
for (let i = 0; i < orders.length; i += 1) {
const order = orders[i];
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log();
console.log();
console.log(
i + 1,
order.date,
order.type,
order.itemType ? `(${order.itemType})` : ''
);
}
const exchangeRateAtOrderDate = exchangeRates[order.date];
if (order.type === 'DIVIDEND') {
const dividend = order.quantity.mul(order.unitPrice);
totalDividend = totalDividend.plus(dividend);
totalDividendInBaseCurrency = totalDividendInBaseCurrency.plus(
dividend.mul(exchangeRateAtOrderDate ?? 1)
);
} else if (order.type === 'INTEREST') {
const interest = order.quantity.mul(order.unitPrice);
totalInterest = totalInterest.plus(interest);
totalInterestInBaseCurrency = totalInterestInBaseCurrency.plus(
interest.mul(exchangeRateAtOrderDate ?? 1)
);
} else if (order.type === 'ITEM') {
const valuables = order.quantity.mul(order.unitPrice);
totalValuables = totalValuables.plus(valuables);
totalValuablesInBaseCurrency = totalValuablesInBaseCurrency.plus(
valuables.mul(exchangeRateAtOrderDate ?? 1)
);
} else if (order.type === 'LIABILITY') {
const liabilities = order.quantity.mul(order.unitPrice);
totalLiabilities = totalLiabilities.plus(liabilities);
totalLiabilitiesInBaseCurrency = totalLiabilitiesInBaseCurrency.plus(
liabilities.mul(exchangeRateAtOrderDate ?? 1)
);
}
if (order.itemType === 'start') {
// Take the unit price of the order as the market price if there are no
// orders of this symbol before the start date
order.unitPrice =
indexOfStartOrder === 0
? orders[i + 1]?.unitPrice
: unitPriceAtStartDate;
}
if (order.fee) {
order.feeInBaseCurrency = order.fee.mul(currentExchangeRate ?? 1);
order.feeInBaseCurrencyWithCurrencyEffect = order.fee.mul(
exchangeRateAtOrderDate ?? 1
);
}
const unitPrice = ['BUY', 'SELL'].includes(order.type)
? order.unitPrice
: order.unitPriceFromMarketData;
if (unitPrice) {
order.unitPriceInBaseCurrency = unitPrice.mul(currentExchangeRate ?? 1);
order.unitPriceInBaseCurrencyWithCurrencyEffect = unitPrice.mul(
exchangeRateAtOrderDate ?? 1
);
}
const marketPriceInBaseCurrency =
order.unitPriceFromMarketData?.mul(currentExchangeRate ?? 1) ??
new Big(0);
const marketPriceInBaseCurrencyWithCurrencyEffect =
order.unitPriceFromMarketData?.mul(exchangeRateAtOrderDate ?? 1) ??
new Big(0);
const valueOfInvestmentBeforeTransaction = totalUnits.mul(
marketPriceInBaseCurrency
);
const valueOfInvestmentBeforeTransactionWithCurrencyEffect =
totalUnits.mul(marketPriceInBaseCurrencyWithCurrencyEffect);
if (!investmentAtStartDate && i >= indexOfStartOrder) {
investmentAtStartDate = totalInvestment ?? new Big(0);
investmentAtStartDateWithCurrencyEffect =
totalInvestmentWithCurrencyEffect ?? new Big(0);
valueAtStartDate = valueOfInvestmentBeforeTransaction;
valueAtStartDateWithCurrencyEffect =
valueOfInvestmentBeforeTransactionWithCurrencyEffect;
}
let transactionInvestment = new Big(0);
let transactionInvestmentWithCurrencyEffect = new Big(0);
if (order.type === 'BUY') {
transactionInvestment = order.quantity
.mul(order.unitPriceInBaseCurrency)
.mul(getFactor(order.type));
transactionInvestmentWithCurrencyEffect = order.quantity
.mul(order.unitPriceInBaseCurrencyWithCurrencyEffect)
.mul(getFactor(order.type));
totalQuantityFromBuyTransactions =
totalQuantityFromBuyTransactions.plus(order.quantity);
totalInvestmentFromBuyTransactions =
totalInvestmentFromBuyTransactions.plus(transactionInvestment);
totalInvestmentFromBuyTransactionsWithCurrencyEffect =
totalInvestmentFromBuyTransactionsWithCurrencyEffect.plus(
transactionInvestmentWithCurrencyEffect
);
} else if (order.type === 'SELL') {
if (totalUnits.gt(0)) {
transactionInvestment = totalInvestment
.div(totalUnits)
.mul(order.quantity)
.mul(getFactor(order.type));
transactionInvestmentWithCurrencyEffect =
totalInvestmentWithCurrencyEffect
.div(totalUnits)
.mul(order.quantity)
.mul(getFactor(order.type));
}
}
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log('order.quantity', order.quantity.toNumber());
console.log('transactionInvestment', transactionInvestment.toNumber());
console.log(
'transactionInvestmentWithCurrencyEffect',
transactionInvestmentWithCurrencyEffect.toNumber()
);
}
const totalInvestmentBeforeTransaction = totalInvestment;
const totalInvestmentBeforeTransactionWithCurrencyEffect =
totalInvestmentWithCurrencyEffect;
totalInvestment = totalInvestment.plus(transactionInvestment);
totalInvestmentWithCurrencyEffect =
totalInvestmentWithCurrencyEffect.plus(
transactionInvestmentWithCurrencyEffect
);
if (i >= indexOfStartOrder && !initialValue) {
if (
i === indexOfStartOrder &&
!valueOfInvestmentBeforeTransaction.eq(0)
) {
initialValue = valueOfInvestmentBeforeTransaction;
initialValueWithCurrencyEffect =
valueOfInvestmentBeforeTransactionWithCurrencyEffect;
} else if (transactionInvestment.gt(0)) {
initialValue = transactionInvestment;
initialValueWithCurrencyEffect =
transactionInvestmentWithCurrencyEffect;
}
}
fees = fees.plus(order.feeInBaseCurrency ?? 0);
feesWithCurrencyEffect = feesWithCurrencyEffect.plus(
order.feeInBaseCurrencyWithCurrencyEffect ?? 0
);
totalUnits = totalUnits.plus(order.quantity.mul(getFactor(order.type)));
const valueOfInvestment = totalUnits.mul(marketPriceInBaseCurrency);
const valueOfInvestmentWithCurrencyEffect = totalUnits.mul(
marketPriceInBaseCurrencyWithCurrencyEffect
);
const grossPerformanceFromSell =
order.type === 'SELL'
? order.unitPriceInBaseCurrency
.minus(lastAveragePrice)
.mul(order.quantity)
: new Big(0);
const grossPerformanceFromSellWithCurrencyEffect =
order.type === 'SELL'
? order.unitPriceInBaseCurrencyWithCurrencyEffect
.minus(lastAveragePriceWithCurrencyEffect)
.mul(order.quantity)
: new Big(0);
grossPerformanceFromSells = grossPerformanceFromSells.plus(
grossPerformanceFromSell
);
grossPerformanceFromSellsWithCurrencyEffect =
grossPerformanceFromSellsWithCurrencyEffect.plus(
grossPerformanceFromSellWithCurrencyEffect
);
lastAveragePrice = totalQuantityFromBuyTransactions.eq(0)
? new Big(0)
: totalInvestmentFromBuyTransactions.div(
totalQuantityFromBuyTransactions
);
lastAveragePriceWithCurrencyEffect = totalQuantityFromBuyTransactions.eq(
0
)
? new Big(0)
: totalInvestmentFromBuyTransactionsWithCurrencyEffect.div(
totalQuantityFromBuyTransactions
);
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log(
'grossPerformanceFromSells',
grossPerformanceFromSells.toNumber()
);
console.log(
'grossPerformanceFromSellWithCurrencyEffect',
grossPerformanceFromSellWithCurrencyEffect.toNumber()
);
}
const newGrossPerformance = valueOfInvestment
.minus(totalInvestment)
.plus(grossPerformanceFromSells);
const newGrossPerformanceWithCurrencyEffect =
valueOfInvestmentWithCurrencyEffect
.minus(totalInvestmentWithCurrencyEffect)
.plus(grossPerformanceFromSellsWithCurrencyEffect);
grossPerformance = newGrossPerformance;
grossPerformanceWithCurrencyEffect =
newGrossPerformanceWithCurrencyEffect;
if (order.itemType === 'start') {
feesAtStartDate = fees;
feesAtStartDateWithCurrencyEffect = feesWithCurrencyEffect;
grossPerformanceAtStartDate = grossPerformance;
grossPerformanceAtStartDateWithCurrencyEffect =
grossPerformanceWithCurrencyEffect;
}
if (i > indexOfStartOrder) {
// Only consider periods with an investment for the calculation of
// the time weighted investment
if (
valueOfInvestmentBeforeTransaction.gt(0) &&
['BUY', 'SELL'].includes(order.type)
) {
// Calculate the number of days since the previous order
const orderDate = new Date(order.date);
const previousOrderDate = new Date(orders[i - 1].date);
let daysSinceLastOrder = differenceInDays(
orderDate,
previousOrderDate
);
if (daysSinceLastOrder <= 0) {
// The time between two activities on the same day is unknown
// -> Set it to the smallest floating point number greater than 0
daysSinceLastOrder = Number.EPSILON;
}
// Sum up the total investment days since the start date to calculate
// the time weighted investment
totalInvestmentDays += daysSinceLastOrder;
sumOfTimeWeightedInvestments = sumOfTimeWeightedInvestments.add(
valueAtStartDate
.minus(investmentAtStartDate)
.plus(totalInvestmentBeforeTransaction)
.mul(daysSinceLastOrder)
);
sumOfTimeWeightedInvestmentsWithCurrencyEffect =
sumOfTimeWeightedInvestmentsWithCurrencyEffect.add(
valueAtStartDateWithCurrencyEffect
.minus(investmentAtStartDateWithCurrencyEffect)
.plus(totalInvestmentBeforeTransactionWithCurrencyEffect)
.mul(daysSinceLastOrder)
);
}
currentValues[order.date] = valueOfInvestment;
currentValuesWithCurrencyEffect[order.date] =
valueOfInvestmentWithCurrencyEffect;
netPerformanceValues[order.date] = grossPerformance
.minus(grossPerformanceAtStartDate)
.minus(fees.minus(feesAtStartDate));
netPerformanceValuesWithCurrencyEffect[order.date] =
grossPerformanceWithCurrencyEffect
.minus(grossPerformanceAtStartDateWithCurrencyEffect)
.minus(
feesWithCurrencyEffect.minus(feesAtStartDateWithCurrencyEffect)
);
investmentValuesAccumulated[order.date] = totalInvestment;
investmentValuesAccumulatedWithCurrencyEffect[order.date] =
totalInvestmentWithCurrencyEffect;
investmentValuesWithCurrencyEffect[order.date] = (
investmentValuesWithCurrencyEffect[order.date] ?? new Big(0)
).add(transactionInvestmentWithCurrencyEffect);
// If duration is effectively zero (first day), use the actual investment as the base.
// Otherwise, use the calculated time-weighted average.
timeWeightedInvestmentValues[order.date] =
totalInvestmentDays > Number.EPSILON
? sumOfTimeWeightedInvestments.div(totalInvestmentDays)
: totalInvestment.gt(0)
? totalInvestment
: new Big(0);
timeWeightedInvestmentValuesWithCurrencyEffect[order.date] =
totalInvestmentDays > Number.EPSILON
? sumOfTimeWeightedInvestmentsWithCurrencyEffect.div(
totalInvestmentDays
)
: totalInvestmentWithCurrencyEffect.gt(0)
? totalInvestmentWithCurrencyEffect
: new Big(0);
}
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log('totalInvestment', totalInvestment.toNumber());
console.log(
'totalInvestmentWithCurrencyEffect',
totalInvestmentWithCurrencyEffect.toNumber()
);
console.log(
'totalGrossPerformance',
grossPerformance.minus(grossPerformanceAtStartDate).toNumber()
);
console.log(
'totalGrossPerformanceWithCurrencyEffect',
grossPerformanceWithCurrencyEffect
.minus(grossPerformanceAtStartDateWithCurrencyEffect)
.toNumber()
);
}
if (i === indexOfEndOrder) {
break;
}
}
const totalGrossPerformance = grossPerformance.minus(
grossPerformanceAtStartDate
);
const totalGrossPerformanceWithCurrencyEffect =
grossPerformanceWithCurrencyEffect.minus(
grossPerformanceAtStartDateWithCurrencyEffect
);
const totalNetPerformance = grossPerformance
.minus(grossPerformanceAtStartDate)
.minus(fees.minus(feesAtStartDate));
const timeWeightedAverageInvestmentBetweenStartAndEndDate =
totalInvestmentDays > 0
? sumOfTimeWeightedInvestments.div(totalInvestmentDays)
: new Big(0);
const timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect =
totalInvestmentDays > 0
? sumOfTimeWeightedInvestmentsWithCurrencyEffect.div(
totalInvestmentDays
)
: new Big(0);
const grossPerformancePercentage =
timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0)
? totalGrossPerformance.div(
timeWeightedAverageInvestmentBetweenStartAndEndDate
)
: new Big(0);
const grossPerformancePercentageWithCurrencyEffect =
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.gt(
0
)
? totalGrossPerformanceWithCurrencyEffect.div(
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect
)
: new Big(0);
const feesPerUnit = totalUnits.gt(0)
? fees.minus(feesAtStartDate).div(totalUnits)
: new Big(0);
const feesPerUnitWithCurrencyEffect = totalUnits.gt(0)
? feesWithCurrencyEffect
.minus(feesAtStartDateWithCurrencyEffect)
.div(totalUnits)
: new Big(0);
const netPerformancePercentage =
timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0)
? totalNetPerformance.div(
timeWeightedAverageInvestmentBetweenStartAndEndDate
)
: new Big(0);
const netPerformancePercentageWithCurrencyEffectMap: {
[key: DateRange]: Big;
} = {};
const netPerformanceWithCurrencyEffectMap: {
[key: DateRange]: Big;
} = {};
for (const dateRange of [
'1d',
'1y',
'5y',
'max',
'mtd',
'wtd',
'ytd'
// TODO:
// ...eachYearOfInterval({ end, start })
// .filter((date) => {
// return !isThisYear(date);
// })
// .map((date) => {
// return format(date, 'yyyy');
// })
] as DateRange[]) {
const dateInterval = getIntervalFromDateRange(dateRange);
const endDate = dateInterval.endDate;
let startDate = dateInterval.startDate;
if (isBefore(startDate, start)) {
startDate = start;
}
const rangeEndDateString = format(endDate, DATE_FORMAT);
const rangeStartDateString = format(startDate, DATE_FORMAT);
const currentValuesAtDateRangeStartWithCurrencyEffect =
currentValuesWithCurrencyEffect[rangeStartDateString] ?? new Big(0);
const investmentValuesAccumulatedAtStartDateWithCurrencyEffect =
investmentValuesAccumulatedWithCurrencyEffect[rangeStartDateString] ??
new Big(0);
const grossPerformanceAtDateRangeStartWithCurrencyEffect =
currentValuesAtDateRangeStartWithCurrencyEffect.minus(
investmentValuesAccumulatedAtStartDateWithCurrencyEffect
);
let average = new Big(0);
let dayCount = 0;
for (let i = this.chartDates.length - 1; i >= 0; i -= 1) {
const date = this.chartDates[i];
if (date > rangeEndDateString) {
continue;
} else if (date < rangeStartDateString) {
break;
}
if (
investmentValuesAccumulatedWithCurrencyEffect[date] instanceof Big &&
investmentValuesAccumulatedWithCurrencyEffect[date].gt(0)
) {
average = average.add(
investmentValuesAccumulatedWithCurrencyEffect[date].add(
grossPerformanceAtDateRangeStartWithCurrencyEffect
)
);
dayCount++;
}
}
if (dayCount > 0) {
average = average.div(dayCount);
}
netPerformanceWithCurrencyEffectMap[dateRange] =
netPerformanceValuesWithCurrencyEffect[rangeEndDateString]?.minus(
// If the date range is 'max', take 0 as a start value. Otherwise,
// the value of the end of the day of the start date is taken which
// differs from the buying price.
dateRange === 'max'
? new Big(0)
: (netPerformanceValuesWithCurrencyEffect[rangeStartDateString] ??
new Big(0))
) ?? new Big(0);
netPerformancePercentageWithCurrencyEffectMap[dateRange] = average.gt(0)
? netPerformanceWithCurrencyEffectMap[dateRange].div(average)
: new Big(0);
}
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log(
`
${symbol}
Unit price: ${orders[indexOfStartOrder].unitPrice.toFixed(
2
)} -> ${unitPriceAtEndDate.toFixed(2)}
Total investment: ${totalInvestment.toFixed(2)}
Total investment with currency effect: ${totalInvestmentWithCurrencyEffect.toFixed(
2
)}
Time weighted investment: ${timeWeightedAverageInvestmentBetweenStartAndEndDate.toFixed(
2
)}
Time weighted investment with currency effect: ${timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.toFixed(
2
)}
Total dividend: ${totalDividend.toFixed(2)}
Gross performance: ${totalGrossPerformance.toFixed(
2
)} / ${grossPerformancePercentage.mul(100).toFixed(2)}%
Gross performance with currency effect: ${totalGrossPerformanceWithCurrencyEffect.toFixed(
2
)} / ${grossPerformancePercentageWithCurrencyEffect
.mul(100)
.toFixed(2)}%
Fees per unit: ${feesPerUnit.toFixed(2)}
Fees per unit with currency effect: ${feesPerUnitWithCurrencyEffect.toFixed(
2
)}
Net performance: ${totalNetPerformance.toFixed(
2
)} / ${netPerformancePercentage.mul(100).toFixed(2)}%
Net performance with currency effect: ${netPerformancePercentageWithCurrencyEffectMap[
'max'
].toFixed(2)}%`
);
}
return {
currentValues,
currentValuesWithCurrencyEffect,
feesWithCurrencyEffect,
grossPerformancePercentage,
grossPerformancePercentageWithCurrencyEffect,
initialValue,
initialValueWithCurrencyEffect,
investmentValuesAccumulated,
investmentValuesAccumulatedWithCurrencyEffect,
investmentValuesWithCurrencyEffect,
netPerformancePercentage,
netPerformancePercentageWithCurrencyEffectMap,
netPerformanceValues,
netPerformanceValuesWithCurrencyEffect,
netPerformanceWithCurrencyEffectMap,
timeWeightedInvestmentValues,
timeWeightedInvestmentValuesWithCurrencyEffect,
totalAccountBalanceInBaseCurrency,
totalDividend,
totalDividendInBaseCurrency,
totalInterest,
totalInterestInBaseCurrency,
totalInvestment,
totalInvestmentWithCurrencyEffect,
totalLiabilities,
totalLiabilitiesInBaseCurrency,
totalValuables,
totalValuablesInBaseCurrency,
grossPerformance: totalGrossPerformance,
grossPerformanceWithCurrencyEffect:
totalGrossPerformanceWithCurrencyEffect,
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
netPerformance: totalNetPerformance,
timeWeightedInvestment:
timeWeightedAverageInvestmentBetweenStartAndEndDate,
timeWeightedInvestmentWithCurrencyEffect:
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect
};
}
}

View File

@ -0,0 +1,29 @@
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
import {
AssetProfileIdentifier,
SymbolMetrics
} from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot } from '@ghostfolio/common/models';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
export class RoiPortfolioCalculator extends PortfolioCalculator {
protected calculateOverallPerformance(): PortfolioSnapshot {
throw new Error('Method not implemented.');
}
protected getPerformanceCalculationType() {
return PerformanceCalculationType.ROI;
}
protected getSymbolMetrics({}: {
end: Date;
exchangeRates: { [dateString: string]: number };
marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
};
start: Date;
step?: number;
} & AssetProfileIdentifier): SymbolMetrics {
throw new Error('Method not implemented.');
}
}

View File

@ -1,969 +1,29 @@
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
import { PortfolioOrderItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order-item.interface';
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
SymbolMetrics
} from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
import { DateRange } from '@ghostfolio/common/types';
import { PortfolioSnapshot } from '@ghostfolio/common/models';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Logger } from '@nestjs/common';
import { Big } from 'big.js';
import { addMilliseconds, differenceInDays, format, isBefore } from 'date-fns';
import { cloneDeep, sortBy } from 'lodash';
export class TWRPortfolioCalculator extends PortfolioCalculator {
private chartDates: string[];
protected calculateOverallPerformance(
positions: TimelinePosition[]
): PortfolioSnapshot {
let currentValueInBaseCurrency = new Big(0);
let grossPerformance = new Big(0);
let grossPerformanceWithCurrencyEffect = new Big(0);
let hasErrors = false;
let netPerformance = new Big(0);
let totalFeesWithCurrencyEffect = new Big(0);
const totalInterestWithCurrencyEffect = new Big(0);
let totalInvestment = new Big(0);
let totalInvestmentWithCurrencyEffect = new Big(0);
let totalTimeWeightedInvestment = new Big(0);
let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0);
for (const currentPosition of positions) {
if (currentPosition.feeInBaseCurrency) {
totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus(
currentPosition.feeInBaseCurrency
);
}
if (currentPosition.valueInBaseCurrency) {
currentValueInBaseCurrency = currentValueInBaseCurrency.plus(
currentPosition.valueInBaseCurrency
);
} else {
hasErrors = true;
}
if (currentPosition.investment) {
totalInvestment = totalInvestment.plus(currentPosition.investment);
totalInvestmentWithCurrencyEffect =
totalInvestmentWithCurrencyEffect.plus(
currentPosition.investmentWithCurrencyEffect
);
} else {
hasErrors = true;
}
if (currentPosition.grossPerformance) {
grossPerformance = grossPerformance.plus(
currentPosition.grossPerformance
);
grossPerformanceWithCurrencyEffect =
grossPerformanceWithCurrencyEffect.plus(
currentPosition.grossPerformanceWithCurrencyEffect
);
netPerformance = netPerformance.plus(currentPosition.netPerformance);
} else if (!currentPosition.quantity.eq(0)) {
hasErrors = true;
}
if (currentPosition.timeWeightedInvestment) {
totalTimeWeightedInvestment = totalTimeWeightedInvestment.plus(
currentPosition.timeWeightedInvestment
);
totalTimeWeightedInvestmentWithCurrencyEffect =
totalTimeWeightedInvestmentWithCurrencyEffect.plus(
currentPosition.timeWeightedInvestmentWithCurrencyEffect
);
} else if (!currentPosition.quantity.eq(0)) {
Logger.warn(
`Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`,
'PortfolioCalculator'
);
hasErrors = true;
}
}
return {
currentValueInBaseCurrency,
hasErrors,
positions,
totalFeesWithCurrencyEffect,
totalInterestWithCurrencyEffect,
totalInvestment,
totalInvestmentWithCurrencyEffect,
activitiesCount: this.activities.filter(({ type }) => {
return ['BUY', 'SELL'].includes(type);
}).length,
createdAt: new Date(),
errors: [],
historicalData: [],
totalLiabilitiesWithCurrencyEffect: new Big(0),
totalValuablesWithCurrencyEffect: new Big(0)
};
export class TwrPortfolioCalculator extends PortfolioCalculator {
protected calculateOverallPerformance(): PortfolioSnapshot {
throw new Error('Method not implemented.');
}
protected getSymbolMetrics({
chartDateMap,
dataSource,
end,
exchangeRates,
marketSymbolMap,
start,
symbol
}: {
chartDateMap?: { [date: string]: boolean };
protected getPerformanceCalculationType() {
return PerformanceCalculationType.TWR;
}
protected getSymbolMetrics({}: {
end: Date;
exchangeRates: { [dateString: string]: number };
marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
};
start: Date;
step?: number;
} & AssetProfileIdentifier): SymbolMetrics {
const currentExchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)];
const currentValues: { [date: string]: Big } = {};
const currentValuesWithCurrencyEffect: { [date: string]: Big } = {};
let fees = new Big(0);
let feesAtStartDate = new Big(0);
let feesAtStartDateWithCurrencyEffect = new Big(0);
let feesWithCurrencyEffect = new Big(0);
let grossPerformance = new Big(0);
let grossPerformanceWithCurrencyEffect = new Big(0);
let grossPerformanceAtStartDate = new Big(0);
let grossPerformanceAtStartDateWithCurrencyEffect = new Big(0);
let grossPerformanceFromSells = new Big(0);
let grossPerformanceFromSellsWithCurrencyEffect = new Big(0);
let initialValue: Big;
let initialValueWithCurrencyEffect: Big;
let investmentAtStartDate: Big;
let investmentAtStartDateWithCurrencyEffect: Big;
const investmentValuesAccumulated: { [date: string]: Big } = {};
const investmentValuesAccumulatedWithCurrencyEffect: {
[date: string]: Big;
} = {};
const investmentValuesWithCurrencyEffect: { [date: string]: Big } = {};
let lastAveragePrice = new Big(0);
let lastAveragePriceWithCurrencyEffect = new Big(0);
const netPerformanceValues: { [date: string]: Big } = {};
const netPerformanceValuesWithCurrencyEffect: { [date: string]: Big } = {};
const timeWeightedInvestmentValues: { [date: string]: Big } = {};
const timeWeightedInvestmentValuesWithCurrencyEffect: {
[date: string]: Big;
} = {};
const totalAccountBalanceInBaseCurrency = new Big(0);
let totalDividend = new Big(0);
let totalDividendInBaseCurrency = new Big(0);
let totalInterest = new Big(0);
let totalInterestInBaseCurrency = new Big(0);
let totalInvestment = new Big(0);
let totalInvestmentFromBuyTransactions = new Big(0);
let totalInvestmentFromBuyTransactionsWithCurrencyEffect = new Big(0);
let totalInvestmentWithCurrencyEffect = new Big(0);
let totalLiabilities = new Big(0);
let totalLiabilitiesInBaseCurrency = new Big(0);
let totalQuantityFromBuyTransactions = new Big(0);
let totalUnits = new Big(0);
let totalValuables = new Big(0);
let totalValuablesInBaseCurrency = new Big(0);
let valueAtStartDate: Big;
let valueAtStartDateWithCurrencyEffect: Big;
// Clone orders to keep the original values in this.orders
let orders: PortfolioOrderItem[] = cloneDeep(
this.activities.filter(({ SymbolProfile }) => {
return SymbolProfile.symbol === symbol;
})
);
if (orders.length <= 0) {
return {
currentValues: {},
currentValuesWithCurrencyEffect: {},
feesWithCurrencyEffect: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big(0),
grossPerformanceWithCurrencyEffect: new Big(0),
hasErrors: false,
initialValue: new Big(0),
initialValueWithCurrencyEffect: new Big(0),
investmentValuesAccumulated: {},
investmentValuesAccumulatedWithCurrencyEffect: {},
investmentValuesWithCurrencyEffect: {},
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
netPerformancePercentageWithCurrencyEffectMap: {},
netPerformanceValues: {},
netPerformanceValuesWithCurrencyEffect: {},
netPerformanceWithCurrencyEffectMap: {},
timeWeightedInvestment: new Big(0),
timeWeightedInvestmentValues: {},
timeWeightedInvestmentValuesWithCurrencyEffect: {},
timeWeightedInvestmentWithCurrencyEffect: new Big(0),
totalAccountBalanceInBaseCurrency: new Big(0),
totalDividend: new Big(0),
totalDividendInBaseCurrency: new Big(0),
totalInterest: new Big(0),
totalInterestInBaseCurrency: new Big(0),
totalInvestment: new Big(0),
totalInvestmentWithCurrencyEffect: new Big(0),
totalLiabilities: new Big(0),
totalLiabilitiesInBaseCurrency: new Big(0),
totalValuables: new Big(0),
totalValuablesInBaseCurrency: new Big(0)
};
}
const dateOfFirstTransaction = new Date(orders[0].date);
const endDateString = format(end, DATE_FORMAT);
const startDateString = format(start, DATE_FORMAT);
const unitPriceAtStartDate = marketSymbolMap[startDateString]?.[symbol];
const unitPriceAtEndDate = marketSymbolMap[endDateString]?.[symbol];
if (
!unitPriceAtEndDate ||
(!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start))
) {
return {
currentValues: {},
currentValuesWithCurrencyEffect: {},
feesWithCurrencyEffect: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big(0),
grossPerformanceWithCurrencyEffect: new Big(0),
hasErrors: true,
initialValue: new Big(0),
initialValueWithCurrencyEffect: new Big(0),
investmentValuesAccumulated: {},
investmentValuesAccumulatedWithCurrencyEffect: {},
investmentValuesWithCurrencyEffect: {},
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
netPerformancePercentageWithCurrencyEffectMap: {},
netPerformanceWithCurrencyEffectMap: {},
netPerformanceValues: {},
netPerformanceValuesWithCurrencyEffect: {},
timeWeightedInvestment: new Big(0),
timeWeightedInvestmentValues: {},
timeWeightedInvestmentValuesWithCurrencyEffect: {},
timeWeightedInvestmentWithCurrencyEffect: new Big(0),
totalAccountBalanceInBaseCurrency: new Big(0),
totalDividend: new Big(0),
totalDividendInBaseCurrency: new Big(0),
totalInterest: new Big(0),
totalInterestInBaseCurrency: new Big(0),
totalInvestment: new Big(0),
totalInvestmentWithCurrencyEffect: new Big(0),
totalLiabilities: new Big(0),
totalLiabilitiesInBaseCurrency: new Big(0),
totalValuables: new Big(0),
totalValuablesInBaseCurrency: new Big(0)
};
}
// Add a synthetic order at the start and the end date
orders.push({
date: startDateString,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
itemType: 'start',
quantity: new Big(0),
SymbolProfile: {
dataSource,
symbol
},
type: 'BUY',
unitPrice: unitPriceAtStartDate
});
orders.push({
date: endDateString,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
itemType: 'end',
SymbolProfile: {
dataSource,
symbol
},
quantity: new Big(0),
type: 'BUY',
unitPrice: unitPriceAtEndDate
});
let lastUnitPrice: Big;
const ordersByDate: { [date: string]: PortfolioOrderItem[] } = {};
for (const order of orders) {
ordersByDate[order.date] = ordersByDate[order.date] ?? [];
ordersByDate[order.date].push(order);
}
if (!this.chartDates) {
this.chartDates = Object.keys(chartDateMap).sort();
}
for (const dateString of this.chartDates) {
if (dateString < startDateString) {
continue;
} else if (dateString > endDateString) {
break;
}
if (ordersByDate[dateString]?.length > 0) {
for (const order of ordersByDate[dateString]) {
order.unitPriceFromMarketData =
marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice;
}
} else {
orders.push({
date: dateString,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
quantity: new Big(0),
SymbolProfile: {
dataSource,
symbol
},
type: 'BUY',
unitPrice: marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice,
unitPriceFromMarketData:
marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice
});
}
const lastOrder = orders.at(-1);
lastUnitPrice = lastOrder.unitPriceFromMarketData ?? lastOrder.unitPrice;
}
// Sort orders so that the start and end placeholder order are at the correct
// position
orders = sortBy(orders, ({ date, itemType }) => {
let sortIndex = new Date(date);
if (itemType === 'end') {
sortIndex = addMilliseconds(sortIndex, 1);
} else if (itemType === 'start') {
sortIndex = addMilliseconds(sortIndex, -1);
}
return sortIndex.getTime();
});
const indexOfStartOrder = orders.findIndex(({ itemType }) => {
return itemType === 'start';
});
const indexOfEndOrder = orders.findIndex(({ itemType }) => {
return itemType === 'end';
});
let totalInvestmentDays = 0;
let sumOfTimeWeightedInvestments = new Big(0);
let sumOfTimeWeightedInvestmentsWithCurrencyEffect = new Big(0);
for (let i = 0; i < orders.length; i += 1) {
const order = orders[i];
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log();
console.log();
console.log(
i + 1,
order.date,
order.type,
order.itemType ? `(${order.itemType})` : ''
);
}
const exchangeRateAtOrderDate = exchangeRates[order.date];
if (order.type === 'DIVIDEND') {
const dividend = order.quantity.mul(order.unitPrice);
totalDividend = totalDividend.plus(dividend);
totalDividendInBaseCurrency = totalDividendInBaseCurrency.plus(
dividend.mul(exchangeRateAtOrderDate ?? 1)
);
} else if (order.type === 'INTEREST') {
const interest = order.quantity.mul(order.unitPrice);
totalInterest = totalInterest.plus(interest);
totalInterestInBaseCurrency = totalInterestInBaseCurrency.plus(
interest.mul(exchangeRateAtOrderDate ?? 1)
);
} else if (order.type === 'ITEM') {
const valuables = order.quantity.mul(order.unitPrice);
totalValuables = totalValuables.plus(valuables);
totalValuablesInBaseCurrency = totalValuablesInBaseCurrency.plus(
valuables.mul(exchangeRateAtOrderDate ?? 1)
);
} else if (order.type === 'LIABILITY') {
const liabilities = order.quantity.mul(order.unitPrice);
totalLiabilities = totalLiabilities.plus(liabilities);
totalLiabilitiesInBaseCurrency = totalLiabilitiesInBaseCurrency.plus(
liabilities.mul(exchangeRateAtOrderDate ?? 1)
);
}
if (order.itemType === 'start') {
// Take the unit price of the order as the market price if there are no
// orders of this symbol before the start date
order.unitPrice =
indexOfStartOrder === 0
? orders[i + 1]?.unitPrice
: unitPriceAtStartDate;
}
if (order.fee) {
order.feeInBaseCurrency = order.fee.mul(currentExchangeRate ?? 1);
order.feeInBaseCurrencyWithCurrencyEffect = order.fee.mul(
exchangeRateAtOrderDate ?? 1
);
}
const unitPrice = ['BUY', 'SELL'].includes(order.type)
? order.unitPrice
: order.unitPriceFromMarketData;
if (unitPrice) {
order.unitPriceInBaseCurrency = unitPrice.mul(currentExchangeRate ?? 1);
order.unitPriceInBaseCurrencyWithCurrencyEffect = unitPrice.mul(
exchangeRateAtOrderDate ?? 1
);
}
const valueOfInvestmentBeforeTransaction = totalUnits.mul(
order.unitPriceInBaseCurrency
);
const valueOfInvestmentBeforeTransactionWithCurrencyEffect =
totalUnits.mul(order.unitPriceInBaseCurrencyWithCurrencyEffect);
if (!investmentAtStartDate && i >= indexOfStartOrder) {
investmentAtStartDate = totalInvestment ?? new Big(0);
investmentAtStartDateWithCurrencyEffect =
totalInvestmentWithCurrencyEffect ?? new Big(0);
valueAtStartDate = valueOfInvestmentBeforeTransaction;
valueAtStartDateWithCurrencyEffect =
valueOfInvestmentBeforeTransactionWithCurrencyEffect;
}
let transactionInvestment = new Big(0);
let transactionInvestmentWithCurrencyEffect = new Big(0);
if (order.type === 'BUY') {
transactionInvestment = order.quantity
.mul(order.unitPriceInBaseCurrency)
.mul(getFactor(order.type));
transactionInvestmentWithCurrencyEffect = order.quantity
.mul(order.unitPriceInBaseCurrencyWithCurrencyEffect)
.mul(getFactor(order.type));
totalQuantityFromBuyTransactions =
totalQuantityFromBuyTransactions.plus(order.quantity);
totalInvestmentFromBuyTransactions =
totalInvestmentFromBuyTransactions.plus(transactionInvestment);
totalInvestmentFromBuyTransactionsWithCurrencyEffect =
totalInvestmentFromBuyTransactionsWithCurrencyEffect.plus(
transactionInvestmentWithCurrencyEffect
);
} else if (order.type === 'SELL') {
if (totalUnits.gt(0)) {
transactionInvestment = totalInvestment
.div(totalUnits)
.mul(order.quantity)
.mul(getFactor(order.type));
transactionInvestmentWithCurrencyEffect =
totalInvestmentWithCurrencyEffect
.div(totalUnits)
.mul(order.quantity)
.mul(getFactor(order.type));
}
}
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log('order.quantity', order.quantity.toNumber());
console.log('transactionInvestment', transactionInvestment.toNumber());
console.log(
'transactionInvestmentWithCurrencyEffect',
transactionInvestmentWithCurrencyEffect.toNumber()
);
}
const totalInvestmentBeforeTransaction = totalInvestment;
const totalInvestmentBeforeTransactionWithCurrencyEffect =
totalInvestmentWithCurrencyEffect;
totalInvestment = totalInvestment.plus(transactionInvestment);
totalInvestmentWithCurrencyEffect =
totalInvestmentWithCurrencyEffect.plus(
transactionInvestmentWithCurrencyEffect
);
if (i >= indexOfStartOrder && !initialValue) {
if (
i === indexOfStartOrder &&
!valueOfInvestmentBeforeTransaction.eq(0)
) {
initialValue = valueOfInvestmentBeforeTransaction;
initialValueWithCurrencyEffect =
valueOfInvestmentBeforeTransactionWithCurrencyEffect;
} else if (transactionInvestment.gt(0)) {
initialValue = transactionInvestment;
initialValueWithCurrencyEffect =
transactionInvestmentWithCurrencyEffect;
}
}
fees = fees.plus(order.feeInBaseCurrency ?? 0);
feesWithCurrencyEffect = feesWithCurrencyEffect.plus(
order.feeInBaseCurrencyWithCurrencyEffect ?? 0
);
totalUnits = totalUnits.plus(order.quantity.mul(getFactor(order.type)));
const valueOfInvestment = totalUnits.mul(order.unitPriceInBaseCurrency);
const valueOfInvestmentWithCurrencyEffect = totalUnits.mul(
order.unitPriceInBaseCurrencyWithCurrencyEffect
);
const grossPerformanceFromSell =
order.type === 'SELL'
? order.unitPriceInBaseCurrency
.minus(lastAveragePrice)
.mul(order.quantity)
: new Big(0);
const grossPerformanceFromSellWithCurrencyEffect =
order.type === 'SELL'
? order.unitPriceInBaseCurrencyWithCurrencyEffect
.minus(lastAveragePriceWithCurrencyEffect)
.mul(order.quantity)
: new Big(0);
grossPerformanceFromSells = grossPerformanceFromSells.plus(
grossPerformanceFromSell
);
grossPerformanceFromSellsWithCurrencyEffect =
grossPerformanceFromSellsWithCurrencyEffect.plus(
grossPerformanceFromSellWithCurrencyEffect
);
lastAveragePrice = totalQuantityFromBuyTransactions.eq(0)
? new Big(0)
: totalInvestmentFromBuyTransactions.div(
totalQuantityFromBuyTransactions
);
lastAveragePriceWithCurrencyEffect = totalQuantityFromBuyTransactions.eq(
0
)
? new Big(0)
: totalInvestmentFromBuyTransactionsWithCurrencyEffect.div(
totalQuantityFromBuyTransactions
);
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log(
'grossPerformanceFromSells',
grossPerformanceFromSells.toNumber()
);
console.log(
'grossPerformanceFromSellWithCurrencyEffect',
grossPerformanceFromSellWithCurrencyEffect.toNumber()
);
}
const newGrossPerformance = valueOfInvestment
.minus(totalInvestment)
.plus(grossPerformanceFromSells);
const newGrossPerformanceWithCurrencyEffect =
valueOfInvestmentWithCurrencyEffect
.minus(totalInvestmentWithCurrencyEffect)
.plus(grossPerformanceFromSellsWithCurrencyEffect);
grossPerformance = newGrossPerformance;
grossPerformanceWithCurrencyEffect =
newGrossPerformanceWithCurrencyEffect;
if (order.itemType === 'start') {
feesAtStartDate = fees;
feesAtStartDateWithCurrencyEffect = feesWithCurrencyEffect;
grossPerformanceAtStartDate = grossPerformance;
grossPerformanceAtStartDateWithCurrencyEffect =
grossPerformanceWithCurrencyEffect;
}
if (i > indexOfStartOrder) {
// Only consider periods with an investment for the calculation of
// the time weighted investment
if (
valueOfInvestmentBeforeTransaction.gt(0) &&
['BUY', 'SELL'].includes(order.type)
) {
// Calculate the number of days since the previous order
const orderDate = new Date(order.date);
const previousOrderDate = new Date(orders[i - 1].date);
let daysSinceLastOrder = differenceInDays(
orderDate,
previousOrderDate
);
if (daysSinceLastOrder <= 0) {
// The time between two activities on the same day is unknown
// -> Set it to the smallest floating point number greater than 0
daysSinceLastOrder = Number.EPSILON;
}
// Sum up the total investment days since the start date to calculate
// the time weighted investment
totalInvestmentDays += daysSinceLastOrder;
sumOfTimeWeightedInvestments = sumOfTimeWeightedInvestments.add(
valueAtStartDate
.minus(investmentAtStartDate)
.plus(totalInvestmentBeforeTransaction)
.mul(daysSinceLastOrder)
);
sumOfTimeWeightedInvestmentsWithCurrencyEffect =
sumOfTimeWeightedInvestmentsWithCurrencyEffect.add(
valueAtStartDateWithCurrencyEffect
.minus(investmentAtStartDateWithCurrencyEffect)
.plus(totalInvestmentBeforeTransactionWithCurrencyEffect)
.mul(daysSinceLastOrder)
);
}
currentValues[order.date] = valueOfInvestment;
currentValuesWithCurrencyEffect[order.date] =
valueOfInvestmentWithCurrencyEffect;
netPerformanceValues[order.date] = grossPerformance
.minus(grossPerformanceAtStartDate)
.minus(fees.minus(feesAtStartDate));
netPerformanceValuesWithCurrencyEffect[order.date] =
grossPerformanceWithCurrencyEffect
.minus(grossPerformanceAtStartDateWithCurrencyEffect)
.minus(
feesWithCurrencyEffect.minus(feesAtStartDateWithCurrencyEffect)
);
investmentValuesAccumulated[order.date] = totalInvestment;
investmentValuesAccumulatedWithCurrencyEffect[order.date] =
totalInvestmentWithCurrencyEffect;
investmentValuesWithCurrencyEffect[order.date] = (
investmentValuesWithCurrencyEffect[order.date] ?? new Big(0)
).add(transactionInvestmentWithCurrencyEffect);
timeWeightedInvestmentValues[order.date] =
totalInvestmentDays > 0
? sumOfTimeWeightedInvestments.div(totalInvestmentDays)
: new Big(0);
timeWeightedInvestmentValuesWithCurrencyEffect[order.date] =
totalInvestmentDays > 0
? sumOfTimeWeightedInvestmentsWithCurrencyEffect.div(
totalInvestmentDays
)
: new Big(0);
}
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log('totalInvestment', totalInvestment.toNumber());
console.log(
'totalInvestmentWithCurrencyEffect',
totalInvestmentWithCurrencyEffect.toNumber()
);
console.log(
'totalGrossPerformance',
grossPerformance.minus(grossPerformanceAtStartDate).toNumber()
);
console.log(
'totalGrossPerformanceWithCurrencyEffect',
grossPerformanceWithCurrencyEffect
.minus(grossPerformanceAtStartDateWithCurrencyEffect)
.toNumber()
);
}
if (i === indexOfEndOrder) {
break;
}
}
const totalGrossPerformance = grossPerformance.minus(
grossPerformanceAtStartDate
);
const totalGrossPerformanceWithCurrencyEffect =
grossPerformanceWithCurrencyEffect.minus(
grossPerformanceAtStartDateWithCurrencyEffect
);
const totalNetPerformance = grossPerformance
.minus(grossPerformanceAtStartDate)
.minus(fees.minus(feesAtStartDate));
const timeWeightedAverageInvestmentBetweenStartAndEndDate =
totalInvestmentDays > 0
? sumOfTimeWeightedInvestments.div(totalInvestmentDays)
: new Big(0);
const timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect =
totalInvestmentDays > 0
? sumOfTimeWeightedInvestmentsWithCurrencyEffect.div(
totalInvestmentDays
)
: new Big(0);
const grossPerformancePercentage =
timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0)
? totalGrossPerformance.div(
timeWeightedAverageInvestmentBetweenStartAndEndDate
)
: new Big(0);
const grossPerformancePercentageWithCurrencyEffect =
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.gt(
0
)
? totalGrossPerformanceWithCurrencyEffect.div(
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect
)
: new Big(0);
const feesPerUnit = totalUnits.gt(0)
? fees.minus(feesAtStartDate).div(totalUnits)
: new Big(0);
const feesPerUnitWithCurrencyEffect = totalUnits.gt(0)
? feesWithCurrencyEffect
.minus(feesAtStartDateWithCurrencyEffect)
.div(totalUnits)
: new Big(0);
const netPerformancePercentage =
timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0)
? totalNetPerformance.div(
timeWeightedAverageInvestmentBetweenStartAndEndDate
)
: new Big(0);
const netPerformancePercentageWithCurrencyEffectMap: {
[key: DateRange]: Big;
} = {};
const netPerformanceWithCurrencyEffectMap: {
[key: DateRange]: Big;
} = {};
for (const dateRange of [
'1d',
'1y',
'5y',
'max',
'mtd',
'wtd',
'ytd'
// TODO:
// ...eachYearOfInterval({ end, start })
// .filter((date) => {
// return !isThisYear(date);
// })
// .map((date) => {
// return format(date, 'yyyy');
// })
] as DateRange[]) {
const dateInterval = getIntervalFromDateRange(dateRange);
const endDate = dateInterval.endDate;
let startDate = dateInterval.startDate;
if (isBefore(startDate, start)) {
startDate = start;
}
const rangeEndDateString = format(endDate, DATE_FORMAT);
const rangeStartDateString = format(startDate, DATE_FORMAT);
const currentValuesAtDateRangeStartWithCurrencyEffect =
currentValuesWithCurrencyEffect[rangeStartDateString] ?? new Big(0);
const investmentValuesAccumulatedAtStartDateWithCurrencyEffect =
investmentValuesAccumulatedWithCurrencyEffect[rangeStartDateString] ??
new Big(0);
const grossPerformanceAtDateRangeStartWithCurrencyEffect =
currentValuesAtDateRangeStartWithCurrencyEffect.minus(
investmentValuesAccumulatedAtStartDateWithCurrencyEffect
);
let average = new Big(0);
let dayCount = 0;
for (let i = this.chartDates.length - 1; i >= 0; i -= 1) {
const date = this.chartDates[i];
if (date > rangeEndDateString) {
continue;
} else if (date < rangeStartDateString) {
break;
}
if (
investmentValuesAccumulatedWithCurrencyEffect[date] instanceof Big &&
investmentValuesAccumulatedWithCurrencyEffect[date].gt(0)
) {
average = average.add(
investmentValuesAccumulatedWithCurrencyEffect[date].add(
grossPerformanceAtDateRangeStartWithCurrencyEffect
)
);
dayCount++;
}
}
if (dayCount > 0) {
average = average.div(dayCount);
}
netPerformanceWithCurrencyEffectMap[dateRange] =
netPerformanceValuesWithCurrencyEffect[rangeEndDateString]?.minus(
// If the date range is 'max', take 0 as a start value. Otherwise,
// the value of the end of the day of the start date is taken which
// differs from the buying price.
dateRange === 'max'
? new Big(0)
: (netPerformanceValuesWithCurrencyEffect[rangeStartDateString] ??
new Big(0))
) ?? new Big(0);
netPerformancePercentageWithCurrencyEffectMap[dateRange] = average.gt(0)
? netPerformanceWithCurrencyEffectMap[dateRange].div(average)
: new Big(0);
}
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log(
`
${symbol}
Unit price: ${orders[indexOfStartOrder].unitPrice.toFixed(
2
)} -> ${unitPriceAtEndDate.toFixed(2)}
Total investment: ${totalInvestment.toFixed(2)}
Total investment with currency effect: ${totalInvestmentWithCurrencyEffect.toFixed(
2
)}
Time weighted investment: ${timeWeightedAverageInvestmentBetweenStartAndEndDate.toFixed(
2
)}
Time weighted investment with currency effect: ${timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.toFixed(
2
)}
Total dividend: ${totalDividend.toFixed(2)}
Gross performance: ${totalGrossPerformance.toFixed(
2
)} / ${grossPerformancePercentage.mul(100).toFixed(2)}%
Gross performance with currency effect: ${totalGrossPerformanceWithCurrencyEffect.toFixed(
2
)} / ${grossPerformancePercentageWithCurrencyEffect
.mul(100)
.toFixed(2)}%
Fees per unit: ${feesPerUnit.toFixed(2)}
Fees per unit with currency effect: ${feesPerUnitWithCurrencyEffect.toFixed(
2
)}
Net performance: ${totalNetPerformance.toFixed(
2
)} / ${netPerformancePercentage.mul(100).toFixed(2)}%
Net performance with currency effect: ${netPerformancePercentageWithCurrencyEffectMap[
'max'
].toFixed(2)}%`
);
}
return {
currentValues,
currentValuesWithCurrencyEffect,
feesWithCurrencyEffect,
grossPerformancePercentage,
grossPerformancePercentageWithCurrencyEffect,
initialValue,
initialValueWithCurrencyEffect,
investmentValuesAccumulated,
investmentValuesAccumulatedWithCurrencyEffect,
investmentValuesWithCurrencyEffect,
netPerformancePercentage,
netPerformancePercentageWithCurrencyEffectMap,
netPerformanceValues,
netPerformanceValuesWithCurrencyEffect,
netPerformanceWithCurrencyEffectMap,
timeWeightedInvestmentValues,
timeWeightedInvestmentValuesWithCurrencyEffect,
totalAccountBalanceInBaseCurrency,
totalDividend,
totalDividendInBaseCurrency,
totalInterest,
totalInterestInBaseCurrency,
totalInvestment,
totalInvestmentWithCurrencyEffect,
totalLiabilities,
totalLiabilitiesInBaseCurrency,
totalValuables,
totalValuablesInBaseCurrency,
grossPerformance: totalGrossPerformance,
grossPerformanceWithCurrencyEffect:
totalGrossPerformanceWithCurrencyEffect,
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
netPerformance: totalNetPerformance,
timeWeightedInvestment:
timeWeightedAverageInvestmentBetweenStartAndEndDate,
timeWeightedInvestmentWithCurrencyEffect:
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect
};
throw new Error('Method not implemented.');
}
}

View File

@ -47,6 +47,10 @@ function mockGetValue(symbol: string, date: Date) {
return { marketPrice: 14156.4 };
} else if (isSameDay(parseDate('2018-01-01'), date)) {
return { marketPrice: 13657.2 };
} else if (isSameDay(parseDate('2021-12-12'), date)) {
return { marketPrice: 50098.3 };
} else if (isSameDay(parseDate('2022-01-14'), date)) {
return { marketPrice: 43099.7 };
}
return { marketPrice: 0 };

View File

@ -6,6 +6,7 @@ import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { DataSource, MarketData } from '@prisma/client';
import { CurrentRateService } from './current-rate.service';
import { DateQuery } from './interfaces/date-query.interface';
import { GetValuesObject } from './interfaces/get-values-object.interface';
jest.mock('@ghostfolio/api/services/market-data/market-data.service', () => {
@ -25,33 +26,40 @@ jest.mock('@ghostfolio/api/services/market-data/market-data.service', () => {
},
getRange: ({
assetProfileIdentifiers,
dateRangeEnd,
dateRangeStart
dateQuery
}: {
assetProfileIdentifiers: AssetProfileIdentifier[];
dateRangeEnd: Date;
dateRangeStart: Date;
dateQuery: DateQuery;
skip?: number;
take?: number;
}) => {
return Promise.resolve<MarketData[]>([
{
createdAt: dateRangeStart,
createdAt: dateQuery.gte,
dataSource: assetProfileIdentifiers[0].dataSource,
date: dateRangeStart,
date: dateQuery.gte,
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
marketPrice: 1841.823902,
state: 'CLOSE',
symbol: assetProfileIdentifiers[0].symbol
},
{
createdAt: dateRangeEnd,
createdAt: dateQuery.lt,
dataSource: assetProfileIdentifiers[0].dataSource,
date: dateRangeEnd,
date: dateQuery.lt,
id: '082d6893-df27-4c91-8a5d-092e84315b56',
marketPrice: 1847.839966,
state: 'CLOSE',
symbol: assetProfileIdentifiers[0].symbol
}
]);
},
getRangeCount: ({}: {
assetProfileIdentifiers: AssetProfileIdentifier[];
dateRangeEnd: Date;
dateRangeStart: Date;
}) => {
return Promise.resolve<number>(2);
}
};
})
@ -128,9 +136,15 @@ describe('CurrentRateService', () => {
values: [
{
dataSource: 'YAHOO',
date: undefined,
date: new Date('2020-01-01T00:00:00.000Z'),
marketPrice: 1841.823902,
symbol: 'AMZN'
},
{
dataSource: 'YAHOO',
date: new Date('2020-01-02T00:00:00.000Z'),
marketPrice: 1847.839966,
symbol: 'AMZN'
}
]
});

View File

@ -21,6 +21,8 @@ import { GetValuesParams } from './interfaces/get-values-params.interface';
@Injectable()
export class CurrentRateService {
private static readonly MARKET_DATA_PAGE_SIZE = 50000;
public constructor(
private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService,
@ -41,42 +43,37 @@ export class CurrentRateService {
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
(!dateQuery.in || this.containsToday(dateQuery.in));
const promises: Promise<GetValueObject[]>[] = [];
const quoteErrors: ResponseError['errors'] = [];
const today = resetHours(new Date());
const values: GetValueObject[] = [];
if (includesToday) {
promises.push(
this.dataProviderService
.getQuotes({ items: dataGatheringItems, user: this.request?.user })
.then((dataResultProvider) => {
const result: GetValueObject[] = [];
const quotesBySymbol = await this.dataProviderService.getQuotes({
items: dataGatheringItems,
user: this.request?.user
});
for (const { dataSource, symbol } of dataGatheringItems) {
if (dataResultProvider?.[symbol]?.dataProviderInfo) {
dataProviderInfos.push(
dataResultProvider[symbol].dataProviderInfo
);
}
for (const { dataSource, symbol } of dataGatheringItems) {
const quote = quotesBySymbol[symbol];
if (dataResultProvider?.[symbol]?.marketPrice) {
result.push({
dataSource,
symbol,
date: today,
marketPrice: dataResultProvider?.[symbol]?.marketPrice
});
} else {
quoteErrors.push({
dataSource,
symbol
});
}
}
if (quote?.dataProviderInfo) {
dataProviderInfos.push(quote.dataProviderInfo);
}
return result;
})
);
if (quote?.marketPrice) {
values.push({
dataSource,
symbol,
date: today,
marketPrice: quote.marketPrice
});
} else {
quoteErrors.push({
dataSource,
symbol
});
}
}
}
const assetProfileIdentifiers: AssetProfileIdentifier[] =
@ -84,34 +81,42 @@ export class CurrentRateService {
return { dataSource, symbol };
});
promises.push(
this.marketDataService
.getRange({
assetProfileIdentifiers,
dateQuery
})
.then((data) => {
return data.map(({ dataSource, date, marketPrice, symbol }) => {
return {
dataSource,
date,
marketPrice,
symbol
};
});
})
);
const values = await Promise.all(promises).then((array) => {
return array.flat();
const marketDataCount = await this.marketDataService.getRangeCount({
assetProfileIdentifiers,
dateQuery
});
for (
let i = 0;
i < marketDataCount;
i += CurrentRateService.MARKET_DATA_PAGE_SIZE
) {
// Use page size to limit the number of records fetched at once
const data = await this.marketDataService.getRange({
assetProfileIdentifiers,
dateQuery,
skip: i,
take: CurrentRateService.MARKET_DATA_PAGE_SIZE
});
values.push(
...data.map(({ dataSource, date, marketPrice, symbol }) => ({
dataSource,
date,
marketPrice,
symbol
}))
);
}
const response: GetValuesObject = {
dataProviderInfos,
errors: quoteErrors.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
}),
values: uniqBy(values, ({ date, symbol }) => `${date}-${symbol}`)
values: uniqBy(values, ({ date, symbol }) => {
return `${date}-${symbol}`;
})
};
if (!isEmpty(quoteErrors)) {

View File

@ -20,6 +20,7 @@ import {
import {
PortfolioDetails,
PortfolioDividends,
PortfolioHoldingResponse,
PortfolioHoldingsResponse,
PortfolioInvestments,
PortfolioPerformanceResponse,
@ -56,7 +57,6 @@ import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
import { Big } from 'big.js';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface';
import { PortfolioService } from './portfolio.service';
import { UpdateHoldingTagsDto } from './update-holding-tags.dto';
@ -365,6 +365,33 @@ export class PortfolioController {
return { dividends };
}
@Get('holding/:dataSource/:symbol')
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getHolding(
@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('holdings')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor)
@ -386,19 +413,19 @@ export class PortfolioController {
filterByAssetClasses,
filterByDataSource,
filterByHoldingType,
filterBySearchQuery,
filterBySymbol,
filterByTags
});
const { holdings } = await this.portfolioService.getDetails({
const holdings = await this.portfolioService.getHoldings({
dateRange,
filters,
impersonationId,
query: filterBySearchQuery,
userId: this.request.user.id
});
return { holdings: Object.values(holdings) };
return { holdings };
}
@Get('investments')
@ -427,7 +454,8 @@ export class PortfolioController {
filters,
groupBy,
impersonationId,
savingsRate: this.request.user?.Settings?.settings.savingsRate
savingsRate: this.request.user?.Settings?.settings.savingsRate,
userId: this.request.user.id
});
if (
@ -583,6 +611,9 @@ export class PortfolioController {
return performanceInformation;
}
/**
* @deprecated
*/
@Get('position/:dataSource/:symbol')
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@ -592,12 +623,13 @@ export class PortfolioController {
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<PortfolioHoldingDetail> {
const holding = await this.portfolioService.getPosition(
): Promise<PortfolioHoldingResponse> {
const holding = await this.portfolioService.getHolding({
dataSource,
impersonationId,
symbol
);
symbol,
userId: this.request.user.id
});
if (!holding) {
throw new HttpException(
@ -614,7 +646,10 @@ export class PortfolioController {
public async getReport(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string
): Promise<PortfolioReportResponse> {
const report = await this.portfolioService.getReport(impersonationId);
const report = await this.portfolioService.getReport({
impersonationId,
userId: this.request.user.id
});
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
@ -634,7 +669,7 @@ export class PortfolioController {
}
@HasPermission(permissions.updateOrder)
@Put('position/:dataSource/:symbol/tags')
@Put('holding/:dataSource/:symbol/tags')
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateHoldingTags(
@ -643,11 +678,48 @@ export class PortfolioController {
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<void> {
const holding = await this.portfolioService.getPosition(
const holding = await this.portfolioService.getHolding({
dataSource,
impersonationId,
symbol
);
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
});
}
/**
* @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(

View File

@ -9,9 +9,11 @@ import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redac
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
@ -33,10 +35,12 @@ import { RulesService } from './rules.service';
imports: [
AccessModule,
ApiModule,
BenchmarkModule,
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
I18nModule,
ImpersonationModule,
MarketDataModule,
OrderModule,

File diff suppressed because it is too large Load Diff

View File

@ -1,17 +1,16 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { createKeyv } from '@keyv/redis';
import { CacheModule } from '@nestjs/cache-manager';
import { Module } from '@nestjs/common';
import { redisStore } from 'cache-manager-redis-yet';
import type { RedisClientOptions } from 'redis';
import { RedisCacheService } from './redis-cache.service';
@Module({
exports: [RedisCacheService],
imports: [
CacheModule.registerAsync<RedisClientOptions>({
CacheModule.registerAsync({
imports: [ConfigurationModule],
inject: [ConfigurationService],
useFactory: async (configurationService: ConfigurationService) => {
@ -20,10 +19,13 @@ import { RedisCacheService } from './redis-cache.service';
);
return {
store: redisStore,
ttl: configurationService.get('CACHE_TTL'),
url: `redis://${redisPassword ? `:${redisPassword}` : ''}@${configurationService.get('REDIS_HOST')}:${configurationService.get('REDIS_PORT')}/${configurationService.get('REDIS_DB')}`
} as RedisClientOptions;
stores: [
createKeyv(
`redis://${redisPassword ? `:${redisPassword}` : ''}@${configurationService.get('REDIS_HOST')}:${configurationService.get('REDIS_PORT')}/${configurationService.get('REDIS_DB')}`
)
],
ttl: configurationService.get('CACHE_TTL')
};
}
}),
ConfigurationModule

View File

@ -2,22 +2,31 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { AssetProfileIdentifier, Filter } from '@ghostfolio/common/interfaces';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { CACHE_MANAGER, Cache } from '@nestjs/cache-manager';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { Milliseconds } from 'cache-manager';
import { RedisCache } from 'cache-manager-redis-yet';
import { createHash } from 'crypto';
import Keyv from 'keyv';
import ms from 'ms';
@Injectable()
export class RedisCacheService {
private client: Keyv;
public constructor(
@Inject(CACHE_MANAGER) private readonly cache: RedisCache,
@Inject(CACHE_MANAGER) private readonly cache: Cache,
private readonly configurationService: ConfigurationService
) {
const client = cache.store.client;
this.client = cache.stores[0];
client.on('error', (error) => {
this.client.deserialize = (value) => {
try {
return JSON.parse(value);
} catch {}
return value;
};
this.client.on('error', (error) => {
Logger.error(error, 'RedisCacheService');
});
}
@ -27,13 +36,18 @@ export class RedisCacheService {
}
public async getKeys(aPrefix?: string): Promise<string[]> {
let prefix = aPrefix;
const keys: string[] = [];
const prefix = aPrefix;
if (prefix) {
prefix = `${prefix}*`;
}
try {
for await (const [key] of this.client.iterator({})) {
if ((prefix && key.startsWith(prefix)) || !prefix) {
keys.push(key);
}
}
} catch {}
return this.cache.store.keys(prefix);
return keys;
}
public getPortfolioSnapshotKey({
@ -61,22 +75,36 @@ export class RedisCacheService {
}
public async isHealthy() {
try {
const client = this.cache.store.client;
const testKey = '__health_check__';
const testValue = Date.now().toString();
const isHealthy = await Promise.race([
client.ping(),
try {
await Promise.race([
(async () => {
await this.set(testKey, testValue, ms('1 second'));
const result = await this.get(testKey);
if (result !== testValue) {
throw new Error('Redis health check failed: value mismatch');
}
})(),
new Promise((_, reject) =>
setTimeout(
() => reject(new Error('Redis health check timeout')),
() => reject(new Error('Redis health check failed: timeout')),
ms('2 seconds')
)
)
]);
return isHealthy === 'PONG';
return true;
} catch (error) {
Logger.error(error?.message, 'RedisCacheService');
return false;
} finally {
try {
await this.remove(testKey);
} catch {}
}
}
@ -93,16 +121,14 @@ export class RedisCacheService {
`${this.getPortfolioSnapshotKey({ userId })}`
);
for (const key of keys) {
await this.remove(key);
}
return this.cache.mdel(keys);
}
public async reset() {
return this.cache.reset();
return this.cache.clear();
}
public async set(key: string, value: string, ttl?: Milliseconds) {
public async set(key: string, value: string, ttl?: number) {
return this.cache.set(
key,
value,

View File

@ -1,80 +0,0 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import {
DATE_FORMAT,
getYesterday,
interpolate
} from '@ghostfolio/common/helper';
import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools';
import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common';
import { format } from 'date-fns';
import { Response } from 'express';
import * as fs from 'fs';
import * as path from 'path';
@Controller('sitemap.xml')
export class SitemapController {
public sitemapXml = '';
public constructor(
private readonly configurationService: ConfigurationService
) {
try {
this.sitemapXml = fs.readFileSync(
path.join(__dirname, 'assets', 'sitemap.xml'),
'utf8'
);
} catch {}
}
@Get()
@Version(VERSION_NEUTRAL)
public async getSitemapXml(@Res() response: Response): Promise<void> {
const currentDate = format(getYesterday(), DATE_FORMAT);
response.setHeader('content-type', 'application/xml');
response.send(
interpolate(this.sitemapXml, {
currentDate,
personalFinanceTools: this.configurationService.get(
'ENABLE_FEATURE_SUBSCRIPTION'
)
? personalFinanceTools
.map(({ alias, key }) => {
return [
'<url>',
` <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-${alias ?? key}</loc>`,
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
'</url>',
'<url>',
` <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-${alias ?? key}</loc>`,
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
'</url>',
'<url>',
` <loc>https://ghostfol.io/es/recursos/personal-finance-tools/alternativa-de-software-libre-a-${alias ?? key}</loc>`,
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
'</url>',
'<url>',
` <loc>https://ghostfol.io/fr/ressources/personal-finance-tools/alternative-open-source-a-${alias ?? key}</loc>`,
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
'</url>',
'<url>',
` <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-${alias ?? key}</loc>`,
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
'</url>',
'<url>',
` <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-${alias ?? key}</loc>`,
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
'</url>',
'<url>',
` <loc>https://ghostfol.io/pt/recursos/personal-finance-tools/alternativa-de-software-livre-ao-${alias ?? key}</loc>`,
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
'</url>'
].join('\n');
})
.join('\n')
: ''
})
);
}
}

View File

@ -49,8 +49,7 @@ export class SubscriptionController {
}
let coupons =
((await this.propertyService.getByKey(PROPERTY_COUPONS)) as Coupon[]) ??
[];
(await this.propertyService.getByKey<Coupon[]>(PROPERTY_COUPONS)) ?? [];
const coupon = coupons.find((currentCoupon) => {
return currentCoupon.code === couponCode;

View File

@ -32,7 +32,7 @@ export class SubscriptionService {
this.stripe = new Stripe(
this.configurationService.get('STRIPE_SECRET_KEY'),
{
apiVersion: '2024-09-30.acacia'
apiVersion: '2025-05-28.basil'
}
);
}
@ -50,8 +50,7 @@ export class SubscriptionService {
const subscriptionOffers: {
[offer in SubscriptionOfferKey]: SubscriptionOffer;
} =
((await this.propertyService.getByKey(PROPERTY_STRIPE_CONFIG)) as any) ??
{};
(await this.propertyService.getByKey<any>(PROPERTY_STRIPE_CONFIG)) ?? {};
const subscriptionOffer = Object.values(subscriptionOffers).find(
(subscriptionOffer) => {
@ -61,7 +60,7 @@ export class SubscriptionService {
const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = {
cancel_url: `${this.configurationService.get('ROOT_URL')}/${
user.Settings?.settings?.language ?? DEFAULT_LANGUAGE_CODE
user.Settings.settings.language
}/account`,
client_reference_id: user.id,
line_items: [
@ -122,7 +121,7 @@ export class SubscriptionService {
data: {
expiresAt,
price,
User: {
user: {
connect: {
id: userId
}
@ -158,38 +157,66 @@ export class SubscriptionService {
}
}
public getSubscription({
public async getSubscription({
createdAt,
subscriptions
}: {
createdAt: UserWithSettings['createdAt'];
subscriptions: Subscription[];
}): UserWithSettings['subscription'] {
}): Promise<UserWithSettings['subscription']> {
if (subscriptions.length > 0) {
const { expiresAt, price } = subscriptions.reduce((a, b) => {
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
});
let offer: SubscriptionOfferKey = price ? 'renewal' : 'default';
let offerKey: SubscriptionOfferKey = price ? 'renewal' : 'default';
if (isBefore(createdAt, parseDate('2023-01-01'))) {
offer = 'renewal-early-bird-2023';
offerKey = 'renewal-early-bird-2023';
} else if (isBefore(createdAt, parseDate('2024-01-01'))) {
offer = 'renewal-early-bird-2024';
offerKey = 'renewal-early-bird-2024';
}
const offer = await this.getSubscriptionOffer({
key: offerKey
});
return {
expiresAt,
offer,
expiresAt: isBefore(new Date(), expiresAt) ? expiresAt : undefined,
type: isBefore(new Date(), expiresAt)
? SubscriptionType.Premium
: SubscriptionType.Basic
};
} else {
const offer = await this.getSubscriptionOffer({
key: 'default'
});
return {
offer: 'default',
offer,
type: SubscriptionType.Basic
};
}
}
public async getSubscriptionOffer({
key
}: {
key: SubscriptionOfferKey;
}): Promise<SubscriptionOffer> {
if (!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
return undefined;
}
const offers: {
[offer in SubscriptionOfferKey]: SubscriptionOffer;
} =
(await this.propertyService.getByKey<any>(PROPERTY_STRIPE_CONFIG)) ?? {};
return {
...offers[key],
isRenewal: key.startsWith('renewal')
};
}
}

View File

@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class UpdateOwnAccessTokenDto {
@IsString()
accessToken: string;
}

View File

@ -1,8 +1,13 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { User, UserSettings } from '@ghostfolio/common/interfaces';
import {
AccessTokenResponse,
User,
UserSettings
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
@ -28,6 +33,7 @@ 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';
@ -36,6 +42,7 @@ export class UserController {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly jwtService: JwtService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
@ -47,24 +54,12 @@ export class UserController {
public async deleteOwnUser(
@Body() data: DeleteOwnUserDto
): Promise<UserModel> {
const hashedAccessToken = this.userService.createAccessToken(
const user = await this.validateAccessToken(
data.accessToken,
this.configurationService.get('ACCESS_TOKEN_SALT')
this.request.user.id
);
const [user] = await this.userService.users({
where: { accessToken: hashedAccessToken, id: this.request.user.id }
});
if (!user) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.userService.deleteUser({
accessToken: hashedAccessToken,
id: user.id
});
}
@ -85,6 +80,29 @@ export class UserController {
});
}
@HasPermission(permissions.accessAdminControl)
@Post(':id/access-token')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateUserAccessToken(
@Param('id') id: string
): Promise<AccessTokenResponse> {
return this.rotateUserAccessToken(id);
}
@HasPermission(permissions.updateOwnAccessToken)
@Post('access-token')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateOwnAccessToken(
@Body() data: UpdateOwnAccessTokenDto
): Promise<AccessTokenResponse> {
const user = await this.validateAccessToken(
data.accessToken,
this.request.user.id
);
return this.rotateUserAccessToken(user.id);
}
@Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getUser(
@ -164,4 +182,43 @@ export class UserController {
userId: this.request.user.id
});
}
private async rotateUserAccessToken(
userId: string
): Promise<AccessTokenResponse> {
const { accessToken, hashedAccessToken } =
this.userService.generateAccessToken({
userId
});
await this.prismaService.user.update({
data: { accessToken: hashedAccessToken },
where: { id: userId }
});
return { accessToken };
}
private async validateAccessToken(
accessToken: string,
userId: string
): Promise<UserModel> {
const hashedAccessToken = this.userService.createAccessToken({
password: accessToken,
salt: this.configurationService.get('ACCESS_TOKEN_SALT')
});
const [user] = await this.userService.users({
where: { accessToken: hashedAccessToken, id: userId }
});
if (!user) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return user;
}
}

View File

@ -1,6 +1,7 @@
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
@ -16,6 +17,7 @@ import { UserService } from './user.service';
exports: [UserService],
imports: [
ConfigurationModule,
I18nModule,
JwtModule.register({
secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '30 days' }

View File

@ -41,6 +41,7 @@ import {
permissions
} from '@ghostfolio/common/permissions';
import { UserWithSettings } from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
@ -51,11 +52,10 @@ import { sortBy, without } from 'lodash';
@Injectable()
export class UserService {
private i18nService = new I18nService();
public constructor(
private readonly configurationService: ConfigurationService,
private readonly eventEmitter: EventEmitter2,
private readonly i18nService: I18nService,
private readonly orderService: OrderService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
@ -67,24 +67,44 @@ export class UserService {
return this.prismaService.user.count(args);
}
public createAccessToken(password: string, salt: string): string {
public createAccessToken({
password,
salt
}: {
password: string;
salt: string;
}): string {
const hash = createHmac('sha512', salt);
hash.update(password);
return hash.digest('hex');
}
public generateAccessToken({ userId }: { userId: string }) {
const accessToken = this.createAccessToken({
password: userId,
salt: getRandomString(10)
});
const hashedAccessToken = this.createAccessToken({
password: accessToken,
salt: this.configurationService.get('ACCESS_TOKEN_SALT')
});
return { accessToken, hashedAccessToken };
}
public async getUser(
{ Account, id, permissions, Settings, subscription }: UserWithSettings,
{ accounts, id, permissions, Settings, subscription }: UserWithSettings,
aLocale = locale
): Promise<IUser> {
const userData = await Promise.all([
this.prismaService.access.findMany({
include: {
User: true
user: true
},
orderBy: { alias: 'asc' },
where: { GranteeUser: { id } }
where: { granteeUserId: id }
}),
this.prismaService.order.count({
where: { userId: id }
@ -105,9 +125,10 @@ export class UserService {
let systemMessage: SystemMessage;
const systemMessageProperty = (await this.propertyService.getByKey(
PROPERTY_SYSTEM_MESSAGE
)) as SystemMessage;
const systemMessageProperty =
await this.propertyService.getByKey<SystemMessage>(
PROPERTY_SYSTEM_MESSAGE
);
if (systemMessageProperty?.targetGroups?.includes(subscription?.type)) {
systemMessage = systemMessageProperty;
@ -121,6 +142,7 @@ export class UserService {
}
return {
accounts,
activitiesCount,
id,
permissions,
@ -134,7 +156,6 @@ export class UserService {
permissions: accessItem.permissions
};
}),
accounts: Account,
dateOfFirstActivity: firstActivity?.date ?? new Date(),
settings: {
...(Settings.settings as UserSettings),
@ -161,26 +182,26 @@ export class UserService {
const {
Access,
accessToken,
Account,
Analytics,
accounts,
analytics,
authChallenge,
createdAt,
id,
provider,
role,
Settings,
Subscription,
subscriptions,
thirdPartyId,
updatedAt
} = await this.prismaService.user.findUnique({
include: {
Access: true,
Account: {
include: { Platform: true }
accounts: {
include: { platform: true }
},
Analytics: true,
analytics: true,
Settings: true,
Subscription: true
subscriptions: true
},
where: userWhereUniqueInput
});
@ -188,7 +209,7 @@ export class UserService {
const user: UserWithSettings = {
Access,
accessToken,
Account,
accounts,
authChallenge,
createdAt,
id,
@ -197,9 +218,9 @@ export class UserService {
Settings: Settings as UserWithSettings['Settings'],
thirdPartyId,
updatedAt,
activityCount: Analytics?.activityCount,
activityCount: analytics?.activityCount,
dataProviderGhostfolioDailyRequests:
Analytics?.dataProviderGhostfolioDailyRequests
analytics?.dataProviderGhostfolioDailyRequests
};
if (user?.Settings) {
@ -226,6 +247,12 @@ export class UserService {
? 'max'
: ((user.Settings.settings as UserSettings)?.dateRange ?? 'max');
// Set default value for performance calculation type
if (!(user.Settings.settings as UserSettings)?.performanceCalculationType) {
(user.Settings.settings as UserSettings).performanceCalculationType =
PerformanceCalculationType.ROAI;
}
// Set default value for view mode
if (!(user.Settings.settings as UserSettings).viewMode) {
(user.Settings.settings as UserSettings).viewMode = 'DEFAULT';
@ -233,28 +260,41 @@ export class UserService {
(user.Settings.settings as UserSettings).xRayRules = {
AccountClusterRiskCurrentInvestment:
new AccountClusterRiskCurrentInvestment(undefined, {}).getSettings(
user.Settings.settings
),
new AccountClusterRiskCurrentInvestment(
undefined,
undefined,
undefined,
{}
).getSettings(user.Settings.settings),
AccountClusterRiskSingleAccount: new AccountClusterRiskSingleAccount(
undefined,
undefined,
undefined,
{}
).getSettings(user.Settings.settings),
AssetClassClusterRiskEquity: new AssetClassClusterRiskEquity(
undefined,
undefined,
undefined,
undefined
).getSettings(user.Settings.settings),
AssetClassClusterRiskFixedIncome: new AssetClassClusterRiskFixedIncome(
undefined,
undefined,
undefined,
undefined
).getSettings(user.Settings.settings),
CurrencyClusterRiskBaseCurrencyCurrentInvestment:
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
undefined,
undefined,
undefined,
undefined
).getSettings(user.Settings.settings),
CurrencyClusterRiskCurrentInvestment:
new CurrencyClusterRiskCurrentInvestment(
undefined,
undefined,
undefined,
undefined
).getSettings(user.Settings.settings),
@ -271,10 +311,14 @@ export class UserService {
undefined
).getSettings(user.Settings.settings),
EmergencyFundSetup: new EmergencyFundSetup(
undefined,
undefined,
undefined,
undefined
).getSettings(user.Settings.settings),
FeeRatioInitialInvestment: new FeeRatioInitialInvestment(
undefined,
undefined,
undefined,
undefined,
undefined
@ -311,6 +355,11 @@ export class UserService {
let currentPermissions = getPermissions(user.role);
if (user.provider === 'ANONYMOUS') {
currentPermissions.push(permissions.deleteOwnUser);
currentPermissions.push(permissions.updateOwnAccessToken);
}
if (!(user.Settings.settings as UserSettings).isExperimentalFeatures) {
// currentPermissions = without(
// currentPermissions,
@ -319,9 +368,9 @@ export class UserService {
}
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
user.subscription = this.subscriptionService.getSubscription({
createdAt: user.createdAt,
subscriptions: Subscription
user.subscription = await this.subscriptionService.getSubscription({
subscriptions,
createdAt: user.createdAt
});
if (user.subscription?.type === 'Basic') {
@ -329,21 +378,23 @@ export class UserService {
new Date(),
user.createdAt
);
let frequency = 10;
let frequency = 7;
if (daysSinceRegistration > 365) {
if (daysSinceRegistration > 720) {
frequency = 1;
} else if (daysSinceRegistration > 360) {
frequency = 2;
} else if (daysSinceRegistration > 180) {
frequency = 3;
} else if (daysSinceRegistration > 60) {
frequency = 4;
} else if (daysSinceRegistration > 30) {
frequency = 6;
frequency = 5;
} else if (daysSinceRegistration > 15) {
frequency = 8;
frequency = 6;
}
if (Analytics?.activityCount % frequency === 1) {
if (analytics?.activityCount % frequency === 1) {
currentPermissions.push(permissions.enableSubscriptionInterstitial);
}
@ -353,6 +404,7 @@ export class UserService {
permissions.createAccess,
permissions.createMarketDataOfOwnAssetProfile,
permissions.createOwnTag,
permissions.createWatchlistItem,
permissions.readAiPrompt,
permissions.readMarketDataOfOwnAssetProfile,
permissions.updateMarketDataOfOwnAssetProfile
@ -364,14 +416,26 @@ export class UserService {
// Reset holdings view mode
user.Settings.settings.holdingsViewMode = undefined;
} else if (user.subscription?.type === 'Premium') {
currentPermissions.push(permissions.createApiKey);
currentPermissions.push(permissions.enableDataProviderGhostfolio);
currentPermissions.push(permissions.reportDataGlitch);
if (!hasRole(user, Role.DEMO)) {
currentPermissions.push(permissions.createApiKey);
currentPermissions.push(permissions.enableDataProviderGhostfolio);
currentPermissions.push(permissions.reportDataGlitch);
}
currentPermissions = without(
currentPermissions,
permissions.deleteOwnUser
);
// Reset offer
user.subscription.offer.coupon = undefined;
user.subscription.offer.couponId = undefined;
user.subscription.offer.durationExtension = undefined;
user.subscription.offer.label = undefined;
}
if (hasRole(user, Role.ADMIN)) {
currentPermissions.push(permissions.syncDemoUserAccount);
}
}
@ -380,9 +444,9 @@ export class UserService {
currentPermissions.push(permissions.toggleReadOnlyMode);
}
const isReadOnlyMode = (await this.propertyService.getByKey(
const isReadOnlyMode = await this.propertyService.getByKey<boolean>(
PROPERTY_IS_READ_ONLY_MODE
)) as boolean;
);
if (isReadOnlyMode) {
currentPermissions = currentPermissions.filter((permission) => {
@ -395,11 +459,11 @@ export class UserService {
}
}
if (!environment.production && role === 'ADMIN') {
if (!environment.production && hasRole(user, Role.ADMIN)) {
currentPermissions.push(permissions.impersonateAllUsers);
}
user.Account = sortBy(user.Account, ({ name }) => {
user.accounts = sortBy(user.accounts, ({ name }) => {
return name.toLowerCase();
});
user.permissions = currentPermissions.sort();
@ -433,10 +497,10 @@ export class UserService {
data.provider = 'ANONYMOUS';
}
let user = await this.prismaService.user.create({
const user = await this.prismaService.user.create({
data: {
...data,
Account: {
accounts: {
create: {
currency: DEFAULT_CURRENCY,
name: this.i18nService.getTranslation({
@ -458,20 +522,17 @@ export class UserService {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
await this.prismaService.analytics.create({
data: {
User: { connect: { id: user.id } }
user: { connect: { id: user.id } }
}
});
}
if (data.provider === 'ANONYMOUS') {
const accessToken = this.createAccessToken(user.id, getRandomString(10));
const { accessToken, hashedAccessToken } = this.generateAccessToken({
userId: user.id
});
const hashedAccessToken = this.createAccessToken(
accessToken,
this.configurationService.get('ACCESS_TOKEN_SALT')
);
user = await this.prismaService.user.update({
await this.prismaService.user.update({
data: { accessToken: hashedAccessToken },
where: { id: user.id }
});
@ -556,7 +617,7 @@ export class UserService {
const { settings } = await this.prismaService.settings.upsert({
create: {
settings: userSettings as unknown as Prisma.JsonObject,
User: {
user: {
connect: {
id: userId
}

File diff suppressed because it is too large Load Diff

View File

@ -25,7 +25,7 @@
"name": "Ghostfolio",
"orientation": "portrait",
"short_name": "Ghostfolio",
"start_url": "/en/",
"start_url": "/${languageCode}/",
"theme_color": "#FFFFFF",
"url": "https://ghostfol.io"
"url": "${rootUrl}"
}

View File

@ -4,593 +4,6 @@
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
<!--
<url>
<loc>https://ghostfol.io/ca</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
-->
<url>
<loc>https://ghostfol.io/de</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/blog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/blog/2021/07/hallo-ghostfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/features</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/haeufig-gestellte-fragen</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/maerkte</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/open</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/preise</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/registrierung</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/lexikon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/maerkte</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/ratgeber</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ueber-uns</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ueber-uns/changelog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ueber-uns/datenschutzbestimmungen</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ueber-uns/lizenz</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ueber-uns/oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/about</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/about/changelog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/about/license</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/about/oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2021/07/hello-ghostfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/01/ghostfolio-first-months-in-open-source</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/07/ghostfolio-meets-internet-identity</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/07/how-do-i-get-my-finances-in-order</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/08/500-stars-on-github</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/10/hacktoberfest-2022</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/11/black-friday-2022</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/12/the-importance-of-tracking-your-personal-finances</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/02/ghostfolio-meets-umbrel</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/03/ghostfolio-reaches-1000-stars-on-github</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/05/unlock-your-financial-potential-with-ghostfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/07/exploring-the-path-to-fire</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/08/ghostfolio-joins-oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/09/ghostfolio-2</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/09/hacktoberfest-2023</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/11/black-week-2023</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/11/hacktoberfest-2023-debriefing</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2024/09/hacktoberfest-2024</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2024/11/black-weeks-2024</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/faq</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/saas</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/self-hosting</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/features</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/markets</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/open</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/pricing</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/register</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/glossary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/guides</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/markets</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/funcionalidades</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/mercados</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/open</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/precios</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/preguntas-mas-frecuentes</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/recursos</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/recursos/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/registro</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/sobre</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/sobre/changelog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/sobre/licencia</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/sobre/oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/sobre/politica-de-privacidad</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/a-propos</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/a-propos/changelog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/a-propos/licence</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/a-propos/oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/a-propos/politique-de-confidentialite</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/enregistrement</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/fonctionnalites</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/foire-aux-questions</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/marches</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/open</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/prix</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/ressources</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/ressources/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/domande-piu-frequenti</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/funzionalita</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/informazioni-su</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/informazioni-su/changelog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/informazioni-su/informativa-sulla-privacy</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/informazioni-su/licenza</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/informazioni-su/oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/iscrizione</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/mercati</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/open</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/prezzi</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/functionaliteiten</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/markten</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/open</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/over</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/over/changelog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/over/licentie</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/over/oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/over/privacybeleid</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/prijzen</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/registratie</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/veelgestelde-vragen</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pl</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pl/blog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pl/cennik</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<!--
<url>
<loc>https://ghostfol.io/pl/faq</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
-->
<url>
<loc>https://ghostfol.io/pl/funkcje</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pl/o-ghostfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pl/open</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pl/rynki</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pl/zarejestruj</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/blog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/funcionalidades</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/mercados</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/open</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/perguntas-mais-frequentes</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/precos</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/recursos</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/recursos/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/registo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/sobre</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/sobre/changelog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/sobre/licenca</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/sobre/oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/sobre/politica-de-privacidade</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/tr</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<!--
<url>
<loc>https://ghostfol.io/uk</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
-->
<!--
<url>
<loc>https://ghostfol.io/zh</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
-->
${publicRoutes}
${personalFinanceTools}
</urlset>

View File

@ -1,4 +1,7 @@
import { DEFAULT_HOST, DEFAULT_PORT } from '@ghostfolio/common/config';
export const environment = {
production: true,
rootUrl: `http://${DEFAULT_HOST}:${DEFAULT_PORT}`,
version: `${require('../../../../package.json').version}`
};

View File

@ -1,4 +1,7 @@
import { DEFAULT_HOST } from '@ghostfolio/common/config';
export const environment = {
production: false,
rootUrl: `https://${DEFAULT_HOST}:4200`,
version: 'dev'
};

View File

@ -1515,11 +1515,11 @@ describe('redactAttributes', () => {
}
},
summary: {
activityCount: 29,
annualizedPerformancePercent: 0.16690880197786,
annualizedPerformancePercentWithCurrencyEffect: 0.1694019484552876,
cash: null,
excludedAccountsAndActivities: null,
firstOrderDate: '2017-01-02T23:00:00.000Z',
netPerformance: null,
netPerformancePercentage: 2.3039314216696174,
netPerformancePercentageWithCurrencyEffect: 2.3589806001456606,
@ -1539,7 +1539,6 @@ describe('redactAttributes', () => {
interest: null,
items: null,
liabilities: null,
ordersCount: 29,
totalInvestment: null,
totalValueInBaseCurrency: null,
currentNetWorth: null
@ -3019,11 +3018,11 @@ describe('redactAttributes', () => {
}
},
summary: {
activityCount: 29,
annualizedPerformancePercent: 0.16690880197786,
annualizedPerformancePercentWithCurrencyEffect: 0.1694019484552876,
cash: null,
excludedAccountsAndActivities: null,
firstOrderDate: '2017-01-02T23:00:00.000Z',
netPerformance: null,
netPerformancePercentage: 2.3039314216696174,
netPerformancePercentageWithCurrencyEffect: 2.3589806001456606,
@ -3043,7 +3042,6 @@ describe('redactAttributes', () => {
interest: null,
items: null,
liabilities: null,
ordersCount: 29,
totalInvestment: null,
totalValueInBaseCurrency: null,
currentNetWorth: null

View File

@ -26,7 +26,7 @@ export class TransformDataSourceInRequestInterceptor<T>
const request = http.getRequest();
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
if (request.body.activities) {
if (request.body?.activities) {
request.body.activities = request.body.activities.map((activity) => {
if (DataSource[activity.dataSource]) {
return activity;
@ -43,7 +43,16 @@ export class TransformDataSourceInRequestInterceptor<T>
const dataSourceValue = request[type]?.dataSource;
if (dataSourceValue && !DataSource[dataSourceValue]) {
request[type].dataSource = decodeDataSource(dataSourceValue);
// In Express 5, request.query is read-only, so request[type].dataSource cannot be directly modified
Object.defineProperty(request, type, {
configurable: true,
enumerable: true,
value: {
...request[type],
dataSource: decodeDataSource(dataSourceValue)
},
writable: true
});
}
}
}

View File

@ -1,3 +1,10 @@
import {
DEFAULT_HOST,
DEFAULT_PORT,
STORYBOOK_PATH,
SUPPORTED_LANGUAGE_CODES
} from '@ghostfolio/common/config';
import {
Logger,
LogLevel,
@ -7,11 +14,11 @@ import {
import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { NextFunction, Request, Response } from 'express';
import helmet from 'helmet';
import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
import { HtmlTemplateMiddleware } from './middlewares/html-template.middleware';
async function bootstrap() {
const configApp = await NestFactory.create(AppModule);
@ -37,7 +44,15 @@ async function bootstrap() {
defaultVersion: '1',
type: VersioningType.URI
});
app.setGlobalPrefix('api', { exclude: ['sitemap.xml'] });
app.setGlobalPrefix('api', {
exclude: [
'sitemap.xml',
...SUPPORTED_LANGUAGE_CODES.map((languageCode) => {
// Exclude language-specific routes with an optional wildcard
return `/${languageCode}{/*wildcard}`;
})
]
});
app.useGlobalPipes(
new ValidationPipe({
forbidNonWhitelisted: true,
@ -50,26 +65,28 @@ async function bootstrap() {
app.useBodyParser('json', { limit: '10mb' });
if (configService.get<string>('ENABLE_FEATURE_SUBSCRIPTION') === 'true') {
app.use(
helmet({
contentSecurityPolicy: {
directives: {
connectSrc: ["'self'", 'https://js.stripe.com'], // Allow connections to Stripe
frameSrc: ["'self'", 'https://js.stripe.com'], // Allow loading frames from Stripe
scriptSrc: ["'self'", "'unsafe-inline'", 'https://js.stripe.com'], // Allow inline scripts and scripts from Stripe
scriptSrcAttr: ["'self'", "'unsafe-inline'"], // Allow inline event handlers
styleSrc: ["'self'", "'unsafe-inline'"] // Allow inline styles
}
},
crossOriginOpenerPolicy: false // Disable Cross-Origin-Opener-Policy header (for Internet Identity)
})
);
app.use((req: Request, res: Response, next: NextFunction) => {
if (req.path.startsWith(STORYBOOK_PATH)) {
next();
} else {
helmet({
contentSecurityPolicy: {
directives: {
connectSrc: ["'self'", 'https://js.stripe.com'], // Allow connections to Stripe
frameSrc: ["'self'", 'https://js.stripe.com'], // Allow loading frames from Stripe
scriptSrc: ["'self'", "'unsafe-inline'", 'https://js.stripe.com'], // Allow inline scripts and scripts from Stripe
scriptSrcAttr: ["'self'", "'unsafe-inline'"], // Allow inline event handlers
styleSrc: ["'self'", "'unsafe-inline'"] // Allow inline styles
}
},
crossOriginOpenerPolicy: false // Disable Cross-Origin-Opener-Policy header (for Internet Identity)
})(req, res, next);
}
});
}
app.use(HtmlTemplateMiddleware);
const HOST = configService.get<string>('HOST') || '0.0.0.0';
const PORT = configService.get<number>('PORT') || 3333;
const HOST = configService.get<string>('HOST') || DEFAULT_HOST;
const PORT = configService.get<number>('PORT') || DEFAULT_PORT;
await app.listen(PORT, HOST, () => {
logLogo();

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