Compare commits
78 Commits
Author | SHA1 | Date | |
---|---|---|---|
9676f96e97 | |||
65e151151b | |||
5d3bbb8f30 | |||
b464fefc57 | |||
bcb7f5f522 | |||
f15b33e950 | |||
ca64492e77 | |||
761376d72d | |||
9c086edffe | |||
585f99e4df | |||
9d907b5eb5 | |||
ba05f5ba30 | |||
3261e3ee59 | |||
5607c6bb52 | |||
1c6050d3e3 | |||
38f2930ec6 | |||
556be61fff | |||
651b4bcff7 | |||
0a8d159f78 | |||
1a4109ebaa | |||
92e502e1c2 | |||
e344c43a5a | |||
d6b78f3457 | |||
9bbb856f66 | |||
d3707bbb87 | |||
7df53896f3 | |||
b2b3fde80e | |||
a83441b3ba | |||
075431d868 | |||
0168c1c4e8 | |||
07de8f87fc | |||
3e16041c16 | |||
5882b7914d | |||
69c9e259b1 | |||
aca37a27f9 | |||
313d2a2f79 | |||
9ac67b0af2 | |||
1e526852a7 | |||
e54638a684 | |||
0179823ad9 | |||
029b7bed9a | |||
635f10e2d0 | |||
cebf879d67 | |||
124bdc028d | |||
d69a69ce18 | |||
15344513ce | |||
b291d9e031 | |||
bee702302f | |||
bb56e09a13 | |||
0873f539c5 | |||
6dcd801d05 | |||
77065dac50 | |||
438484879d | |||
e37a650c70 | |||
6e8c90b3fc | |||
9e1a7fc981 | |||
ff638adf03 | |||
fa44cee781 | |||
db1d474ddf | |||
994275e093 | |||
ee397c8047 | |||
7203939c42 | |||
9725f16c81 | |||
bb8b1e4f43 | |||
9d3610331a | |||
0043b44670 | |||
bbc4e64cb4 | |||
c7f4825499 | |||
8f583709ef | |||
4c30212a72 | |||
cade2f6a5e | |||
3b9a8fabb5 | |||
3435b3a348 | |||
5d39b267ab | |||
ffaaa14dba | |||
c65746d119 | |||
1a6840f1f6 | |||
fb7fb886f6 |
191
CHANGELOG.md
191
CHANGELOG.md
@ -5,6 +5,197 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## 1.108.0 - 27.01.2022
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the annualized performance in the new calculation engine
|
||||
- Increased the historical data chart of the _Fear & Greed Index_ (market mood) to 90 days
|
||||
|
||||
## 1.107.0 - 24.01.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added a new calculation engine (experimental)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the styling in the footer row of the activities table
|
||||
|
||||
## 1.106.0 - 23.01.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added the footer row with total fees and total value to the activities table
|
||||
|
||||
### Changed
|
||||
|
||||
- Extended the historical data view in the admin control panel
|
||||
- Upgraded _Stripe_ dependencies
|
||||
- Upgraded `prisma` from version `3.7.0` to `3.8.1`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Improved the redirection on logout
|
||||
|
||||
## 1.105.0 - 20.01.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for fetching multiple symbols in the `GOOGLE_SHEETS` data provider
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the data provider with grouping by data source and thereby reducing the number of requests
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the unresolved account names in the _X-ray_ section
|
||||
- Fixed the date conversion in the `GOOGLE_SHEETS` data provider
|
||||
|
||||
## 1.104.0 - 16.01.2022
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the fallback to load currencies directly from the data provider
|
||||
- Fixed the missing symbol profile data connection in the import functionality for activities
|
||||
|
||||
## 1.103.0 - 13.01.2022
|
||||
|
||||
### Changed
|
||||
|
||||
- Added links to the statistics section on the about page
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the currency of the value in the position detail dialog
|
||||
|
||||
## 1.102.0 - 11.01.2022
|
||||
|
||||
### Changed
|
||||
|
||||
- Start eliminating `dataSource` from activity
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the support for multiple accounts with the same name
|
||||
- Fixed the preselected default account of the create activity dialog
|
||||
|
||||
## 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
|
||||
|
||||
### Added
|
||||
|
20
README.md
20
README.md
@ -12,7 +12,7 @@
|
||||
<strong>Open Source Wealth Management Software made for Humans</strong>
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/en/blog/2021/07/hello-ghostfolio"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
|
||||
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/blog"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
|
||||
</p>
|
||||
<p>
|
||||
<a href="#contributing">
|
||||
@ -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.
|
||||
|
||||
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?
|
||||
|
||||
@ -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
|
||||
```
|
||||
|
||||
#### 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
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
### Setup Database
|
||||
#### Setup Database
|
||||
|
||||
Run the following command to setup the database once Ghostfolio is running:
|
||||
|
||||
```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
|
||||
@ -145,9 +153,7 @@ docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn dat
|
||||
### Setup
|
||||
|
||||
1. Run `yarn install`
|
||||
1. Run `cd docker`
|
||||
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 `docker-compose -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||
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. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||
import { PortfolioServiceStrategy } from '@ghostfolio/api/app/portfolio/portfolio-service.strategy';
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import {
|
||||
nullifyValuesInObject,
|
||||
@ -35,7 +35,7 @@ export class AccountController {
|
||||
public constructor(
|
||||
private readonly accountService: AccountService,
|
||||
private readonly impersonationService: ImpersonationService,
|
||||
private readonly portfolioService: PortfolioService,
|
||||
private readonly portfolioServiceStrategy: PortfolioServiceStrategy,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
private readonly userService: UserService
|
||||
) {}
|
||||
@ -91,10 +91,9 @@ export class AccountController {
|
||||
this.request.user.id
|
||||
);
|
||||
|
||||
let accountsWithAggregations =
|
||||
await this.portfolioService.getAccountsWithAggregations(
|
||||
impersonationUserId || this.request.user.id
|
||||
);
|
||||
let accountsWithAggregations = await this.portfolioServiceStrategy
|
||||
.get()
|
||||
.getAccountsWithAggregations(impersonationUserId || this.request.user.id);
|
||||
|
||||
if (
|
||||
impersonationUserId ||
|
||||
|
@ -96,6 +96,29 @@ export class AdminController {
|
||||
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')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async gatherSymbol(
|
||||
@ -215,7 +238,7 @@ export class AdminController {
|
||||
const date = new Date(dateString);
|
||||
|
||||
return this.marketDataService.updateMarketData({
|
||||
data,
|
||||
data: { ...data, dataSource },
|
||||
where: {
|
||||
date_symbol: {
|
||||
date,
|
||||
|
@ -9,7 +9,8 @@ import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config';
|
||||
import {
|
||||
AdminData,
|
||||
AdminMarketData,
|
||||
AdminMarketDataDetails
|
||||
AdminMarketDataDetails,
|
||||
AdminMarketDataItem
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Property } from '@prisma/client';
|
||||
@ -56,12 +57,67 @@ export class AdminService {
|
||||
}
|
||||
|
||||
public async getMarketData(): Promise<AdminMarketData> {
|
||||
return {
|
||||
marketData: await (
|
||||
await this.dataGatheringService.getSymbolsMax()
|
||||
).map((symbol) => {
|
||||
return symbol;
|
||||
const marketData = await this.prismaService.marketData.groupBy({
|
||||
_count: true,
|
||||
by: ['dataSource', 'symbol']
|
||||
});
|
||||
|
||||
const currencyPairsToGather: AdminMarketDataItem[] =
|
||||
this.exchangeRateDataService
|
||||
.getCurrencyPairs()
|
||||
.map(({ dataSource, symbol }) => {
|
||||
const marketDataItemCount =
|
||||
marketData.find((marketDataItem) => {
|
||||
return (
|
||||
marketDataItem.dataSource === dataSource &&
|
||||
marketDataItem.symbol === symbol
|
||||
);
|
||||
})?._count ?? 0;
|
||||
|
||||
return {
|
||||
dataSource,
|
||||
marketDataItemCount,
|
||||
symbol
|
||||
};
|
||||
});
|
||||
|
||||
const symbolProfilesToGather: AdminMarketDataItem[] = (
|
||||
await this.prismaService.symbolProfile.findMany({
|
||||
orderBy: [{ symbol: 'asc' }],
|
||||
select: {
|
||||
_count: {
|
||||
select: { Order: true }
|
||||
},
|
||||
dataSource: true,
|
||||
Order: {
|
||||
orderBy: [{ date: 'asc' }],
|
||||
select: { date: true },
|
||||
take: 1
|
||||
},
|
||||
scraperConfiguration: true,
|
||||
symbol: true
|
||||
}
|
||||
})
|
||||
).map((symbolProfile) => {
|
||||
const marketDataItemCount =
|
||||
marketData.find((marketDataItem) => {
|
||||
return (
|
||||
marketDataItem.dataSource === symbolProfile.dataSource &&
|
||||
marketDataItem.symbol === symbolProfile.symbol
|
||||
);
|
||||
})?._count ?? 0;
|
||||
|
||||
return {
|
||||
marketDataItemCount,
|
||||
activityCount: symbolProfile._count.Order,
|
||||
dataSource: symbolProfile.dataSource,
|
||||
date: symbolProfile.Order?.[0]?.date,
|
||||
symbol: symbolProfile.symbol
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
marketData: [...currencyPairsToGather, ...symbolProfilesToGather]
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
@ -15,10 +15,11 @@ import { ImportService } from './import.service';
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
OrderModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule
|
||||
],
|
||||
controllers: [ImportController],
|
||||
providers: [CacheService, ImportService, OrderService]
|
||||
providers: [CacheService, ImportService]
|
||||
})
|
||||
export class ImportModule {}
|
||||
|
@ -34,11 +34,6 @@ export class ImportService {
|
||||
unitPrice
|
||||
} of orders) {
|
||||
await this.orderService.createOrder({
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: { userId, id: accountId }
|
||||
}
|
||||
},
|
||||
currency,
|
||||
dataSource,
|
||||
fee,
|
||||
@ -46,7 +41,26 @@ export class ImportService {
|
||||
symbol,
|
||||
type,
|
||||
unitPrice,
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: { userId, id: accountId }
|
||||
}
|
||||
},
|
||||
date: parseISO(<string>(<unknown>date)),
|
||||
SymbolProfile: {
|
||||
connectOrCreate: {
|
||||
create: {
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
where: {
|
||||
dataSource_symbol: {
|
||||
dataSource,
|
||||
symbol
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
User: { connect: { id: userId } }
|
||||
});
|
||||
}
|
||||
|
@ -6,7 +6,9 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import {
|
||||
DEMO_USER_ID,
|
||||
PROPERTY_IS_READ_ONLY_MODE,
|
||||
PROPERTY_SLACK_COMMUNITY_USERS,
|
||||
PROPERTY_STRIPE_CONFIG,
|
||||
PROPERTY_SYSTEM_MESSAGE
|
||||
} from '@ghostfolio/common/config';
|
||||
@ -22,7 +24,6 @@ import { subDays } from 'date-fns';
|
||||
@Injectable()
|
||||
export class InfoService {
|
||||
private static CACHE_KEY_STATISTICS = 'STATISTICS';
|
||||
private static DEMO_USER_ID = '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f';
|
||||
|
||||
public constructor(
|
||||
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() {
|
||||
return this.jwtService.sign({
|
||||
id: InfoService.DEMO_USER_ID
|
||||
id: DEMO_USER_ID
|
||||
});
|
||||
}
|
||||
|
||||
@ -218,19 +225,19 @@ export class InfoService {
|
||||
} catch {}
|
||||
|
||||
const activeUsers1d = await this.countActiveUsers(1);
|
||||
const activeUsers7d = await this.countActiveUsers(7);
|
||||
const activeUsers30d = await this.countActiveUsers(30);
|
||||
const newUsers30d = await this.countNewUsers(30);
|
||||
const gitHubContributors = await this.countGitHubContributors();
|
||||
const gitHubStargazers = await this.countGitHubStargazers();
|
||||
const slackCommunityUsers = await this.countSlackCommunityUsers();
|
||||
|
||||
statistics = {
|
||||
activeUsers1d,
|
||||
activeUsers7d,
|
||||
activeUsers30d,
|
||||
gitHubContributors,
|
||||
gitHubStargazers,
|
||||
newUsers30d
|
||||
newUsers30d,
|
||||
slackCommunityUsers
|
||||
};
|
||||
|
||||
await this.redisCacheService.set(
|
||||
|
10
apps/api/src/app/order/interfaces/activities.interface.ts
Normal file
10
apps/api/src/app/order/interfaces/activities.interface.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
|
||||
export interface Activities {
|
||||
activities: Activity[];
|
||||
}
|
||||
|
||||
export interface Activity extends OrderWithAccount {
|
||||
feeInBaseCurrency: number;
|
||||
valueInBaseCurrency: number;
|
||||
}
|
@ -23,6 +23,7 @@ import { parseISO } from 'date-fns';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { CreateOrderDto } from './create-order.dto';
|
||||
import { Activities } from './interfaces/activities.interface';
|
||||
import { OrderService } from './order.service';
|
||||
import { UpdateOrderDto } from './update-order.dto';
|
||||
|
||||
@ -59,38 +60,35 @@ export class OrderController {
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getAllOrders(
|
||||
@Headers('impersonation-id') impersonationId
|
||||
): Promise<OrderModel[]> {
|
||||
): Promise<Activities> {
|
||||
const impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(
|
||||
impersonationId,
|
||||
this.request.user.id
|
||||
);
|
||||
const userCurrency = this.request.user.Settings.currency;
|
||||
|
||||
let orders = await this.orderService.orders({
|
||||
include: {
|
||||
Account: {
|
||||
include: {
|
||||
Platform: true
|
||||
}
|
||||
},
|
||||
SymbolProfile: {
|
||||
select: {
|
||||
name: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: { date: 'desc' },
|
||||
where: { userId: impersonationUserId || this.request.user.id }
|
||||
let activities = await this.orderService.getOrders({
|
||||
userCurrency,
|
||||
includeDrafts: true,
|
||||
userId: impersonationUserId || this.request.user.id
|
||||
});
|
||||
|
||||
if (
|
||||
impersonationUserId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
orders = nullifyValuesInObjects(orders, ['fee', 'quantity', 'unitPrice']);
|
||||
activities = nullifyValuesInObjects(activities, [
|
||||
'fee',
|
||||
'feeInBaseCurrency',
|
||||
'quantity',
|
||||
'unitPrice',
|
||||
'value',
|
||||
'valueInBaseCurrency'
|
||||
]);
|
||||
}
|
||||
|
||||
return orders;
|
||||
return { activities };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ -123,23 +121,23 @@ export class OrderController {
|
||||
|
||||
return this.orderService.createOrder({
|
||||
...data,
|
||||
date,
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: { id: accountId, userId: this.request.user.id }
|
||||
}
|
||||
},
|
||||
date,
|
||||
SymbolProfile: {
|
||||
connectOrCreate: {
|
||||
create: {
|
||||
dataSource: data.dataSource,
|
||||
symbol: data.symbol
|
||||
},
|
||||
where: {
|
||||
dataSource_symbol: {
|
||||
dataSource: data.dataSource,
|
||||
symbol: data.symbol
|
||||
}
|
||||
},
|
||||
create: {
|
||||
dataSource: data.dataSource,
|
||||
symbol: data.symbol
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -4,6 +4,7 @@ import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
@ -16,6 +17,7 @@ import { OrderService } from './order.service';
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
ImpersonationModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule,
|
||||
|
@ -1,15 +1,20 @@
|
||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
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 { Activity } from './interfaces/activities.interface';
|
||||
|
||||
@Injectable()
|
||||
export class OrderService {
|
||||
public constructor(
|
||||
private readonly cacheService: CacheService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly prismaService: PrismaService
|
||||
) {}
|
||||
@ -82,28 +87,65 @@ export class OrderService {
|
||||
});
|
||||
}
|
||||
|
||||
public getOrders({
|
||||
public async getOrders({
|
||||
includeDrafts = false,
|
||||
types,
|
||||
userCurrency,
|
||||
userId
|
||||
}: {
|
||||
includeDrafts?: boolean;
|
||||
types?: TypeOfOrder[];
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
}) {
|
||||
}): Promise<Activity[]> {
|
||||
const where: Prisma.OrderWhereInput = { userId };
|
||||
|
||||
if (includeDrafts === false) {
|
||||
where.isDraft = false;
|
||||
}
|
||||
|
||||
return this.orders({
|
||||
where,
|
||||
include: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
Account: true,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
SymbolProfile: true
|
||||
},
|
||||
orderBy: { date: 'asc' }
|
||||
if (types) {
|
||||
where.OR = types.map((type) => {
|
||||
return {
|
||||
type: {
|
||||
equals: type
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
await this.orders({
|
||||
where,
|
||||
include: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
Account: {
|
||||
include: {
|
||||
Platform: true
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
SymbolProfile: true
|
||||
},
|
||||
orderBy: { date: 'asc' }
|
||||
})
|
||||
).map((order) => {
|
||||
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
|
||||
|
||||
return {
|
||||
...order,
|
||||
value,
|
||||
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
order.fee,
|
||||
order.currency,
|
||||
userCurrency
|
||||
),
|
||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
value,
|
||||
order.currency,
|
||||
userCurrency
|
||||
)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -85,19 +85,6 @@ describe('CurrentRateService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('getValue', async () => {
|
||||
expect(
|
||||
await currentRateService.getValue({
|
||||
currency: 'USD',
|
||||
date: new Date(Date.UTC(2020, 0, 1, 0, 0, 0)),
|
||||
symbol: 'AMZN',
|
||||
userCurrency: 'CHF'
|
||||
})
|
||||
).toMatchObject({
|
||||
marketPrice: 1847.839966
|
||||
});
|
||||
});
|
||||
|
||||
it('getValues', async () => {
|
||||
expect(
|
||||
await currentRateService.getValues({
|
||||
|
@ -7,7 +7,6 @@ import { isBefore, isToday } from 'date-fns';
|
||||
import { flatten } from 'lodash';
|
||||
|
||||
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||
import { GetValueParams } from './interfaces/get-value-params.interface';
|
||||
import { GetValuesParams } from './interfaces/get-values-params.interface';
|
||||
|
||||
@Injectable()
|
||||
@ -18,46 +17,6 @@ export class CurrentRateService {
|
||||
private readonly marketDataService: MarketDataService
|
||||
) {}
|
||||
|
||||
public async getValue({
|
||||
currency,
|
||||
date,
|
||||
symbol,
|
||||
userCurrency
|
||||
}: GetValueParams): Promise<GetValueObject> {
|
||||
if (isToday(date)) {
|
||||
const dataProviderResult = await this.dataProviderService.get([
|
||||
{
|
||||
symbol,
|
||||
dataSource: this.dataProviderService.getPrimaryDataSource()
|
||||
}
|
||||
]);
|
||||
return {
|
||||
symbol,
|
||||
date: resetHours(date),
|
||||
marketPrice: dataProviderResult?.[symbol]?.marketPrice ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
const marketData = await this.marketDataService.get({
|
||||
date,
|
||||
symbol
|
||||
});
|
||||
|
||||
if (marketData) {
|
||||
return {
|
||||
date: marketData.date,
|
||||
marketPrice: this.exchangeRateDataService.toCurrency(
|
||||
marketData.marketPrice,
|
||||
currency,
|
||||
userCurrency
|
||||
),
|
||||
symbol: marketData.symbol
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Value not found for ${symbol} at ${resetHours(date)}`);
|
||||
}
|
||||
|
||||
public async getValues({
|
||||
currencies,
|
||||
dataGatheringItems,
|
||||
|
@ -6,7 +6,7 @@ export interface CurrentPositions {
|
||||
positions: TimelinePosition[];
|
||||
grossPerformance: Big;
|
||||
grossPerformancePercentage: Big;
|
||||
netAnnualizedPerformance: Big;
|
||||
netAnnualizedPerformance?: Big;
|
||||
netPerformance: Big;
|
||||
netPerformancePercentage: Big;
|
||||
currentValue: Big;
|
||||
|
@ -1,6 +0,0 @@
|
||||
export interface GetValueParams {
|
||||
currency: string;
|
||||
date: Date;
|
||||
symbol: string;
|
||||
userCurrency: string;
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
import { PortfolioOrder } from './portfolio-order.interface';
|
||||
|
||||
export interface PortfolioOrderItem extends PortfolioOrder {
|
||||
itemType?: '' | 'start' | 'end';
|
||||
}
|
@ -1,3 +1,4 @@
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
import { AssetClass, AssetSubClass } from '@prisma/client';
|
||||
|
||||
export interface PortfolioPositionDetail {
|
||||
@ -16,6 +17,7 @@ export interface PortfolioPositionDetail {
|
||||
name: string;
|
||||
netPerformance: number;
|
||||
netPerformancePercent: number;
|
||||
orders: OrderWithAccount[];
|
||||
quantity: number;
|
||||
symbol: string;
|
||||
transactionCount: number;
|
||||
|
73
apps/api/src/app/portfolio/portfolio-calculator-new.spec.ts
Normal file
73
apps/api/src/app/portfolio/portfolio-calculator-new.spec.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import Big from 'big.js';
|
||||
|
||||
import { CurrentRateService } from './current-rate.service';
|
||||
import { PortfolioCalculatorNew } from './portfolio-calculator-new';
|
||||
|
||||
describe('PortfolioCalculatorNew', () => {
|
||||
let currentRateService: CurrentRateService;
|
||||
|
||||
beforeEach(() => {
|
||||
currentRateService = new CurrentRateService(null, null, null);
|
||||
});
|
||||
|
||||
describe('annualized performance percentage', () => {
|
||||
const portfolioCalculatorNew = new PortfolioCalculatorNew({
|
||||
currentRateService,
|
||||
currency: 'USD',
|
||||
orders: []
|
||||
});
|
||||
|
||||
it('Get annualized performance', async () => {
|
||||
expect(
|
||||
portfolioCalculatorNew
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket: NaN, // differenceInDays of date-fns returns NaN for the same day
|
||||
netPerformancePercent: new Big(0)
|
||||
})
|
||||
.toNumber()
|
||||
).toEqual(0);
|
||||
|
||||
expect(
|
||||
portfolioCalculatorNew
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket: 0,
|
||||
netPerformancePercent: new Big(0)
|
||||
})
|
||||
.toNumber()
|
||||
).toEqual(0);
|
||||
|
||||
/**
|
||||
* Source: https://www.readyratios.com/reference/analysis/annualized_rate.html
|
||||
*/
|
||||
expect(
|
||||
portfolioCalculatorNew
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket: 65, // < 1 year
|
||||
netPerformancePercent: new Big(0.1025)
|
||||
})
|
||||
.toNumber()
|
||||
).toBeCloseTo(0.729705);
|
||||
|
||||
expect(
|
||||
portfolioCalculatorNew
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket: 365, // 1 year
|
||||
netPerformancePercent: new Big(0.05)
|
||||
})
|
||||
.toNumber()
|
||||
).toBeCloseTo(0.05);
|
||||
|
||||
/**
|
||||
* Source: https://www.investopedia.com/terms/a/annualized-total-return.asp#annualized-return-formula-and-calculation
|
||||
*/
|
||||
expect(
|
||||
portfolioCalculatorNew
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket: 575, // > 1 year
|
||||
netPerformancePercent: new Big(0.2374)
|
||||
})
|
||||
.toNumber()
|
||||
).toBeCloseTo(0.145);
|
||||
});
|
||||
});
|
||||
});
|
849
apps/api/src/app/portfolio/portfolio-calculator-new.ts
Normal file
849
apps/api/src/app/portfolio/portfolio-calculator-new.ts
Normal file
@ -0,0 +1,849 @@
|
||||
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
|
||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||
import { TimelinePosition } from '@ghostfolio/common/interfaces';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Type as TypeOfOrder } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import {
|
||||
addDays,
|
||||
addMilliseconds,
|
||||
addMonths,
|
||||
addYears,
|
||||
endOfDay,
|
||||
format,
|
||||
isAfter,
|
||||
isBefore,
|
||||
max,
|
||||
min
|
||||
} from 'date-fns';
|
||||
import { first, flatten, isNumber, sortBy } from 'lodash';
|
||||
|
||||
import { CurrentRateService } from './current-rate.service';
|
||||
import { CurrentPositions } from './interfaces/current-positions.interface';
|
||||
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||
import { PortfolioOrderItem } from './interfaces/portfolio-calculator.interface';
|
||||
import { PortfolioOrder } from './interfaces/portfolio-order.interface';
|
||||
import { TimelinePeriod } from './interfaces/timeline-period.interface';
|
||||
import {
|
||||
Accuracy,
|
||||
TimelineSpecification
|
||||
} from './interfaces/timeline-specification.interface';
|
||||
import { TransactionPointSymbol } from './interfaces/transaction-point-symbol.interface';
|
||||
import { TransactionPoint } from './interfaces/transaction-point.interface';
|
||||
|
||||
export class PortfolioCalculatorNew {
|
||||
private currency: string;
|
||||
private currentRateService: CurrentRateService;
|
||||
private orders: PortfolioOrder[];
|
||||
private transactionPoints: TransactionPoint[];
|
||||
|
||||
public constructor({
|
||||
currency,
|
||||
currentRateService,
|
||||
orders
|
||||
}: {
|
||||
currency: string;
|
||||
currentRateService: CurrentRateService;
|
||||
orders: PortfolioOrder[];
|
||||
}) {
|
||||
this.currency = currency;
|
||||
this.currentRateService = currentRateService;
|
||||
this.orders = orders;
|
||||
|
||||
this.orders.sort((a, b) => a.date.localeCompare(b.date));
|
||||
}
|
||||
|
||||
public computeTransactionPoints() {
|
||||
this.transactionPoints = [];
|
||||
const symbols: { [symbol: string]: TransactionPointSymbol } = {};
|
||||
|
||||
let lastDate: string = null;
|
||||
let lastTransactionPoint: TransactionPoint = null;
|
||||
for (const order of this.orders) {
|
||||
const currentDate = order.date;
|
||||
|
||||
let currentTransactionPointItem: TransactionPointSymbol;
|
||||
const oldAccumulatedSymbol = symbols[order.symbol];
|
||||
|
||||
const factor = this.getFactor(order.type);
|
||||
const unitPrice = new Big(order.unitPrice);
|
||||
if (oldAccumulatedSymbol) {
|
||||
const newQuantity = order.quantity
|
||||
.mul(factor)
|
||||
.plus(oldAccumulatedSymbol.quantity);
|
||||
currentTransactionPointItem = {
|
||||
currency: order.currency,
|
||||
dataSource: order.dataSource,
|
||||
fee: order.fee.plus(oldAccumulatedSymbol.fee),
|
||||
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
|
||||
investment: newQuantity.eq(0)
|
||||
? new Big(0)
|
||||
: unitPrice
|
||||
.mul(order.quantity)
|
||||
.mul(factor)
|
||||
.add(oldAccumulatedSymbol.investment),
|
||||
quantity: newQuantity,
|
||||
symbol: order.symbol,
|
||||
transactionCount: oldAccumulatedSymbol.transactionCount + 1
|
||||
};
|
||||
} else {
|
||||
currentTransactionPointItem = {
|
||||
currency: order.currency,
|
||||
dataSource: order.dataSource,
|
||||
fee: order.fee,
|
||||
firstBuyDate: order.date,
|
||||
investment: unitPrice.mul(order.quantity).mul(factor),
|
||||
quantity: order.quantity.mul(factor),
|
||||
symbol: order.symbol,
|
||||
transactionCount: 1
|
||||
};
|
||||
}
|
||||
|
||||
symbols[order.symbol] = currentTransactionPointItem;
|
||||
|
||||
const items = lastTransactionPoint?.items ?? [];
|
||||
const newItems = items.filter(
|
||||
(transactionPointItem) => transactionPointItem.symbol !== order.symbol
|
||||
);
|
||||
newItems.push(currentTransactionPointItem);
|
||||
newItems.sort((a, b) => a.symbol.localeCompare(b.symbol));
|
||||
if (lastDate !== currentDate || lastTransactionPoint === null) {
|
||||
lastTransactionPoint = {
|
||||
date: currentDate,
|
||||
items: newItems
|
||||
};
|
||||
this.transactionPoints.push(lastTransactionPoint);
|
||||
} else {
|
||||
lastTransactionPoint.items = newItems;
|
||||
}
|
||||
lastDate = currentDate;
|
||||
}
|
||||
}
|
||||
|
||||
public getAnnualizedPerformancePercent({
|
||||
daysInMarket,
|
||||
netPerformancePercent
|
||||
}: {
|
||||
daysInMarket: number;
|
||||
netPerformancePercent: Big;
|
||||
}): Big {
|
||||
if (isNumber(daysInMarket) && daysInMarket > 0) {
|
||||
const exponent = new Big(365).div(daysInMarket).toNumber();
|
||||
return new Big(
|
||||
Math.pow(netPerformancePercent.plus(1).toNumber(), exponent)
|
||||
).minus(1);
|
||||
}
|
||||
|
||||
return new Big(0);
|
||||
}
|
||||
|
||||
public getTransactionPoints(): TransactionPoint[] {
|
||||
return this.transactionPoints;
|
||||
}
|
||||
|
||||
public setTransactionPoints(transactionPoints: TransactionPoint[]) {
|
||||
this.transactionPoints = transactionPoints;
|
||||
}
|
||||
|
||||
public async getCurrentPositions(start: Date): Promise<CurrentPositions> {
|
||||
if (!this.transactionPoints?.length) {
|
||||
return {
|
||||
currentValue: new Big(0),
|
||||
hasErrors: false,
|
||||
grossPerformance: new Big(0),
|
||||
grossPerformancePercentage: new Big(0),
|
||||
netPerformance: new Big(0),
|
||||
netPerformancePercentage: new Big(0),
|
||||
positions: [],
|
||||
totalInvestment: new Big(0)
|
||||
};
|
||||
}
|
||||
|
||||
const lastTransactionPoint =
|
||||
this.transactionPoints[this.transactionPoints.length - 1];
|
||||
|
||||
// use Date.now() to use the mock for today
|
||||
const today = new Date(Date.now());
|
||||
|
||||
let firstTransactionPoint: TransactionPoint = null;
|
||||
let firstIndex = this.transactionPoints.length;
|
||||
const dates = [];
|
||||
const dataGatheringItems: IDataGatheringItem[] = [];
|
||||
const currencies: { [symbol: string]: string } = {};
|
||||
|
||||
dates.push(resetHours(start));
|
||||
for (const item of this.transactionPoints[firstIndex - 1].items) {
|
||||
dataGatheringItems.push({
|
||||
dataSource: item.dataSource,
|
||||
symbol: item.symbol
|
||||
});
|
||||
currencies[item.symbol] = item.currency;
|
||||
}
|
||||
for (let i = 0; i < this.transactionPoints.length; i++) {
|
||||
if (
|
||||
!isBefore(parseDate(this.transactionPoints[i].date), start) &&
|
||||
firstTransactionPoint === null
|
||||
) {
|
||||
firstTransactionPoint = this.transactionPoints[i];
|
||||
firstIndex = i;
|
||||
}
|
||||
if (firstTransactionPoint !== null) {
|
||||
dates.push(resetHours(parseDate(this.transactionPoints[i].date)));
|
||||
}
|
||||
}
|
||||
|
||||
dates.push(resetHours(today));
|
||||
|
||||
const marketSymbols = await this.currentRateService.getValues({
|
||||
currencies,
|
||||
dataGatheringItems,
|
||||
dateQuery: {
|
||||
in: dates
|
||||
},
|
||||
userCurrency: this.currency
|
||||
});
|
||||
|
||||
const marketSymbolMap: {
|
||||
[date: string]: { [symbol: string]: Big };
|
||||
} = {};
|
||||
|
||||
for (const marketSymbol of marketSymbols) {
|
||||
const date = format(marketSymbol.date, DATE_FORMAT);
|
||||
if (!marketSymbolMap[date]) {
|
||||
marketSymbolMap[date] = {};
|
||||
}
|
||||
if (marketSymbol.marketPrice) {
|
||||
marketSymbolMap[date][marketSymbol.symbol] = new Big(
|
||||
marketSymbol.marketPrice
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const todayString = format(today, DATE_FORMAT);
|
||||
|
||||
if (firstIndex > 0) {
|
||||
firstIndex--;
|
||||
}
|
||||
const initialValues: { [symbol: string]: Big } = {};
|
||||
|
||||
const positions: TimelinePosition[] = [];
|
||||
let hasErrorsInSymbolMetrics = false;
|
||||
|
||||
for (const item of lastTransactionPoint.items) {
|
||||
const marketValue = marketSymbolMap[todayString]?.[item.symbol];
|
||||
|
||||
const {
|
||||
grossPerformance,
|
||||
grossPerformancePercentage,
|
||||
hasErrors,
|
||||
initialValue,
|
||||
netPerformance,
|
||||
netPerformancePercentage
|
||||
} = this.getSymbolMetrics({
|
||||
marketSymbolMap,
|
||||
start,
|
||||
symbol: item.symbol
|
||||
});
|
||||
|
||||
hasErrorsInSymbolMetrics = hasErrorsInSymbolMetrics || hasErrors;
|
||||
|
||||
initialValues[item.symbol] = initialValue;
|
||||
|
||||
positions.push({
|
||||
averagePrice: item.quantity.eq(0)
|
||||
? new Big(0)
|
||||
: item.investment.div(item.quantity),
|
||||
currency: item.currency,
|
||||
dataSource: item.dataSource,
|
||||
firstBuyDate: item.firstBuyDate,
|
||||
grossPerformance: !hasErrors ? grossPerformance ?? null : null,
|
||||
grossPerformancePercentage: !hasErrors
|
||||
? grossPerformancePercentage ?? null
|
||||
: null,
|
||||
investment: item.investment,
|
||||
marketPrice: marketValue?.toNumber() ?? null,
|
||||
netPerformance: !hasErrors ? netPerformance ?? null : null,
|
||||
netPerformancePercentage: !hasErrors
|
||||
? netPerformancePercentage ?? null
|
||||
: null,
|
||||
quantity: item.quantity,
|
||||
symbol: item.symbol,
|
||||
transactionCount: item.transactionCount
|
||||
});
|
||||
}
|
||||
const overall = this.calculateOverallPerformance(positions, initialValues);
|
||||
|
||||
return {
|
||||
...overall,
|
||||
positions,
|
||||
hasErrors: hasErrorsInSymbolMetrics || overall.hasErrors
|
||||
};
|
||||
}
|
||||
|
||||
public getSymbolMetrics({
|
||||
marketSymbolMap,
|
||||
start,
|
||||
symbol
|
||||
}: {
|
||||
marketSymbolMap: {
|
||||
[date: string]: { [symbol: string]: Big };
|
||||
};
|
||||
start: Date;
|
||||
symbol: string;
|
||||
}) {
|
||||
let orders: PortfolioOrderItem[] = this.orders.filter((order) => {
|
||||
return order.symbol === symbol;
|
||||
});
|
||||
|
||||
if (orders.length <= 0) {
|
||||
return {
|
||||
hasErrors: false,
|
||||
initialValue: new Big(0),
|
||||
netPerformance: new Big(0),
|
||||
netPerformancePercentage: new Big(0),
|
||||
grossPerformance: new Big(0),
|
||||
grossPerformancePercentage: new Big(0)
|
||||
};
|
||||
}
|
||||
|
||||
const dateOfFirstTransaction = new Date(first(orders).date);
|
||||
const endDate = new Date(Date.now());
|
||||
|
||||
const unitPriceAtStartDate =
|
||||
marketSymbolMap[format(start, DATE_FORMAT)]?.[symbol];
|
||||
|
||||
const unitPriceAtEndDate =
|
||||
marketSymbolMap[format(endDate, DATE_FORMAT)]?.[symbol];
|
||||
|
||||
if (
|
||||
!unitPriceAtEndDate ||
|
||||
(!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start))
|
||||
) {
|
||||
return {
|
||||
hasErrors: true,
|
||||
initialValue: new Big(0),
|
||||
netPerformance: new Big(0),
|
||||
netPerformancePercentage: new Big(0),
|
||||
grossPerformance: new Big(0),
|
||||
grossPerformancePercentage: new Big(0)
|
||||
};
|
||||
}
|
||||
|
||||
let feesAtStartDate = new Big(0);
|
||||
let fees = new Big(0);
|
||||
let grossPerformance = new Big(0);
|
||||
let grossPerformanceAtStartDate = new Big(0);
|
||||
let grossPerformanceFromSells = new Big(0);
|
||||
let initialValue: Big;
|
||||
let lastAveragePrice = new Big(0);
|
||||
let lastValueOfInvestment = new Big(0);
|
||||
let lastNetValueOfInvestment = new Big(0);
|
||||
let timeWeightedGrossPerformancePercentage = new Big(1);
|
||||
let timeWeightedNetPerformancePercentage = new Big(1);
|
||||
let totalInvestment = new Big(0);
|
||||
let totalUnits = new Big(0);
|
||||
|
||||
// Add a synthetic order at the start and the end date
|
||||
orders.push({
|
||||
symbol,
|
||||
currency: null,
|
||||
date: format(start, DATE_FORMAT),
|
||||
dataSource: null,
|
||||
fee: new Big(0),
|
||||
itemType: 'start',
|
||||
name: '',
|
||||
quantity: new Big(0),
|
||||
type: TypeOfOrder.BUY,
|
||||
unitPrice: unitPriceAtStartDate ?? new Big(0)
|
||||
});
|
||||
|
||||
orders.push({
|
||||
symbol,
|
||||
currency: null,
|
||||
date: format(endDate, DATE_FORMAT),
|
||||
dataSource: null,
|
||||
fee: new Big(0),
|
||||
itemType: 'end',
|
||||
name: '',
|
||||
quantity: new Big(0),
|
||||
type: TypeOfOrder.BUY,
|
||||
unitPrice: unitPriceAtEndDate ?? new Big(0)
|
||||
});
|
||||
|
||||
// Sort orders so that the start and end placeholder order are at the right
|
||||
// position
|
||||
orders = sortBy(orders, (order) => {
|
||||
let sortIndex = new Date(order.date);
|
||||
|
||||
if (order.itemType === 'start') {
|
||||
sortIndex = addMilliseconds(sortIndex, -1);
|
||||
}
|
||||
|
||||
if (order.itemType === 'end') {
|
||||
sortIndex = addMilliseconds(sortIndex, 1);
|
||||
}
|
||||
|
||||
return sortIndex.getTime();
|
||||
});
|
||||
|
||||
const indexOfStartOrder = orders.findIndex((order) => {
|
||||
return order.itemType === 'start';
|
||||
});
|
||||
|
||||
for (let i = 0; i < orders.length; i += 1) {
|
||||
const order = orders[i];
|
||||
|
||||
const transactionInvestment = order.quantity.mul(order.unitPrice);
|
||||
|
||||
if (
|
||||
!initialValue &&
|
||||
order.itemType !== 'start' &&
|
||||
order.itemType !== 'end'
|
||||
) {
|
||||
initialValue = transactionInvestment;
|
||||
}
|
||||
|
||||
fees = fees.plus(order.fee);
|
||||
|
||||
totalUnits = totalUnits.plus(
|
||||
order.quantity.mul(this.getFactor(order.type))
|
||||
);
|
||||
|
||||
const valueOfInvestment = totalUnits.mul(order.unitPrice);
|
||||
const netValueOfInvestment = totalUnits.mul(order.unitPrice).sub(fees);
|
||||
|
||||
const grossPerformanceFromSell =
|
||||
order.type === TypeOfOrder.SELL
|
||||
? order.unitPrice.minus(lastAveragePrice).mul(order.quantity)
|
||||
: new Big(0);
|
||||
|
||||
grossPerformanceFromSells = grossPerformanceFromSells.plus(
|
||||
grossPerformanceFromSell
|
||||
);
|
||||
|
||||
totalInvestment = totalInvestment
|
||||
.plus(transactionInvestment.mul(this.getFactor(order.type)))
|
||||
.plus(grossPerformanceFromSell);
|
||||
|
||||
lastAveragePrice = totalUnits.eq(0)
|
||||
? new Big(0)
|
||||
: totalInvestment.div(totalUnits);
|
||||
|
||||
const newGrossPerformance = valueOfInvestment
|
||||
.minus(totalInvestment)
|
||||
.plus(grossPerformanceFromSells);
|
||||
|
||||
if (
|
||||
i > indexOfStartOrder &&
|
||||
!lastValueOfInvestment
|
||||
.plus(transactionInvestment.mul(this.getFactor(order.type)))
|
||||
.eq(0)
|
||||
) {
|
||||
timeWeightedGrossPerformancePercentage =
|
||||
timeWeightedGrossPerformancePercentage.mul(
|
||||
new Big(1).plus(
|
||||
valueOfInvestment
|
||||
.minus(
|
||||
lastValueOfInvestment.plus(
|
||||
transactionInvestment.mul(this.getFactor(order.type))
|
||||
)
|
||||
)
|
||||
.div(
|
||||
lastValueOfInvestment.plus(
|
||||
transactionInvestment.mul(this.getFactor(order.type))
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
timeWeightedNetPerformancePercentage =
|
||||
timeWeightedNetPerformancePercentage.mul(
|
||||
new Big(1).plus(
|
||||
netValueOfInvestment
|
||||
.minus(
|
||||
lastNetValueOfInvestment.plus(
|
||||
transactionInvestment.mul(this.getFactor(order.type))
|
||||
)
|
||||
)
|
||||
.div(
|
||||
lastNetValueOfInvestment.plus(
|
||||
transactionInvestment.mul(this.getFactor(order.type))
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
grossPerformance = newGrossPerformance;
|
||||
lastNetValueOfInvestment = netValueOfInvestment;
|
||||
lastValueOfInvestment = valueOfInvestment;
|
||||
|
||||
if (order.itemType === 'start') {
|
||||
feesAtStartDate = fees;
|
||||
grossPerformanceAtStartDate = grossPerformance;
|
||||
}
|
||||
}
|
||||
|
||||
timeWeightedGrossPerformancePercentage =
|
||||
timeWeightedGrossPerformancePercentage.sub(1);
|
||||
|
||||
timeWeightedNetPerformancePercentage =
|
||||
timeWeightedNetPerformancePercentage.sub(1);
|
||||
|
||||
const totalGrossPerformance = grossPerformance.minus(
|
||||
grossPerformanceAtStartDate
|
||||
);
|
||||
|
||||
const totalNetPerformance = grossPerformance
|
||||
.minus(grossPerformanceAtStartDate)
|
||||
.minus(fees.minus(feesAtStartDate));
|
||||
|
||||
return {
|
||||
initialValue,
|
||||
hasErrors: !initialValue || !unitPriceAtEndDate,
|
||||
netPerformance: totalNetPerformance,
|
||||
netPerformancePercentage: timeWeightedNetPerformancePercentage,
|
||||
grossPerformance: totalGrossPerformance,
|
||||
grossPerformancePercentage: timeWeightedGrossPerformancePercentage
|
||||
};
|
||||
}
|
||||
|
||||
public getInvestments(): { date: string; investment: Big }[] {
|
||||
if (this.transactionPoints.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.transactionPoints.map((transactionPoint) => {
|
||||
return {
|
||||
date: transactionPoint.date,
|
||||
investment: transactionPoint.items.reduce(
|
||||
(investment, transactionPointSymbol) =>
|
||||
investment.add(transactionPointSymbol.investment),
|
||||
new Big(0)
|
||||
)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public async calculateTimeline(
|
||||
timelineSpecification: TimelineSpecification[],
|
||||
endDate: string
|
||||
): Promise<TimelineInfoInterface> {
|
||||
if (timelineSpecification.length === 0) {
|
||||
return {
|
||||
maxNetPerformance: new Big(0),
|
||||
minNetPerformance: new Big(0),
|
||||
timelinePeriods: []
|
||||
};
|
||||
}
|
||||
|
||||
const startDate = timelineSpecification[0].start;
|
||||
const start = parseDate(startDate);
|
||||
const end = parseDate(endDate);
|
||||
|
||||
const timelinePeriodPromises: Promise<TimelineInfoInterface>[] = [];
|
||||
let i = 0;
|
||||
let j = -1;
|
||||
for (
|
||||
let currentDate = start;
|
||||
!isAfter(currentDate, end);
|
||||
currentDate = this.addToDate(
|
||||
currentDate,
|
||||
timelineSpecification[i].accuracy
|
||||
)
|
||||
) {
|
||||
if (this.isNextItemActive(timelineSpecification, currentDate, i)) {
|
||||
i++;
|
||||
}
|
||||
while (
|
||||
j + 1 < this.transactionPoints.length &&
|
||||
!isAfter(parseDate(this.transactionPoints[j + 1].date), currentDate)
|
||||
) {
|
||||
j++;
|
||||
}
|
||||
|
||||
let periodEndDate = currentDate;
|
||||
if (timelineSpecification[i].accuracy === 'day') {
|
||||
let nextEndDate = end;
|
||||
if (j + 1 < this.transactionPoints.length) {
|
||||
nextEndDate = parseDate(this.transactionPoints[j + 1].date);
|
||||
}
|
||||
periodEndDate = min([
|
||||
addMonths(currentDate, 3),
|
||||
max([currentDate, nextEndDate])
|
||||
]);
|
||||
}
|
||||
const timePeriodForDates = this.getTimePeriodForDate(
|
||||
j,
|
||||
currentDate,
|
||||
endOfDay(periodEndDate)
|
||||
);
|
||||
currentDate = periodEndDate;
|
||||
if (timePeriodForDates != null) {
|
||||
timelinePeriodPromises.push(timePeriodForDates);
|
||||
}
|
||||
}
|
||||
|
||||
const timelineInfoInterfaces: TimelineInfoInterface[] = await Promise.all(
|
||||
timelinePeriodPromises
|
||||
);
|
||||
const minNetPerformance = timelineInfoInterfaces
|
||||
.map((timelineInfo) => timelineInfo.minNetPerformance)
|
||||
.filter((performance) => performance !== null)
|
||||
.reduce((minPerformance, current) => {
|
||||
if (minPerformance.lt(current)) {
|
||||
return minPerformance;
|
||||
} else {
|
||||
return current;
|
||||
}
|
||||
});
|
||||
|
||||
const maxNetPerformance = timelineInfoInterfaces
|
||||
.map((timelineInfo) => timelineInfo.maxNetPerformance)
|
||||
.filter((performance) => performance !== null)
|
||||
.reduce((maxPerformance, current) => {
|
||||
if (maxPerformance.gt(current)) {
|
||||
return maxPerformance;
|
||||
} else {
|
||||
return current;
|
||||
}
|
||||
});
|
||||
|
||||
const timelinePeriods = timelineInfoInterfaces.map(
|
||||
(timelineInfo) => timelineInfo.timelinePeriods
|
||||
);
|
||||
|
||||
return {
|
||||
maxNetPerformance,
|
||||
minNetPerformance,
|
||||
timelinePeriods: flatten(timelinePeriods)
|
||||
};
|
||||
}
|
||||
|
||||
private calculateOverallPerformance(
|
||||
positions: TimelinePosition[],
|
||||
initialValues: { [p: string]: Big }
|
||||
) {
|
||||
let hasErrors = false;
|
||||
let currentValue = new Big(0);
|
||||
let totalInvestment = new Big(0);
|
||||
let grossPerformance = new Big(0);
|
||||
let grossPerformancePercentage = new Big(0);
|
||||
let netPerformance = new Big(0);
|
||||
let netPerformancePercentage = new Big(0);
|
||||
let completeInitialValue = new Big(0);
|
||||
|
||||
for (const currentPosition of positions) {
|
||||
if (currentPosition.marketPrice) {
|
||||
currentValue = currentValue.add(
|
||||
new Big(currentPosition.marketPrice).mul(currentPosition.quantity)
|
||||
);
|
||||
} else {
|
||||
hasErrors = true;
|
||||
}
|
||||
totalInvestment = totalInvestment.add(currentPosition.investment);
|
||||
if (currentPosition.grossPerformance) {
|
||||
grossPerformance = grossPerformance.plus(
|
||||
currentPosition.grossPerformance
|
||||
);
|
||||
netPerformance = netPerformance.plus(currentPosition.netPerformance);
|
||||
} else if (!currentPosition.quantity.eq(0)) {
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
if (
|
||||
currentPosition.grossPerformancePercentage &&
|
||||
initialValues[currentPosition.symbol]
|
||||
) {
|
||||
const currentInitialValue = initialValues[currentPosition.symbol];
|
||||
completeInitialValue = completeInitialValue.plus(currentInitialValue);
|
||||
grossPerformancePercentage = grossPerformancePercentage.plus(
|
||||
currentPosition.grossPerformancePercentage.mul(currentInitialValue)
|
||||
);
|
||||
netPerformancePercentage = netPerformancePercentage.plus(
|
||||
currentPosition.netPerformancePercentage.mul(currentInitialValue)
|
||||
);
|
||||
} else if (!currentPosition.quantity.eq(0)) {
|
||||
Logger.warn(
|
||||
`Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`
|
||||
);
|
||||
hasErrors = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!completeInitialValue.eq(0)) {
|
||||
grossPerformancePercentage =
|
||||
grossPerformancePercentage.div(completeInitialValue);
|
||||
netPerformancePercentage =
|
||||
netPerformancePercentage.div(completeInitialValue);
|
||||
}
|
||||
|
||||
return {
|
||||
currentValue,
|
||||
grossPerformance,
|
||||
grossPerformancePercentage,
|
||||
hasErrors,
|
||||
netPerformance,
|
||||
netPerformancePercentage,
|
||||
totalInvestment
|
||||
};
|
||||
}
|
||||
|
||||
private async getTimePeriodForDate(
|
||||
j: number,
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): Promise<TimelineInfoInterface> {
|
||||
let investment: Big = new Big(0);
|
||||
let fees: Big = new Big(0);
|
||||
|
||||
const marketSymbolMap: {
|
||||
[date: string]: { [symbol: string]: Big };
|
||||
} = {};
|
||||
if (j >= 0) {
|
||||
const currencies: { [name: string]: string } = {};
|
||||
const dataGatheringItems: IDataGatheringItem[] = [];
|
||||
|
||||
for (const item of this.transactionPoints[j].items) {
|
||||
currencies[item.symbol] = item.currency;
|
||||
dataGatheringItems.push({
|
||||
dataSource: item.dataSource,
|
||||
symbol: item.symbol
|
||||
});
|
||||
investment = investment.add(item.investment);
|
||||
fees = fees.add(item.fee);
|
||||
}
|
||||
|
||||
let marketSymbols: GetValueObject[] = [];
|
||||
if (dataGatheringItems.length > 0) {
|
||||
try {
|
||||
marketSymbols = await this.currentRateService.getValues({
|
||||
currencies,
|
||||
dataGatheringItems,
|
||||
dateQuery: {
|
||||
gte: startDate,
|
||||
lt: endOfDay(endDate)
|
||||
},
|
||||
userCurrency: this.currency
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
`Failed to fetch info for date ${startDate} with exception`,
|
||||
error
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
for (const marketSymbol of marketSymbols) {
|
||||
const date = format(marketSymbol.date, DATE_FORMAT);
|
||||
if (!marketSymbolMap[date]) {
|
||||
marketSymbolMap[date] = {};
|
||||
}
|
||||
if (marketSymbol.marketPrice) {
|
||||
marketSymbolMap[date][marketSymbol.symbol] = new Big(
|
||||
marketSymbol.marketPrice
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const results: TimelinePeriod[] = [];
|
||||
let maxNetPerformance: Big = null;
|
||||
let minNetPerformance: Big = null;
|
||||
for (
|
||||
let currentDate = startDate;
|
||||
isBefore(currentDate, endDate);
|
||||
currentDate = addDays(currentDate, 1)
|
||||
) {
|
||||
let value = new Big(0);
|
||||
const currentDateAsString = format(currentDate, DATE_FORMAT);
|
||||
let invalid = false;
|
||||
if (j >= 0) {
|
||||
for (const item of this.transactionPoints[j].items) {
|
||||
if (
|
||||
!marketSymbolMap[currentDateAsString]?.hasOwnProperty(item.symbol)
|
||||
) {
|
||||
invalid = true;
|
||||
break;
|
||||
}
|
||||
value = value.add(
|
||||
item.quantity.mul(marketSymbolMap[currentDateAsString][item.symbol])
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!invalid) {
|
||||
const grossPerformance = value.minus(investment);
|
||||
const netPerformance = grossPerformance.minus(fees);
|
||||
if (
|
||||
minNetPerformance === null ||
|
||||
minNetPerformance.gt(netPerformance)
|
||||
) {
|
||||
minNetPerformance = netPerformance;
|
||||
}
|
||||
if (
|
||||
maxNetPerformance === null ||
|
||||
maxNetPerformance.lt(netPerformance)
|
||||
) {
|
||||
maxNetPerformance = netPerformance;
|
||||
}
|
||||
|
||||
const result = {
|
||||
grossPerformance,
|
||||
investment,
|
||||
netPerformance,
|
||||
value,
|
||||
date: currentDateAsString
|
||||
};
|
||||
results.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
maxNetPerformance,
|
||||
minNetPerformance,
|
||||
timelinePeriods: results
|
||||
};
|
||||
}
|
||||
|
||||
private getFactor(type: TypeOfOrder) {
|
||||
let factor: number;
|
||||
|
||||
switch (type) {
|
||||
case 'BUY':
|
||||
factor = 1;
|
||||
break;
|
||||
case 'SELL':
|
||||
factor = -1;
|
||||
break;
|
||||
default:
|
||||
factor = 0;
|
||||
break;
|
||||
}
|
||||
|
||||
return factor;
|
||||
}
|
||||
|
||||
private addToDate(date: Date, accuracy: Accuracy): Date {
|
||||
switch (accuracy) {
|
||||
case 'day':
|
||||
return addDays(date, 1);
|
||||
case 'month':
|
||||
return addMonths(date, 1);
|
||||
case 'year':
|
||||
return addYears(date, 1);
|
||||
}
|
||||
}
|
||||
|
||||
private isNextItemActive(
|
||||
timelineSpecification: TimelineSpecification[],
|
||||
currentDate: Date,
|
||||
i: number
|
||||
) {
|
||||
return (
|
||||
i + 1 < timelineSpecification.length &&
|
||||
!isBefore(currentDate, parseDate(timelineSpecification[i + 1].start))
|
||||
);
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
25
apps/api/src/app/portfolio/portfolio-service.strategy.ts
Normal file
25
apps/api/src/app/portfolio/portfolio-service.strategy.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
import { PortfolioServiceNew } from './portfolio.service-new';
|
||||
|
||||
@Injectable()
|
||||
export class PortfolioServiceStrategy {
|
||||
public constructor(
|
||||
private readonly portfolioService: PortfolioService,
|
||||
private readonly portfolioServiceNew: PortfolioServiceNew,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
public get() {
|
||||
if (
|
||||
this.request.user?.Settings?.settings?.['isNewCalculationEngine'] === true
|
||||
) {
|
||||
return this.portfolioServiceNew;
|
||||
}
|
||||
|
||||
return this.portfolioService;
|
||||
}
|
||||
}
|
@ -10,12 +10,12 @@ import { baseCurrency } from '@ghostfolio/common/config';
|
||||
import {
|
||||
PortfolioChart,
|
||||
PortfolioDetails,
|
||||
PortfolioInvestments,
|
||||
PortfolioPerformance,
|
||||
PortfolioPublicDetails,
|
||||
PortfolioReport,
|
||||
PortfolioSummary
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Controller,
|
||||
@ -35,7 +35,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
|
||||
import { PortfolioPositions } from './interfaces/portfolio-positions.interface';
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
import { PortfolioServiceStrategy } from './portfolio-service.strategy';
|
||||
|
||||
@Controller('portfolio')
|
||||
export class PortfolioController {
|
||||
@ -43,73 +43,32 @@ export class PortfolioController {
|
||||
private readonly accessService: AccessService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly portfolioService: PortfolioService,
|
||||
private readonly portfolioServiceStrategy: PortfolioServiceStrategy,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
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')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getChart(
|
||||
@Headers('impersonation-id') impersonationId,
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('range') range,
|
||||
@Res() res: Response
|
||||
): Promise<PortfolioChart> {
|
||||
const historicalDataContainer = await this.portfolioService.getChart(
|
||||
impersonationId,
|
||||
range
|
||||
);
|
||||
const historicalDataContainer = await this.portfolioServiceStrategy
|
||||
.get()
|
||||
.getChart(impersonationId, range);
|
||||
|
||||
let chartData = historicalDataContainer.items;
|
||||
|
||||
let hasNullValue = false;
|
||||
let hasError = false;
|
||||
|
||||
chartData.forEach((chartDataItem) => {
|
||||
if (hasNotDefinedValuesInObject(chartDataItem)) {
|
||||
hasNullValue = true;
|
||||
hasError = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (hasNullValue) {
|
||||
res.status(StatusCodes.ACCEPTED);
|
||||
}
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
@ -131,6 +90,7 @@ export class PortfolioController {
|
||||
}
|
||||
|
||||
return <any>res.json({
|
||||
hasError,
|
||||
chart: chartData,
|
||||
isAllTimeHigh: historicalDataContainer.isAllTimeHigh,
|
||||
isAllTimeLow: historicalDataContainer.isAllTimeLow
|
||||
@ -140,7 +100,7 @@ export class PortfolioController {
|
||||
@Get('details')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getDetails(
|
||||
@Headers('impersonation-id') impersonationId,
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('range') range,
|
||||
@Res() res: Response
|
||||
): Promise<PortfolioDetails> {
|
||||
@ -152,15 +112,15 @@ export class PortfolioController {
|
||||
return <any>res.json({ accounts: {}, holdings: {} });
|
||||
}
|
||||
|
||||
let hasError = false;
|
||||
|
||||
const { accounts, holdings, hasErrors } =
|
||||
await this.portfolioService.getDetails(
|
||||
impersonationId,
|
||||
this.request.user.id,
|
||||
range
|
||||
);
|
||||
await this.portfolioServiceStrategy
|
||||
.get()
|
||||
.getDetails(impersonationId, this.request.user.id, range);
|
||||
|
||||
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
||||
res.status(StatusCodes.ACCEPTED);
|
||||
hasError = true;
|
||||
}
|
||||
|
||||
if (
|
||||
@ -198,54 +158,79 @@ 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.portfolioServiceStrategy
|
||||
.get()
|
||||
.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')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getPerformance(
|
||||
@Headers('impersonation-id') impersonationId,
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('range') range,
|
||||
@Res() res: Response
|
||||
): Promise<PortfolioPerformance> {
|
||||
const performanceInformation = await this.portfolioService.getPerformance(
|
||||
impersonationId,
|
||||
range
|
||||
);
|
||||
): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> {
|
||||
const performanceInformation = await this.portfolioServiceStrategy
|
||||
.get()
|
||||
.getPerformance(impersonationId, range);
|
||||
|
||||
if (performanceInformation?.hasErrors) {
|
||||
res.status(StatusCodes.ACCEPTED);
|
||||
}
|
||||
|
||||
let performance = performanceInformation.performance;
|
||||
if (
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
performance = nullifyValuesInObject(performance, [
|
||||
'currentGrossPerformance',
|
||||
'currentValue'
|
||||
]);
|
||||
performanceInformation.performance = nullifyValuesInObject(
|
||||
performanceInformation.performance,
|
||||
['currentGrossPerformance', 'currentValue']
|
||||
);
|
||||
}
|
||||
|
||||
return <any>res.json(performance);
|
||||
return <any>res.json(performanceInformation);
|
||||
}
|
||||
|
||||
@Get('positions')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getPositions(
|
||||
@Headers('impersonation-id') impersonationId,
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('range') range,
|
||||
@Res() res: Response
|
||||
): Promise<PortfolioPositions> {
|
||||
const result = await this.portfolioService.getPositions(
|
||||
impersonationId,
|
||||
range
|
||||
);
|
||||
|
||||
if (result?.hasErrors) {
|
||||
res.status(StatusCodes.ACCEPTED);
|
||||
}
|
||||
const result = await this.portfolioServiceStrategy
|
||||
.get()
|
||||
.getPositions(impersonationId, range);
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
@ -284,10 +269,9 @@ export class PortfolioController {
|
||||
hasDetails = user.subscription.type === 'Premium';
|
||||
}
|
||||
|
||||
const { holdings } = await this.portfolioService.getDetails(
|
||||
access.userId,
|
||||
access.userId
|
||||
);
|
||||
const { holdings } = await this.portfolioServiceStrategy
|
||||
.get()
|
||||
.getDetails(access.userId, access.userId);
|
||||
|
||||
const portfolioPublicDetails: PortfolioPublicDetails = {
|
||||
hasDetails,
|
||||
@ -328,7 +312,9 @@ export class PortfolioController {
|
||||
public async getSummary(
|
||||
@Headers('impersonation-id') impersonationId
|
||||
): Promise<PortfolioSummary> {
|
||||
let summary = await this.portfolioService.getSummary(impersonationId);
|
||||
let summary = await this.portfolioServiceStrategy
|
||||
.get()
|
||||
.getSummary(impersonationId);
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
@ -340,6 +326,7 @@ export class PortfolioController {
|
||||
'currentGrossPerformance',
|
||||
'currentNetPerformance',
|
||||
'currentValue',
|
||||
'dividend',
|
||||
'fees',
|
||||
'netWorth',
|
||||
'totalBuy',
|
||||
@ -353,13 +340,12 @@ export class PortfolioController {
|
||||
@Get('position/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getPosition(
|
||||
@Headers('impersonation-id') impersonationId,
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Param('symbol') symbol
|
||||
): Promise<PortfolioPositionDetail> {
|
||||
let position = await this.portfolioService.getPosition(
|
||||
impersonationId,
|
||||
symbol
|
||||
);
|
||||
let position = await this.portfolioServiceStrategy
|
||||
.get()
|
||||
.getPosition(impersonationId, symbol);
|
||||
|
||||
if (position) {
|
||||
if (
|
||||
@ -370,6 +356,7 @@ export class PortfolioController {
|
||||
'grossPerformance',
|
||||
'investment',
|
||||
'netPerformance',
|
||||
'orders',
|
||||
'quantity',
|
||||
'value'
|
||||
]);
|
||||
@ -387,7 +374,7 @@ export class PortfolioController {
|
||||
@Get('report')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getReport(
|
||||
@Headers('impersonation-id') impersonationId,
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Res() res: Response
|
||||
): Promise<PortfolioReport> {
|
||||
if (
|
||||
@ -399,7 +386,9 @@ export class PortfolioController {
|
||||
}
|
||||
|
||||
return <any>(
|
||||
res.json(await this.portfolioService.getReport(impersonationId))
|
||||
res.json(
|
||||
await this.portfolioServiceStrategy.get().getReport(impersonationId)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -13,12 +13,14 @@ import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.mod
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CurrentRateService } from './current-rate.service';
|
||||
import { PortfolioServiceStrategy } from './portfolio-service.strategy';
|
||||
import { PortfolioController } from './portfolio.controller';
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
import { PortfolioServiceNew } from './portfolio.service-new';
|
||||
import { RulesService } from './rules.service';
|
||||
|
||||
@Module({
|
||||
exports: [PortfolioService],
|
||||
exports: [PortfolioServiceStrategy],
|
||||
imports: [
|
||||
AccessModule,
|
||||
ConfigurationModule,
|
||||
@ -37,6 +39,8 @@ import { RulesService } from './rules.service';
|
||||
AccountService,
|
||||
CurrentRateService,
|
||||
PortfolioService,
|
||||
PortfolioServiceNew,
|
||||
PortfolioServiceStrategy,
|
||||
RulesService
|
||||
]
|
||||
})
|
||||
|
1206
apps/api/src/app/portfolio/portfolio.service-new.ts
Normal file
1206
apps/api/src/app/portfolio/portfolio.service-new.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -55,7 +55,7 @@ import {
|
||||
subDays,
|
||||
subYears
|
||||
} from 'date-fns';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { isEmpty, sortBy } from 'lodash';
|
||||
|
||||
import {
|
||||
HistoricalDataContainer,
|
||||
@ -107,7 +107,7 @@ export class PortfolioService {
|
||||
account.currency,
|
||||
userCurrency
|
||||
),
|
||||
value: details.accounts[account.name]?.current ?? 0
|
||||
value: details.accounts[account.id]?.current ?? 0
|
||||
};
|
||||
|
||||
delete result.Order;
|
||||
@ -150,12 +150,33 @@ export class PortfolioService {
|
||||
return [];
|
||||
}
|
||||
|
||||
return portfolioCalculator.getInvestments().map((item) => {
|
||||
const investments = portfolioCalculator.getInvestments().map((item) => {
|
||||
return {
|
||||
date: item.date,
|
||||
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(
|
||||
@ -367,11 +388,12 @@ export class PortfolioService {
|
||||
aImpersonationId: string,
|
||||
aSymbol: string
|
||||
): Promise<PortfolioPositionDetail> {
|
||||
const userCurrency = this.request.user.Settings.currency;
|
||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||
|
||||
const orders = (await this.orderService.getOrders({ userId })).filter(
|
||||
(order) => order.symbol === aSymbol
|
||||
);
|
||||
const orders = (
|
||||
await this.orderService.getOrders({ userCurrency, userId })
|
||||
).filter((order) => order.symbol === aSymbol);
|
||||
|
||||
if (orders.length <= 0) {
|
||||
return {
|
||||
@ -388,6 +410,7 @@ export class PortfolioService {
|
||||
name: undefined,
|
||||
netPerformance: undefined,
|
||||
netPerformancePercent: undefined,
|
||||
orders: [],
|
||||
quantity: undefined,
|
||||
symbol: aSymbol,
|
||||
transactionCount: undefined,
|
||||
@ -400,17 +423,21 @@ export class PortfolioService {
|
||||
const positionCurrency = orders[0].currency;
|
||||
const name = orders[0].SymbolProfile?.name ?? '';
|
||||
|
||||
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
|
||||
currency: order.currency,
|
||||
dataSource: order.dataSource,
|
||||
date: format(order.date, DATE_FORMAT),
|
||||
fee: new Big(order.fee),
|
||||
name: order.SymbolProfile?.name,
|
||||
quantity: new Big(order.quantity),
|
||||
symbol: order.symbol,
|
||||
type: order.type,
|
||||
unitPrice: new Big(order.unitPrice)
|
||||
}));
|
||||
const portfolioOrders: PortfolioOrder[] = orders
|
||||
.filter((order) => {
|
||||
return order.type === 'BUY' || order.type === 'SELL';
|
||||
})
|
||||
.map((order) => ({
|
||||
currency: order.currency,
|
||||
dataSource: order.SymbolProfile?.dataSource ?? order.dataSource,
|
||||
date: format(order.date, DATE_FORMAT),
|
||||
fee: new Big(order.fee),
|
||||
name: order.SymbolProfile?.name,
|
||||
quantity: new Big(order.quantity),
|
||||
symbol: order.symbol,
|
||||
type: order.type,
|
||||
unitPrice: new Big(order.unitPrice)
|
||||
}));
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator(
|
||||
this.currentRateService,
|
||||
@ -442,17 +469,17 @@ export class PortfolioService {
|
||||
// Convert investment, gross and net performance to currency of user
|
||||
const userCurrency = this.request.user.Settings.currency;
|
||||
const investment = this.exchangeRateDataService.toCurrency(
|
||||
position.investment.toNumber(),
|
||||
position.investment?.toNumber(),
|
||||
currency,
|
||||
userCurrency
|
||||
);
|
||||
const grossPerformance = this.exchangeRateDataService.toCurrency(
|
||||
position.grossPerformance.toNumber(),
|
||||
position.grossPerformance?.toNumber(),
|
||||
currency,
|
||||
userCurrency
|
||||
);
|
||||
const netPerformance = this.exchangeRateDataService.toCurrency(
|
||||
position.netPerformance.toNumber(),
|
||||
position.netPerformance?.toNumber(),
|
||||
currency,
|
||||
userCurrency
|
||||
);
|
||||
@ -521,6 +548,7 @@ export class PortfolioService {
|
||||
minPrice,
|
||||
name,
|
||||
netPerformance,
|
||||
orders,
|
||||
transactionCount,
|
||||
averagePrice: averagePrice.toNumber(),
|
||||
grossPerformancePercent: position.grossPerformancePercentage.toNumber(),
|
||||
@ -578,6 +606,7 @@ export class PortfolioService {
|
||||
maxPrice,
|
||||
minPrice,
|
||||
name,
|
||||
orders,
|
||||
averagePrice: 0,
|
||||
currency: currentData[aSymbol]?.currency,
|
||||
firstBuyDate: undefined,
|
||||
@ -655,7 +684,9 @@ export class PortfolioService {
|
||||
grossPerformancePercentage:
|
||||
position.grossPerformancePercentage?.toNumber() ?? null,
|
||||
investment: new Big(position.investment).toNumber(),
|
||||
marketState: dataProviderResponses[position.symbol].marketState,
|
||||
marketState:
|
||||
dataProviderResponses[position.symbol]?.marketState ??
|
||||
MarketState.delayed,
|
||||
name: symbolProfileMap[position.symbol].name,
|
||||
netPerformance: position.netPerformance?.toNumber() ?? null,
|
||||
netPerformancePercentage:
|
||||
@ -726,22 +757,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> {
|
||||
const currency = this.request.user.Settings.currency;
|
||||
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||
@ -822,7 +837,7 @@ export class PortfolioService {
|
||||
new FeeRatioInitialInvestment(
|
||||
this.exchangeRateDataService,
|
||||
currentPositions.totalInvestment.toNumber(),
|
||||
this.getFees(orders)
|
||||
this.getFees(orders).toNumber()
|
||||
)
|
||||
],
|
||||
{ baseCurrency: currency }
|
||||
@ -832,21 +847,25 @@ export class PortfolioService {
|
||||
}
|
||||
|
||||
public async getSummary(aImpersonationId: string): Promise<PortfolioSummary> {
|
||||
const currency = this.request.user.Settings.currency;
|
||||
const userCurrency = this.request.user.Settings.currency;
|
||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||
|
||||
const performanceInformation = await this.getPerformance(aImpersonationId);
|
||||
|
||||
const { balance } = await this.accountService.getCashDetails(
|
||||
userId,
|
||||
currency
|
||||
userCurrency
|
||||
);
|
||||
const orders = await this.orderService.getOrders({ userId });
|
||||
const fees = this.getFees(orders);
|
||||
const orders = await this.orderService.getOrders({
|
||||
userCurrency,
|
||||
userId
|
||||
});
|
||||
const dividend = this.getDividend(orders).toNumber();
|
||||
const fees = this.getFees(orders).toNumber();
|
||||
const firstOrderDate = orders[0]?.date;
|
||||
|
||||
const totalBuy = this.getTotalByType(orders, currency, 'BUY');
|
||||
const totalSell = this.getTotalByType(orders, currency, 'SELL');
|
||||
const totalBuy = this.getTotalByType(orders, userCurrency, 'BUY');
|
||||
const totalSell = this.getTotalByType(orders, userCurrency, 'SELL');
|
||||
|
||||
const committedFunds = new Big(totalBuy).sub(totalSell);
|
||||
|
||||
@ -856,14 +875,19 @@ export class PortfolioService {
|
||||
|
||||
return {
|
||||
...performanceInformation.performance,
|
||||
dividend,
|
||||
fees,
|
||||
firstOrderDate,
|
||||
netWorth,
|
||||
totalBuy,
|
||||
totalSell,
|
||||
annualizedPerformancePercent:
|
||||
performanceInformation.performance.annualizedPerformancePercent,
|
||||
cash: balance,
|
||||
committedFunds: committedFunds.toNumber(),
|
||||
ordersCount: orders.length,
|
||||
totalBuy: totalBuy,
|
||||
totalSell: totalSell
|
||||
ordersCount: orders.filter((order) => {
|
||||
return order.type === 'BUY' || order.type === 'SELL';
|
||||
}).length
|
||||
};
|
||||
}
|
||||
|
||||
@ -875,8 +899,8 @@ export class PortfolioService {
|
||||
}: {
|
||||
cashDetails: CashDetails;
|
||||
investment: Big;
|
||||
value: Big;
|
||||
userCurrency: string;
|
||||
value: Big;
|
||||
}) {
|
||||
const cashPositions = {};
|
||||
|
||||
@ -936,6 +960,47 @@ export class PortfolioService {
|
||||
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) {
|
||||
switch (aDateRange) {
|
||||
case '1d':
|
||||
@ -964,16 +1029,22 @@ export class PortfolioService {
|
||||
transactionPoints: TransactionPoint[];
|
||||
orders: OrderWithAccount[];
|
||||
}> {
|
||||
const orders = await this.orderService.getOrders({ includeDrafts, userId });
|
||||
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
|
||||
|
||||
const orders = await this.orderService.getOrders({
|
||||
includeDrafts,
|
||||
userCurrency,
|
||||
userId,
|
||||
types: ['BUY', 'SELL']
|
||||
});
|
||||
|
||||
if (orders.length <= 0) {
|
||||
return { transactionPoints: [], orders: [] };
|
||||
}
|
||||
|
||||
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
|
||||
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
|
||||
currency: order.currency,
|
||||
dataSource: order.dataSource,
|
||||
dataSource: order.SymbolProfile?.dataSource ?? order.dataSource,
|
||||
date: format(order.date, DATE_FORMAT),
|
||||
fee: new Big(
|
||||
this.exchangeRateDataService.toCurrency(
|
||||
@ -1026,10 +1097,11 @@ export class PortfolioService {
|
||||
account.currency,
|
||||
userCurrency
|
||||
);
|
||||
accounts[account.name] = {
|
||||
accounts[account.id] = {
|
||||
balance: convertedBalance,
|
||||
currency: account.currency,
|
||||
current: convertedBalance,
|
||||
name: account.name,
|
||||
original: convertedBalance
|
||||
};
|
||||
|
||||
@ -1043,16 +1115,17 @@ export class PortfolioService {
|
||||
originalValueOfSymbol *= -1;
|
||||
}
|
||||
|
||||
if (accounts[order.Account?.name || UNKNOWN_KEY]?.current) {
|
||||
accounts[order.Account?.name || UNKNOWN_KEY].current +=
|
||||
if (accounts[order.Account?.id || UNKNOWN_KEY]?.current) {
|
||||
accounts[order.Account?.id || UNKNOWN_KEY].current +=
|
||||
currentValueOfSymbol;
|
||||
accounts[order.Account?.name || UNKNOWN_KEY].original +=
|
||||
accounts[order.Account?.id || UNKNOWN_KEY].original +=
|
||||
originalValueOfSymbol;
|
||||
} else {
|
||||
accounts[order.Account?.name || UNKNOWN_KEY] = {
|
||||
accounts[order.Account?.id || UNKNOWN_KEY] = {
|
||||
balance: 0,
|
||||
currency: order.Account?.currency,
|
||||
current: currentValueOfSymbol,
|
||||
name: account.name,
|
||||
original: originalValueOfSymbol
|
||||
};
|
||||
}
|
||||
|
@ -1,19 +1,14 @@
|
||||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Controller,
|
||||
DefaultValuePipe,
|
||||
Get,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
ParseBoolPipe,
|
||||
Query,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { DataSource, MarketData } from '@prisma/client';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
import { isDate, isEmpty } from 'lodash';
|
||||
|
||||
@ -23,10 +18,7 @@ import { SymbolService } from './symbol.service';
|
||||
|
||||
@Controller('symbol')
|
||||
export class SymbolController {
|
||||
public constructor(
|
||||
private readonly symbolService: SymbolService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
public constructor(private readonly symbolService: SymbolService) {}
|
||||
|
||||
/**
|
||||
* Must be before /:symbol
|
||||
@ -37,8 +29,7 @@ export class SymbolController {
|
||||
@Query() { query = '' }
|
||||
): Promise<{ items: LookupItem[] }> {
|
||||
try {
|
||||
const encodedQuery = encodeURIComponent(query.toLowerCase());
|
||||
return this.symbolService.lookup(encodedQuery);
|
||||
return this.symbolService.lookup(query.toLowerCase());
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||
@ -55,8 +46,7 @@ export class SymbolController {
|
||||
public async getSymbolData(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string,
|
||||
@Query('includeHistoricalData', new DefaultValuePipe(false), ParseBoolPipe)
|
||||
includeHistoricalData: boolean
|
||||
@Query('includeHistoricalData') includeHistoricalData?: number
|
||||
): Promise<SymbolItem> {
|
||||
if (!DataSource[dataSource]) {
|
||||
throw new HttpException(
|
||||
|
@ -5,10 +5,9 @@ import {
|
||||
IDataProviderHistoricalResponse
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource, MarketData } from '@prisma/client';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { format, subDays } from 'date-fns';
|
||||
|
||||
import { LookupItem } from './interfaces/lookup-item.interface';
|
||||
@ -18,35 +17,34 @@ import { SymbolItem } from './interfaces/symbol-item.interface';
|
||||
export class SymbolService {
|
||||
public constructor(
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly marketDataService: MarketDataService,
|
||||
private readonly prismaService: PrismaService
|
||||
private readonly marketDataService: MarketDataService
|
||||
) {}
|
||||
|
||||
public async get({
|
||||
dataGatheringItem,
|
||||
includeHistoricalData = false
|
||||
includeHistoricalData
|
||||
}: {
|
||||
dataGatheringItem: IDataGatheringItem;
|
||||
includeHistoricalData?: boolean;
|
||||
includeHistoricalData?: number;
|
||||
}): Promise<SymbolItem> {
|
||||
const response = await this.dataProviderService.get([dataGatheringItem]);
|
||||
const { currency, marketPrice } = response[dataGatheringItem.symbol] ?? {};
|
||||
|
||||
if (dataGatheringItem.dataSource && marketPrice) {
|
||||
let historicalData: HistoricalDataItem[];
|
||||
let historicalData: HistoricalDataItem[] = [];
|
||||
|
||||
if (includeHistoricalData) {
|
||||
const days = 10;
|
||||
if (includeHistoricalData > 0) {
|
||||
const days = includeHistoricalData;
|
||||
|
||||
const marketData = await this.marketDataService.getRange({
|
||||
dateQuery: { gte: subDays(new Date(), days) },
|
||||
symbols: [dataGatheringItem.symbol]
|
||||
});
|
||||
|
||||
historicalData = marketData.map(({ date, marketPrice }) => {
|
||||
historicalData = marketData.map(({ date, marketPrice: value }) => {
|
||||
return {
|
||||
date: date.toISOString(),
|
||||
value: marketPrice
|
||||
value,
|
||||
date: date.toISOString()
|
||||
};
|
||||
});
|
||||
}
|
||||
@ -93,32 +91,6 @@ export class SymbolService {
|
||||
try {
|
||||
const { items } = await this.dataProviderService.search(aQuery);
|
||||
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;
|
||||
} catch (error) {
|
||||
Logger.error(error);
|
||||
|
@ -1,6 +1,11 @@
|
||||
import { IsBoolean } from 'class-validator';
|
||||
import { IsBoolean, IsOptional } from 'class-validator';
|
||||
|
||||
export class UpdateUserSettingDto {
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isNewCalculationEngine?: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isRestrictedView?: boolean;
|
||||
}
|
||||
|
@ -23,7 +23,7 @@ import {
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Provider, Role } from '@prisma/client';
|
||||
import { Provider } from '@prisma/client';
|
||||
import { User as UserModel } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
@ -115,6 +115,12 @@ export class UserController {
|
||||
...data
|
||||
};
|
||||
|
||||
for (const key in userSettings) {
|
||||
if (userSettings[key] === false) {
|
||||
delete userSettings[key];
|
||||
}
|
||||
}
|
||||
|
||||
return await this.userService.updateUserSetting({
|
||||
userSettings,
|
||||
userId: this.request.user.id
|
||||
|
@ -25,17 +25,17 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
};
|
||||
} = {};
|
||||
|
||||
for (const account of Object.keys(this.accounts)) {
|
||||
accounts[account] = {
|
||||
name: account,
|
||||
investment: this.accounts[account].current
|
||||
for (const [accountId, account] of Object.entries(this.accounts)) {
|
||||
accounts[accountId] = {
|
||||
name: account.name,
|
||||
investment: account.current
|
||||
};
|
||||
}
|
||||
|
||||
let maxItem;
|
||||
let totalInvestment = 0;
|
||||
|
||||
Object.values(accounts).forEach((account) => {
|
||||
for (const account of Object.values(accounts)) {
|
||||
if (!maxItem) {
|
||||
maxItem = account;
|
||||
}
|
||||
@ -47,7 +47,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
if (account.investment > maxItem?.investment) {
|
||||
maxItem = account;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const maxInvestmentRatio = maxItem.investment / totalInvestment;
|
||||
|
||||
|
@ -19,35 +19,35 @@ export class AccountClusterRiskInitialInvestment extends Rule<Settings> {
|
||||
}
|
||||
|
||||
public evaluate(ruleSettings?: Settings) {
|
||||
const platforms: {
|
||||
const accounts: {
|
||||
[symbol: string]: Pick<PortfolioPosition, 'name'> & {
|
||||
investment: number;
|
||||
};
|
||||
} = {};
|
||||
|
||||
for (const account of Object.keys(this.accounts)) {
|
||||
platforms[account] = {
|
||||
name: account,
|
||||
investment: this.accounts[account].original
|
||||
for (const [accountId, account] of Object.entries(this.accounts)) {
|
||||
accounts[accountId] = {
|
||||
name: account.name,
|
||||
investment: account.original
|
||||
};
|
||||
}
|
||||
|
||||
let maxItem;
|
||||
let totalInvestment = 0;
|
||||
|
||||
Object.values(platforms).forEach((platform) => {
|
||||
for (const account of Object.values(accounts)) {
|
||||
if (!maxItem) {
|
||||
maxItem = platform;
|
||||
maxItem = account;
|
||||
}
|
||||
|
||||
// Calculate total investment
|
||||
totalInvestment += platform.investment;
|
||||
totalInvestment += account.investment;
|
||||
|
||||
// Find maximum
|
||||
if (platform.investment > maxItem?.investment) {
|
||||
maxItem = platform;
|
||||
if (account.investment > maxItem?.investment) {
|
||||
maxItem = account;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const maxInvestmentRatio = maxItem.investment / totalInvestment;
|
||||
|
||||
|
@ -13,6 +13,7 @@ export class ConfigurationService {
|
||||
ACCESS_TOKEN_SALT: str(),
|
||||
ALPHA_VANTAGE_API_KEY: str({ default: '' }),
|
||||
CACHE_TTL: num({ default: 1 }),
|
||||
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
|
||||
DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }),
|
||||
ENABLE_FEATURE_BLOG: bool({ default: false }),
|
||||
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
|
||||
@ -25,6 +26,9 @@ export class ConfigurationService {
|
||||
ENABLE_FEATURE_SYSTEM_MESSAGE: bool({ default: false }),
|
||||
GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }),
|
||||
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({}),
|
||||
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
|
||||
MAX_ORDERS_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
|
||||
|
@ -1,7 +1,9 @@
|
||||
{
|
||||
"1INCH": "1inch",
|
||||
"ALGO": "Algorand",
|
||||
"ATOM": "Cosmos",
|
||||
"AVAX": "Avalanche",
|
||||
"DOT": "Polkadot",
|
||||
"MATIC": "Polygon",
|
||||
"SHIB": "Shiba Inu",
|
||||
"SOL": "Solana",
|
||||
|
@ -1,12 +1,11 @@
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import {
|
||||
PROPERTY_LAST_DATA_GATHERING,
|
||||
PROPERTY_LOCKED_DATA_GATHERING,
|
||||
ghostfolioFearAndGreedIndexSymbol
|
||||
PROPERTY_LOCKED_DATA_GATHERING
|
||||
} from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource, MarketData } from '@prisma/client';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import {
|
||||
differenceInHours,
|
||||
format,
|
||||
@ -17,7 +16,6 @@ import {
|
||||
subDays
|
||||
} from 'date-fns';
|
||||
|
||||
import { ConfigurationService } from './configuration.service';
|
||||
import { DataProviderService } from './data-provider/data-provider.service';
|
||||
import { DataEnhancerInterface } from './data-provider/interfaces/data-enhancer.interface';
|
||||
import { ExchangeRateDataService } from './exchange-rate-data.service';
|
||||
@ -29,7 +27,6 @@ export class DataGatheringService {
|
||||
private dataGatheringProgress: number;
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
@Inject('DataEnhancers')
|
||||
private readonly dataEnhancers: DataEnhancerInterface[],
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
@ -245,7 +242,7 @@ export class DataGatheringService {
|
||||
try {
|
||||
currentData[symbol] = await dataEnhancer.enhance({
|
||||
response,
|
||||
symbol: symbolMapping[dataEnhancer.getName()] ?? symbol
|
||||
symbol: symbolMapping?.[dataEnhancer.getName()] ?? symbol
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.error(`Failed to enhance data for symbol ${symbol}`, error);
|
||||
@ -337,16 +334,25 @@ export class DataGatheringService {
|
||||
?.marketPrice;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.prismaService.marketData.create({
|
||||
data: {
|
||||
dataSource,
|
||||
symbol,
|
||||
date: currentDate,
|
||||
marketPrice: lastMarketPrice
|
||||
}
|
||||
});
|
||||
} catch {}
|
||||
if (lastMarketPrice) {
|
||||
try {
|
||||
await this.prismaService.marketData.create({
|
||||
data: {
|
||||
dataSource,
|
||||
symbol,
|
||||
date: currentDate,
|
||||
marketPrice: lastMarketPrice
|
||||
}
|
||||
});
|
||||
} catch {}
|
||||
} else {
|
||||
Logger.warn(
|
||||
`Failed to gather data for symbol ${symbol} at ${format(
|
||||
currentDate,
|
||||
DATE_FORMAT
|
||||
)}.`
|
||||
);
|
||||
}
|
||||
|
||||
// Count month one up for iteration
|
||||
currentDate = new Date(
|
||||
@ -448,11 +454,7 @@ export class DataGatheringService {
|
||||
};
|
||||
});
|
||||
|
||||
return [
|
||||
...this.getBenchmarksToGather(startDate),
|
||||
...currencyPairsToGather,
|
||||
...symbolProfilesToGather
|
||||
];
|
||||
return [...currencyPairsToGather, ...symbolProfilesToGather];
|
||||
}
|
||||
|
||||
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[]> {
|
||||
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 = (
|
||||
await this.prismaService.symbolProfile.findMany({
|
||||
orderBy: [{ symbol: 'asc' }],
|
||||
@ -494,15 +500,22 @@ export class DataGatheringService {
|
||||
symbol: true
|
||||
}
|
||||
})
|
||||
).map((symbolProfile) => {
|
||||
return {
|
||||
...symbolProfile,
|
||||
date: startDate
|
||||
};
|
||||
});
|
||||
)
|
||||
.filter(({ symbol }) => {
|
||||
return symbolsToGather.includes(symbol);
|
||||
})
|
||||
.map((symbolProfile) => {
|
||||
return {
|
||||
...symbolProfile,
|
||||
date: startDate
|
||||
};
|
||||
});
|
||||
|
||||
const currencyPairsToGather = this.exchangeRateDataService
|
||||
.getCurrencyPairs()
|
||||
.filter(({ symbol }) => {
|
||||
return symbolsToGather.includes(symbol);
|
||||
})
|
||||
.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
dataSource,
|
||||
@ -511,30 +524,22 @@ export class DataGatheringService {
|
||||
};
|
||||
});
|
||||
|
||||
return [
|
||||
...this.getBenchmarksToGather(startDate),
|
||||
...currencyPairsToGather,
|
||||
...symbolProfilesToGather
|
||||
];
|
||||
return [...currencyPairsToGather, ...symbolProfilesToGather];
|
||||
}
|
||||
|
||||
private async getSymbolsProfileData(): Promise<IDataGatheringItem[]> {
|
||||
const startDate = subDays(resetHours(new Date()), 7);
|
||||
|
||||
const distinctOrders = await this.prismaService.order.findMany({
|
||||
distinct: ['symbol'],
|
||||
orderBy: [{ symbol: 'asc' }],
|
||||
select: { dataSource: true, symbol: true }
|
||||
});
|
||||
|
||||
return [...this.getBenchmarksToGather(startDate), ...distinctOrders].filter(
|
||||
(distinctOrder) => {
|
||||
return (
|
||||
distinctOrder.dataSource !== DataSource.GHOSTFOLIO &&
|
||||
distinctOrder.dataSource !== DataSource.RAKUTEN
|
||||
);
|
||||
}
|
||||
);
|
||||
return distinctOrders.filter((distinctOrder) => {
|
||||
return (
|
||||
distinctOrder.dataSource !== DataSource.GHOSTFOLIO &&
|
||||
distinctOrder.dataSource !== DataSource.RAKUTEN
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private async isDataGatheringNeeded() {
|
||||
|
@ -88,13 +88,13 @@ export class AlphaVantageService implements DataProviderInterface {
|
||||
return DataSource.ALPHA_VANTAGE;
|
||||
}
|
||||
|
||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
||||
const result = await this.alphaVantage.data.search(aSymbol);
|
||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||
const result = await this.alphaVantage.data.search(aQuery);
|
||||
|
||||
return {
|
||||
items: result?.bestMatches?.map((bestMatch) => {
|
||||
return {
|
||||
dataSource: DataSource.ALPHA_VANTAGE,
|
||||
dataSource: this.getName(),
|
||||
name: bestMatch['2. name'],
|
||||
symbol: bestMatch['1. symbol']
|
||||
};
|
||||
|
@ -7,6 +7,9 @@ const getJSON = bent('json');
|
||||
export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
||||
private static baseUrl = 'https://data.trackinsight.com/holdings';
|
||||
private static countries = require('countries-list/dist/countries.json');
|
||||
private static countriesMapping = {
|
||||
'Russian Federation': 'Russia'
|
||||
};
|
||||
private static sectorsMapping = {
|
||||
'Consumer Discretionary': 'Consumer Cyclical',
|
||||
'Consumer Defensive': 'Consumer Staples',
|
||||
@ -45,7 +48,11 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
||||
for (const [key, country] of Object.entries<any>(
|
||||
TrackinsightDataEnhancerService.countries
|
||||
)) {
|
||||
if (country.name === name) {
|
||||
if (
|
||||
country.name === name ||
|
||||
country.name ===
|
||||
TrackinsightDataEnhancerService.countriesMapping[name]
|
||||
) {
|
||||
countryCode = key;
|
||||
break;
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.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 { 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 { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
@ -21,12 +22,14 @@ import { DataProviderService } from './data-provider.service';
|
||||
AlphaVantageService,
|
||||
DataProviderService,
|
||||
GhostfolioScraperApiService,
|
||||
GoogleSheetsService,
|
||||
RakutenRapidApiService,
|
||||
YahooFinanceService,
|
||||
{
|
||||
inject: [
|
||||
AlphaVantageService,
|
||||
GhostfolioScraperApiService,
|
||||
GoogleSheetsService,
|
||||
RakutenRapidApiService,
|
||||
YahooFinanceService
|
||||
],
|
||||
@ -34,11 +37,13 @@ import { DataProviderService } from './data-provider.service';
|
||||
useFactory: (
|
||||
alphaVantageService,
|
||||
ghostfolioScraperApiService,
|
||||
googleSheetsService,
|
||||
rakutenRapidApiService,
|
||||
yahooFinanceService
|
||||
) => [
|
||||
alphaVantageService,
|
||||
ghostfolioScraperApiService,
|
||||
googleSheetsService,
|
||||
rakutenRapidApiService,
|
||||
yahooFinanceService
|
||||
]
|
||||
|
@ -12,7 +12,7 @@ import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource, MarketData } from '@prisma/client';
|
||||
import { format, isValid } from 'date-fns';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { groupBy, isEmpty } from 'lodash';
|
||||
|
||||
@Injectable()
|
||||
export class DataProviderService {
|
||||
@ -30,18 +30,27 @@ export class DataProviderService {
|
||||
[symbol: string]: IDataProviderResponse;
|
||||
} = {};
|
||||
|
||||
for (const item of items) {
|
||||
const dataProvider = this.getDataProvider(item.dataSource);
|
||||
response[item.symbol] = (await dataProvider.get([item.symbol]))[
|
||||
item.symbol
|
||||
];
|
||||
}
|
||||
const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource);
|
||||
|
||||
const promises = [];
|
||||
for (const symbol of Object.keys(response)) {
|
||||
const promise = Promise.resolve(response[symbol]);
|
||||
|
||||
for (const [dataSource, dataGatheringItems] of Object.entries(
|
||||
itemsGroupedByDataSource
|
||||
)) {
|
||||
const symbols = dataGatheringItems.map((dataGatheringItem) => {
|
||||
return dataGatheringItem.symbol;
|
||||
});
|
||||
|
||||
const promise = Promise.resolve(
|
||||
this.getDataProvider(DataSource[dataSource]).get(symbols)
|
||||
);
|
||||
|
||||
promises.push(
|
||||
promise.then((currentResponse) => (response[symbol] = currentResponse))
|
||||
promise.then((result) => {
|
||||
for (const [symbol, dataProviderResponse] of Object.entries(result)) {
|
||||
response[symbol] = dataProviderResponse;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@ -149,13 +158,13 @@ export class DataProviderService {
|
||||
return result;
|
||||
}
|
||||
|
||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||
const promises: Promise<{ items: LookupItem[] }>[] = [];
|
||||
let lookupItems: LookupItem[] = [];
|
||||
|
||||
for (const dataSource of this.configurationService.get('DATA_SOURCES')) {
|
||||
promises.push(
|
||||
this.getDataProvider(DataSource[dataSource]).search(aSymbol)
|
||||
this.getDataProvider(DataSource[dataSource]).search(aQuery)
|
||||
);
|
||||
}
|
||||
|
||||
@ -176,7 +185,7 @@ export class DataProviderService {
|
||||
}
|
||||
|
||||
public getPrimaryDataSource(): DataSource {
|
||||
return DataSource[this.configurationService.get('DATA_SOURCES')[0]];
|
||||
return DataSource[this.configurationService.get('DATA_SOURCE_PRIMARY')];
|
||||
}
|
||||
|
||||
private getDataProvider(providerName: DataSource) {
|
||||
|
@ -1,4 +1,10 @@
|
||||
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 { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import {
|
||||
@ -13,13 +19,6 @@ import * as bent from 'bent';
|
||||
import * as cheerio from 'cheerio';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse,
|
||||
MarketState
|
||||
} from '../../interfaces/interfaces';
|
||||
import { DataProviderInterface } from '../interfaces/data-provider.interface';
|
||||
|
||||
@Injectable()
|
||||
export class GhostfolioScraperApiService implements DataProviderInterface {
|
||||
private static NUMERIC_REGEXP = /[-]{0,1}[\d]*[.,]{0,1}[\d]+/g;
|
||||
@ -59,7 +58,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
||||
[symbol]: {
|
||||
marketPrice,
|
||||
currency: symbolProfile?.currency,
|
||||
dataSource: DataSource.GHOSTFOLIO,
|
||||
dataSource: this.getName(),
|
||||
marketState: MarketState.delayed
|
||||
}
|
||||
};
|
||||
@ -116,8 +115,35 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
||||
return DataSource.GHOSTFOLIO;
|
||||
}
|
||||
|
||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
||||
return { items: [] };
|
||||
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 extractNumberFromString(aString: string): number {
|
||||
|
@ -0,0 +1,181 @@
|
||||
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, parseDate } 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 response: { [symbol: string]: IDataProviderResponse } = {};
|
||||
|
||||
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
|
||||
aSymbols
|
||||
);
|
||||
|
||||
const sheet = await this.getSheet({
|
||||
sheetId: this.configurationService.get('GOOGLE_SHEETS_ID'),
|
||||
symbol: 'Overview'
|
||||
});
|
||||
|
||||
const rows = await sheet.getRows();
|
||||
|
||||
for (const row of rows) {
|
||||
const marketPrice = parseFloat(row['marketPrice']);
|
||||
const symbol = row['symbol'];
|
||||
|
||||
if (aSymbols.includes(symbol)) {
|
||||
response[symbol] = {
|
||||
marketPrice,
|
||||
currency: symbolProfiles.find((symbolProfile) => {
|
||||
return symbolProfile.symbol === symbol;
|
||||
})?.currency,
|
||||
dataSource: this.getName(),
|
||||
marketState: MarketState.delayed
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
} 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 = parseDate(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;
|
||||
}
|
||||
}
|
@ -1,11 +1,10 @@
|
||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { DataSource } from '@prisma/client';
|
||||
|
||||
import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse
|
||||
} from '../../interfaces/interfaces';
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { DataSource } from '@prisma/client';
|
||||
|
||||
export interface DataProviderInterface {
|
||||
canHandle(symbol: string): boolean;
|
||||
@ -23,5 +22,5 @@ export interface DataProviderInterface {
|
||||
|
||||
getName(): DataSource;
|
||||
|
||||
search(aSymbol: string): Promise<{ items: LookupItem[] }>;
|
||||
search(aQuery: string): Promise<{ items: LookupItem[] }>;
|
||||
}
|
||||
|
@ -45,7 +45,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
||||
return {
|
||||
[ghostfolioFearAndGreedIndexSymbol]: {
|
||||
currency: undefined,
|
||||
dataSource: DataSource.RAKUTEN,
|
||||
dataSource: this.getName(),
|
||||
marketPrice: fgi.now.value,
|
||||
marketState: MarketState.open,
|
||||
name: RakutenRapidApiService.FEAR_AND_GREED_INDEX_NAME
|
||||
@ -85,7 +85,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
||||
await this.prismaService.marketData.create({
|
||||
data: {
|
||||
symbol,
|
||||
dataSource: DataSource.RAKUTEN,
|
||||
dataSource: this.getName(),
|
||||
date: subWeeks(getToday(), 1),
|
||||
marketPrice: fgi.oneWeekAgo.value
|
||||
}
|
||||
@ -94,7 +94,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
||||
await this.prismaService.marketData.create({
|
||||
data: {
|
||||
symbol,
|
||||
dataSource: DataSource.RAKUTEN,
|
||||
dataSource: this.getName(),
|
||||
date: subMonths(getToday(), 1),
|
||||
marketPrice: fgi.oneMonthAgo.value
|
||||
}
|
||||
@ -103,7 +103,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
||||
await this.prismaService.marketData.create({
|
||||
data: {
|
||||
symbol,
|
||||
dataSource: DataSource.RAKUTEN,
|
||||
dataSource: this.getName(),
|
||||
date: subYears(getToday(), 1),
|
||||
marketPrice: fgi.oneYearAgo.value
|
||||
}
|
||||
@ -129,7 +129,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
||||
return DataSource.RAKUTEN;
|
||||
}
|
||||
|
||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||
return { items: [] };
|
||||
}
|
||||
|
||||
|
@ -103,7 +103,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
currency: value.price?.currency,
|
||||
dataSource: DataSource.YAHOO,
|
||||
dataSource: this.getName(),
|
||||
exchange: this.parseExchange(value.price?.exchangeName),
|
||||
marketState:
|
||||
value.price?.marketState === 'REGULAR' ||
|
||||
@ -221,12 +221,14 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
return DataSource.YAHOO;
|
||||
}
|
||||
|
||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||
const items: LookupItem[] = [];
|
||||
|
||||
try {
|
||||
const get = bent(
|
||||
`${this.yahooFinanceHostname}/v1/finance/search?q=${aSymbol}&lang=en-US®ion=US"esCount=8&newsCount=0&enableFuzzyQuery=false"esQueryId=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®ion=US"esCount=8&newsCount=0&enableFuzzyQuery=false"esQueryId=tss_match_phrase_query&multiQuoteQueryId=multi_quote_single_token_query&newsQueryId=news_cie_vespa&enableCb=true&enableNavLinks=false&enableEnhancedTrivialQuery=true`,
|
||||
'GET',
|
||||
'json',
|
||||
200
|
||||
@ -268,7 +270,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
items.push({
|
||||
symbol,
|
||||
currency: value.currency,
|
||||
dataSource: DataSource.YAHOO,
|
||||
dataSource: this.getName(),
|
||||
name: value.name
|
||||
});
|
||||
}
|
||||
|
@ -58,9 +58,9 @@ export class ExchangeRateDataService {
|
||||
getYesterday()
|
||||
);
|
||||
|
||||
if (isEmpty(result)) {
|
||||
if (Object.keys(result).length !== this.currencyPairs.length) {
|
||||
// Load currencies directly from data provider as a fallback
|
||||
// if historical data is not yet available
|
||||
// if historical data is not fully available
|
||||
const historicalData = await this.dataProviderService.get(
|
||||
this.currencyPairs.map(({ dataSource, symbol }) => {
|
||||
return { dataSource, symbol };
|
||||
@ -157,7 +157,12 @@ export class ExchangeRateDataService {
|
||||
await this.prismaService.account.findMany({
|
||||
distinct: ['currency'],
|
||||
orderBy: [{ currency: 'asc' }],
|
||||
select: { currency: true }
|
||||
select: { currency: true },
|
||||
where: {
|
||||
currency: {
|
||||
not: null
|
||||
}
|
||||
}
|
||||
})
|
||||
).forEach((account) => {
|
||||
currencies.push(account.currency);
|
||||
@ -167,7 +172,12 @@ export class ExchangeRateDataService {
|
||||
await this.prismaService.settings.findMany({
|
||||
distinct: ['currency'],
|
||||
orderBy: [{ currency: 'asc' }],
|
||||
select: { currency: true }
|
||||
select: { currency: true },
|
||||
where: {
|
||||
currency: {
|
||||
not: null
|
||||
}
|
||||
}
|
||||
})
|
||||
).forEach((userSettings) => {
|
||||
currencies.push(userSettings.currency);
|
||||
@ -177,7 +187,12 @@ export class ExchangeRateDataService {
|
||||
await this.prismaService.symbolProfile.findMany({
|
||||
distinct: ['currency'],
|
||||
orderBy: [{ currency: 'asc' }],
|
||||
select: { currency: true }
|
||||
select: { currency: true },
|
||||
where: {
|
||||
currency: {
|
||||
not: null
|
||||
}
|
||||
}
|
||||
})
|
||||
).forEach((symbolProfile) => {
|
||||
currencies.push(symbolProfile.currency);
|
||||
|
@ -4,6 +4,7 @@ export interface Environment extends CleanedEnvAccessors {
|
||||
ACCESS_TOKEN_SALT: string;
|
||||
ALPHA_VANTAGE_API_KEY: string;
|
||||
CACHE_TTL: number;
|
||||
DATA_SOURCE_PRIMARY: string;
|
||||
DATA_SOURCES: string | string[]; // string is not correct, error in envalid?
|
||||
ENABLE_FEATURE_BLOG: boolean;
|
||||
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
|
||||
@ -16,6 +17,9 @@ export interface Environment extends CleanedEnvAccessors {
|
||||
ENABLE_FEATURE_SYSTEM_MESSAGE: boolean;
|
||||
GOOGLE_CLIENT_ID: string;
|
||||
GOOGLE_SECRET: string;
|
||||
GOOGLE_SHEETS_ACCOUNT: string;
|
||||
GOOGLE_SHEETS_ID: string;
|
||||
GOOGLE_SHEETS_PRIVATE_KEY: string;
|
||||
JWT_SECRET_KEY: string;
|
||||
MAX_ITEM_IN_CACHE: number;
|
||||
MAX_ORDERS_TO_IMPORT: number;
|
||||
|
@ -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 { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { resetHours } from '@ghostfolio/common/helper';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { MarketData, Prisma } from '@prisma/client';
|
||||
import { DataSource, MarketData, Prisma } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class MarketDataService {
|
||||
@ -67,14 +68,20 @@ export class MarketDataService {
|
||||
}
|
||||
|
||||
public async updateMarketData(params: {
|
||||
data: Prisma.MarketDataUpdateInput;
|
||||
data: { dataSource: DataSource } & UpdateMarketDataDto;
|
||||
where: Prisma.MarketDataWhereUniqueInput;
|
||||
}): Promise<MarketData> {
|
||||
const { data, where } = params;
|
||||
|
||||
return this.prismaService.marketData.update({
|
||||
data,
|
||||
where
|
||||
return this.prismaService.marketData.upsert({
|
||||
where,
|
||||
create: {
|
||||
dataSource: data.dataSource,
|
||||
date: where.date_symbol.date,
|
||||
marketPrice: data.marketPrice,
|
||||
symbol: where.date_symbol.symbol
|
||||
},
|
||||
update: { marketPrice: data.marketPrice }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -9,6 +9,13 @@ const routes: Routes = [
|
||||
loadChildren: () =>
|
||||
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',
|
||||
loadChildren: () =>
|
||||
@ -33,6 +40,11 @@ const routes: Routes = [
|
||||
loadChildren: () =>
|
||||
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',
|
||||
loadChildren: () =>
|
||||
@ -47,6 +59,13 @@ const routes: Routes = [
|
||||
'./pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.module'
|
||||
).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',
|
||||
loadChildren: () =>
|
||||
@ -66,6 +85,13 @@ const routes: Routes = [
|
||||
(m) => m.PortfolioPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'portfolio/activities',
|
||||
loadChildren: () =>
|
||||
import('./pages/portfolio/transactions/transactions-page.module').then(
|
||||
(m) => m.TransactionsPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'portfolio/allocations',
|
||||
loadChildren: () =>
|
||||
@ -87,13 +113,6 @@ const routes: Routes = [
|
||||
(m) => m.ReportPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'portfolio/transactions',
|
||||
loadChildren: () =>
|
||||
import('./pages/portfolio/transactions/transactions-page.module').then(
|
||||
(m) => m.TransactionsPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'pricing',
|
||||
loadChildren: () =>
|
||||
|
@ -89,7 +89,7 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
this.tokenStorageService.signOut();
|
||||
this.userService.remove();
|
||||
|
||||
this.router.navigate(['/']);
|
||||
document.location.href = '/';
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
|
@ -15,7 +15,7 @@
|
||||
>(Default)</span
|
||||
>
|
||||
</td>
|
||||
<td *matFooterCellDef class="px-1" mat-footer-cell>Total</td>
|
||||
<td *matFooterCellDef class="px-1" mat-footer-cell i18n>Total</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="currency">
|
||||
|
@ -5,10 +5,10 @@ import { MatInputModule } from '@angular/material/input';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module';
|
||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
|
||||
import { GfSymbolIconModule } from '../symbol-icon/symbol-icon.module';
|
||||
import { AccountsTableComponent } from './accounts-table.component';
|
||||
|
||||
@NgModule({
|
||||
|
@ -43,6 +43,19 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
||||
this.fetchAdminMarketData();
|
||||
}
|
||||
|
||||
public onGatherProfileDataBySymbol({
|
||||
dataSource,
|
||||
symbol
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
symbol: string;
|
||||
}) {
|
||||
this.adminService
|
||||
.gatherProfileDataBySymbol({ dataSource, symbol })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {});
|
||||
}
|
||||
|
||||
public onGatherSymbol({
|
||||
dataSource,
|
||||
symbol
|
||||
|
@ -6,7 +6,9 @@
|
||||
<tr class="mat-header-row">
|
||||
<th class="mat-header-cell px-1 py-2" i18n>Symbol</th>
|
||||
<th class="mat-header-cell px-1 py-2" i18n>Data Source</th>
|
||||
<th class="mat-header-cell px-1 py-2" i18n>First Transaction</th>
|
||||
<th class="mat-header-cell px-1 py-2" i18n>First Activity</th>
|
||||
<th class="mat-header-cell px-1 py-2" i18n>Activity Count</th>
|
||||
<th class="mat-header-cell px-1 py-2" i18n>Historical Data</th>
|
||||
<th class="mat-header-cell px-1 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -16,11 +18,13 @@
|
||||
class="cursor-pointer mat-row"
|
||||
(click)="setCurrentSymbol(item.symbol)"
|
||||
>
|
||||
<td class="mat-cell px-1 py-2">{{ item.symbol }}</td>
|
||||
<td class="mat-cell px-1 py-2">{{ item.dataSource}}</td>
|
||||
<td class="mat-cell px-1 py-2">{{ item.symbol }}</td>
|
||||
<td class="mat-cell px-1 py-2">{{ item.dataSource }}</td>
|
||||
<td class="mat-cell px-1 py-2">
|
||||
{{ (item.date | date: defaultDateFormat) ?? '' }}
|
||||
</td>
|
||||
<td class="mat-cell px-1 py-2">{{ item.activityCount }}</td>
|
||||
<td class="mat-cell px-1 py-2">{{ item.marketDataItemCount }}</td>
|
||||
<td class="mat-cell px-1 py-2">
|
||||
<button
|
||||
class="mx-1 no-min-width px-2"
|
||||
@ -38,6 +42,13 @@
|
||||
>
|
||||
Gather Data
|
||||
</button>
|
||||
<button
|
||||
i18n
|
||||
mat-menu-item
|
||||
(click)="onGatherProfileDataBySymbol({dataSource: item.dataSource, symbol: item.symbol})"
|
||||
>
|
||||
Gather Profile Data
|
||||
</button>
|
||||
</mat-menu>
|
||||
</td>
|
||||
</tr>
|
||||
|
@ -1,5 +1,6 @@
|
||||
<button
|
||||
*ngIf="deviceType === 'mobile'"
|
||||
class="mt-2"
|
||||
mat-button
|
||||
(click)="onClickCloseButton()"
|
||||
>
|
||||
|
@ -1,4 +1,7 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
margin-bottom: 0;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
@ -1,10 +1,14 @@
|
||||
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 {
|
||||
RANGE,
|
||||
SettingsStorageService
|
||||
} from '@ghostfolio/client/services/settings-storage.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { defaultDateRangeOptions } from '@ghostfolio/common/config';
|
||||
import { Position, User } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { DateRange } from '@ghostfolio/common/types';
|
||||
@ -19,6 +23,7 @@ import { takeUntil } from 'rxjs/operators';
|
||||
})
|
||||
export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||
public dateRange: DateRange;
|
||||
public dateRangeOptions = defaultDateRangeOptions;
|
||||
public deviceType: string;
|
||||
public hasPermissionToCreateOrder: boolean;
|
||||
public positions: Position[];
|
||||
@ -33,9 +38,20 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
private deviceService: DeviceDetectorService,
|
||||
private dialog: MatDialog,
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private settingsStorageService: SettingsStorageService,
|
||||
private userService: UserService
|
||||
) {
|
||||
route.queryParams
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((params) => {
|
||||
if (params['positionDetailDialog'] && params['symbol']) {
|
||||
this.openPositionDialog({ symbol: params['symbol'] });
|
||||
}
|
||||
});
|
||||
|
||||
this.userService.stateChanged
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((state) => {
|
||||
@ -64,12 +80,48 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||
this.update();
|
||||
}
|
||||
|
||||
public onChangeDateRange(aDateRange: DateRange) {
|
||||
this.dateRange = aDateRange;
|
||||
this.settingsStorageService.setSetting(RANGE, this.dateRange);
|
||||
this.update();
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
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() {
|
||||
this.positions = undefined;
|
||||
|
||||
this.dataService
|
||||
.fetchPositions({ range: this.dateRange })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
|
@ -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="align-items-center col-xs-12 col-md-8 offset-md-2">
|
||||
<mat-card class="p-0">
|
||||
@ -6,6 +14,7 @@
|
||||
<gf-positions
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[deviceType]="deviceType"
|
||||
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
|
||||
[locale]="user?.settings?.locale"
|
||||
[positions]="positions"
|
||||
[range]="dateRange"
|
||||
@ -17,8 +26,8 @@
|
||||
class="mt-3"
|
||||
i18n
|
||||
mat-button
|
||||
[routerLink]="['/portfolio', 'transactions']"
|
||||
>Manage Transactions...</a
|
||||
[routerLink]="['/portfolio', 'activities']"
|
||||
>Manage Activities...</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -3,7 +3,9 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
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 { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
|
||||
|
||||
import { HomeHoldingsComponent } from './home-holdings.component';
|
||||
|
||||
@ -12,7 +14,9 @@ import { HomeHoldingsComponent } from './home-holdings.component';
|
||||
exports: [],
|
||||
imports: [
|
||||
CommonModule,
|
||||
GfPositionDetailDialogModule,
|
||||
GfPositionsModule,
|
||||
GfToggleModule,
|
||||
MatButtonModule,
|
||||
MatCardModule,
|
||||
RouterModule
|
||||
|
@ -20,6 +20,7 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
|
||||
public hasPermissionToAccessFearAndGreedIndex: boolean;
|
||||
public historicalData: HistoricalDataItem[];
|
||||
public isLoading = true;
|
||||
public readonly numberOfDays = 90;
|
||||
public user: User;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
@ -49,7 +50,7 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
|
||||
this.dataService
|
||||
.fetchSymbolItem({
|
||||
dataSource: DataSource.RAKUTEN,
|
||||
includeHistoricalData: true,
|
||||
includeHistoricalData: this.numberOfDays,
|
||||
symbol: ghostfolioFearAndGreedIndexSymbol
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
|
@ -1,18 +1,10 @@
|
||||
<div
|
||||
class="
|
||||
align-items-center
|
||||
container
|
||||
d-flex
|
||||
flex-grow-1
|
||||
h-100
|
||||
justify-content-center
|
||||
w-100
|
||||
"
|
||||
class="align-items-center container d-flex flex-grow-1 h-100 justify-content-center w-100"
|
||||
>
|
||||
<div class="no-gutters row w-100">
|
||||
<div class="col-xs-12 col-md-8 offset-md-2">
|
||||
<div class="mb-2 text-center text-muted">
|
||||
<small i18n>Last 10 Days</small>
|
||||
<small i18n>Last {{ numberOfDays }} Days</small>
|
||||
</div>
|
||||
<gf-line-chart
|
||||
class="mb-5"
|
||||
|
@ -1,5 +1,4 @@
|
||||
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 { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||
import {
|
||||
@ -7,7 +6,9 @@ import {
|
||||
SettingsStorageService
|
||||
} from '@ghostfolio/client/services/settings-storage.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { defaultDateRangeOptions } from '@ghostfolio/common/config';
|
||||
import { PortfolioPerformance, User } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { DateRange } from '@ghostfolio/common/types';
|
||||
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
@ -21,15 +22,11 @@ import { takeUntil } from 'rxjs/operators';
|
||||
})
|
||||
export class HomeOverviewComponent implements OnDestroy, OnInit {
|
||||
public dateRange: DateRange;
|
||||
public dateRangeOptions: ToggleOption[] = [
|
||||
{ label: 'Today', value: '1d' },
|
||||
{ label: 'YTD', value: 'ytd' },
|
||||
{ label: '1Y', value: '1y' },
|
||||
{ label: '5Y', value: '5y' },
|
||||
{ label: 'Max', value: 'max' }
|
||||
];
|
||||
public dateRangeOptions = defaultDateRangeOptions;
|
||||
public deviceType: string;
|
||||
public hasError: boolean;
|
||||
public hasImpersonationId: boolean;
|
||||
public hasPermissionToCreateOrder: boolean;
|
||||
public historicalDataItems: LineChartItem[];
|
||||
public isAllTimeHigh: boolean;
|
||||
public isAllTimeLow: boolean;
|
||||
@ -56,6 +53,11 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
|
||||
if (state?.user) {
|
||||
this.user = state.user;
|
||||
|
||||
this.hasPermissionToCreateOrder = hasPermission(
|
||||
this.user.permissions,
|
||||
permissions.createOrder
|
||||
);
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}
|
||||
});
|
||||
@ -116,7 +118,8 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
|
||||
.fetchPortfolioPerformance({ range: this.dateRange })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((response) => {
|
||||
this.performance = response;
|
||||
this.hasError = response.hasErrors;
|
||||
this.performance = response.performance;
|
||||
this.isLoadingPerformance = false;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
|
@ -1,15 +1,5 @@
|
||||
<div
|
||||
class="
|
||||
align-items-center
|
||||
container
|
||||
d-flex
|
||||
flex-column
|
||||
h-100
|
||||
justify-content-center
|
||||
overview
|
||||
p-0
|
||||
position-relative
|
||||
"
|
||||
class="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="chart-container col">
|
||||
@ -23,7 +13,7 @@
|
||||
[showYAxis]="false"
|
||||
></gf-line-chart>
|
||||
<div
|
||||
*ngIf="historicalDataItems?.length === 0"
|
||||
*ngIf="hasPermissionToCreateOrder && historicalDataItems?.length === 0"
|
||||
class="align-items-center d-flex h-100 justify-content-center w-100"
|
||||
>
|
||||
<div class="d-flex justify-content-center">
|
||||
@ -37,6 +27,8 @@
|
||||
<gf-portfolio-performance
|
||||
class="pb-4"
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[deviceType]="deviceType"
|
||||
[hasError]="hasError"
|
||||
[isAllTimeHigh]="isAllTimeHigh"
|
||||
[isAllTimeLow]="isAllTimeLow"
|
||||
[isLoading]="isLoadingPerformance"
|
||||
|
@ -10,6 +10,7 @@ import {
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { primaryColorRgb } from '@ghostfolio/common/config';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||
import {
|
||||
Chart,
|
||||
@ -19,7 +20,7 @@ import {
|
||||
PointElement,
|
||||
TimeScale
|
||||
} from 'chart.js';
|
||||
import { addMonths, isAfter, parseISO, subMonths } from 'date-fns';
|
||||
import { addDays, isAfter, parseISO, subDays } from 'date-fns';
|
||||
|
||||
@Component({
|
||||
selector: 'gf-investment-chart',
|
||||
@ -27,8 +28,10 @@ import { addMonths, isAfter, parseISO, subMonths } from 'date-fns';
|
||||
templateUrl: './investment-chart.component.html',
|
||||
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() isInPercent = false;
|
||||
|
||||
@ViewChild('chartCanvas') chartCanvas;
|
||||
|
||||
@ -45,8 +48,6 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
|
||||
);
|
||||
}
|
||||
|
||||
public ngOnInit() {}
|
||||
|
||||
public ngOnChanges() {
|
||||
if (this.investments) {
|
||||
this.initialize();
|
||||
@ -61,19 +62,25 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
|
||||
this.isLoading = true;
|
||||
|
||||
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];
|
||||
this.investments.unshift({
|
||||
...firstItem,
|
||||
date: subMonths(parseISO(firstItem.date), 3).toISOString(),
|
||||
date: subDays(
|
||||
parseISO(firstItem.date),
|
||||
this.daysInMarket * 0.05 || 90
|
||||
).toISOString(),
|
||||
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];
|
||||
this.investments.push({
|
||||
...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: {
|
||||
display: false,
|
||||
display: !this.isInPercent,
|
||||
grid: {
|
||||
display: false
|
||||
},
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -7,7 +7,6 @@ import { InvestmentChartComponent } from './investment-chart.component';
|
||||
@NgModule({
|
||||
declarations: [InvestmentChartComponent],
|
||||
exports: [InvestmentChartComponent],
|
||||
imports: [CommonModule, NgxSkeletonLoaderModule],
|
||||
providers: []
|
||||
imports: [CommonModule, NgxSkeletonLoaderModule]
|
||||
})
|
||||
export class GfInvestmentChartModule {}
|
||||
|
@ -1,12 +1,18 @@
|
||||
<div class="container p-0">
|
||||
<div
|
||||
class="no-gutters row"
|
||||
[ngClass]="{
|
||||
'text-danger': isAllTimeLow,
|
||||
'text-success': isAllTimeHigh
|
||||
}"
|
||||
>
|
||||
<div class="flex-grow-1"></div>
|
||||
<div class="no-gutters row">
|
||||
<div
|
||||
class="flex-grow-1 status text-muted text-right"
|
||||
[title]="
|
||||
hasError && !isLoading
|
||||
? 'Sorry! Our data provider partner is experiencing the hiccups.'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
<ion-icon
|
||||
*ngIf="hasError && !isLoading"
|
||||
name="alert-circle-outline"
|
||||
></ion-icon>
|
||||
</div>
|
||||
<div *ngIf="isLoading" class="align-items-center d-flex">
|
||||
<ngx-skeleton-loader
|
||||
animation="pulse"
|
||||
@ -20,6 +26,10 @@
|
||||
<div
|
||||
class="display-4 font-weight-bold m-0 text-center value-container"
|
||||
[hidden]="isLoading"
|
||||
[ngClass]="{
|
||||
'text-danger': isAllTimeLow,
|
||||
'text-success': isAllTimeHigh
|
||||
}"
|
||||
>
|
||||
<span #value id="value"></span>
|
||||
</div>
|
||||
|
@ -1,6 +1,10 @@
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
.status {
|
||||
font-size: 1.33rem;
|
||||
}
|
||||
|
||||
.value-container {
|
||||
#value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
@ -19,6 +19,8 @@ import { isNumber } from 'lodash';
|
||||
})
|
||||
export class PortfolioPerformanceComponent implements OnChanges, OnInit {
|
||||
@Input() baseCurrency: string;
|
||||
@Input() deviceType: string;
|
||||
@Input() hasError: boolean;
|
||||
@Input() isAllTimeHigh: boolean;
|
||||
@Input() isAllTimeLow: boolean;
|
||||
@Input() isLoading: boolean;
|
||||
@ -44,7 +46,11 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
|
||||
this.unit = this.baseCurrency;
|
||||
|
||||
new CountUp('value', this.performance?.currentValue, {
|
||||
decimalPlaces: 2,
|
||||
decimalPlaces:
|
||||
this.deviceType === 'mobile' &&
|
||||
this.performance?.currentValue >= 100000
|
||||
? 0
|
||||
: 2,
|
||||
duration: 1,
|
||||
separator: `'`
|
||||
}).start();
|
||||
|
@ -169,4 +169,18 @@
|
||||
></gf-value>
|
||||
</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>
|
||||
|
@ -3,11 +3,13 @@ import {
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
Inject,
|
||||
OnDestroy
|
||||
OnDestroy,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
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 { AssetSubClass } from '@prisma/client';
|
||||
import { format, isSameMonth, isToday, parseISO } from 'date-fns';
|
||||
@ -23,7 +25,7 @@ import { PositionDetailDialogParams } from './interfaces/interfaces';
|
||||
templateUrl: 'position-detail-dialog.html',
|
||||
styleUrls: ['./position-detail-dialog.component.scss']
|
||||
})
|
||||
export class PositionDetailDialog implements OnDestroy {
|
||||
export class PositionDetailDialog implements OnDestroy, OnInit {
|
||||
public assetSubClass: AssetSubClass;
|
||||
public averagePrice: number;
|
||||
public benchmarkDataItems: LineChartItem[];
|
||||
@ -39,6 +41,7 @@ export class PositionDetailDialog implements OnDestroy {
|
||||
public name: string;
|
||||
public netPerformance: number;
|
||||
public netPerformancePercent: number;
|
||||
public orders: OrderWithAccount[];
|
||||
public quantity: number;
|
||||
public quantityPrecision = 2;
|
||||
public symbol: string;
|
||||
@ -52,9 +55,11 @@ export class PositionDetailDialog implements OnDestroy {
|
||||
private dataService: DataService,
|
||||
public dialogRef: MatDialogRef<PositionDetailDialog>,
|
||||
@Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams
|
||||
) {
|
||||
) {}
|
||||
|
||||
public ngOnInit(): void {
|
||||
this.dataService
|
||||
.fetchPositionDetail(data.symbol)
|
||||
.fetchPositionDetail(this.data.symbol)
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(
|
||||
({
|
||||
@ -72,6 +77,7 @@ export class PositionDetailDialog implements OnDestroy {
|
||||
name,
|
||||
netPerformance,
|
||||
netPerformancePercent,
|
||||
orders,
|
||||
quantity,
|
||||
symbol,
|
||||
transactionCount,
|
||||
@ -104,6 +110,7 @@ export class PositionDetailDialog implements OnDestroy {
|
||||
this.name = name;
|
||||
this.netPerformance = netPerformance;
|
||||
this.netPerformancePercent = netPerformancePercent;
|
||||
this.orders = orders;
|
||||
this.quantity = quantity;
|
||||
this.symbol = symbol;
|
||||
this.transactionCount = transactionCount;
|
||||
|
@ -124,6 +124,20 @@
|
||||
</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>
|
||||
|
||||
<gf-dialog-footer
|
||||
|
@ -2,12 +2,13 @@ import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
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 { GfValueModule } from '@ghostfolio/ui/value';
|
||||
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';
|
||||
|
||||
@NgModule({
|
||||
@ -15,6 +16,7 @@ import { PositionDetailDialog } from './position-detail-dialog.component';
|
||||
exports: [],
|
||||
imports: [
|
||||
CommonModule,
|
||||
GfActivitiesTableModule,
|
||||
GfDialogFooterModule,
|
||||
GfDialogHeaderModule,
|
||||
GfLineChartModule,
|
||||
|
@ -5,14 +5,9 @@ import {
|
||||
OnDestroy,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||
import { Position } from '@ghostfolio/common/interfaces';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
import { PositionDetailDialog } from './position-detail-dialog/position-detail-dialog.component';
|
||||
|
||||
@Component({
|
||||
selector: 'gf-position',
|
||||
@ -32,23 +27,7 @@ export class PositionComponent implements OnDestroy, OnInit {
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
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 constructor() {}
|
||||
|
||||
public ngOnInit() {}
|
||||
|
||||
@ -56,25 +35,4 @@ export class PositionComponent implements OnDestroy, OnInit {
|
||||
this.unsubscribeSubject.next();
|
||||
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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -123,7 +123,12 @@
|
||||
}"
|
||||
></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
|
||||
[hasBorder]="false"
|
||||
></gf-no-transactions-info-indicator>
|
||||
|
@ -9,17 +9,13 @@ import {
|
||||
Output,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatPaginator } from '@angular/material/paginator';
|
||||
import { MatSort } from '@angular/material/sort';
|
||||
import { MatTableDataSource } from '@angular/material/table';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Router } from '@angular/router';
|
||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||
import { AssetClass, Order as OrderModel } from '@prisma/client';
|
||||
import { Subject, Subscription } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
import { PositionDetailDialog } from '../position/position-detail-dialog/position-detail-dialog.component';
|
||||
|
||||
@Component({
|
||||
selector: 'gf-positions-table',
|
||||
@ -30,6 +26,7 @@ import { PositionDetailDialog } from '../position/position-detail-dialog/positio
|
||||
export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
|
||||
@Input() baseCurrency: string;
|
||||
@Input() deviceType: string;
|
||||
@Input() hasPermissionToCreateOrder: boolean;
|
||||
@Input() locale: string;
|
||||
@Input() positions: PortfolioPosition[];
|
||||
|
||||
@ -49,21 +46,7 @@ export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
public constructor(
|
||||
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 constructor(private router: Router) {}
|
||||
|
||||
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() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
|
@ -7,12 +7,12 @@ import { MatPaginatorModule } from '@angular/material/paginator';
|
||||
import { MatSortModule } from '@angular/material/sort';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
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 { GfNoTransactionsInfoModule } from '@ghostfolio/ui/no-transactions-info';
|
||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||
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 { PositionsTableComponent } from './positions-table.component';
|
||||
|
||||
|
@ -23,7 +23,10 @@
|
||||
[range]="range"
|
||||
></gf-position>
|
||||
</ng-container>
|
||||
<div *ngIf="!hasPositions" class="p-3 text-center">
|
||||
<div
|
||||
*ngIf="hasPermissionToCreateOrder && !hasPositions"
|
||||
class="p-3 text-center"
|
||||
>
|
||||
<gf-no-transactions-info-indicator
|
||||
[hasBorder]="false"
|
||||
></gf-no-transactions-info-indicator>
|
||||
|
@ -17,6 +17,7 @@ import { Position } from '@ghostfolio/common/interfaces';
|
||||
export class PositionsComponent implements OnChanges, OnInit {
|
||||
@Input() baseCurrency: string;
|
||||
@Input() deviceType: string;
|
||||
@Input() hasPermissionToCreateOrder: boolean;
|
||||
@Input() locale: string;
|
||||
@Input() positions: Position[];
|
||||
@Input() range: string;
|
||||
|
@ -1,7 +1,10 @@
|
||||
<div class="container p-0">
|
||||
<div class="row no-gutters">
|
||||
<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
|
||||
[hasBorder]="false"
|
||||
></gf-no-transactions-info-indicator>
|
||||
|
@ -8,6 +8,7 @@ import { PortfolioReportRule } from '@ghostfolio/common/interfaces';
|
||||
styleUrls: ['./rules.component.scss']
|
||||
})
|
||||
export class RulesComponent {
|
||||
@Input() hasPermissionToCreateOrder: boolean;
|
||||
@Input() rules: PortfolioReportRule;
|
||||
|
||||
public constructor() {}
|
||||
|
@ -8,8 +8,7 @@ import {
|
||||
Output
|
||||
} from '@angular/core';
|
||||
import { FormControl } from '@angular/forms';
|
||||
|
||||
import { ToggleOption } from './interfaces/toggle-option.type';
|
||||
import { ToggleOption } from '@ghostfolio/common/types';
|
||||
|
||||
@Component({
|
||||
selector: 'gf-toggle',
|
||||
|
@ -16,6 +16,8 @@ import { UserService } from '../services/user/user.service';
|
||||
export class AuthGuard implements CanActivate {
|
||||
private static PUBLIC_PAGE_ROUTES = [
|
||||
'/about',
|
||||
'/about/changelog',
|
||||
'/blog',
|
||||
'/de/blog',
|
||||
'/en/blog',
|
||||
'/p',
|
||||
|
@ -4,8 +4,7 @@ import {
|
||||
HttpEvent,
|
||||
HttpHandler,
|
||||
HttpInterceptor,
|
||||
HttpRequest,
|
||||
HttpResponse
|
||||
HttpRequest
|
||||
} from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import {
|
||||
@ -43,26 +42,6 @@ export class HttpResponseInterceptor implements HttpInterceptor {
|
||||
): Observable<HttpEvent<any>> {
|
||||
return next.handle(request).pipe(
|
||||
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;
|
||||
}),
|
||||
catchError((error: HttpErrorResponse) => {
|
||||
|
@ -35,8 +35,8 @@
|
||||
new feature, please join the Ghostfolio
|
||||
<a
|
||||
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
||||
title="Join the Ghostfolio Slack channel"
|
||||
>Slack channel</a
|
||||
title="Join the Ghostfolio Slack community"
|
||||
>Slack community</a
|
||||
>, tweet to
|
||||
<a
|
||||
href="https://twitter.com/ghostfolio_"
|
||||
@ -108,12 +108,7 @@
|
||||
<mat-card-content>
|
||||
<div class="row">
|
||||
<div class="col-xs-12 col-md-4 my-2">
|
||||
<h3
|
||||
class="mb-0"
|
||||
[hidden]="statistics?.activeUsers1d === undefined"
|
||||
>
|
||||
{{ statistics?.activeUsers1d || '-' }}
|
||||
</h3>
|
||||
<h3 class="mb-0">{{ statistics?.activeUsers1d || '-' }}</h3>
|
||||
<div class="h6 mb-0">
|
||||
<span i18n>Active Users</span> <small class="text-muted"
|
||||
>(Last 24 hours)</small
|
||||
@ -121,35 +116,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-4 my-2">
|
||||
<h3
|
||||
class="mb-0"
|
||||
[hidden]="statistics?.activeUsers7d === undefined"
|
||||
>
|
||||
{{ statistics?.activeUsers7d ?? '-' }}
|
||||
</h3>
|
||||
<div class="h6 mb-0">
|
||||
<span i18n>Active Users</span> <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> <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>
|
||||
<h3 class="mb-0">{{ statistics?.newUsers30d ?? '-' }}</h3>
|
||||
<div class="h6 mb-0">
|
||||
<span i18n>New Users</span> <small class="text-muted"
|
||||
>(Last 30 days)</small
|
||||
@ -157,69 +124,42 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-4 my-2">
|
||||
<h3
|
||||
class="mb-0"
|
||||
[hidden]="statistics?.gitHubContributors === undefined"
|
||||
>
|
||||
{{ statistics?.gitHubContributors ?? '-' }}
|
||||
</h3>
|
||||
<div class="h6 mb-0" i18n>Contributors on GitHub</div>
|
||||
<h3 class="mb-0">{{ statistics?.activeUsers30d ?? '-' }}</h3>
|
||||
<div class="h6 mb-0">
|
||||
<span i18n>Active Users</span> <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?.gitHubStargazers === undefined"
|
||||
>
|
||||
{{ statistics?.gitHubStargazers ?? '-' }}
|
||||
</h3>
|
||||
<div class="h6 mb-0" i18n>Stars on GitHub</div>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</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']"
|
||||
class="d-block"
|
||||
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
||||
>
|
||||
<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>
|
||||
<h3 class="mb-0">
|
||||
{{ statistics?.slackCommunityUsers ?? '-' }}
|
||||
</h3>
|
||||
<div class="h6 mb-0" i18n>Users in Slack community</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex-nowrap no-gutters row">
|
||||
<div class="col-xs-12 col-md-4 my-2">
|
||||
<a
|
||||
class="d-flex w-100"
|
||||
[routerLink]="['/de', 'blog', '2021', '07', 'hallo-ghostfolio']"
|
||||
class="d-block"
|
||||
href="https://github.com/ghostfolio/ghostfolio/graphs/contributors"
|
||||
>
|
||||
<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>
|
||||
<h3 class="mb-0">
|
||||
{{ statistics?.gitHubContributors ?? '-' }}
|
||||
</h3>
|
||||
<div class="h6 mb-0" i18n>Contributors on GitHub</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-4 my-2">
|
||||
<a
|
||||
class="d-block"
|
||||
href="https://github.com/ghostfolio/ghostfolio/stargazers"
|
||||
>
|
||||
<h3 class="mb-0">{{ statistics?.gitHubStargazers ?? '-' }}</h3>
|
||||
<div class="h6 mb-0" i18n>Stars on GitHub</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@ -228,25 +168,29 @@
|
||||
</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="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
|
||||
class="col-md-6 col-xs-12 my-2"
|
||||
[ngClass]="{ 'offset-md-3': !hasPermissionForBlog }"
|
||||
>
|
||||
<a
|
||||
class="py-2 w-100"
|
||||
color="primary"
|
||||
i18n
|
||||
mat-stroked-button
|
||||
[routerLink]="['/about', 'changelog']"
|
||||
>Changelog & License</a
|
||||
>
|
||||
</div>
|
||||
<div *ngIf="hasPermissionForBlog" class="col-md-6 col-xs-12 my-2">
|
||||
<a
|
||||
class="py-2 w-100"
|
||||
color="primary"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[routerLink]="['/blog']"
|
||||
>Blog</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -2,7 +2,6 @@ import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MarkdownModule } from 'ngx-markdown';
|
||||
|
||||
import { AboutPageRoutingModule } from './about-page-routing.module';
|
||||
import { AboutPageComponent } from './about-page.component';
|
||||
@ -13,7 +12,6 @@ import { AboutPageComponent } from './about-page.component';
|
||||
imports: [
|
||||
AboutPageRoutingModule,
|
||||
CommonModule,
|
||||
MarkdownModule.forChild(),
|
||||
MatButtonModule,
|
||||
MatCardModule
|
||||
],
|
||||
|
@ -2,13 +2,8 @@
|
||||
color: rgb(var(--dark-primary-text));
|
||||
display: block;
|
||||
|
||||
a {
|
||||
color: rgb(var(--dark-primary-text));
|
||||
}
|
||||
|
||||
.mat-card {
|
||||
&.about-container,
|
||||
&.changelog {
|
||||
&.about-container {
|
||||
a {
|
||||
color: rgba(var(--palette-primary-500), 1);
|
||||
font-weight: 500;
|
||||
@ -19,29 +14,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.changelog {
|
||||
::ng-deep {
|
||||
markdown {
|
||||
h1,
|
||||
p {
|
||||
display: none;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 18px;
|
||||
|
||||
&:not(:first-of-type) {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.independent-and-bootstrapped-logo {
|
||||
background-image: url('/assets/bootstrapped-dark.svg');
|
||||
background-position: center;
|
||||
@ -57,10 +29,6 @@
|
||||
:host-context(.is-dark-theme) {
|
||||
color: rgb(var(--light-primary-text));
|
||||
|
||||
a {
|
||||
color: rgb(var(--light-primary-text));
|
||||
}
|
||||
|
||||
.mat-card {
|
||||
.independent-and-bootstrapped-logo {
|
||||
background-image: url('/assets/bootstrapped-light.svg');
|
||||
|
@ -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 {}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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>
|
@ -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 {}
|
@ -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));
|
||||
}
|
@ -192,6 +192,24 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
public onNewCalculationChange(aEvent: MatSlideToggleChange) {
|
||||
this.dataService
|
||||
.putUserSetting({ isNewCalculationEngine: aEvent.checked })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
this.userService.remove();
|
||||
|
||||
this.userService
|
||||
.get()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((user) => {
|
||||
this.user = user;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public onRedeemCoupon() {
|
||||
let couponCode = prompt('Please enter your coupon code:');
|
||||
couponCode = couponCode?.trim();
|
||||
|
@ -135,6 +135,23 @@
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
*ngIf="user?.subscription"
|
||||
class="align-items-center d-flex mt-4 py-1"
|
||||
>
|
||||
<div class="pr-1 w-50">
|
||||
<div i18n>New Calculation Engine</div>
|
||||
<div class="hint-text text-muted" i18n>Experimental</div>
|
||||
</div>
|
||||
<div class="pl-1 w-50">
|
||||
<mat-slide-toggle
|
||||
color="primary"
|
||||
[checked]="user.settings.isNewCalculationEngine"
|
||||
[disabled]="!hasPermissionToUpdateUserSettings"
|
||||
(change)="onNewCalculationChange($event)"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
|
@ -1,6 +1,6 @@
|
||||
<div class="blog container">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="col-md-8 offset-md-2">
|
||||
<article>
|
||||
<div class="mb-4 text-center">
|
||||
<h1 class="mb-1" i18n>Hallo Ghostfolio 👋</h1>
|
||||
@ -141,58 +141,59 @@
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<ul class="list-inline">
|
||||
<li class="h5">
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Aktie</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Altersvorsorge</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Anlage</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2">App</span>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Cryptocurrency</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2">ETF</span>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Feedback</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Fintech</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Ghostfolio</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Investition</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Open Source</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2">OSS</span>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Portfolio</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Software</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Strategie</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Trading</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>TypeScript</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Vermögen</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Wealth Management</span
|
||||
>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Aktie</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Altersvorsorge</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Anlage</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">App</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">Feedback</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">Investition</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">Software</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Strategie</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Trading</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">TypeScript</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Vermögen</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Wealth Management</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
@ -7,9 +7,7 @@ import { HalloGhostfolioPageComponent } from './hallo-ghostfolio-page.component'
|
||||
|
||||
@NgModule({
|
||||
declarations: [HalloGhostfolioPageComponent],
|
||||
exports: [],
|
||||
imports: [CommonModule, HalloGhostfolioPageRoutingModule, RouterModule],
|
||||
providers: [],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
export class HalloGhostfolioPageModule {}
|
||||
|
@ -1,6 +1,6 @@
|
||||
<div class="blog container">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="col-md-8 offset-md-2">
|
||||
<article>
|
||||
<div class="mb-4 text-center">
|
||||
<h1 class="mb-1" i18n>Hello Ghostfolio 👋</h1>
|
||||
@ -136,42 +136,44 @@
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<ul class="list-inline">
|
||||
<li class="h5">
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Cryptocurrency</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2">ETF</span>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Fintech</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Ghostfolio</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Investment</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Open Source</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2">OSS</span>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Portfolio</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Software</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Stock</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Strategy</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Wealth</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Wealth Management</span
|
||||
>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Cryptocurrency</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">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">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>
|
||||
|
@ -7,9 +7,7 @@ import { HelloGhostfolioPageComponent } from './hello-ghostfolio-page.component'
|
||||
|
||||
@NgModule({
|
||||
declarations: [HelloGhostfolioPageComponent],
|
||||
exports: [],
|
||||
imports: [CommonModule, HelloGhostfolioPageRoutingModule, RouterModule],
|
||||
providers: [],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
export class HelloGhostfolioPageModule {}
|
||||
|
@ -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 {}
|
@ -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 {}
|
@ -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, I’m 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>
|
@ -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 {}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user