Compare commits

..

51 Commits

Author SHA1 Message Date
a83441b3ba Release 1.101.0 (#621) 2022-01-08 18:21:33 +01:00
075431d868 Feature/add google sheets as data source (#620)
* Add google sheets as data source

* Update changelog
2022-01-08 18:19:25 +01:00
0168c1c4e8 Feature/exclude url pattern of shared portfolios in robots.txt (#619)
* Exclude shared portfolios

* Update changelog
2022-01-08 09:37:54 +01:00
07de8f87fc Set market prices explicitly (#618)
* Set market prices explicitly

* Set comments explicitly
2022-01-07 08:09:12 +01:00
3e16041c16 Release 1.100.0 (#617) 2022-01-05 20:24:34 +01:00
5882b7914d Feature/add first months in open source blog post (#616)
* Add blog post

* Update changelog
2022-01-05 20:22:59 +01:00
69c9e259b1 Bugfix/fix routing of create activity dialog (#615)
* Fix routing of create activity dialog

* Update changelog
2022-01-03 21:31:55 +01:00
aca37a27f9 Feature/add top performers to analysis page (#613)
* Add Top 3 / Bottom 3 performers

* Update changelog
2022-01-02 13:29:45 +01:00
313d2a2f79 Release 1.99.0 (#612) 2022-01-01 16:40:55 +01:00
9ac67b0af2 Feature/expose profile data gathering by symbol endpoint (#611)
* Expose profile data gathering by symbol endpoint

* Update changelog
2022-01-01 16:18:18 +01:00
1e526852a7 Bugfix/fix mapping for russia in trackinsight data enhancer (#610)
* Fix mapping for Russia

* Update changelog
2022-01-01 13:55:53 +01:00
e54638a684 Feature/improve analysis page (#609)
* Improve analysis page (show y-axis, extend chart in relation to days in market)

* Update changelog
2022-01-01 12:09:49 +01:00
0179823ad9 Feature/restructure about page (#608)
* Restructure about page: introduce pages for blog and changelog

* Update changelog
2022-01-01 10:10:37 +01:00
029b7bed9a Bugfix/improve error handling in position api endpoint (#607)
* Add guards

* Update changelog
2021-12-31 22:00:58 +01:00
635f10e2d0 Bugfix/hide data provider warning while loading (#605)
* Hide data provider warning while loading

* Update changelog
2021-12-31 10:21:41 +01:00
cebf879d67 Feature/refactor demo user (#604)
* Refactor demo user id

* Update changelog
2021-12-31 09:52:03 +01:00
124bdc028d Bugfix/fix reload of position detail dialog (#603)
* Fix reload of position detail dialog

* Update changelog
2021-12-31 09:51:30 +01:00
d69a69ce18 Bugfix/fix exception with market state (#602)
* Fix exception with market state

* Update changelog
2021-12-30 22:19:08 +01:00
15344513ce Feature/upgrade chart.js to version 3.7.0 (#601)
* Upgrade chart.js from version 3.5.0 to 3.7.0

* Update changelog
2021-12-30 22:18:39 +01:00
b291d9e031 Feature/refactor transactions to activities table (#600)
* Refactor transactions to activities table with attributes and routes

* Update changelog
2021-12-30 18:56:51 +01:00
bee702302f Upgrade angular and Nx dependencies (#599) 2021-12-30 17:31:07 +01:00
bb56e09a13 Clean up preview feature flag (#598) 2021-12-30 10:15:30 +01:00
0873f539c5 Release 1.98.0 (#597) 2021-12-29 18:40:18 +01:00
6dcd801d05 Feature/extend statistics section with users in slack community (#596)
* Extend statistics with users in Slack community

* Update changelog
2021-12-29 18:38:55 +01:00
77065dac50 Feature/add date range selector to holdings tab (#595)
* Add date range selector to holdings tab

* Update changelog
2021-12-29 18:14:24 +01:00
438484879d Bugfix/fix creation of historical data (#594)
* Fix creation of historical data (upsert instead of update)

* Update changelog
2021-12-29 17:03:37 +01:00
e37a650c70 Bugfix/fix scrolling issue in position detail dialog on mobile (#593)
* Fix scrolling in position detail dialog on mobile

* Update changelog
2021-12-29 10:51:11 +01:00
6e8c90b3fc Release 1.97.0 (#592) 2021-12-28 21:40:10 +01:00
9e1a7fc981 Feature/dividend (#547)
* Add dividend to order type

* Support dividend in transactions table

* Support dividend in transaction dialog

* Extend import file with dividend

* Add dividend to portfolio summary

* Update changelog

Co-authored-by: Fly Man <fly.man.opensim@gmail.com>
2021-12-28 21:12:12 +01:00
ff638adf03 Feature/add transactions to position detail dialog (#591)
* Add transactions to position detail dialog

* Update changelog
2021-12-28 17:45:04 +01:00
fa44cee781 Release 1.96.0 (#590) 2021-12-27 21:08:33 +01:00
db1d474ddf Feature/more discreet data provider warning (#589)
* Upgrade http-status-codes to version 2.2.0

* Make the data provider warning more discreet

* Update changelog
2021-12-27 12:14:41 +01:00
994275e093 Feature/upgrade angular 3rd party dependencies (#588)
* Upgrade angular 3rd party dependencies
  * ngx-device-detector
  * ngx-markdown
  * ngx-stripe

* Update changelog
2021-12-26 21:58:56 +01:00
ee397c8047 Bugfix/fix file type detection for import (#587)
* Fix file type detection ("application/vnd.ms-excel" instead of "text/csv")

* Update changelog
2021-12-26 20:54:53 +01:00
7203939c42 Feature/upgrade prisma to version 3.7.0 (#586)
* Upgrade prisma from version 3.6.0 to 3.7.0

* Update changelog
2021-12-26 17:30:26 +01:00
9725f16c81 Clean up schema.prisma (#584) 2021-12-26 14:33:18 +01:00
bb8b1e4f43 Release 1.95.0 (#583) 2021-12-26 10:14:13 +01:00
9d3610331a Add guard (#582) 2021-12-26 10:07:51 +01:00
0043b44670 Feature/improve data gathering for currencies (#581)
* Improve data gathering for currencies, add warning if it fails

* Update changelog
2021-12-26 09:15:10 +01:00
bbc4e64cb4 Bugfix/filter currencies with null value (#579)
* Filter currencies with null value

* Update changelog
2021-12-25 17:08:56 +01:00
c7f4825499 Release 1.94.0 (#578) 2021-12-25 14:45:58 +01:00
8f583709ef Feature/add support for cosmos and polkadot (#577)
* Add support for cryptocurrencies ATOM and DOT

* Update changelog
2021-12-25 14:23:07 +01:00
4c30212a72 Feature/improve data gathering (#576)
* Eliminate benchmarks to gather

* Optimize 7d data gathering

* Update changelog
2021-12-25 14:18:46 +01:00
cade2f6a5e Feature/upgrade prettier to version 2.5.1 (#575)
* Upgrade prettier from version 2.3.2 to 2.5.1

* Update changelog
2021-12-25 10:29:56 +01:00
3b9a8fabb5 Clean up (#574) 2021-12-24 18:42:30 +01:00
3435b3a348 Feature/make the csv import more flexible (#573)
* Make the csv import more flexible

* Update changelog
2021-12-24 18:21:27 +01:00
5d39b267ab write portfolio calculator test case for symbol BALN.SW (refs #554) (#572) 2021-12-24 17:52:08 +01:00
ffaaa14dba Feature/increase fear and greed index to 30 days (#571)
* Increase Fear & Greed index to 30 days

* Update changelog
2021-12-24 09:40:24 +01:00
c65746d119 Simplify instructions for development setup (#570) 2021-12-22 18:09:00 +01:00
1a6840f1f6 Fix instruction for database setup (#568) 2021-12-21 20:59:01 +01:00
fb7fb886f6 Fix anchor link (#567) 2021-12-21 20:53:15 +01:00
118 changed files with 3909 additions and 1848 deletions

View File

@ -5,6 +5,121 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 1.101.0 - 08.01.2022
### Added
- Added `GOOGLE_SHEETS` as a new data source type
### Changed
- Excluded the url pattern of shared portfolios in the `robots.txt` file
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.100.0 - 05.01.2022
### Added
- Added the _Top 3_ and _Bottom 3_ performers to the analysis page
- Added a blog post
### Fixed
- Fixed the routing of the create activity dialog
- Fixed the link color in the blog posts
## 1.99.0 - 01.01.2022
### Added
- Exposed the profile data gathering by symbol as an endpoint
### Changed
- Improved the portfolio analysis page: show the y-axis and extend the chart in relation to the days in market
- Restructured the about page
- Start refactoring _transactions_ to _activities_
- Refactored the demo user id
- Upgraded `angular` from version `13.0.2` to `13.1.1`
- Upgraded `chart.js` from version `3.5.0` to `3.7.0`
- Upgraded `Nx` from version `13.3.0` to `13.4.1`
### Fixed
- Hid the data provider warning while loading
- Fixed an exception with the market state caused by a failed data provider request
- Fixed an exception in the portfolio position endpoint
- Fixed the reload of the position detail dialog (with query parameters)
- Fixed the missing mapping for Russia in the data enhancer for symbol profile data via _Trackinsight_
## 1.98.0 - 29.12.2021
### Added
- Added the date range component to the holdings tab
### Changed
- Extended the statistics section on the about page (users in Slack community)
### Fixed
- Fixed the creation of historical data in the admin control panel (upsert instead of update)
- Fixed the scrolling issue in the position detail dialog on mobile
## 1.97.0 - 28.12.2021
### Added
- Added the transactions to the position detail dialog
- Added support for dividend
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.96.0 - 27.12.2021
### Changed
- Made the data provider warning more discreet
- Upgraded `http-status-codes` from version `2.1.4` to `2.2.0`
- Upgraded `ngx-device-detector` from version `2.1.1` to `3.0.0`
- Upgraded `ngx-markdown` from version `12.0.1` to `13.0.0`
- Upgraded `ngx-stripe` from version `12.0.2` to `13.0.0`
- Upgraded `prisma` from version `3.6.0` to `3.7.0`
### Fixed
- Fixed the file type detection in the import functionality for transactions
## 1.95.0 - 26.12.2021
### Added
- Added a warning to the log if the data gathering fails
### Fixed
- Filtered potential `null` currencies
- Improved the 7d data gathering optimization for currencies
## 1.94.0 - 25.12.2021
### Added
- Added support for cryptocurrencies _Cosmos_ (`ATOM-USD`) and _Polkadot_ (`DOT-USD`)
### Changed
- Increased the historical data chart of the _Fear & Greed Index_ (market mood) to 30 days
- Made the import functionality for transactions by `csv` files more flexible
- Optimized the 7d data gathering (only consider symbols with incomplete market data)
- Upgraded `prettier` from version `2.3.2` to `2.5.1`
## 1.93.0 - 21.12.2021 ## 1.93.0 - 21.12.2021
### Added ### Added

View File

@ -34,7 +34,7 @@
Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs. Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the section [Run with Docker](#run-with-docker). If you prefer to run Ghostfolio on your own infrastructure (self-hosting), please find further instructions in the section [Run with Docker](#run-with-docker-self-hosting).
## Why Ghostfolio? ## Why Ghostfolio?
@ -95,6 +95,14 @@ Run the following command to start the Docker images from [Docker Hub](https://h
docker-compose -f docker/docker-compose.yml up docker-compose -f docker/docker-compose.yml up
``` ```
#### Setup Database
Run the following command to setup the database once Ghostfolio is running:
```bash
docker-compose -f docker/docker-compose.yml exec ghostfolio yarn database:setup
```
### b. Build and run environment ### b. Build and run environment
Run the following commands to build and start the Docker images: Run the following commands to build and start the Docker images:
@ -104,12 +112,12 @@ docker-compose -f docker/docker-compose.build.yml build
docker-compose -f docker/docker-compose.build.yml up docker-compose -f docker/docker-compose.build.yml up
``` ```
### Setup Database #### Setup Database
Run the following command to setup the database once Ghostfolio is running: Run the following command to setup the database once Ghostfolio is running:
```bash ```bash
docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn database:setup docker-compose -f docker/docker-compose.build.yml exec ghostfolio yarn database:setup
``` ```
### Fetch Historical Data ### Fetch Historical Data
@ -145,9 +153,7 @@ docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn dat
### Setup ### Setup
1. Run `yarn install` 1. Run `yarn install`
1. Run `cd docker` 1. Run `docker-compose -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
1. Run `docker compose up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
1. Run `cd -` to go back to the project root directory
1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data 1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data
1. Start server and client (see [_Development_](#Development)) 1. Start server and client (see [_Development_](#Development))
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9` 1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`

View File

@ -96,6 +96,29 @@ export class AdminController {
return; return;
} }
@Post('gather/profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
public async gatherProfileDataForSymbol(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
this.dataGatheringService.gatherProfileData([{ dataSource, symbol }]);
return;
}
@Post('gather/:dataSource/:symbol') @Post('gather/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async gatherSymbol( public async gatherSymbol(
@ -215,7 +238,7 @@ export class AdminController {
const date = new Date(dateString); const date = new Date(dateString);
return this.marketDataService.updateMarketData({ return this.marketDataService.updateMarketData({
data, data: { ...data, dataSource },
where: { where: {
date_symbol: { date_symbol: {
date, date,

View File

@ -6,7 +6,9 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { import {
DEMO_USER_ID,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_SLACK_COMMUNITY_USERS,
PROPERTY_STRIPE_CONFIG, PROPERTY_STRIPE_CONFIG,
PROPERTY_SYSTEM_MESSAGE PROPERTY_SYSTEM_MESSAGE
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
@ -22,7 +24,6 @@ import { subDays } from 'date-fns';
@Injectable() @Injectable()
export class InfoService { export class InfoService {
private static CACHE_KEY_STATISTICS = 'STATISTICS'; private static CACHE_KEY_STATISTICS = 'STATISTICS';
private static DEMO_USER_ID = '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f';
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
@ -187,9 +188,15 @@ export class InfoService {
}); });
} }
private async countSlackCommunityUsers() {
return (await this.propertyService.getByKey(
PROPERTY_SLACK_COMMUNITY_USERS
)) as string;
}
private getDemoAuthToken() { private getDemoAuthToken() {
return this.jwtService.sign({ return this.jwtService.sign({
id: InfoService.DEMO_USER_ID id: DEMO_USER_ID
}); });
} }
@ -218,19 +225,19 @@ export class InfoService {
} catch {} } catch {}
const activeUsers1d = await this.countActiveUsers(1); const activeUsers1d = await this.countActiveUsers(1);
const activeUsers7d = await this.countActiveUsers(7);
const activeUsers30d = await this.countActiveUsers(30); const activeUsers30d = await this.countActiveUsers(30);
const newUsers30d = await this.countNewUsers(30); const newUsers30d = await this.countNewUsers(30);
const gitHubContributors = await this.countGitHubContributors(); const gitHubContributors = await this.countGitHubContributors();
const gitHubStargazers = await this.countGitHubStargazers(); const gitHubStargazers = await this.countGitHubStargazers();
const slackCommunityUsers = await this.countSlackCommunityUsers();
statistics = { statistics = {
activeUsers1d, activeUsers1d,
activeUsers7d,
activeUsers30d, activeUsers30d,
gitHubContributors, gitHubContributors,
gitHubStargazers, gitHubStargazers,
newUsers30d newUsers30d,
slackCommunityUsers
}; };
await this.redisCacheService.set( await this.redisCacheService.set(

View File

@ -66,28 +66,21 @@ export class OrderController {
this.request.user.id this.request.user.id
); );
let orders = await this.orderService.orders({ let orders = await this.orderService.getOrders({
include: { includeDrafts: true,
Account: { userId: impersonationUserId || this.request.user.id
include: {
Platform: true
}
},
SymbolProfile: {
select: {
name: true
}
}
},
orderBy: { date: 'desc' },
where: { userId: impersonationUserId || this.request.user.id }
}); });
if ( if (
impersonationUserId || impersonationUserId ||
this.userService.isRestrictedView(this.request.user) this.userService.isRestrictedView(this.request.user)
) { ) {
orders = nullifyValuesInObjects(orders, ['fee', 'quantity', 'unitPrice']); orders = nullifyValuesInObjects(orders, [
'fee',
'quantity',
'unitPrice',
'value'
]);
} }
return orders; return orders;

View File

@ -3,7 +3,8 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, Order, Prisma } from '@prisma/client'; import { DataSource, Order, Prisma, Type as TypeOfOrder } from '@prisma/client';
import Big from 'big.js';
import { endOfToday, isAfter } from 'date-fns'; import { endOfToday, isAfter } from 'date-fns';
@Injectable() @Injectable()
@ -82,11 +83,13 @@ export class OrderService {
}); });
} }
public getOrders({ public async getOrders({
includeDrafts = false, includeDrafts = false,
types,
userId userId
}: { }: {
includeDrafts?: boolean; includeDrafts?: boolean;
types?: TypeOfOrder[];
userId: string; userId: string;
}) { }) {
const where: Prisma.OrderWhereInput = { userId }; const where: Prisma.OrderWhereInput = { userId };
@ -95,15 +98,39 @@ export class OrderService {
where.isDraft = false; where.isDraft = false;
} }
return this.orders({ if (types) {
where.OR = types.map((type) => {
return {
type: {
equals: type
}
};
});
}
return (
await this.orders({
where, where,
include: { include: {
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
Account: true, Account: {
include: {
Platform: true
}
},
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
SymbolProfile: true SymbolProfile: true
}, },
orderBy: { date: 'asc' } orderBy: { date: 'asc' }
})
).map((order) => {
return {
...order,
value: new Big(order.quantity)
.mul(order.unitPrice)
.plus(order.fee)
.toNumber()
};
}); });
} }

View File

@ -1,3 +1,4 @@
import { OrderWithAccount } from '@ghostfolio/common/types';
import { AssetClass, AssetSubClass } from '@prisma/client'; import { AssetClass, AssetSubClass } from '@prisma/client';
export interface PortfolioPositionDetail { export interface PortfolioPositionDetail {
@ -16,6 +17,7 @@ export interface PortfolioPositionDetail {
name: string; name: string;
netPerformance: number; netPerformance: number;
netPerformancePercent: number; netPerformancePercent: number;
orders: OrderWithAccount[];
quantity: number; quantity: number;
symbol: string; symbol: string;
transactionCount: number; transactionCount: number;

File diff suppressed because it is too large Load Diff

View File

@ -10,12 +10,12 @@ import { baseCurrency } from '@ghostfolio/common/config';
import { import {
PortfolioChart, PortfolioChart,
PortfolioDetails, PortfolioDetails,
PortfolioInvestments,
PortfolioPerformance, PortfolioPerformance,
PortfolioPublicDetails, PortfolioPublicDetails,
PortfolioReport, PortfolioReport,
PortfolioSummary PortfolioSummary
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
@ -48,46 +48,10 @@ export class PortfolioController {
private readonly userService: UserService private readonly userService: UserService
) {} ) {}
@Get('investments')
@UseGuards(AuthGuard('jwt'))
public async findAll(
@Headers('impersonation-id') impersonationId,
@Res() res: Response
): Promise<InvestmentItem[]> {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
res.status(StatusCodes.FORBIDDEN);
return <any>res.json([]);
}
let investments = await this.portfolioService.getInvestments(
impersonationId
);
if (
impersonationId ||
this.userService.isRestrictedView(this.request.user)
) {
const maxInvestment = investments.reduce(
(investment, item) => Math.max(investment, item.investment),
1
);
investments = investments.map((item) => ({
date: item.date,
investment: item.investment / maxInvestment
}));
}
return <any>res.json(investments);
}
@Get('chart') @Get('chart')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getChart( public async getChart(
@Headers('impersonation-id') impersonationId, @Headers('impersonation-id') impersonationId: string,
@Query('range') range, @Query('range') range,
@Res() res: Response @Res() res: Response
): Promise<PortfolioChart> { ): Promise<PortfolioChart> {
@ -98,18 +62,14 @@ export class PortfolioController {
let chartData = historicalDataContainer.items; let chartData = historicalDataContainer.items;
let hasNullValue = false; let hasError = false;
chartData.forEach((chartDataItem) => { chartData.forEach((chartDataItem) => {
if (hasNotDefinedValuesInObject(chartDataItem)) { if (hasNotDefinedValuesInObject(chartDataItem)) {
hasNullValue = true; hasError = true;
} }
}); });
if (hasNullValue) {
res.status(StatusCodes.ACCEPTED);
}
if ( if (
impersonationId || impersonationId ||
this.userService.isRestrictedView(this.request.user) this.userService.isRestrictedView(this.request.user)
@ -131,6 +91,7 @@ export class PortfolioController {
} }
return <any>res.json({ return <any>res.json({
hasError,
chart: chartData, chart: chartData,
isAllTimeHigh: historicalDataContainer.isAllTimeHigh, isAllTimeHigh: historicalDataContainer.isAllTimeHigh,
isAllTimeLow: historicalDataContainer.isAllTimeLow isAllTimeLow: historicalDataContainer.isAllTimeLow
@ -140,7 +101,7 @@ export class PortfolioController {
@Get('details') @Get('details')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getDetails( public async getDetails(
@Headers('impersonation-id') impersonationId, @Headers('impersonation-id') impersonationId: string,
@Query('range') range, @Query('range') range,
@Res() res: Response @Res() res: Response
): Promise<PortfolioDetails> { ): Promise<PortfolioDetails> {
@ -152,6 +113,8 @@ export class PortfolioController {
return <any>res.json({ accounts: {}, holdings: {} }); return <any>res.json({ accounts: {}, holdings: {} });
} }
let hasError = false;
const { accounts, holdings, hasErrors } = const { accounts, holdings, hasErrors } =
await this.portfolioService.getDetails( await this.portfolioService.getDetails(
impersonationId, impersonationId,
@ -160,7 +123,7 @@ export class PortfolioController {
); );
if (hasErrors || hasNotDefinedValuesInObject(holdings)) { if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
res.status(StatusCodes.ACCEPTED); hasError = true;
} }
if ( if (
@ -198,43 +161,74 @@ export class PortfolioController {
} }
} }
return <any>res.json({ accounts, holdings }); return <any>res.json({ accounts, hasError, holdings });
}
@Get('investments')
@UseGuards(AuthGuard('jwt'))
public async getInvestments(
@Headers('impersonation-id') impersonationId: string,
@Res() res: Response
): Promise<PortfolioInvestments> {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
res.status(StatusCodes.FORBIDDEN);
return <any>res.json({});
}
let investments = await this.portfolioService.getInvestments(
impersonationId
);
if (
impersonationId ||
this.userService.isRestrictedView(this.request.user)
) {
const maxInvestment = investments.reduce(
(investment, item) => Math.max(investment, item.investment),
1
);
investments = investments.map((item) => ({
date: item.date,
investment: item.investment / maxInvestment
}));
}
return <any>res.json({ firstOrderDate: investments[0]?.date, investments });
} }
@Get('performance') @Get('performance')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getPerformance( public async getPerformance(
@Headers('impersonation-id') impersonationId, @Headers('impersonation-id') impersonationId: string,
@Query('range') range, @Query('range') range,
@Res() res: Response @Res() res: Response
): Promise<PortfolioPerformance> { ): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> {
const performanceInformation = await this.portfolioService.getPerformance( const performanceInformation = await this.portfolioService.getPerformance(
impersonationId, impersonationId,
range range
); );
if (performanceInformation?.hasErrors) {
res.status(StatusCodes.ACCEPTED);
}
let performance = performanceInformation.performance;
if ( if (
impersonationId || impersonationId ||
this.userService.isRestrictedView(this.request.user) this.userService.isRestrictedView(this.request.user)
) { ) {
performance = nullifyValuesInObject(performance, [ performanceInformation.performance = nullifyValuesInObject(
'currentGrossPerformance', performanceInformation.performance,
'currentValue' ['currentGrossPerformance', 'currentValue']
]); );
} }
return <any>res.json(performance); return <any>res.json(performanceInformation);
} }
@Get('positions') @Get('positions')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getPositions( public async getPositions(
@Headers('impersonation-id') impersonationId, @Headers('impersonation-id') impersonationId: string,
@Query('range') range, @Query('range') range,
@Res() res: Response @Res() res: Response
): Promise<PortfolioPositions> { ): Promise<PortfolioPositions> {
@ -243,10 +237,6 @@ export class PortfolioController {
range range
); );
if (result?.hasErrors) {
res.status(StatusCodes.ACCEPTED);
}
if ( if (
impersonationId || impersonationId ||
this.userService.isRestrictedView(this.request.user) this.userService.isRestrictedView(this.request.user)
@ -340,6 +330,7 @@ export class PortfolioController {
'currentGrossPerformance', 'currentGrossPerformance',
'currentNetPerformance', 'currentNetPerformance',
'currentValue', 'currentValue',
'dividend',
'fees', 'fees',
'netWorth', 'netWorth',
'totalBuy', 'totalBuy',
@ -353,7 +344,7 @@ export class PortfolioController {
@Get('position/:symbol') @Get('position/:symbol')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getPosition( public async getPosition(
@Headers('impersonation-id') impersonationId, @Headers('impersonation-id') impersonationId: string,
@Param('symbol') symbol @Param('symbol') symbol
): Promise<PortfolioPositionDetail> { ): Promise<PortfolioPositionDetail> {
let position = await this.portfolioService.getPosition( let position = await this.portfolioService.getPosition(
@ -370,6 +361,7 @@ export class PortfolioController {
'grossPerformance', 'grossPerformance',
'investment', 'investment',
'netPerformance', 'netPerformance',
'orders',
'quantity', 'quantity',
'value' 'value'
]); ]);
@ -387,7 +379,7 @@ export class PortfolioController {
@Get('report') @Get('report')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getReport( public async getReport(
@Headers('impersonation-id') impersonationId, @Headers('impersonation-id') impersonationId: string,
@Res() res: Response @Res() res: Response
): Promise<PortfolioReport> { ): Promise<PortfolioReport> {
if ( if (

View File

@ -55,7 +55,7 @@ import {
subDays, subDays,
subYears subYears
} from 'date-fns'; } from 'date-fns';
import { isEmpty } from 'lodash'; import { isEmpty, sortBy } from 'lodash';
import { import {
HistoricalDataContainer, HistoricalDataContainer,
@ -150,12 +150,33 @@ export class PortfolioService {
return []; return [];
} }
return portfolioCalculator.getInvestments().map((item) => { const investments = portfolioCalculator.getInvestments().map((item) => {
return { return {
date: item.date, date: item.date,
investment: item.investment.toNumber() investment: item.investment.toNumber()
}; };
}); });
// Add investment of today
const investmentOfToday = investments.filter((investment) => {
return investment.date === format(new Date(), DATE_FORMAT);
});
if (investmentOfToday.length <= 0) {
const pastInvestments = investments.filter((investment) => {
return isBefore(parseDate(investment.date), new Date());
});
const lastInvestment = pastInvestments[pastInvestments.length - 1];
investments.push({
date: format(new Date(), DATE_FORMAT),
investment: lastInvestment?.investment ?? 0
});
}
return sortBy(investments, (investment) => {
return investment.date;
});
} }
public async getChart( public async getChart(
@ -388,6 +409,7 @@ export class PortfolioService {
name: undefined, name: undefined,
netPerformance: undefined, netPerformance: undefined,
netPerformancePercent: undefined, netPerformancePercent: undefined,
orders: [],
quantity: undefined, quantity: undefined,
symbol: aSymbol, symbol: aSymbol,
transactionCount: undefined, transactionCount: undefined,
@ -400,7 +422,11 @@ export class PortfolioService {
const positionCurrency = orders[0].currency; const positionCurrency = orders[0].currency;
const name = orders[0].SymbolProfile?.name ?? ''; const name = orders[0].SymbolProfile?.name ?? '';
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({ const portfolioOrders: PortfolioOrder[] = orders
.filter((order) => {
return order.type === 'BUY' || order.type === 'SELL';
})
.map((order) => ({
currency: order.currency, currency: order.currency,
dataSource: order.dataSource, dataSource: order.dataSource,
date: format(order.date, DATE_FORMAT), date: format(order.date, DATE_FORMAT),
@ -442,17 +468,17 @@ export class PortfolioService {
// Convert investment, gross and net performance to currency of user // Convert investment, gross and net performance to currency of user
const userCurrency = this.request.user.Settings.currency; const userCurrency = this.request.user.Settings.currency;
const investment = this.exchangeRateDataService.toCurrency( const investment = this.exchangeRateDataService.toCurrency(
position.investment.toNumber(), position.investment?.toNumber(),
currency, currency,
userCurrency userCurrency
); );
const grossPerformance = this.exchangeRateDataService.toCurrency( const grossPerformance = this.exchangeRateDataService.toCurrency(
position.grossPerformance.toNumber(), position.grossPerformance?.toNumber(),
currency, currency,
userCurrency userCurrency
); );
const netPerformance = this.exchangeRateDataService.toCurrency( const netPerformance = this.exchangeRateDataService.toCurrency(
position.netPerformance.toNumber(), position.netPerformance?.toNumber(),
currency, currency,
userCurrency userCurrency
); );
@ -521,6 +547,7 @@ export class PortfolioService {
minPrice, minPrice,
name, name,
netPerformance, netPerformance,
orders,
transactionCount, transactionCount,
averagePrice: averagePrice.toNumber(), averagePrice: averagePrice.toNumber(),
grossPerformancePercent: position.grossPerformancePercentage.toNumber(), grossPerformancePercent: position.grossPerformancePercentage.toNumber(),
@ -578,6 +605,7 @@ export class PortfolioService {
maxPrice, maxPrice,
minPrice, minPrice,
name, name,
orders,
averagePrice: 0, averagePrice: 0,
currency: currentData[aSymbol]?.currency, currency: currentData[aSymbol]?.currency,
firstBuyDate: undefined, firstBuyDate: undefined,
@ -655,7 +683,9 @@ export class PortfolioService {
grossPerformancePercentage: grossPerformancePercentage:
position.grossPerformancePercentage?.toNumber() ?? null, position.grossPerformancePercentage?.toNumber() ?? null,
investment: new Big(position.investment).toNumber(), investment: new Big(position.investment).toNumber(),
marketState: dataProviderResponses[position.symbol].marketState, marketState:
dataProviderResponses[position.symbol]?.marketState ??
MarketState.delayed,
name: symbolProfileMap[position.symbol].name, name: symbolProfileMap[position.symbol].name,
netPerformance: position.netPerformance?.toNumber() ?? null, netPerformance: position.netPerformance?.toNumber() ?? null,
netPerformancePercentage: netPerformancePercentage:
@ -726,22 +756,6 @@ export class PortfolioService {
}; };
} }
public getFees(orders: OrderWithAccount[], date = new Date(0)) {
return orders
.filter((order) => {
// Filter out all orders before given date
return isBefore(date, new Date(order.date));
})
.map((order) => {
return this.exchangeRateDataService.toCurrency(
order.fee,
order.currency,
this.request.user.Settings.currency
);
})
.reduce((previous, current) => previous + current, 0);
}
public async getReport(impersonationId: string): Promise<PortfolioReport> { public async getReport(impersonationId: string): Promise<PortfolioReport> {
const currency = this.request.user.Settings.currency; const currency = this.request.user.Settings.currency;
const userId = await this.getUserId(impersonationId, this.request.user.id); const userId = await this.getUserId(impersonationId, this.request.user.id);
@ -822,7 +836,7 @@ export class PortfolioService {
new FeeRatioInitialInvestment( new FeeRatioInitialInvestment(
this.exchangeRateDataService, this.exchangeRateDataService,
currentPositions.totalInvestment.toNumber(), currentPositions.totalInvestment.toNumber(),
this.getFees(orders) this.getFees(orders).toNumber()
) )
], ],
{ baseCurrency: currency } { baseCurrency: currency }
@ -841,8 +855,11 @@ export class PortfolioService {
userId, userId,
currency currency
); );
const orders = await this.orderService.getOrders({ userId }); const orders = await this.orderService.getOrders({
const fees = this.getFees(orders); userId
});
const dividend = this.getDividend(orders).toNumber();
const fees = this.getFees(orders).toNumber();
const firstOrderDate = orders[0]?.date; const firstOrderDate = orders[0]?.date;
const totalBuy = this.getTotalByType(orders, currency, 'BUY'); const totalBuy = this.getTotalByType(orders, currency, 'BUY');
@ -856,14 +873,17 @@ export class PortfolioService {
return { return {
...performanceInformation.performance, ...performanceInformation.performance,
dividend,
fees, fees,
firstOrderDate, firstOrderDate,
netWorth, netWorth,
totalBuy,
totalSell,
cash: balance, cash: balance,
committedFunds: committedFunds.toNumber(), committedFunds: committedFunds.toNumber(),
ordersCount: orders.length, ordersCount: orders.filter((order) => {
totalBuy: totalBuy, return order.type === 'BUY' || order.type === 'SELL';
totalSell: totalSell }).length
}; };
} }
@ -936,6 +956,47 @@ export class PortfolioService {
return cashPositions; return cashPositions;
} }
private getDividend(orders: OrderWithAccount[], date = new Date(0)) {
return orders
.filter((order) => {
// Filter out all orders before given date and type dividend
return (
isBefore(date, new Date(order.date)) &&
order.type === TypeOfOrder.DIVIDEND
);
})
.map((order) => {
return this.exchangeRateDataService.toCurrency(
new Big(order.quantity).mul(order.unitPrice).toNumber(),
order.currency,
this.request.user.Settings.currency
);
})
.reduce(
(previous, current) => new Big(previous).plus(current),
new Big(0)
);
}
private getFees(orders: OrderWithAccount[], date = new Date(0)) {
return orders
.filter((order) => {
// Filter out all orders before given date
return isBefore(date, new Date(order.date));
})
.map((order) => {
return this.exchangeRateDataService.toCurrency(
order.fee,
order.currency,
this.request.user.Settings.currency
);
})
.reduce(
(previous, current) => new Big(previous).plus(current),
new Big(0)
);
}
private getStartDate(aDateRange: DateRange, portfolioStart: Date) { private getStartDate(aDateRange: DateRange, portfolioStart: Date) {
switch (aDateRange) { switch (aDateRange) {
case '1d': case '1d':
@ -964,7 +1025,11 @@ export class PortfolioService {
transactionPoints: TransactionPoint[]; transactionPoints: TransactionPoint[];
orders: OrderWithAccount[]; orders: OrderWithAccount[];
}> { }> {
const orders = await this.orderService.getOrders({ includeDrafts, userId }); const orders = await this.orderService.getOrders({
includeDrafts,
userId,
types: ['BUY', 'SELL']
});
if (orders.length <= 0) { if (orders.length <= 0) {
return { transactionPoints: [], orders: [] }; return { transactionPoints: [], orders: [] };

View File

@ -13,7 +13,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { DataSource, MarketData } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { isDate, isEmpty } from 'lodash'; import { isDate, isEmpty } from 'lodash';
@ -37,8 +37,7 @@ export class SymbolController {
@Query() { query = '' } @Query() { query = '' }
): Promise<{ items: LookupItem[] }> { ): Promise<{ items: LookupItem[] }> {
try { try {
const encodedQuery = encodeURIComponent(query.toLowerCase()); return this.symbolService.lookup(query.toLowerCase());
return this.symbolService.lookup(encodedQuery);
} catch { } catch {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),

View File

@ -8,7 +8,7 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data.service'
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource, MarketData } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { format, subDays } from 'date-fns'; import { format, subDays } from 'date-fns';
import { LookupItem } from './interfaces/lookup-item.interface'; import { LookupItem } from './interfaces/lookup-item.interface';
@ -36,17 +36,17 @@ export class SymbolService {
let historicalData: HistoricalDataItem[]; let historicalData: HistoricalDataItem[];
if (includeHistoricalData) { if (includeHistoricalData) {
const days = 10; const days = 30;
const marketData = await this.marketDataService.getRange({ const marketData = await this.marketDataService.getRange({
dateQuery: { gte: subDays(new Date(), days) }, dateQuery: { gte: subDays(new Date(), days) },
symbols: [dataGatheringItem.symbol] symbols: [dataGatheringItem.symbol]
}); });
historicalData = marketData.map(({ date, marketPrice }) => { historicalData = marketData.map(({ date, marketPrice: value }) => {
return { return {
date: date.toISOString(), value,
value: marketPrice date: date.toISOString()
}; };
}); });
} }
@ -93,32 +93,6 @@ export class SymbolService {
try { try {
const { items } = await this.dataProviderService.search(aQuery); const { items } = await this.dataProviderService.search(aQuery);
results.items = items; results.items = items;
// Add custom symbols
const ghostfolioSymbolProfiles =
await this.prismaService.symbolProfile.findMany({
select: {
currency: true,
dataSource: true,
name: true,
symbol: true
},
where: {
AND: [
{
dataSource: DataSource.GHOSTFOLIO,
name: {
startsWith: aQuery
}
}
]
}
});
for (const ghostfolioSymbolProfile of ghostfolioSymbolProfiles) {
results.items.push(ghostfolioSymbolProfile);
}
return results; return results;
} catch (error) { } catch (error) {
Logger.error(error); Logger.error(error);

View File

@ -13,6 +13,7 @@ export class ConfigurationService {
ACCESS_TOKEN_SALT: str(), ACCESS_TOKEN_SALT: str(),
ALPHA_VANTAGE_API_KEY: str({ default: '' }), ALPHA_VANTAGE_API_KEY: str({ default: '' }),
CACHE_TTL: num({ default: 1 }), CACHE_TTL: num({ default: 1 }),
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }), DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }),
ENABLE_FEATURE_BLOG: bool({ default: false }), ENABLE_FEATURE_BLOG: bool({ default: false }),
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }), ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
@ -25,6 +26,9 @@ export class ConfigurationService {
ENABLE_FEATURE_SYSTEM_MESSAGE: bool({ default: false }), ENABLE_FEATURE_SYSTEM_MESSAGE: bool({ default: false }),
GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }), GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }),
GOOGLE_SECRET: str({ default: 'dummySecret' }), GOOGLE_SECRET: str({ default: 'dummySecret' }),
GOOGLE_SHEETS_ACCOUNT: str({ default: '' }),
GOOGLE_SHEETS_ID: str({ default: '' }),
GOOGLE_SHEETS_PRIVATE_KEY: str({ default: '' }),
JWT_SECRET_KEY: str({}), JWT_SECRET_KEY: str({}),
MAX_ITEM_IN_CACHE: num({ default: 9999 }), MAX_ITEM_IN_CACHE: num({ default: 9999 }),
MAX_ORDERS_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }), MAX_ORDERS_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),

View File

@ -1,7 +1,9 @@
{ {
"1INCH": "1inch", "1INCH": "1inch",
"ALGO": "Algorand", "ALGO": "Algorand",
"ATOM": "Cosmos",
"AVAX": "Avalanche", "AVAX": "Avalanche",
"DOT": "Polkadot",
"MATIC": "Polygon", "MATIC": "Polygon",
"SHIB": "Shiba Inu", "SHIB": "Shiba Inu",
"SOL": "Solana", "SOL": "Solana",

View File

@ -1,12 +1,11 @@
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { import {
PROPERTY_LAST_DATA_GATHERING, PROPERTY_LAST_DATA_GATHERING,
PROPERTY_LOCKED_DATA_GATHERING, PROPERTY_LOCKED_DATA_GATHERING
ghostfolioFearAndGreedIndexSymbol
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper'; import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource, MarketData } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { import {
differenceInHours, differenceInHours,
format, format,
@ -17,7 +16,6 @@ import {
subDays subDays
} from 'date-fns'; } from 'date-fns';
import { ConfigurationService } from './configuration.service';
import { DataProviderService } from './data-provider/data-provider.service'; import { DataProviderService } from './data-provider/data-provider.service';
import { DataEnhancerInterface } from './data-provider/interfaces/data-enhancer.interface'; import { DataEnhancerInterface } from './data-provider/interfaces/data-enhancer.interface';
import { ExchangeRateDataService } from './exchange-rate-data.service'; import { ExchangeRateDataService } from './exchange-rate-data.service';
@ -29,7 +27,6 @@ export class DataGatheringService {
private dataGatheringProgress: number; private dataGatheringProgress: number;
public constructor( public constructor(
private readonly configurationService: ConfigurationService,
@Inject('DataEnhancers') @Inject('DataEnhancers')
private readonly dataEnhancers: DataEnhancerInterface[], private readonly dataEnhancers: DataEnhancerInterface[],
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
@ -245,7 +242,7 @@ export class DataGatheringService {
try { try {
currentData[symbol] = await dataEnhancer.enhance({ currentData[symbol] = await dataEnhancer.enhance({
response, response,
symbol: symbolMapping[dataEnhancer.getName()] ?? symbol symbol: symbolMapping?.[dataEnhancer.getName()] ?? symbol
}); });
} catch (error) { } catch (error) {
Logger.error(`Failed to enhance data for symbol ${symbol}`, error); Logger.error(`Failed to enhance data for symbol ${symbol}`, error);
@ -337,6 +334,7 @@ export class DataGatheringService {
?.marketPrice; ?.marketPrice;
} }
if (lastMarketPrice) {
try { try {
await this.prismaService.marketData.create({ await this.prismaService.marketData.create({
data: { data: {
@ -347,6 +345,14 @@ export class DataGatheringService {
} }
}); });
} catch {} } catch {}
} else {
Logger.warn(
`Failed to gather data for symbol ${symbol} at ${format(
currentDate,
DATE_FORMAT
)}.`
);
}
// Count month one up for iteration // Count month one up for iteration
currentDate = new Date( currentDate = new Date(
@ -448,11 +454,7 @@ export class DataGatheringService {
}; };
}); });
return [ return [...currencyPairsToGather, ...symbolProfilesToGather];
...this.getBenchmarksToGather(startDate),
...currencyPairsToGather,
...symbolProfilesToGather
];
} }
public async reset() { public async reset() {
@ -468,23 +470,27 @@ export class DataGatheringService {
}); });
} }
private getBenchmarksToGather(startDate: Date): IDataGatheringItem[] {
const benchmarksToGather: IDataGatheringItem[] = [];
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
benchmarksToGather.push({
dataSource: DataSource.RAKUTEN,
date: startDate,
symbol: ghostfolioFearAndGreedIndexSymbol
});
}
return benchmarksToGather;
}
private async getSymbols7D(): Promise<IDataGatheringItem[]> { private async getSymbols7D(): Promise<IDataGatheringItem[]> {
const startDate = subDays(resetHours(new Date()), 7); const startDate = subDays(resetHours(new Date()), 7);
// Only consider symbols with incomplete market data for the last
// 7 days
const symbolsToGather = (
await this.prismaService.marketData.groupBy({
_count: true,
by: ['symbol'],
where: {
date: { gt: startDate }
}
})
)
.filter((group) => {
return group._count < 6;
})
.map((group) => {
return group.symbol;
});
const symbolProfilesToGather = ( const symbolProfilesToGather = (
await this.prismaService.symbolProfile.findMany({ await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }], orderBy: [{ symbol: 'asc' }],
@ -494,7 +500,11 @@ export class DataGatheringService {
symbol: true symbol: true
} }
}) })
).map((symbolProfile) => { )
.filter(({ symbol }) => {
return symbolsToGather.includes(symbol);
})
.map((symbolProfile) => {
return { return {
...symbolProfile, ...symbolProfile,
date: startDate date: startDate
@ -503,6 +513,9 @@ export class DataGatheringService {
const currencyPairsToGather = this.exchangeRateDataService const currencyPairsToGather = this.exchangeRateDataService
.getCurrencyPairs() .getCurrencyPairs()
.filter(({ symbol }) => {
return symbolsToGather.includes(symbol);
})
.map(({ dataSource, symbol }) => { .map(({ dataSource, symbol }) => {
return { return {
dataSource, dataSource,
@ -511,30 +524,22 @@ export class DataGatheringService {
}; };
}); });
return [ return [...currencyPairsToGather, ...symbolProfilesToGather];
...this.getBenchmarksToGather(startDate),
...currencyPairsToGather,
...symbolProfilesToGather
];
} }
private async getSymbolsProfileData(): Promise<IDataGatheringItem[]> { private async getSymbolsProfileData(): Promise<IDataGatheringItem[]> {
const startDate = subDays(resetHours(new Date()), 7);
const distinctOrders = await this.prismaService.order.findMany({ const distinctOrders = await this.prismaService.order.findMany({
distinct: ['symbol'], distinct: ['symbol'],
orderBy: [{ symbol: 'asc' }], orderBy: [{ symbol: 'asc' }],
select: { dataSource: true, symbol: true } select: { dataSource: true, symbol: true }
}); });
return [...this.getBenchmarksToGather(startDate), ...distinctOrders].filter( return distinctOrders.filter((distinctOrder) => {
(distinctOrder) => {
return ( return (
distinctOrder.dataSource !== DataSource.GHOSTFOLIO && distinctOrder.dataSource !== DataSource.GHOSTFOLIO &&
distinctOrder.dataSource !== DataSource.RAKUTEN distinctOrder.dataSource !== DataSource.RAKUTEN
); );
} });
);
} }
private async isDataGatheringNeeded() { private async isDataGatheringNeeded() {

View File

@ -88,13 +88,13 @@ export class AlphaVantageService implements DataProviderInterface {
return DataSource.ALPHA_VANTAGE; return DataSource.ALPHA_VANTAGE;
} }
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> { public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const result = await this.alphaVantage.data.search(aSymbol); const result = await this.alphaVantage.data.search(aQuery);
return { return {
items: result?.bestMatches?.map((bestMatch) => { items: result?.bestMatches?.map((bestMatch) => {
return { return {
dataSource: DataSource.ALPHA_VANTAGE, dataSource: this.getName(),
name: bestMatch['2. name'], name: bestMatch['2. name'],
symbol: bestMatch['1. symbol'] symbol: bestMatch['1. symbol']
}; };

View File

@ -7,6 +7,9 @@ const getJSON = bent('json');
export class TrackinsightDataEnhancerService implements DataEnhancerInterface { export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
private static baseUrl = 'https://data.trackinsight.com/holdings'; private static baseUrl = 'https://data.trackinsight.com/holdings';
private static countries = require('countries-list/dist/countries.json'); private static countries = require('countries-list/dist/countries.json');
private static countriesMapping = {
'Russian Federation': 'Russia'
};
private static sectorsMapping = { private static sectorsMapping = {
'Consumer Discretionary': 'Consumer Cyclical', 'Consumer Discretionary': 'Consumer Cyclical',
'Consumer Defensive': 'Consumer Staples', 'Consumer Defensive': 'Consumer Staples',
@ -45,7 +48,11 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
for (const [key, country] of Object.entries<any>( for (const [key, country] of Object.entries<any>(
TrackinsightDataEnhancerService.countries TrackinsightDataEnhancerService.countries
)) { )) {
if (country.name === name) { if (
country.name === name ||
country.name ===
TrackinsightDataEnhancerService.countriesMapping[name]
) {
countryCode = key; countryCode = key;
break; break;
} }

View File

@ -1,6 +1,7 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module'; import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service'; import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service'; import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service'; import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
@ -21,12 +22,14 @@ import { DataProviderService } from './data-provider.service';
AlphaVantageService, AlphaVantageService,
DataProviderService, DataProviderService,
GhostfolioScraperApiService, GhostfolioScraperApiService,
GoogleSheetsService,
RakutenRapidApiService, RakutenRapidApiService,
YahooFinanceService, YahooFinanceService,
{ {
inject: [ inject: [
AlphaVantageService, AlphaVantageService,
GhostfolioScraperApiService, GhostfolioScraperApiService,
GoogleSheetsService,
RakutenRapidApiService, RakutenRapidApiService,
YahooFinanceService YahooFinanceService
], ],
@ -34,11 +37,13 @@ import { DataProviderService } from './data-provider.service';
useFactory: ( useFactory: (
alphaVantageService, alphaVantageService,
ghostfolioScraperApiService, ghostfolioScraperApiService,
googleSheetsService,
rakutenRapidApiService, rakutenRapidApiService,
yahooFinanceService yahooFinanceService
) => [ ) => [
alphaVantageService, alphaVantageService,
ghostfolioScraperApiService, ghostfolioScraperApiService,
googleSheetsService,
rakutenRapidApiService, rakutenRapidApiService,
yahooFinanceService yahooFinanceService
] ]

View File

@ -149,13 +149,13 @@ export class DataProviderService {
return result; return result;
} }
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> { public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const promises: Promise<{ items: LookupItem[] }>[] = []; const promises: Promise<{ items: LookupItem[] }>[] = [];
let lookupItems: LookupItem[] = []; let lookupItems: LookupItem[] = [];
for (const dataSource of this.configurationService.get('DATA_SOURCES')) { for (const dataSource of this.configurationService.get('DATA_SOURCES')) {
promises.push( promises.push(
this.getDataProvider(DataSource[dataSource]).search(aSymbol) this.getDataProvider(DataSource[dataSource]).search(aQuery)
); );
} }
@ -176,7 +176,7 @@ export class DataProviderService {
} }
public getPrimaryDataSource(): DataSource { public getPrimaryDataSource(): DataSource {
return DataSource[this.configurationService.get('DATA_SOURCES')[0]]; return DataSource[this.configurationService.get('DATA_SOURCE_PRIMARY')];
} }
private getDataProvider(providerName: DataSource) { private getDataProvider(providerName: DataSource) {

View File

@ -1,4 +1,10 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse,
MarketState
} from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { import {
@ -13,13 +19,6 @@ import * as bent from 'bent';
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import { format } from 'date-fns'; import { format } from 'date-fns';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse,
MarketState
} from '../../interfaces/interfaces';
import { DataProviderInterface } from '../interfaces/data-provider.interface';
@Injectable() @Injectable()
export class GhostfolioScraperApiService implements DataProviderInterface { export class GhostfolioScraperApiService implements DataProviderInterface {
private static NUMERIC_REGEXP = /[-]{0,1}[\d]*[.,]{0,1}[\d]+/g; private static NUMERIC_REGEXP = /[-]{0,1}[\d]*[.,]{0,1}[\d]+/g;
@ -59,7 +58,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
[symbol]: { [symbol]: {
marketPrice, marketPrice,
currency: symbolProfile?.currency, currency: symbolProfile?.currency,
dataSource: DataSource.GHOSTFOLIO, dataSource: this.getName(),
marketState: MarketState.delayed marketState: MarketState.delayed
} }
}; };
@ -116,8 +115,35 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
return DataSource.GHOSTFOLIO; return DataSource.GHOSTFOLIO;
} }
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> { public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
return { items: [] }; const items = await this.prismaService.symbolProfile.findMany({
select: {
currency: true,
dataSource: true,
name: true,
symbol: true
},
where: {
OR: [
{
dataSource: this.getName(),
name: {
mode: 'insensitive',
startsWith: aQuery
}
},
{
dataSource: this.getName(),
symbol: {
mode: 'insensitive',
startsWith: aQuery
}
}
]
}
});
return { items };
} }
private extractNumberFromString(aString: string): number { private extractNumberFromString(aString: string): number {

View File

@ -0,0 +1,172 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse,
MarketState
} from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { format } from 'date-fns';
import { GoogleSpreadsheet } from 'google-spreadsheet';
@Injectable()
export class GoogleSheetsService implements DataProviderInterface {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService
) {}
public canHandle(symbol: string) {
return true;
}
public async get(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
if (aSymbols.length <= 0) {
return {};
}
try {
const [symbol] = aSymbols;
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
[symbol]
);
const sheet = await this.getSheet({
sheetId: this.configurationService.get('GOOGLE_SHEETS_ID'),
symbol
});
const marketPrice = parseFloat(
(await sheet.getCellByA1('B1').value) as string
);
return {
[symbol]: {
marketPrice,
currency: symbolProfile?.currency,
dataSource: this.getName(),
marketState: MarketState.delayed
}
};
} catch (error) {
Logger.error(error);
}
return {};
}
public async getHistorical(
aSymbols: string[],
aGranularity: Granularity = 'day',
from: Date,
to: Date
): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> {
if (aSymbols.length <= 0) {
return {};
}
try {
const [symbol] = aSymbols;
const sheet = await this.getSheet({
symbol,
sheetId: this.configurationService.get('GOOGLE_SHEETS_ID')
});
const rows = await sheet.getRows();
const historicalData: {
[date: string]: IDataProviderHistoricalResponse;
} = {};
rows
.filter((row, index) => {
return index >= 1;
})
.forEach((row) => {
const date = new Date(row._rawData[0]);
const close = parseFloat(row._rawData[1]);
historicalData[format(date, DATE_FORMAT)] = { marketPrice: close };
});
return {
[symbol]: historicalData
};
} catch (error) {
Logger.error(error);
}
return {};
}
public getName(): DataSource {
return DataSource.GOOGLE_SHEETS;
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const items = await this.prismaService.symbolProfile.findMany({
select: {
currency: true,
dataSource: true,
name: true,
symbol: true
},
where: {
OR: [
{
dataSource: this.getName(),
name: {
mode: 'insensitive',
startsWith: aQuery
}
},
{
dataSource: this.getName(),
symbol: {
mode: 'insensitive',
startsWith: aQuery
}
}
]
}
});
return { items };
}
private async getSheet({
sheetId,
symbol
}: {
sheetId: string;
symbol: string;
}) {
const doc = new GoogleSpreadsheet(sheetId);
await doc.useServiceAccountAuth({
client_email: this.configurationService.get('GOOGLE_SHEETS_ACCOUNT'),
private_key: this.configurationService
.get('GOOGLE_SHEETS_PRIVATE_KEY')
.replace(/\\n/g, '\n')
});
await doc.loadInfo();
const sheet = doc.sheetsByTitle[symbol];
await sheet.loadCells();
return sheet;
}
}

View File

@ -1,11 +1,10 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { Granularity } from '@ghostfolio/common/types';
import { DataSource } from '@prisma/client';
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from '../../interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { Granularity } from '@ghostfolio/common/types';
import { DataSource } from '@prisma/client';
export interface DataProviderInterface { export interface DataProviderInterface {
canHandle(symbol: string): boolean; canHandle(symbol: string): boolean;
@ -23,5 +22,5 @@ export interface DataProviderInterface {
getName(): DataSource; getName(): DataSource;
search(aSymbol: string): Promise<{ items: LookupItem[] }>; search(aQuery: string): Promise<{ items: LookupItem[] }>;
} }

View File

@ -45,7 +45,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
return { return {
[ghostfolioFearAndGreedIndexSymbol]: { [ghostfolioFearAndGreedIndexSymbol]: {
currency: undefined, currency: undefined,
dataSource: DataSource.RAKUTEN, dataSource: this.getName(),
marketPrice: fgi.now.value, marketPrice: fgi.now.value,
marketState: MarketState.open, marketState: MarketState.open,
name: RakutenRapidApiService.FEAR_AND_GREED_INDEX_NAME name: RakutenRapidApiService.FEAR_AND_GREED_INDEX_NAME
@ -85,7 +85,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
await this.prismaService.marketData.create({ await this.prismaService.marketData.create({
data: { data: {
symbol, symbol,
dataSource: DataSource.RAKUTEN, dataSource: this.getName(),
date: subWeeks(getToday(), 1), date: subWeeks(getToday(), 1),
marketPrice: fgi.oneWeekAgo.value marketPrice: fgi.oneWeekAgo.value
} }
@ -94,7 +94,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
await this.prismaService.marketData.create({ await this.prismaService.marketData.create({
data: { data: {
symbol, symbol,
dataSource: DataSource.RAKUTEN, dataSource: this.getName(),
date: subMonths(getToday(), 1), date: subMonths(getToday(), 1),
marketPrice: fgi.oneMonthAgo.value marketPrice: fgi.oneMonthAgo.value
} }
@ -103,7 +103,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
await this.prismaService.marketData.create({ await this.prismaService.marketData.create({
data: { data: {
symbol, symbol,
dataSource: DataSource.RAKUTEN, dataSource: this.getName(),
date: subYears(getToday(), 1), date: subYears(getToday(), 1),
marketPrice: fgi.oneYearAgo.value marketPrice: fgi.oneYearAgo.value
} }
@ -129,7 +129,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
return DataSource.RAKUTEN; return DataSource.RAKUTEN;
} }
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> { public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
return { items: [] }; return { items: [] };
} }

View File

@ -103,7 +103,7 @@ export class YahooFinanceService implements DataProviderInterface {
assetClass, assetClass,
assetSubClass, assetSubClass,
currency: value.price?.currency, currency: value.price?.currency,
dataSource: DataSource.YAHOO, dataSource: this.getName(),
exchange: this.parseExchange(value.price?.exchangeName), exchange: this.parseExchange(value.price?.exchangeName),
marketState: marketState:
value.price?.marketState === 'REGULAR' || value.price?.marketState === 'REGULAR' ||
@ -221,12 +221,14 @@ export class YahooFinanceService implements DataProviderInterface {
return DataSource.YAHOO; return DataSource.YAHOO;
} }
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> { public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const items: LookupItem[] = []; const items: LookupItem[] = [];
try { try {
const get = bent( const get = bent(
`${this.yahooFinanceHostname}/v1/finance/search?q=${aSymbol}&lang=en-US&region=US&quotesCount=8&newsCount=0&enableFuzzyQuery=false&quotesQueryId=tss_match_phrase_query&multiQuoteQueryId=multi_quote_single_token_query&newsQueryId=news_cie_vespa&enableCb=true&enableNavLinks=false&enableEnhancedTrivialQuery=true`, `${this.yahooFinanceHostname}/v1/finance/search?q=${encodeURIComponent(
aQuery
)}&lang=en-US&region=US&quotesCount=8&newsCount=0&enableFuzzyQuery=false&quotesQueryId=tss_match_phrase_query&multiQuoteQueryId=multi_quote_single_token_query&newsQueryId=news_cie_vespa&enableCb=true&enableNavLinks=false&enableEnhancedTrivialQuery=true`,
'GET', 'GET',
'json', 'json',
200 200
@ -268,7 +270,7 @@ export class YahooFinanceService implements DataProviderInterface {
items.push({ items.push({
symbol, symbol,
currency: value.currency, currency: value.currency,
dataSource: DataSource.YAHOO, dataSource: this.getName(),
name: value.name name: value.name
}); });
} }

View File

@ -157,7 +157,12 @@ export class ExchangeRateDataService {
await this.prismaService.account.findMany({ await this.prismaService.account.findMany({
distinct: ['currency'], distinct: ['currency'],
orderBy: [{ currency: 'asc' }], orderBy: [{ currency: 'asc' }],
select: { currency: true } select: { currency: true },
where: {
currency: {
not: null
}
}
}) })
).forEach((account) => { ).forEach((account) => {
currencies.push(account.currency); currencies.push(account.currency);
@ -167,7 +172,12 @@ export class ExchangeRateDataService {
await this.prismaService.settings.findMany({ await this.prismaService.settings.findMany({
distinct: ['currency'], distinct: ['currency'],
orderBy: [{ currency: 'asc' }], orderBy: [{ currency: 'asc' }],
select: { currency: true } select: { currency: true },
where: {
currency: {
not: null
}
}
}) })
).forEach((userSettings) => { ).forEach((userSettings) => {
currencies.push(userSettings.currency); currencies.push(userSettings.currency);
@ -177,7 +187,12 @@ export class ExchangeRateDataService {
await this.prismaService.symbolProfile.findMany({ await this.prismaService.symbolProfile.findMany({
distinct: ['currency'], distinct: ['currency'],
orderBy: [{ currency: 'asc' }], orderBy: [{ currency: 'asc' }],
select: { currency: true } select: { currency: true },
where: {
currency: {
not: null
}
}
}) })
).forEach((symbolProfile) => { ).forEach((symbolProfile) => {
currencies.push(symbolProfile.currency); currencies.push(symbolProfile.currency);

View File

@ -4,6 +4,7 @@ export interface Environment extends CleanedEnvAccessors {
ACCESS_TOKEN_SALT: string; ACCESS_TOKEN_SALT: string;
ALPHA_VANTAGE_API_KEY: string; ALPHA_VANTAGE_API_KEY: string;
CACHE_TTL: number; CACHE_TTL: number;
DATA_SOURCE_PRIMARY: string;
DATA_SOURCES: string | string[]; // string is not correct, error in envalid? DATA_SOURCES: string | string[]; // string is not correct, error in envalid?
ENABLE_FEATURE_BLOG: boolean; ENABLE_FEATURE_BLOG: boolean;
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean; ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
@ -16,6 +17,9 @@ export interface Environment extends CleanedEnvAccessors {
ENABLE_FEATURE_SYSTEM_MESSAGE: boolean; ENABLE_FEATURE_SYSTEM_MESSAGE: boolean;
GOOGLE_CLIENT_ID: string; GOOGLE_CLIENT_ID: string;
GOOGLE_SECRET: string; GOOGLE_SECRET: string;
GOOGLE_SHEETS_ACCOUNT: string;
GOOGLE_SHEETS_ID: string;
GOOGLE_SHEETS_PRIVATE_KEY: string;
JWT_SECRET_KEY: string; JWT_SECRET_KEY: string;
MAX_ITEM_IN_CACHE: number; MAX_ITEM_IN_CACHE: number;
MAX_ORDERS_TO_IMPORT: number; MAX_ORDERS_TO_IMPORT: number;

View File

@ -1,8 +1,9 @@
import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto';
import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.interface'; import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.interface';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { resetHours } from '@ghostfolio/common/helper'; import { resetHours } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { MarketData, Prisma } from '@prisma/client'; import { DataSource, MarketData, Prisma } from '@prisma/client';
@Injectable() @Injectable()
export class MarketDataService { export class MarketDataService {
@ -67,14 +68,20 @@ export class MarketDataService {
} }
public async updateMarketData(params: { public async updateMarketData(params: {
data: Prisma.MarketDataUpdateInput; data: { dataSource: DataSource } & UpdateMarketDataDto;
where: Prisma.MarketDataWhereUniqueInput; where: Prisma.MarketDataWhereUniqueInput;
}): Promise<MarketData> { }): Promise<MarketData> {
const { data, where } = params; const { data, where } = params;
return this.prismaService.marketData.update({ return this.prismaService.marketData.upsert({
data, where,
where create: {
dataSource: data.dataSource,
date: where.date_symbol.date,
marketPrice: data.marketPrice,
symbol: where.date_symbol.symbol
},
update: { marketPrice: data.marketPrice }
}); });
} }
} }

View File

@ -9,6 +9,13 @@ const routes: Routes = [
loadChildren: () => loadChildren: () =>
import('./pages/about/about-page.module').then((m) => m.AboutPageModule) import('./pages/about/about-page.module').then((m) => m.AboutPageModule)
}, },
{
path: 'about/changelog',
loadChildren: () =>
import('./pages/about/changelog/changelog-page.module').then(
(m) => m.ChangelogPageModule
)
},
{ {
path: 'account', path: 'account',
loadChildren: () => loadChildren: () =>
@ -33,6 +40,11 @@ const routes: Routes = [
loadChildren: () => loadChildren: () =>
import('./pages/auth/auth-page.module').then((m) => m.AuthPageModule) import('./pages/auth/auth-page.module').then((m) => m.AuthPageModule)
}, },
{
path: 'blog',
loadChildren: () =>
import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule)
},
{ {
path: 'de/blog/2021/07/hallo-ghostfolio', path: 'de/blog/2021/07/hallo-ghostfolio',
loadChildren: () => loadChildren: () =>
@ -47,6 +59,13 @@ const routes: Routes = [
'./pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.module' './pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.module'
).then((m) => m.HelloGhostfolioPageModule) ).then((m) => m.HelloGhostfolioPageModule)
}, },
{
path: 'en/blog/2022/01/ghostfolio-first-months-in-open-source',
loadChildren: () =>
import(
'./pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.module'
).then((m) => m.FirstMonthsInOpenSourcePageModule)
},
{ {
path: 'home', path: 'home',
loadChildren: () => loadChildren: () =>
@ -66,6 +85,13 @@ const routes: Routes = [
(m) => m.PortfolioPageModule (m) => m.PortfolioPageModule
) )
}, },
{
path: 'portfolio/activities',
loadChildren: () =>
import('./pages/portfolio/transactions/transactions-page.module').then(
(m) => m.TransactionsPageModule
)
},
{ {
path: 'portfolio/allocations', path: 'portfolio/allocations',
loadChildren: () => loadChildren: () =>
@ -87,13 +113,6 @@ const routes: Routes = [
(m) => m.ReportPageModule (m) => m.ReportPageModule
) )
}, },
{
path: 'portfolio/transactions',
loadChildren: () =>
import('./pages/portfolio/transactions/transactions-page.module').then(
(m) => m.TransactionsPageModule
)
},
{ {
path: 'pricing', path: 'pricing',
loadChildren: () => loadChildren: () =>

View File

@ -5,10 +5,10 @@ import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatTableModule } from '@angular/material/table'; import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module';
import { GfValueModule } from '@ghostfolio/ui/value'; import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfSymbolIconModule } from '../symbol-icon/symbol-icon.module';
import { AccountsTableComponent } from './accounts-table.component'; import { AccountsTableComponent } from './accounts-table.component';
@NgModule({ @NgModule({

View File

@ -43,6 +43,19 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
this.fetchAdminMarketData(); this.fetchAdminMarketData();
} }
public onGatherProfileDataBySymbol({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
this.adminService
.gatherProfileDataBySymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {});
}
public onGatherSymbol({ public onGatherSymbol({
dataSource, dataSource,
symbol symbol

View File

@ -38,6 +38,13 @@
> >
Gather Data Gather Data
</button> </button>
<button
i18n
mat-menu-item
(click)="onGatherProfileDataBySymbol({dataSource: item.dataSource, symbol: item.symbol})"
>
Gather Profile Data
</button>
</mat-menu> </mat-menu>
</td> </td>
</tr> </tr>

View File

@ -1,5 +1,6 @@
<button <button
*ngIf="deviceType === 'mobile'" *ngIf="deviceType === 'mobile'"
class="mt-2"
mat-button mat-button
(click)="onClickCloseButton()" (click)="onClickCloseButton()"
> >

View File

@ -1,4 +1,7 @@
:host { :host {
display: flex; display: flex;
flex: 0 0 auto;
margin-bottom: 0;
min-height: 0; min-height: 0;
padding: 0;
} }

View File

@ -1,10 +1,14 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { import {
RANGE, RANGE,
SettingsStorageService SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service'; } from '@ghostfolio/client/services/settings-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { defaultDateRangeOptions } from '@ghostfolio/common/config';
import { Position, User } from '@ghostfolio/common/interfaces'; import { Position, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types'; import { DateRange } from '@ghostfolio/common/types';
@ -19,6 +23,7 @@ import { takeUntil } from 'rxjs/operators';
}) })
export class HomeHoldingsComponent implements OnDestroy, OnInit { export class HomeHoldingsComponent implements OnDestroy, OnInit {
public dateRange: DateRange; public dateRange: DateRange;
public dateRangeOptions = defaultDateRangeOptions;
public deviceType: string; public deviceType: string;
public hasPermissionToCreateOrder: boolean; public hasPermissionToCreateOrder: boolean;
public positions: Position[]; public positions: Position[];
@ -33,9 +38,20 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private route: ActivatedRoute,
private router: Router,
private settingsStorageService: SettingsStorageService, private settingsStorageService: SettingsStorageService,
private userService: UserService private userService: UserService
) { ) {
route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (params['positionDetailDialog'] && params['symbol']) {
this.openPositionDialog({ symbol: params['symbol'] });
}
});
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => { .subscribe((state) => {
@ -64,12 +80,48 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
this.update(); this.update();
} }
public onChangeDateRange(aDateRange: DateRange) {
this.dateRange = aDateRange;
this.settingsStorageService.setSetting(RANGE, this.dateRange);
this.update();
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private openPositionDialog({ symbol }: { symbol: string }) {
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false,
data: {
symbol,
baseCurrency: this.user?.settings?.baseCurrency,
deviceType: this.deviceType,
locale: this.user?.settings?.locale
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
});
}
private update() { private update() {
this.positions = undefined;
this.dataService this.dataService
.fetchPositions({ range: this.dateRange }) .fetchPositions({ range: this.dateRange })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))

View File

@ -1,4 +1,12 @@
<div class="container justify-content-center pb-3 px-3"> <div class="container justify-content-center p-3">
<div class="mb-3 text-center">
<gf-toggle
[defaultValue]="dateRange"
[isLoading]="positions === undefined"
[options]="dateRangeOptions"
(change)="onChangeDateRange($event.value)"
></gf-toggle>
</div>
<div class="row"> <div class="row">
<div class="align-items-center col-xs-12 col-md-8 offset-md-2"> <div class="align-items-center col-xs-12 col-md-8 offset-md-2">
<mat-card class="p-0"> <mat-card class="p-0">
@ -6,6 +14,7 @@
<gf-positions <gf-positions
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType" [deviceType]="deviceType"
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[positions]="positions" [positions]="positions"
[range]="dateRange" [range]="dateRange"
@ -17,8 +26,8 @@
class="mt-3" class="mt-3"
i18n i18n
mat-button mat-button
[routerLink]="['/portfolio', 'transactions']" [routerLink]="['/portfolio', 'activities']"
>Manage Transactions...</a >Manage Activities...</a
> >
</div> </div>
</div> </div>

View File

@ -3,7 +3,9 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { GfPositionDetailDialogModule } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.module';
import { GfPositionsModule } from '@ghostfolio/client/components/positions/positions.module'; import { GfPositionsModule } from '@ghostfolio/client/components/positions/positions.module';
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
import { HomeHoldingsComponent } from './home-holdings.component'; import { HomeHoldingsComponent } from './home-holdings.component';
@ -12,7 +14,9 @@ import { HomeHoldingsComponent } from './home-holdings.component';
exports: [], exports: [],
imports: [ imports: [
CommonModule, CommonModule,
GfPositionDetailDialogModule,
GfPositionsModule, GfPositionsModule,
GfToggleModule,
MatButtonModule, MatButtonModule,
MatCardModule, MatCardModule,
RouterModule RouterModule

View File

@ -12,7 +12,7 @@
<div class="no-gutters row w-100"> <div class="no-gutters row w-100">
<div class="col-xs-12 col-md-8 offset-md-2"> <div class="col-xs-12 col-md-8 offset-md-2">
<div class="mb-2 text-center text-muted"> <div class="mb-2 text-center text-muted">
<small i18n>Last 10 Days</small> <small i18n>Last 30 Days</small>
</div> </div>
<gf-line-chart <gf-line-chart
class="mb-5" class="mb-5"

View File

@ -1,5 +1,4 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { ToggleOption } from '@ghostfolio/client/components/toggle/interfaces/toggle-option.type';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { import {
@ -7,7 +6,9 @@ import {
SettingsStorageService SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service'; } from '@ghostfolio/client/services/settings-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { defaultDateRangeOptions } from '@ghostfolio/common/config';
import { PortfolioPerformance, User } from '@ghostfolio/common/interfaces'; import { PortfolioPerformance, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types'; import { DateRange } from '@ghostfolio/common/types';
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface'; import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
@ -21,15 +22,11 @@ import { takeUntil } from 'rxjs/operators';
}) })
export class HomeOverviewComponent implements OnDestroy, OnInit { export class HomeOverviewComponent implements OnDestroy, OnInit {
public dateRange: DateRange; public dateRange: DateRange;
public dateRangeOptions: ToggleOption[] = [ public dateRangeOptions = defaultDateRangeOptions;
{ label: 'Today', value: '1d' },
{ label: 'YTD', value: 'ytd' },
{ label: '1Y', value: '1y' },
{ label: '5Y', value: '5y' },
{ label: 'Max', value: 'max' }
];
public deviceType: string; public deviceType: string;
public hasError: boolean;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public hasPermissionToCreateOrder: boolean;
public historicalDataItems: LineChartItem[]; public historicalDataItems: LineChartItem[];
public isAllTimeHigh: boolean; public isAllTimeHigh: boolean;
public isAllTimeLow: boolean; public isAllTimeLow: boolean;
@ -56,6 +53,11 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
this.hasPermissionToCreateOrder = hasPermission(
this.user.permissions,
permissions.createOrder
);
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
}); });
@ -116,7 +118,8 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
.fetchPortfolioPerformance({ range: this.dateRange }) .fetchPortfolioPerformance({ range: this.dateRange })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => { .subscribe((response) => {
this.performance = response; this.hasError = response.hasErrors;
this.performance = response.performance;
this.isLoadingPerformance = false; this.isLoadingPerformance = false;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();

View File

@ -1,15 +1,5 @@
<div <div
class=" class="align-items-center container d-flex flex-column h-100 justify-content-center overview p-0 position-relative"
align-items-center
container
d-flex
flex-column
h-100
justify-content-center
overview
p-0
position-relative
"
> >
<div class="row w-100"> <div class="row w-100">
<div class="chart-container col"> <div class="chart-container col">
@ -23,7 +13,7 @@
[showYAxis]="false" [showYAxis]="false"
></gf-line-chart> ></gf-line-chart>
<div <div
*ngIf="historicalDataItems?.length === 0" *ngIf="hasPermissionToCreateOrder&& historicalDataItems?.length === 0"
class="align-items-center d-flex h-100 justify-content-center w-100" class="align-items-center d-flex h-100 justify-content-center w-100"
> >
<div class="d-flex justify-content-center"> <div class="d-flex justify-content-center">
@ -37,6 +27,8 @@
<gf-portfolio-performance <gf-portfolio-performance
class="pb-4" class="pb-4"
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[hasError]="hasError"
[isAllTimeHigh]="isAllTimeHigh" [isAllTimeHigh]="isAllTimeHigh"
[isAllTimeLow]="isAllTimeLow" [isAllTimeLow]="isAllTimeLow"
[isLoading]="isLoadingPerformance" [isLoading]="isLoadingPerformance"

View File

@ -10,6 +10,7 @@ import {
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { primaryColorRgb } from '@ghostfolio/common/config'; import { primaryColorRgb } from '@ghostfolio/common/config';
import { parseDate } from '@ghostfolio/common/helper';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface'; import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { import {
Chart, Chart,
@ -19,7 +20,7 @@ import {
PointElement, PointElement,
TimeScale TimeScale
} from 'chart.js'; } from 'chart.js';
import { addMonths, isAfter, parseISO, subMonths } from 'date-fns'; import { addDays, isAfter, parseISO, subDays } from 'date-fns';
@Component({ @Component({
selector: 'gf-investment-chart', selector: 'gf-investment-chart',
@ -27,8 +28,10 @@ import { addMonths, isAfter, parseISO, subMonths } from 'date-fns';
templateUrl: './investment-chart.component.html', templateUrl: './investment-chart.component.html',
styleUrls: ['./investment-chart.component.scss'] styleUrls: ['./investment-chart.component.scss']
}) })
export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit { export class InvestmentChartComponent implements OnChanges, OnDestroy {
@Input() daysInMarket: number;
@Input() investments: InvestmentItem[]; @Input() investments: InvestmentItem[];
@Input() isInPercent = false;
@ViewChild('chartCanvas') chartCanvas; @ViewChild('chartCanvas') chartCanvas;
@ -45,8 +48,6 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
); );
} }
public ngOnInit() {}
public ngOnChanges() { public ngOnChanges() {
if (this.investments) { if (this.investments) {
this.initialize(); this.initialize();
@ -61,19 +62,25 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
this.isLoading = true; this.isLoading = true;
if (this.investments?.length > 0) { if (this.investments?.length > 0) {
// Extend chart by three months (before) // Extend chart by 5% of days in market (before)
const firstItem = this.investments[0]; const firstItem = this.investments[0];
this.investments.unshift({ this.investments.unshift({
...firstItem, ...firstItem,
date: subMonths(parseISO(firstItem.date), 3).toISOString(), date: subDays(
parseISO(firstItem.date),
this.daysInMarket * 0.05 || 90
).toISOString(),
investment: 0 investment: 0
}); });
// Extend chart by three months (after) // Extend chart by 5% of days in market (after)
const lastItem = this.investments[this.investments.length - 1]; const lastItem = this.investments[this.investments.length - 1];
this.investments.push({ this.investments.push({
...lastItem, ...lastItem,
date: addMonths(new Date(), 3).toISOString() date: addDays(
parseDate(lastItem.date),
this.daysInMarket * 0.05 || 90
).toISOString()
}); });
} }
@ -136,12 +143,26 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
} }
}, },
y: { y: {
display: false, display: !this.isInPercent,
grid: { grid: {
display: false display: false
}, },
ticks: { ticks: {
display: false display: true,
callback: (tickValue, index, ticks) => {
if (index === 0 || index === ticks.length - 1) {
// Only print last and first legend entry
if (typeof tickValue === 'number') {
return tickValue.toFixed(2);
}
return tickValue;
}
return '';
},
mirror: true,
z: 1
} }
} }
} }

View File

@ -7,7 +7,6 @@ import { InvestmentChartComponent } from './investment-chart.component';
@NgModule({ @NgModule({
declarations: [InvestmentChartComponent], declarations: [InvestmentChartComponent],
exports: [InvestmentChartComponent], exports: [InvestmentChartComponent],
imports: [CommonModule, NgxSkeletonLoaderModule], imports: [CommonModule, NgxSkeletonLoaderModule]
providers: []
}) })
export class GfInvestmentChartModule {} export class GfInvestmentChartModule {}

View File

@ -1,12 +1,18 @@
<div class="container p-0"> <div class="container p-0">
<div class="no-gutters row">
<div <div
class="no-gutters row" class="flex-grow-1 status text-muted text-right"
[ngClass]="{ [title]="
'text-danger': isAllTimeLow, hasError && !isLoading
'text-success': isAllTimeHigh ? 'Sorry! Our data provider partner is experiencing the hiccups.'
}" : ''
"
> >
<div class="flex-grow-1"></div> <ion-icon
*ngIf="hasError && !isLoading"
name="alert-circle-outline"
></ion-icon>
</div>
<div *ngIf="isLoading" class="align-items-center d-flex"> <div *ngIf="isLoading" class="align-items-center d-flex">
<ngx-skeleton-loader <ngx-skeleton-loader
animation="pulse" animation="pulse"
@ -20,6 +26,10 @@
<div <div
class="display-4 font-weight-bold m-0 text-center value-container" class="display-4 font-weight-bold m-0 text-center value-container"
[hidden]="isLoading" [hidden]="isLoading"
[ngClass]="{
'text-danger': isAllTimeLow,
'text-success': isAllTimeHigh
}"
> >
<span #value id="value"></span> <span #value id="value"></span>
</div> </div>

View File

@ -1,6 +1,10 @@
:host { :host {
display: block; display: block;
.status {
font-size: 1.33rem;
}
.value-container { .value-container {
#value { #value {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;

View File

@ -19,6 +19,8 @@ import { isNumber } from 'lodash';
}) })
export class PortfolioPerformanceComponent implements OnChanges, OnInit { export class PortfolioPerformanceComponent implements OnChanges, OnInit {
@Input() baseCurrency: string; @Input() baseCurrency: string;
@Input() deviceType: string;
@Input() hasError: boolean;
@Input() isAllTimeHigh: boolean; @Input() isAllTimeHigh: boolean;
@Input() isAllTimeLow: boolean; @Input() isAllTimeLow: boolean;
@Input() isLoading: boolean; @Input() isLoading: boolean;
@ -44,7 +46,11 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
this.unit = this.baseCurrency; this.unit = this.baseCurrency;
new CountUp('value', this.performance?.currentValue, { new CountUp('value', this.performance?.currentValue, {
decimalPlaces: 2, decimalPlaces:
this.deviceType === 'mobile' &&
this.performance?.currentValue >= 100000
? 0
: 2,
duration: 1, duration: 1,
separator: `'` separator: `'`
}).start(); }).start();

View File

@ -169,4 +169,18 @@
></gf-value> ></gf-value>
</div> </div>
</div> </div>
<div class="row">
<div class="col"><hr /></div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Dividend</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : summary?.dividend"
></gf-value>
</div>
</div>
</div> </div>

View File

@ -3,11 +3,13 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
Inject, Inject,
OnDestroy OnDestroy,
OnInit
} from '@angular/core'; } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface'; import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
import { AssetSubClass } from '@prisma/client'; import { AssetSubClass } from '@prisma/client';
import { format, isSameMonth, isToday, parseISO } from 'date-fns'; import { format, isSameMonth, isToday, parseISO } from 'date-fns';
@ -23,7 +25,7 @@ import { PositionDetailDialogParams } from './interfaces/interfaces';
templateUrl: 'position-detail-dialog.html', templateUrl: 'position-detail-dialog.html',
styleUrls: ['./position-detail-dialog.component.scss'] styleUrls: ['./position-detail-dialog.component.scss']
}) })
export class PositionDetailDialog implements OnDestroy { export class PositionDetailDialog implements OnDestroy, OnInit {
public assetSubClass: AssetSubClass; public assetSubClass: AssetSubClass;
public averagePrice: number; public averagePrice: number;
public benchmarkDataItems: LineChartItem[]; public benchmarkDataItems: LineChartItem[];
@ -39,6 +41,7 @@ export class PositionDetailDialog implements OnDestroy {
public name: string; public name: string;
public netPerformance: number; public netPerformance: number;
public netPerformancePercent: number; public netPerformancePercent: number;
public orders: OrderWithAccount[];
public quantity: number; public quantity: number;
public quantityPrecision = 2; public quantityPrecision = 2;
public symbol: string; public symbol: string;
@ -52,9 +55,11 @@ export class PositionDetailDialog implements OnDestroy {
private dataService: DataService, private dataService: DataService,
public dialogRef: MatDialogRef<PositionDetailDialog>, public dialogRef: MatDialogRef<PositionDetailDialog>,
@Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams @Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams
) { ) {}
public ngOnInit(): void {
this.dataService this.dataService
.fetchPositionDetail(data.symbol) .fetchPositionDetail(this.data.symbol)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe( .subscribe(
({ ({
@ -72,6 +77,7 @@ export class PositionDetailDialog implements OnDestroy {
name, name,
netPerformance, netPerformance,
netPerformancePercent, netPerformancePercent,
orders,
quantity, quantity,
symbol, symbol,
transactionCount, transactionCount,
@ -104,6 +110,7 @@ export class PositionDetailDialog implements OnDestroy {
this.name = name; this.name = name;
this.netPerformance = netPerformance; this.netPerformance = netPerformance;
this.netPerformancePercent = netPerformancePercent; this.netPerformancePercent = netPerformancePercent;
this.orders = orders;
this.quantity = quantity; this.quantity = quantity;
this.symbol = symbol; this.symbol = symbol;
this.transactionCount = transactionCount; this.transactionCount = transactionCount;

View File

@ -12,7 +12,7 @@
<div class="col-12 d-flex justify-content-center mb-3"> <div class="col-12 d-flex justify-content-center mb-3">
<gf-value <gf-value
size="large" size="large"
[currency]="data.baseCurrency" [currency]="currency"
[locale]="data.locale" [locale]="data.locale"
[value]="value" [value]="value"
></gf-value> ></gf-value>
@ -124,6 +124,20 @@
</div> </div>
</div> </div>
</div> </div>
<gf-activities-table
*ngIf="orders?.length > 0"
[activities]="orders"
[baseCurrency]="data.baseCurrency"
[deviceType]="data.deviceType"
[hasPermissionToCreateActivity]="false"
[hasPermissionToFilter]="false"
[hasPermissionToImportActivities]="false"
[hasPermissionToOpenDetails]="false"
[locale]="data.locale"
[showActions]="false"
[showSymbolColumn]="false"
></gf-activities-table>
</div> </div>
<gf-dialog-footer <gf-dialog-footer

View File

@ -2,12 +2,13 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module'; import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
import { GfValueModule } from '@ghostfolio/ui/value'; import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfDialogFooterModule } from '../../dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '../../dialog-header/dialog-header.module';
import { PositionDetailDialog } from './position-detail-dialog.component'; import { PositionDetailDialog } from './position-detail-dialog.component';
@NgModule({ @NgModule({
@ -15,6 +16,7 @@ import { PositionDetailDialog } from './position-detail-dialog.component';
exports: [], exports: [],
imports: [ imports: [
CommonModule, CommonModule,
GfActivitiesTableModule,
GfDialogFooterModule, GfDialogFooterModule,
GfDialogHeaderModule, GfDialogHeaderModule,
GfLineChartModule, GfLineChartModule,

View File

@ -5,14 +5,9 @@ import {
OnDestroy, OnDestroy,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { Position } from '@ghostfolio/common/interfaces'; import { Position } from '@ghostfolio/common/interfaces';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { PositionDetailDialog } from './position-detail-dialog/position-detail-dialog.component';
@Component({ @Component({
selector: 'gf-position', selector: 'gf-position',
@ -32,23 +27,7 @@ export class PositionComponent implements OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor() {}
private dialog: MatDialog,
private route: ActivatedRoute,
private router: Router
) {
route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (
params['positionDetailDialog'] &&
params['symbol'] &&
params['symbol'] === this.position?.symbol
) {
this.openDialog();
}
});
}
public ngOnInit() {} public ngOnInit() {}
@ -56,25 +35,4 @@ export class PositionComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private openDialog(): void {
const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false,
data: {
baseCurrency: this.baseCurrency,
deviceType: this.deviceType,
locale: this.locale,
symbol: this.position?.symbol
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
}
} }

View File

@ -123,7 +123,12 @@
}" }"
></ngx-skeleton-loader> ></ngx-skeleton-loader>
<div *ngIf="dataSource.data.length === 0 && !isLoading" class="p-3 text-center"> <div
*ngIf="
dataSource.data.length === 0 && hasPermissionToCreateOrder && !isLoading
"
class="p-3 text-center"
>
<gf-no-transactions-info-indicator <gf-no-transactions-info-indicator
[hasBorder]="false" [hasBorder]="false"
></gf-no-transactions-info-indicator> ></gf-no-transactions-info-indicator>

View File

@ -9,17 +9,13 @@ import {
Output, Output,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatPaginator } from '@angular/material/paginator'; import { MatPaginator } from '@angular/material/paginator';
import { MatSort } from '@angular/material/sort'; import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router'; import { Router } from '@angular/router';
import { PortfolioPosition } from '@ghostfolio/common/interfaces'; import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { AssetClass, Order as OrderModel } from '@prisma/client'; import { AssetClass, Order as OrderModel } from '@prisma/client';
import { Subject, Subscription } from 'rxjs'; import { Subject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { PositionDetailDialog } from '../position/position-detail-dialog/position-detail-dialog.component';
@Component({ @Component({
selector: 'gf-positions-table', selector: 'gf-positions-table',
@ -30,6 +26,7 @@ import { PositionDetailDialog } from '../position/position-detail-dialog/positio
export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit { export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
@Input() baseCurrency: string; @Input() baseCurrency: string;
@Input() deviceType: string; @Input() deviceType: string;
@Input() hasPermissionToCreateOrder: boolean;
@Input() locale: string; @Input() locale: string;
@Input() positions: PortfolioPosition[]; @Input() positions: PortfolioPosition[];
@ -49,21 +46,7 @@ export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(private router: Router) {}
private dialog: MatDialog,
private route: ActivatedRoute,
private router: Router
) {
this.routeQueryParams = route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (params['positionDetailDialog'] && params['symbol']) {
this.openPositionDialog({
symbol: params['symbol']
});
}
});
}
public ngOnInit() {} public ngOnInit() {}
@ -106,27 +89,6 @@ export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
}); });
} }
public openPositionDialog({ symbol }: { symbol: string }): void {
const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false,
data: {
symbol,
baseCurrency: this.baseCurrency,
deviceType: this.deviceType,
locale: this.locale
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();

View File

@ -7,12 +7,12 @@ import { MatPaginatorModule } from '@angular/material/paginator';
import { MatSortModule } from '@angular/material/sort'; import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table'; import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { GfPositionDetailDialogModule } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.module';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module'; import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { GfNoTransactionsInfoModule } from '@ghostfolio/ui/no-transactions-info'; import { GfNoTransactionsInfoModule } from '@ghostfolio/ui/no-transactions-info';
import { GfValueModule } from '@ghostfolio/ui/value'; import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfPositionDetailDialogModule } from '../position/position-detail-dialog/position-detail-dialog.module';
import { GfSymbolIconModule } from '../symbol-icon/symbol-icon.module'; import { GfSymbolIconModule } from '../symbol-icon/symbol-icon.module';
import { PositionsTableComponent } from './positions-table.component'; import { PositionsTableComponent } from './positions-table.component';

View File

@ -23,7 +23,10 @@
[range]="range" [range]="range"
></gf-position> ></gf-position>
</ng-container> </ng-container>
<div *ngIf="!hasPositions" class="p-3 text-center"> <div
*ngIf="hasPermissionToCreateOrder && !hasPositions"
class="p-3 text-center"
>
<gf-no-transactions-info-indicator <gf-no-transactions-info-indicator
[hasBorder]="false" [hasBorder]="false"
></gf-no-transactions-info-indicator> ></gf-no-transactions-info-indicator>

View File

@ -17,6 +17,7 @@ import { Position } from '@ghostfolio/common/interfaces';
export class PositionsComponent implements OnChanges, OnInit { export class PositionsComponent implements OnChanges, OnInit {
@Input() baseCurrency: string; @Input() baseCurrency: string;
@Input() deviceType: string; @Input() deviceType: string;
@Input() hasPermissionToCreateOrder: boolean;
@Input() locale: string; @Input() locale: string;
@Input() positions: Position[]; @Input() positions: Position[];
@Input() range: string; @Input() range: string;

View File

@ -1,7 +1,10 @@
<div class="container p-0"> <div class="container p-0">
<div class="row no-gutters"> <div class="row no-gutters">
<div class="col"> <div class="col">
<mat-card *ngIf="rules === null" class="my-2 text-center"> <mat-card
*ngIf="hasPermissionToCreateOrder && rules === null"
class="my-2 text-center"
>
<gf-no-transactions-info-indicator <gf-no-transactions-info-indicator
[hasBorder]="false" [hasBorder]="false"
></gf-no-transactions-info-indicator> ></gf-no-transactions-info-indicator>

View File

@ -8,6 +8,7 @@ import { PortfolioReportRule } from '@ghostfolio/common/interfaces';
styleUrls: ['./rules.component.scss'] styleUrls: ['./rules.component.scss']
}) })
export class RulesComponent { export class RulesComponent {
@Input() hasPermissionToCreateOrder: boolean;
@Input() rules: PortfolioReportRule; @Input() rules: PortfolioReportRule;
public constructor() {} public constructor() {}

View File

@ -8,8 +8,7 @@ import {
Output Output
} from '@angular/core'; } from '@angular/core';
import { FormControl } from '@angular/forms'; import { FormControl } from '@angular/forms';
import { ToggleOption } from '@ghostfolio/common/types';
import { ToggleOption } from './interfaces/toggle-option.type';
@Component({ @Component({
selector: 'gf-toggle', selector: 'gf-toggle',

View File

@ -16,6 +16,8 @@ import { UserService } from '../services/user/user.service';
export class AuthGuard implements CanActivate { export class AuthGuard implements CanActivate {
private static PUBLIC_PAGE_ROUTES = [ private static PUBLIC_PAGE_ROUTES = [
'/about', '/about',
'/about/changelog',
'/blog',
'/de/blog', '/de/blog',
'/en/blog', '/en/blog',
'/p', '/p',

View File

@ -4,8 +4,7 @@ import {
HttpEvent, HttpEvent,
HttpHandler, HttpHandler,
HttpInterceptor, HttpInterceptor,
HttpRequest, HttpRequest
HttpResponse
} from '@angular/common/http'; } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { import {
@ -43,26 +42,6 @@ export class HttpResponseInterceptor implements HttpInterceptor {
): Observable<HttpEvent<any>> { ): Observable<HttpEvent<any>> {
return next.handle(request).pipe( return next.handle(request).pipe(
tap((event: HttpEvent<any>) => { tap((event: HttpEvent<any>) => {
if (event instanceof HttpResponse) {
if (event.status === StatusCodes.ACCEPTED) {
if (!this.snackBarRef) {
this.snackBarRef = this.snackBar.open(
'Sorry! Our data provider partner is experiencing a mild case of the hiccups ;(',
'Try again?',
{ duration: 6000 }
);
this.snackBarRef.afterDismissed().subscribe(() => {
this.snackBarRef = undefined;
});
this.snackBarRef.onAction().subscribe(() => {
window.location.reload();
});
}
}
}
return event; return event;
}), }),
catchError((error: HttpErrorResponse) => { catchError((error: HttpErrorResponse) => {

View File

@ -35,8 +35,8 @@
new feature, please join the Ghostfolio new feature, please join the Ghostfolio
<a <a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg" href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
title="Join the Ghostfolio Slack channel" title="Join the Ghostfolio Slack community"
>Slack channel</a >Slack community</a
>, tweet to >, tweet to
<a <a
href="https://twitter.com/ghostfolio_" href="https://twitter.com/ghostfolio_"
@ -108,12 +108,7 @@
<mat-card-content> <mat-card-content>
<div class="row"> <div class="row">
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">
<h3 <h3 class="mb-0">{{ statistics?.activeUsers1d || '-' }}</h3>
class="mb-0"
[hidden]="statistics?.activeUsers1d === undefined"
>
{{ statistics?.activeUsers1d || '-' }}
</h3>
<div class="h6 mb-0"> <div class="h6 mb-0">
<span i18n>Active Users</span>&nbsp;<small class="text-muted" <span i18n>Active Users</span>&nbsp;<small class="text-muted"
>(Last 24 hours)</small >(Last 24 hours)</small
@ -121,35 +116,7 @@
</div> </div>
</div> </div>
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">
<h3 <h3 class="mb-0">{{ statistics?.newUsers30d ?? '-' }}</h3>
class="mb-0"
[hidden]="statistics?.activeUsers7d === undefined"
>
{{ statistics?.activeUsers7d ?? '-' }}
</h3>
<div class="h6 mb-0">
<span i18n>Active Users</span>&nbsp;<small class="text-muted"
>(Last 7 days)</small
>
</div>
</div>
<div class="col-xs-12 col-md-4 my-2">
<h3
class="mb-0"
[hidden]="statistics?.activeUsers30d === undefined"
>
{{ statistics?.activeUsers30d ?? '-' }}
</h3>
<div class="h6 mb-0">
<span i18n>Active Users</span>&nbsp;<small class="text-muted"
>(Last 30 days)</small
>
</div>
</div>
<div class="col-xs-12 col-md-4 my-2">
<h3 class="mb-0" [hidden]="statistics?.newUsers30d === undefined">
{{ statistics?.newUsers30d ?? '-' }}
</h3>
<div class="h6 mb-0"> <div class="h6 mb-0">
<span i18n>New Users</span>&nbsp;<small class="text-muted" <span i18n>New Users</span>&nbsp;<small class="text-muted"
>(Last 30 days)</small >(Last 30 days)</small
@ -157,21 +124,23 @@
</div> </div>
</div> </div>
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">
<h3 <h3 class="mb-0">{{ statistics?.activeUsers30d ?? '-' }}</h3>
class="mb-0" <div class="h6 mb-0">
[hidden]="statistics?.gitHubContributors === undefined" <span i18n>Active Users</span>&nbsp;<small class="text-muted"
>(Last 30 days)</small
> >
{{ statistics?.gitHubContributors ?? '-' }} </div>
</h3> </div>
<div class="col-xs-12 col-md-4 my-2">
<h3 class="mb-0">{{ statistics?.slackCommunityUsers ?? '-' }}</h3>
<div class="h6 mb-0" i18n>Users in Slack community</div>
</div>
<div class="col-xs-12 col-md-4 my-2">
<h3 class="mb-0">{{ statistics?.gitHubContributors ?? '-' }}</h3>
<div class="h6 mb-0" i18n>Contributors on GitHub</div> <div class="h6 mb-0" i18n>Contributors on GitHub</div>
</div> </div>
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">
<h3 <h3 class="mb-0">{{ statistics?.gitHubStargazers ?? '-' }}</h3>
class="mb-0"
[hidden]="statistics?.gitHubStargazers === undefined"
>
{{ statistics?.gitHubStargazers ?? '-' }}
</h3>
<div class="h6 mb-0" i18n>Stars on GitHub</div> <div class="h6 mb-0" i18n>Stars on GitHub</div>
</div> </div>
</div> </div>
@ -180,73 +149,23 @@
</div> </div>
</div> </div>
<div *ngIf="hasPermissionForBlog" class="mb-5 row">
<div class="col">
<h3 class="mb-3 text-center" i18n>Blog</h3>
<mat-card class="blog-container">
<mat-card-content>
<div class="container p-0">
<div class="flex-nowrap mb-3 no-gutters row">
<a
class="d-flex w-100"
[routerLink]="['/en', 'blog', '2021', '07', 'hello-ghostfolio']"
>
<div class="flex-grow-1">
<div class="h6 m-0 text-truncate">Hello Ghostfolio</div>
<div class="d-flex text-muted">31.07.2021</div>
</div>
<div class="align-items-center d-flex">
<ion-icon
class="chevron text-muted"
name="chevron-forward-outline"
size="small"
></ion-icon>
</div>
</a>
</div>
<div class="flex-nowrap no-gutters row">
<a
class="d-flex w-100"
[routerLink]="['/de', 'blog', '2021', '07', 'hallo-ghostfolio']"
>
<div class="flex-grow-1">
<div class="h6 m-0 text-truncate">Hallo Ghostfolio</div>
<div class="d-flex text-muted">31.07.2021</div>
</div>
<div class="align-items-center d-flex">
<ion-icon
class="chevron text-muted"
name="chevron-forward-outline"
size="small"
></ion-icon>
</div>
</a>
</div>
</div>
</mat-card-content>
</mat-card>
</div>
</div>
<div class="mb-5 row">
<div class="col">
<h3 class="mb-3 text-center" i18n>Changelog</h3>
<mat-card class="changelog">
<mat-card-content>
<markdown [src]="'assets/CHANGELOG.md'"></markdown>
</mat-card-content>
</mat-card>
</div>
</div>
<div class="row"> <div class="row">
<div class="col"> <div *ngIf="hasPermissionForBlog" class="col-md-6 col-xs-12 my-2">
<h3 class="mb-3 text-center" i18n>License</h3> <a class="py-2 w-100" i18n mat-stroked-button [routerLink]="['/blog']"
<mat-card> >Blog</a
<mat-card-content> >
<markdown [src]="'assets/LICENSE'"></markdown> </div>
</mat-card-content> <div
</mat-card> class="col-md-6 col-xs-12 my-2"
[ngClass]="{ 'offset-md-3': !hasPermissionForBlog }"
>
<a
class="py-2 w-100"
i18n
mat-stroked-button
[routerLink]="['/about', 'changelog']"
>Changelog & License</a
>
</div> </div>
</div> </div>
</div> </div>

View File

@ -2,7 +2,6 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MarkdownModule } from 'ngx-markdown';
import { AboutPageRoutingModule } from './about-page-routing.module'; import { AboutPageRoutingModule } from './about-page-routing.module';
import { AboutPageComponent } from './about-page.component'; import { AboutPageComponent } from './about-page.component';
@ -13,7 +12,6 @@ import { AboutPageComponent } from './about-page.component';
imports: [ imports: [
AboutPageRoutingModule, AboutPageRoutingModule,
CommonModule, CommonModule,
MarkdownModule.forChild(),
MatButtonModule, MatButtonModule,
MatCardModule MatCardModule
], ],

View File

@ -0,0 +1,15 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { ChangelogPageComponent } from './changelog-page.component';
const routes: Routes = [
{ path: '', component: ChangelogPageComponent, canActivate: [AuthGuard] }
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class ChangelogPageRoutingModule {}

View File

@ -0,0 +1,22 @@
import { Component, OnDestroy } from '@angular/core';
import { Subject } from 'rxjs';
@Component({
host: { class: 'mb-5' },
selector: 'gf-changelog-page',
styleUrls: ['./changelog-page.scss'],
templateUrl: './changelog-page.html'
})
export class ChangelogPageComponent implements OnDestroy {
private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor() {}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -0,0 +1,23 @@
<div class="container">
<div class="mb-5 row">
<div class="col">
<h3 class="mb-3 text-center" i18n>Changelog</h3>
<mat-card class="changelog">
<mat-card-content>
<markdown [src]="'assets/CHANGELOG.md'"></markdown>
</mat-card-content>
</mat-card>
</div>
</div>
<div class="row">
<div class="col">
<h3 class="mb-3 text-center" i18n>License</h3>
<mat-card>
<mat-card-content>
<markdown [src]="'assets/LICENSE'"></markdown>
</mat-card-content>
</mat-card>
</div>
</div>
</div>

View File

@ -0,0 +1,19 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { MarkdownModule } from 'ngx-markdown';
import { ChangelogPageRoutingModule } from './changelog-page-routing.module';
import { ChangelogPageComponent } from './changelog-page.component';
@NgModule({
declarations: [ChangelogPageComponent],
imports: [
ChangelogPageRoutingModule,
CommonModule,
MarkdownModule.forChild(),
MatCardModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class ChangelogPageModule {}

View File

@ -0,0 +1,44 @@
:host {
color: rgb(var(--dark-primary-text));
display: block;
.mat-card {
&.changelog {
a {
color: rgba(var(--palette-primary-500), 1);
font-weight: 500;
&:hover {
color: rgba(var(--palette-primary-300), 1);
}
}
}
&.changelog {
::ng-deep {
markdown {
h1,
p {
display: none;
}
h2 {
font-size: 18px;
&:not(:first-of-type) {
margin-top: 2rem;
}
}
h3 {
font-size: 15px;
}
}
}
}
}
}
:host-context(.is-dark-theme) {
color: rgb(var(--light-primary-text));
}

View File

@ -1,6 +1,6 @@
<div class="blog container"> <div class="blog container">
<div class="row"> <div class="row">
<div class="col"> <div class="col-md-8 offset-md-2">
<article> <article>
<div class="mb-4 text-center"> <div class="mb-4 text-center">
<h1 class="mb-1" i18n>Hallo Ghostfolio 👋</h1> <h1 class="mb-1" i18n>Hallo Ghostfolio 👋</h1>
@ -141,58 +141,59 @@
</section> </section>
<section class="mb-4"> <section class="mb-4">
<ul class="list-inline"> <ul class="list-inline">
<li class="h5"> <li class="list-inline-item">
<span class="badge badge-light font-weight-normal mr-2" <span class="badge badge-light">Aktie</span>
>Aktie</span </li>
> <li class="list-inline-item">
<span class="badge badge-light font-weight-normal mr-2" <span class="badge badge-light">Altersvorsorge</span>
>Altersvorsorge</span </li>
> <li class="list-inline-item">
<span class="badge badge-light font-weight-normal mr-2" <span class="badge badge-light">Anlage</span>
>Anlage</span </li>
> <li class="list-inline-item">
<span class="badge badge-light font-weight-normal mr-2">App</span> <span class="badge badge-light">App</span>
<span class="badge badge-light font-weight-normal mr-2" </li>
>Cryptocurrency</span <li class="list-inline-item">
> <span class="badge badge-light">Cryptocurrency</span>
<span class="badge badge-light font-weight-normal mr-2">ETF</span> </li>
<span class="badge badge-light font-weight-normal mr-2" <li class="list-inline-item">
>Feedback</span <span class="badge badge-light">Feedback</span>
> </li>
<span class="badge badge-light font-weight-normal mr-2" <li class="list-inline-item">
>Fintech</span <span class="badge badge-light">Fintech</span>
> </li>
<span class="badge badge-light font-weight-normal mr-2" <li class="list-inline-item">
>Ghostfolio</span <span class="badge badge-light">Ghostfolio</span>
> </li>
<span class="badge badge-light font-weight-normal mr-2" <li class="list-inline-item">
>Investition</span <span class="badge badge-light">Investition</span>
> </li>
<span class="badge badge-light font-weight-normal mr-2" <li class="list-inline-item">
>Open Source</span <span class="badge badge-light">Open Source</span>
> </li>
<span class="badge badge-light font-weight-normal mr-2">OSS</span> <li class="list-inline-item">
<span class="badge badge-light font-weight-normal mr-2" <span class="badge badge-light">OSS</span>
>Portfolio</span </li>
> <li class="list-inline-item">
<span class="badge badge-light font-weight-normal mr-2" <span class="badge badge-light">Portfolio</span>
>Software</span </li>
> <li class="list-inline-item">
<span class="badge badge-light font-weight-normal mr-2" <span class="badge badge-light">Software</span>
>Strategie</span </li>
> <li class="list-inline-item">
<span class="badge badge-light font-weight-normal mr-2" <span class="badge badge-light">Strategie</span>
>Trading</span </li>
> <li class="list-inline-item">
<span class="badge badge-light font-weight-normal mr-2" <span class="badge badge-light">Trading</span>
>TypeScript</span </li>
> <li class="list-inline-item">
<span class="badge badge-light font-weight-normal mr-2" <span class="badge badge-light">TypeScript</span>
>Vermögen</span </li>
> <li class="list-inline-item">
<span class="badge badge-light font-weight-normal mr-2" <span class="badge badge-light">Vermögen</span>
>Wealth Management</span </li>
> <li class="list-inline-item">
<span class="badge badge-light">Wealth Management</span>
</li> </li>
</ul> </ul>
</section> </section>

View File

@ -7,9 +7,7 @@ import { HalloGhostfolioPageComponent } from './hallo-ghostfolio-page.component'
@NgModule({ @NgModule({
declarations: [HalloGhostfolioPageComponent], declarations: [HalloGhostfolioPageComponent],
exports: [],
imports: [CommonModule, HalloGhostfolioPageRoutingModule, RouterModule], imports: [CommonModule, HalloGhostfolioPageRoutingModule, RouterModule],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class HalloGhostfolioPageModule {} export class HalloGhostfolioPageModule {}

View File

@ -1,6 +1,6 @@
<div class="blog container"> <div class="blog container">
<div class="row"> <div class="row">
<div class="col"> <div class="col-md-8 offset-md-2">
<article> <article>
<div class="mb-4 text-center"> <div class="mb-4 text-center">
<h1 class="mb-1" i18n>Hello Ghostfolio 👋</h1> <h1 class="mb-1" i18n>Hello Ghostfolio 👋</h1>
@ -136,42 +136,44 @@
</section> </section>
<section class="mb-4"> <section class="mb-4">
<ul class="list-inline"> <ul class="list-inline">
<li class="h5"> <li class="list-inline-item">
<span class="badge badge-light font-weight-normal mr-2" <span class="badge badge-light">Cryptocurrency</span>
>Cryptocurrency</span </li>
> <li class="list-inline-item">
<span class="badge badge-light font-weight-normal mr-2">ETF</span> <span class="badge badge-light">ETF</span>
<span class="badge badge-light font-weight-normal mr-2" </li>
>Fintech</span <li class="list-inline-item">
> <span class="badge badge-light">Fintech</span>
<span class="badge badge-light font-weight-normal mr-2" </li>
>Ghostfolio</span <li class="list-inline-item">
> <span class="badge badge-light">Ghostfolio</span>
<span class="badge badge-light font-weight-normal mr-2" </li>
>Investment</span <li class="list-inline-item">
> <span class="badge badge-light">Investment</span>
<span class="badge badge-light font-weight-normal mr-2" </li>
>Open Source</span <li class="list-inline-item">
> <span class="badge badge-light">Open Source</span>
<span class="badge badge-light font-weight-normal mr-2">OSS</span> </li>
<span class="badge badge-light font-weight-normal mr-2" <li class="list-inline-item">
>Portfolio</span <span class="badge badge-light">OSS</span>
> </li>
<span class="badge badge-light font-weight-normal mr-2" <li class="list-inline-item">
>Software</span <span class="badge badge-light">Portfolio</span>
> </li>
<span class="badge badge-light font-weight-normal mr-2" <li class="list-inline-item">
>Stock</span <span class="badge badge-light">Software</span>
> </li>
<span class="badge badge-light font-weight-normal mr-2" <li class="list-inline-item">
>Strategy</span <span class="badge badge-light">Stock</span>
> </li>
<span class="badge badge-light font-weight-normal mr-2" <li class="list-inline-item">
>Wealth</span <span class="badge badge-light">Strategy</span>
> </li>
<span class="badge badge-light font-weight-normal mr-2" <li class="list-inline-item">
>Wealth Management</span <span class="badge badge-light">Wealth</span>
> </li>
<li class="list-inline-item">
<span class="badge badge-light">Wealth Management</span>
</li> </li>
</ul> </ul>
</section> </section>

View File

@ -7,9 +7,7 @@ import { HelloGhostfolioPageComponent } from './hello-ghostfolio-page.component'
@NgModule({ @NgModule({
declarations: [HelloGhostfolioPageComponent], declarations: [HelloGhostfolioPageComponent],
exports: [],
imports: [CommonModule, HelloGhostfolioPageRoutingModule, RouterModule], imports: [CommonModule, HelloGhostfolioPageRoutingModule, RouterModule],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class HelloGhostfolioPageModule {} export class HelloGhostfolioPageModule {}

View File

@ -0,0 +1,19 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { FirstMonthsInOpenSourcePageComponent } from './first-months-in-open-source-page.component';
const routes: Routes = [
{
path: '',
component: FirstMonthsInOpenSourcePageComponent,
canActivate: [AuthGuard]
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class FirstMonthsInOpenSourceRoutingModule {}

View File

@ -0,0 +1,9 @@
import { Component } from '@angular/core';
@Component({
host: { class: 'mb-5' },
selector: 'gf-first-months-in-open-source-page',
styleUrls: ['./first-months-in-open-source-page.scss'],
templateUrl: './first-months-in-open-source-page.html'
})
export class FirstMonthsInOpenSourcePageComponent {}

View File

@ -0,0 +1,185 @@
<div class="blog container">
<div class="row">
<div class="col-md-8 offset-md-2">
<article>
<div class="mb-4 text-center">
<h1 class="mb-1" i18n>
👻 Ghostfolio
<span class="text-nowrap">First months in Open Source</span>
</h1>
<div class="text-muted"><small>05.01.2022</small></div>
</div>
<section class="mb-4">
<p>
In this article I would like to recap the first months running the
open source project <a href="https://ghostfol.io">Ghostfolio</a>, a
web-based personal finance management software.
</p>
</section>
<section class="mb-4">
<h2 class="h4">From 1* to 100 stars on GitHub</h2>
<p>
When I decided to
<a [routerLink]="['/en', 'blog', '2021', '07', 'hello-ghostfolio']"
>publish</a
>
the project as
<a href="https://github.com/ghostfolio/ghostfolio"
>open source software</a
>
(OSS), I did not know what exactly to expect. In the worst case,
nobody would care. And in the best case, the repository would be
overrun with contributions. The truth is probably somewhere in
between.
</p>
<p>
In the beginning, it felt quite weird to develop in public where
anyone can observe the progress. Stupid mistakes remain visible
forever. But this feeling, fortunately, quickly settled. I believe
the benefits like all the learning clearly outweigh the drawbacks
when you just do it.
</p>
<p>
At the end of 2021, Ghostfolio reached an important milestone:
<a href="https://twitter.com/ghostfolio_/status/1470075774640218121"
>100 stars</a
>
on GitHub. This is really exciting with almost no marketing. I am a
technical founder, so I prefer writing code over anything else. But
there is so much more to make this project happen: writing
documentation, maintaining bug reports and feature requests,
supporting users and managing the community, keeping the SaaS
running, etc.
</p>
<p>
Reaching 100 stars will not only attract very early adopters, but
also the early adopters. At the same time, the demands and
expectations are also increasing.
</p>
</section>
<section class="mb-4">
<h2 class="h4">What is new?</h2>
<p>
During the last months, Ghostfolio has transformed from a one man
project into a prospering wealth management application with 9
contributors and counting. User feedback has directly shaped the
direction of the product development.
</p>
<p>These are some selected key features:</p>
<ul>
<li>
Simplified self-hosting with an
<a href="https://hub.docker.com/r/ghostfolio/ghostfolio"
>official Ghostfolio docker image</a
>
on Docker Hub
</li>
<li>Improved import for activities (transactions and dividend)</li>
<li>Enriched market data for ETFs (region and industries)</li>
</ul>
</section>
<section class="mb-4">
<h2 class="h4">What is coming?</h2>
<p>Here is a brief overview of what I am planning in 2022.</p>
<p>
The goal remains to offer a simple and solid software to manage
personal finances. Thus, the main focus is on the core
functionality.
</p>
<p>
My personal goal is to reach break-even with the Saas offering (<a
[routerLink]="['/pricing']"
>Ghostfolio Premium</a
>) and regularly report about the progress and my learnings on this
exciting journey.
</p>
<p>
I have already started to build a
<a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
>community</a
>
of users. In the future, I would like to involve more contributors
to further extend the functionality of Ghostfolio (e.g. with new
reports). Get in touch with me by email at
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a> if you
are interested, Im happy to discuss ideas.
</p>
<p>
I would like to say thank you for all your feedback and support
during the last months.
</p>
<p>
Have a great start into the new year and happy investing<br />
Thomas from Ghostfolio
</p>
</section>
<section class="mb-4">
<p>* Pro Tip: add the first star to your own open source project</p>
</section>
<section class="mb-4">
<ul class="list-inline">
<li class="list-inline-item">
<span class="badge badge-light">BuildInPublic</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Community</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Cryptocurrency</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Docker</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">ETF</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Fintech</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Ghostfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Image</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Investment</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Open Source</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">OSS</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Portfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Progress</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">SaaS</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Software</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Stock</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Strategy</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Wealth</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Wealth Management</span>
</li>
</ul>
</section>
</article>
</div>
</div>
</div>

View File

@ -0,0 +1,13 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { FirstMonthsInOpenSourceRoutingModule } from './first-months-in-open-source-page-routing.module';
import { FirstMonthsInOpenSourcePageComponent } from './first-months-in-open-source-page.component';
@NgModule({
declarations: [FirstMonthsInOpenSourcePageComponent],
imports: [CommonModule, FirstMonthsInOpenSourceRoutingModule, RouterModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class FirstMonthsInOpenSourcePageModule {}

View File

@ -0,0 +1,15 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { BlogPageComponent } from './blog-page.component';
const routes: Routes = [
{ path: '', component: BlogPageComponent, canActivate: [AuthGuard] }
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class BlogPageRoutingModule {}

View File

@ -0,0 +1,22 @@
import { Component, OnDestroy } from '@angular/core';
import { Subject } from 'rxjs';
@Component({
host: { class: 'mb-5' },
selector: 'gf-blog-page',
styleUrls: ['./blog-page.scss'],
templateUrl: './blog-page.html'
})
export class BlogPageComponent implements OnDestroy {
private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor() {}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -0,0 +1,69 @@
<div class="container">
<div class="mb-5 row">
<div class="col">
<h3 class="mb-3 text-center" i18n>Blog</h3>
<mat-card class="blog-container">
<mat-card-content>
<div class="container p-0">
<div class="flex-nowrap mb-3 no-gutters row">
<a
class="d-flex w-100"
[routerLink]="['/en', 'blog', '2022', '01', 'ghostfolio-first-months-in-open-source']"
>
<div class="flex-grow-1">
<div class="h6 m-0 text-truncate">
First months in Open Source
</div>
<div class="d-flex text-muted">05.01.2021</div>
</div>
<div class="align-items-center d-flex">
<ion-icon
class="chevron text-muted"
name="chevron-forward-outline"
size="small"
></ion-icon>
</div>
</a>
</div>
<div class="flex-nowrap mb-3 no-gutters row">
<a
class="d-flex w-100"
[routerLink]="['/en', 'blog', '2021', '07', 'hello-ghostfolio']"
>
<div class="flex-grow-1">
<div class="h6 m-0 text-truncate">Hello Ghostfolio</div>
<div class="d-flex text-muted">31.07.2021</div>
</div>
<div class="align-items-center d-flex">
<ion-icon
class="chevron text-muted"
name="chevron-forward-outline"
size="small"
></ion-icon>
</div>
</a>
</div>
<div class="flex-nowrap no-gutters row">
<a
class="d-flex w-100"
[routerLink]="['/de', 'blog', '2021', '07', 'hallo-ghostfolio']"
>
<div class="flex-grow-1">
<div class="h6 m-0 text-truncate">Hallo Ghostfolio</div>
<div class="d-flex text-muted">31.07.2021</div>
</div>
<div class="align-items-center d-flex">
<ion-icon
class="chevron text-muted"
name="chevron-forward-outline"
size="small"
></ion-icon>
</div>
</a>
</div>
</div>
</mat-card-content>
</mat-card>
</div>
</div>
</div>

View File

@ -0,0 +1,13 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { BlogPageRoutingModule } from './blog-page-routing.module';
import { BlogPageComponent } from './blog-page.component';
@NgModule({
declarations: [BlogPageComponent],
imports: [BlogPageRoutingModule, CommonModule, MatCardModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class BlogPageModule {}

View File

@ -0,0 +1,8 @@
:host {
color: rgb(var(--dark-primary-text));
display: block;
}
:host-context(.is-dark-theme) {
color: rgb(var(--light-primary-text));
}

View File

@ -1,5 +1,7 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { ToggleOption } from '@ghostfolio/client/components/toggle/interfaces/toggle-option.type'; import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
@ -10,9 +12,11 @@ import {
PortfolioPosition, PortfolioPosition,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { ToggleOption } from '@ghostfolio/common/types';
import { AssetClass } from '@prisma/client'; import { AssetClass } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@Component({ @Component({
@ -33,6 +37,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
}; };
public deviceType: string; public deviceType: string;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public hasPermissionToCreateOrder: boolean;
public period = 'current'; public period = 'current';
public periodOptions: ToggleOption[] = [ public periodOptions: ToggleOption[] = [
{ label: 'Initial', value: 'original' }, { label: 'Initial', value: 'original' },
@ -51,6 +56,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
>; >;
}; };
public positionsArray: PortfolioPosition[]; public positionsArray: PortfolioPosition[];
public routeQueryParams: Subscription;
public sectors: { public sectors: {
[name: string]: { name: string; value: number }; [name: string]: { name: string; value: number };
}; };
@ -69,9 +75,22 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private impersonationStorageService: ImpersonationStorageService, private impersonationStorageService: ImpersonationStorageService,
private route: ActivatedRoute,
private router: Router,
private userService: UserService private userService: UserService
) {} ) {
this.routeQueryParams = route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (params['positionDetailDialog'] && params['symbol']) {
this.openPositionDialog({
symbol: params['symbol']
});
}
});
}
/** /**
* Initializes the controller * Initializes the controller
@ -103,6 +122,11 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
this.hasPermissionToCreateOrder = hasPermission(
this.user.permissions,
permissions.createOrder
);
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
}); });
@ -266,4 +290,32 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private openPositionDialog({ symbol }: { symbol: string }) {
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false,
data: {
symbol,
baseCurrency: this.user?.settings?.baseCurrency,
deviceType: this.deviceType,
locale: this.user?.settings?.locale
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
});
}
} }

View File

@ -197,6 +197,7 @@
<gf-positions-table <gf-positions-table
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType" [deviceType]="deviceType"
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[positions]="positionsArray" [positions]="positionsArray"
></gf-positions-table> ></gf-positions-table>

View File

@ -1,10 +1,11 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { ToggleOption } from '@ghostfolio/client/components/toggle/interfaces/toggle-option.type';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { PortfolioPosition, User } from '@ghostfolio/common/interfaces'; import { Position, User } from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface'; import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { differenceInDays } from 'date-fns';
import { sortBy } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -16,28 +17,12 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './analysis-page.html' templateUrl: './analysis-page.html'
}) })
export class AnalysisPageComponent implements OnDestroy, OnInit { export class AnalysisPageComponent implements OnDestroy, OnInit {
public accounts: { public bottom3: Position[];
[symbol: string]: Pick<PortfolioPosition, 'name'> & { value: number }; public daysInMarket: number;
};
public continents: {
[code: string]: { name: string; value: number };
};
public countries: {
[code: string]: { name: string; value: number };
};
public deviceType: string; public deviceType: string;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public period = 'current';
public periodOptions: ToggleOption[] = [
{ label: 'Initial', value: 'original' },
{ label: 'Current', value: 'current' }
];
public investments: InvestmentItem[]; public investments: InvestmentItem[];
public portfolioPositions: { [symbol: string]: PortfolioPosition }; public top3: Position[];
public positions: { [symbol: string]: any };
public sectors: {
[name: string]: { name: string; value: number };
};
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -69,8 +54,29 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
this.dataService this.dataService
.fetchInvestments() .fetchInvestments()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => { .subscribe(({ firstOrderDate, investments }) => {
this.investments = response; this.daysInMarket = differenceInDays(new Date(), firstOrderDate);
this.investments = investments;
this.changeDetectorRef.markForCheck();
});
this.dataService
.fetchPositions({ range: 'max' })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ positions }) => {
const positionsSorted = sortBy(
positions,
'netPerformancePercentage'
).reverse();
this.top3 = positionsSorted.slice(0, 3);
if (positions?.length > 3) {
this.bottom3 = positionsSorted.slice(-3).reverse();
} else {
this.bottom3 = [];
}
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });

View File

@ -5,16 +5,96 @@
<mat-card class="mb-3"> <mat-card class="mb-3">
<mat-card-header> <mat-card-header>
<mat-card-title class="align-items-center d-flex" i18n <mat-card-title class="align-items-center d-flex" i18n
>Timeline</mat-card-title >Investment Timeline</mat-card-title
> >
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<gf-investment-chart <gf-investment-chart
class="h-100" class="h-100"
[daysInMarket]="daysInMarket"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[investments]="investments" [investments]="investments"
></gf-investment-chart> ></gf-investment-chart>
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</div> </div>
</div> </div>
<div class="row">
<div class="col-md-6">
<mat-card class="mb-3">
<mat-card-header>
<mat-card-title class="align-items-center d-flex" i18n
>Top 3</mat-card-title
>
</mat-card-header>
<mat-card-content>
<div *ngFor="let position of top3; let i = index" class="d-flex py-1">
<div class="flex-grow-1 mr-2 text-truncate">
{{ i + 1 }}. {{ position.name }}
</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
position="end"
[colorizeSign]="true"
[isPercent]="true"
[locale]="user?.settings?.locale"
[value]="position.netPerformancePercentage"
></gf-value>
</div>
</div>
<div>
<ngx-skeleton-loader
*ngIf="!top3"
animation="pulse"
[theme]="{
height: '1.5rem',
width: '100%'
}"
></ngx-skeleton-loader>
</div>
</mat-card-content>
</mat-card>
</div>
<div class="col-md-6">
<mat-card class="mb-3">
<mat-card-header>
<mat-card-title class="align-items-center d-flex" i18n
>Bottom 3</mat-card-title
>
</mat-card-header>
<mat-card-content>
<div
*ngFor="let position of bottom3; let i = index"
class="d-flex py-1"
>
<div class="flex-grow-1 mr-2 text-truncate">
{{ i + 1 }}. {{ position.name }}
</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
position="end"
[colorizeSign]="true"
[isPercent]="true"
[locale]="user?.settings?.locale"
[value]="position.netPerformancePercentage"
></gf-value>
</div>
</div>
<div>
<ngx-skeleton-loader
*ngIf="!bottom3"
animation="pulse"
[theme]="{
height: '1.5rem',
width: '100%'
}"
></ngx-skeleton-loader>
</div>
</mat-card-content>
</mat-card>
</div>
</div>
</div> </div>

View File

@ -2,6 +2,8 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.module'; import { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.module';
import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { AnalysisPageRoutingModule } from './analysis-page-routing.module'; import { AnalysisPageRoutingModule } from './analysis-page-routing.module';
import { AnalysisPageComponent } from './analysis-page.component'; import { AnalysisPageComponent } from './analysis-page.component';
@ -13,7 +15,9 @@ import { AnalysisPageComponent } from './analysis-page.component';
AnalysisPageRoutingModule, AnalysisPageRoutingModule,
CommonModule, CommonModule,
GfInvestmentChartModule, GfInvestmentChartModule,
MatCardModule GfValueModule,
MatCardModule,
NgxSkeletonLoaderModule
], ],
providers: [], providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]

View File

@ -3,16 +3,16 @@
<div class="row"> <div class="row">
<div class="col-xs-12 col-md-6"> <div class="col-xs-12 col-md-6">
<mat-card class="mb-3"> <mat-card class="mb-3">
<h4 i18n>Transactions</h4> <h4 i18n>Activities</h4>
<p class="mb-0">Manage your transactions.</p> <p class="mb-0">Manage your activities.</p>
<p class="text-right"> <p class="text-right">
<a <a
color="primary" color="primary"
i18n
mat-button mat-button
[routerLink]="['/portfolio', 'transactions']" [routerLink]="['/portfolio', 'activities']"
> >
Open Transactions → <span i18n>Open Activities</span>
<ion-icon class="ml-1" name="arrow-forward-outline"></ion-icon>
</a> </a>
</p> </p>
</mat-card> </mat-card>
@ -31,12 +31,12 @@
<p class="text-right"> <p class="text-right">
<a <a
color="primary" color="primary"
i18n
mat-button mat-button
[disabled]="hasPermissionForSubscription && user?.settings?.viewMode !== 'DEFAULT'" [disabled]="hasPermissionForSubscription && user?.settings?.viewMode !== 'DEFAULT'"
[routerLink]="['/portfolio', 'allocations']" [routerLink]="['/portfolio', 'allocations']"
> >
Open Allocations <span i18n>Open Allocations</span>
<ion-icon class="ml-1" name="arrow-forward-outline"></ion-icon>
</a> </a>
</p> </p>
</mat-card> </mat-card>
@ -57,12 +57,12 @@
<p class="text-right"> <p class="text-right">
<a <a
color="primary" color="primary"
i18n
mat-button mat-button
[disabled]="hasPermissionForSubscription && user?.settings?.viewMode !== 'DEFAULT'" [disabled]="hasPermissionForSubscription && user?.settings?.viewMode !== 'DEFAULT'"
[routerLink]="['/portfolio', 'analysis']" [routerLink]="['/portfolio', 'analysis']"
> >
Open Analysis → <span i18n>Open Analysis</span>
<ion-icon class="ml-1" name="arrow-forward-outline"></ion-icon>
</a> </a>
</p> </p>
</mat-card> </mat-card>
@ -84,12 +84,12 @@
<p class="text-right"> <p class="text-right">
<a <a
color="primary" color="primary"
i18n
mat-button mat-button
[disabled]="hasPermissionForSubscription && user?.settings?.viewMode !== 'DEFAULT'" [disabled]="hasPermissionForSubscription && user?.settings?.viewMode !== 'DEFAULT'"
[routerLink]="['/portfolio', 'report']" [routerLink]="['/portfolio', 'report']"
> >
Open X-ray → <span i18n>Open X-ray</span>
<ion-icon class="ml-1" name="arrow-forward-outline"></ion-icon>
</a> </a>
</p> </p>
</mat-card> </mat-card>

View File

@ -1,6 +1,8 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { PortfolioReportRule } from '@ghostfolio/common/interfaces'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { PortfolioReportRule, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -14,6 +16,8 @@ export class ReportPageComponent implements OnDestroy, OnInit {
public accountClusterRiskRules: PortfolioReportRule[]; public accountClusterRiskRules: PortfolioReportRule[];
public currencyClusterRiskRules: PortfolioReportRule[]; public currencyClusterRiskRules: PortfolioReportRule[];
public feeRules: PortfolioReportRule[]; public feeRules: PortfolioReportRule[];
public hasPermissionToCreateOrder: boolean;
public user: User;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -22,7 +26,8 @@ export class ReportPageComponent implements OnDestroy, OnInit {
*/ */
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService private dataService: DataService,
private userService: UserService
) {} ) {}
/** /**
@ -41,6 +46,21 @@ export class ReportPageComponent implements OnDestroy, OnInit {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.hasPermissionToCreateOrder = hasPermission(
this.user.permissions,
permissions.createOrder
);
this.changeDetectorRef.markForCheck();
}
});
} }
public ngOnDestroy() { public ngOnDestroy() {

View File

@ -15,15 +15,24 @@
</p> </p>
<div class="mb-4"> <div class="mb-4">
<h4 class="m-0" i18n>Currency Cluster Risks</h4> <h4 class="m-0" i18n>Currency Cluster Risks</h4>
<gf-rules [rules]="currencyClusterRiskRules"></gf-rules> <gf-rules
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
[rules]="currencyClusterRiskRules"
></gf-rules>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<h4 class="m-0" i18n>Account Cluster Risks</h4> <h4 class="m-0" i18n>Account Cluster Risks</h4>
<gf-rules [rules]="accountClusterRiskRules"></gf-rules> <gf-rules
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
[rules]="accountClusterRiskRules"
></gf-rules>
</div> </div>
<div> <div>
<h4 class="m-0" i18n>Fees</h4> <h4 class="m-0" i18n>Fees</h4>
<gf-rules [rules]="feeRules"></gf-rules> <gf-rules
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
[rules]="feeRules"
></gf-rules>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,6 +1,6 @@
<form #addTransactionForm="ngForm" class="d-flex flex-column h-100"> <form #addTransactionForm="ngForm" class="d-flex flex-column h-100">
<h1 *ngIf="data.transaction.id" mat-dialog-title i18n>Update transaction</h1> <h1 *ngIf="data.transaction.id" mat-dialog-title i18n>Update activity</h1>
<h1 *ngIf="!data.transaction.id" mat-dialog-title i18n>Add transaction</h1> <h1 *ngIf="!data.transaction.id" mat-dialog-title i18n>Add activity</h1>
<div class="flex-grow-1" mat-dialog-content> <div class="flex-grow-1" mat-dialog-content>
<div> <div>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
@ -53,6 +53,7 @@
<mat-label i18n>Type</mat-label> <mat-label i18n>Type</mat-label>
<mat-select name="type" required [(value)]="data.transaction.type"> <mat-select name="type" required [(value)]="data.transaction.type">
<mat-option value="BUY" i18n>BUY</mat-option> <mat-option value="BUY" i18n>BUY</mat-option>
<mat-option value="DIVIDEND" i18n>DIVIDEND</mat-option>
<mat-option value="SELL" i18n>SELL</mat-option> <mat-option value="SELL" i18n>SELL</mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
@ -141,7 +142,7 @@
[(ngModel)]="data.transaction.unitPrice" [(ngModel)]="data.transaction.unitPrice"
/> />
<button <button
*ngIf="currentMarketPrice" *ngIf="currentMarketPrice && (data.transaction.type === 'BUY' || data.transaction.type === 'SELL')"
mat-icon-button mat-icon-button
matSuffix matSuffix
title="Apply current market price" title="Apply current market price"

View File

@ -4,6 +4,7 @@ import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto'; import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { ImportTransactionsService } from '@ghostfolio/client/services/import-transactions.service'; import { ImportTransactionsService } from '@ghostfolio/client/services/import-transactions.service';
@ -61,7 +62,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
this.routeQueryParams = route.queryParams this.routeQueryParams = route.queryParams
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => { .subscribe((params) => {
if (params['createDialog'] && this.hasPermissionToCreateOrder) { if (params['createDialog']) {
this.openCreateTransactionDialog(); this.openCreateTransactionDialog();
} else if (params['editDialog']) { } else if (params['editDialog']) {
if (this.transactions) { if (this.transactions) {
@ -73,6 +74,10 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
} else { } else {
this.router.navigate(['.'], { relativeTo: this.route }); this.router.navigate(['.'], { relativeTo: this.route });
} }
} else if (params['positionDetailDialog'] && params['symbol']) {
this.openPositionDialog({
symbol: params['symbol']
});
} }
}); });
} }
@ -188,7 +193,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
const fileContent = readerEvent.target.result as string; const fileContent = readerEvent.target.result as string;
try { try {
if (file.type === 'application/json') { if (file.name.endsWith('.json')) {
const content = JSON.parse(fileContent); const content = JSON.parse(fileContent);
if (!isArray(content.orders)) { if (!isArray(content.orders)) {
@ -203,11 +208,12 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
this.handleImportSuccess(); this.handleImportSuccess();
} catch (error) { } catch (error) {
console.error(error);
this.handleImportError({ error, orders: content.orders }); this.handleImportError({ error, orders: content.orders });
} }
return; return;
} else if (file.type === 'text/csv') { } else if (file.name.endsWith('.csv')) {
try { try {
await this.importTransactionsService.importCsv({ await this.importTransactionsService.importCsv({
fileContent, fileContent,
@ -217,6 +223,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
this.handleImportSuccess(); this.handleImportSuccess();
} catch (error) { } catch (error) {
console.error(error);
this.handleImportError({ this.handleImportError({
error: { error: {
error: { message: error?.error?.message ?? [error?.message] } error: { message: error?.error?.message ?? [error?.message] }
@ -230,6 +237,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
throw new Error(); throw new Error();
} catch (error) { } catch (error) {
console.error(error);
this.handleImportError({ this.handleImportError({
error: { error: { message: ['Unexpected format'] } }, error: { error: { message: ['Unexpected format'] } },
orders: [] orders: []
@ -383,4 +391,32 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
this.router.navigate(['.'], { relativeTo: this.route }); this.router.navigate(['.'], { relativeTo: this.route });
}); });
} }
private openPositionDialog({ symbol }: { symbol: string }) {
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false,
data: {
symbol,
baseCurrency: this.user?.settings?.baseCurrency,
deviceType: this.deviceType,
locale: this.user?.settings?.locale
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
});
}
} }

View File

@ -1,21 +1,21 @@
<div class="container"> <div class="container">
<div class="row mb-3"> <div class="row mb-3">
<div class="col"> <div class="col">
<h3 class="d-flex justify-content-center mb-3" i18n>Transactions</h3> <h3 class="d-flex justify-content-center mb-3" i18n>Activities</h3>
<gf-transactions-table <gf-activities-table
[activities]="transactions"
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType" [deviceType]="deviceType"
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder" [hasPermissionToCreateActivity]="hasPermissionToCreateOrder"
[hasPermissionToImportOrders]="hasPermissionToImportOrders" [hasPermissionToImportActivities]="hasPermissionToImportOrders"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[showActions]="!hasImpersonationId && hasPermissionToDeleteOrder && !user.settings.isRestrictedView" [showActions]="!hasImpersonationId && hasPermissionToDeleteOrder && !user.settings.isRestrictedView"
[transactions]="transactions" (activityDeleted)="onDeleteTransaction($event)"
(activityToClone)="onCloneTransaction($event)"
(activityToUpdate)="onUpdateTransaction($event)"
(export)="onExport()" (export)="onExport()"
(import)="onImport()" (import)="onImport()"
(transactionDeleted)="onDeleteTransaction($event)" ></gf-activities-table>
(transactionToClone)="onCloneTransaction($event)"
(transactionToUpdate)="onUpdateTransaction($event)"
></gf-transactions-table>
</div> </div>
</div> </div>

View File

@ -3,8 +3,8 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatSnackBarModule } from '@angular/material/snack-bar'; import { MatSnackBarModule } from '@angular/material/snack-bar';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { GfTransactionsTableModule } from '@ghostfolio/client/components/transactions-table/transactions-table.module';
import { ImportTransactionsService } from '@ghostfolio/client/services/import-transactions.service'; import { ImportTransactionsService } from '@ghostfolio/client/services/import-transactions.service';
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
import { GfCreateOrUpdateTransactionDialogModule } from './create-or-update-transaction-dialog/create-or-update-transaction-dialog.module'; import { GfCreateOrUpdateTransactionDialogModule } from './create-or-update-transaction-dialog/create-or-update-transaction-dialog.module';
import { GfImportTransactionDialogModule } from './import-transaction-dialog/import-transaction-dialog.module'; import { GfImportTransactionDialogModule } from './import-transaction-dialog/import-transaction-dialog.module';
@ -16,9 +16,9 @@ import { TransactionsPageComponent } from './transactions-page.component';
exports: [], exports: [],
imports: [ imports: [
CommonModule, CommonModule,
GfActivitiesTableModule,
GfCreateOrUpdateTransactionDialogModule, GfCreateOrUpdateTransactionDialogModule,
GfImportTransactionDialogModule, GfImportTransactionDialogModule,
GfTransactionsTableModule,
MatButtonModule, MatButtonModule,
MatSnackBarModule, MatSnackBarModule,
RouterModule, RouterModule,

View File

@ -20,6 +20,19 @@ export class AdminService {
return this.http.post<void>(`/api/admin/gather/profile-data`, {}); return this.http.post<void>(`/api/admin/gather/profile-data`, {});
} }
public gatherProfileDataBySymbol({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
return this.http.post<void>(
`/api/admin/gather/profile-data/${dataSource}/${symbol}`,
{}
);
}
public gatherSymbol({ public gatherSymbol({
dataSource, dataSource,
date, date,

View File

@ -5,7 +5,6 @@ import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto'; import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto'; import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { PortfolioPositionDetail } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
import { PortfolioPositions } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-positions.interface'; import { PortfolioPositions } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-positions.interface';
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { SymbolItem } from '@ghostfolio/api/app/symbol/interfaces/symbol-item.interface'; import { SymbolItem } from '@ghostfolio/api/app/symbol/interfaces/symbol-item.interface';
@ -23,13 +22,13 @@ import {
InfoItem, InfoItem,
PortfolioChart, PortfolioChart,
PortfolioDetails, PortfolioDetails,
PortfolioInvestments,
PortfolioPerformance, PortfolioPerformance,
PortfolioPublicDetails, PortfolioPublicDetails,
PortfolioReport, PortfolioReport,
PortfolioSummary, PortfolioSummary,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types'; import { DateRange } from '@ghostfolio/common/types';
import { DataSource, Order as OrderModel } from '@prisma/client'; import { DataSource, Order as OrderModel } from '@prisma/client';
@ -124,6 +123,18 @@ export class DataService {
return info; return info;
} }
public fetchInvestments(): Observable<PortfolioInvestments> {
return this.http.get<any>('/api/portfolio/investments').pipe(
map((response) => {
if (response.firstOrderDate) {
response.firstOrderDate = parseISO(response.firstOrderDate);
}
return response;
})
);
}
public fetchSymbolItem({ public fetchSymbolItem({
dataSource, dataSource,
includeHistoricalData = false, includeHistoricalData = false,
@ -170,10 +181,6 @@ export class DataService {
); );
} }
public fetchInvestments() {
return this.http.get<InvestmentItem[]>('/api/portfolio/investments');
}
public fetchPortfolioDetails(aParams: { [param: string]: any }) { public fetchPortfolioDetails(aParams: { [param: string]: any }) {
return this.http.get<PortfolioDetails>('/api/portfolio/details', { return this.http.get<PortfolioDetails>('/api/portfolio/details', {
params: aParams params: aParams
@ -181,7 +188,10 @@ export class DataService {
} }
public fetchPortfolioPerformance(aParams: { [param: string]: any }) { public fetchPortfolioPerformance(aParams: { [param: string]: any }) {
return this.http.get<PortfolioPerformance>('/api/portfolio/performance', { return this.http.get<{
hasErrors: boolean;
performance: PortfolioPerformance;
}>('/api/portfolio/performance', {
params: aParams params: aParams
}); });
} }
@ -209,8 +219,17 @@ export class DataService {
} }
public fetchPositionDetail(aSymbol: string) { public fetchPositionDetail(aSymbol: string) {
return this.http.get<PortfolioPositionDetail>( return this.http.get<any>(`/api/portfolio/position/${aSymbol}`).pipe(
`/api/portfolio/position/${aSymbol}` map((data) => {
if (data.orders) {
for (const order of data.orders) {
order.createdAt = parseISO(order.createdAt);
order.date = parseISO(order.date);
}
}
return data;
})
); );
} }

View File

@ -15,8 +15,8 @@ export class ImportTransactionsService {
private static CURRENCY_KEYS = ['ccy', 'currency']; private static CURRENCY_KEYS = ['ccy', 'currency'];
private static DATE_KEYS = ['date']; private static DATE_KEYS = ['date'];
private static FEE_KEYS = ['commission', 'fee']; private static FEE_KEYS = ['commission', 'fee'];
private static QUANTITY_KEYS = ['qty', 'quantity', 'shares']; private static QUANTITY_KEYS = ['qty', 'quantity', 'shares', 'units'];
private static SYMBOL_KEYS = ['code', 'symbol']; private static SYMBOL_KEYS = ['code', 'symbol', 'ticker'];
private static TYPE_KEYS = ['action', 'type']; private static TYPE_KEYS = ['action', 'type'];
private static UNIT_PRICE_KEYS = ['price', 'unitprice', 'value']; private static UNIT_PRICE_KEYS = ['price', 'unitprice', 'value'];
@ -214,10 +214,15 @@ export class ImportTransactionsService {
for (const key of ImportTransactionsService.TYPE_KEYS) { for (const key of ImportTransactionsService.TYPE_KEYS) {
if (item[key]) { if (item[key]) {
if (item[key].toLowerCase() === 'buy') { switch (item[key].toLowerCase()) {
case 'buy':
return Type.BUY; return Type.BUY;
} else if (item[key].toLowerCase() === 'sell') { case 'dividend':
return Type.DIVIDEND;
case 'sell':
return Type.SELL; return Type.SELL;
default:
break;
} }
} }
} }

View File

@ -1,4 +1,5 @@
User-agent: * User-agent: *
Allow: / Allow: /
Disallow: /p/*
Sitemap: https://ghostfol.io/sitemap.xml Sitemap: https://ghostfol.io/sitemap.xml

View File

@ -6,30 +6,42 @@
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"> http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
<url> <url>
<loc>https://ghostfol.io</loc> <loc>https://ghostfol.io</loc>
<lastmod>2021-07-31T00:00:00+00:00</lastmod> <lastmod>2022-01-01T00:00:00+00:00</lastmod>
</url> </url>
<url> <url>
<loc>https://ghostfol.io/about</loc> <loc>https://ghostfol.io/about</loc>
<lastmod>2021-07-31T00:00:00+00:00</lastmod> <lastmod>2022-01-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/about/changelog</loc>
<lastmod>2022-01-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/blog</loc>
<lastmod>2022-01-01T00:00:00+00:00</lastmod>
</url> </url>
<url> <url>
<loc>https://ghostfol.io/de/blog/2021/07/hallo-ghostfolio</loc> <loc>https://ghostfol.io/de/blog/2021/07/hallo-ghostfolio</loc>
<lastmod>2021-07-31T00:00:00+00:00</lastmod> <lastmod>2022-01-01T00:00:00+00:00</lastmod>
</url> </url>
<url> <url>
<loc>https://ghostfol.io/en/blog/2021/07/hello-ghostfolio</loc> <loc>https://ghostfol.io/en/blog/2021/07/hello-ghostfolio</loc>
<lastmod>2021-07-31T00:00:00+00:00</lastmod> <lastmod>2022-01-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/01/ghostfolio-first-months-in-open-source</loc>
<lastmod>2022-01-05T00:00:00+00:00</lastmod>
</url> </url>
<url> <url>
<loc>https://ghostfol.io/pricing</loc> <loc>https://ghostfol.io/pricing</loc>
<lastmod>2021-07-31T00:00:00+00:00</lastmod> <lastmod>2022-01-01T00:00:00+00:00</lastmod>
</url> </url>
<url> <url>
<loc>https://ghostfol.io/register</loc> <loc>https://ghostfol.io/register</loc>
<lastmod>2021-07-31T00:00:00+00:00</lastmod> <lastmod>2022-01-01T00:00:00+00:00</lastmod>
</url> </url>
<url> <url>
<loc>https://ghostfol.io/resources</loc> <loc>https://ghostfol.io/resources</loc>
<lastmod>2021-07-31T00:00:00+00:00</lastmod> <lastmod>2022-01-01T00:00:00+00:00</lastmod>
</url> </url>
</urlset> </urlset>

View File

@ -38,7 +38,7 @@ body {
.blog { .blog {
a { a {
color: rgba(var(--palette-primary-500), 1); color: rgba(var(--palette-primary-500), 1) !important;
font-weight: 500; font-weight: 500;
&:hover { &:hover {

View File

@ -1,5 +1,17 @@
import { ToggleOption } from './types';
export const baseCurrency = 'USD'; export const baseCurrency = 'USD';
export const defaultDateRangeOptions: ToggleOption[] = [
{ label: 'Today', value: '1d' },
{ label: 'YTD', value: 'ytd' },
{ label: '1Y', value: '1y' },
{ label: '5Y', value: '5y' },
{ label: 'Max', value: 'max' }
];
export const DEMO_USER_ID = '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f';
export const ghostfolioScraperApiSymbolPrefix = '_GF_'; export const ghostfolioScraperApiSymbolPrefix = '_GF_';
export const ghostfolioCashSymbol = `${ghostfolioScraperApiSymbolPrefix}CASH`; export const ghostfolioCashSymbol = `${ghostfolioScraperApiSymbolPrefix}CASH`;
export const ghostfolioFearAndGreedIndexSymbol = `${ghostfolioScraperApiSymbolPrefix}FEAR_AND_GREED_INDEX`; export const ghostfolioFearAndGreedIndexSymbol = `${ghostfolioScraperApiSymbolPrefix}FEAR_AND_GREED_INDEX`;
@ -35,6 +47,7 @@ export const PROPERTY_CURRENCIES = 'CURRENCIES';
export const PROPERTY_IS_READ_ONLY_MODE = 'IS_READ_ONLY_MODE'; export const PROPERTY_IS_READ_ONLY_MODE = 'IS_READ_ONLY_MODE';
export const PROPERTY_LAST_DATA_GATHERING = 'LAST_DATA_GATHERING'; export const PROPERTY_LAST_DATA_GATHERING = 'LAST_DATA_GATHERING';
export const PROPERTY_LOCKED_DATA_GATHERING = 'LOCKED_DATA_GATHERING'; export const PROPERTY_LOCKED_DATA_GATHERING = 'LOCKED_DATA_GATHERING';
export const PROPERTY_SLACK_COMMUNITY_USERS = 'SLACK_COMMUNITY_USERS';
export const PROPERTY_STRIPE_CONFIG = 'STRIPE_CONFIG'; export const PROPERTY_STRIPE_CONFIG = 'STRIPE_CONFIG';
export const PROPERTY_SYSTEM_MESSAGE = 'SYSTEM_MESSAGE'; export const PROPERTY_SYSTEM_MESSAGE = 'SYSTEM_MESSAGE';

View File

@ -3,8 +3,6 @@ import { getDate, getMonth, getYear, parse, subDays } from 'date-fns';
import { ghostfolioScraperApiSymbolPrefix } from './config'; import { ghostfolioScraperApiSymbolPrefix } from './config';
export const DEMO_USER_ID = '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f';
export function capitalize(aString: string) { export function capitalize(aString: string) {
return aString.charAt(0).toUpperCase() + aString.slice(1).toLowerCase(); return aString.charAt(0).toUpperCase() + aString.slice(1).toLowerCase();
} }

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