Compare commits

..

18 Commits

Author SHA1 Message Date
702ee956a2 Release 1.4.0 (#108) 2021-05-20 20:43:48 +02:00
200a7d2d65 Feature/refactor search functionality (#105)
* Refactor search functionality

* Update changelog

* Improvements after code review
2021-05-20 20:36:08 +02:00
79edc09710 Store utm_source in local storage (#106) 2021-05-19 20:36:44 +02:00
77255df4be Feature/disable base currency selector for demo user (#104)
* Disable base currency selector based on permission

* Update changelog
2021-05-18 19:36:24 +02:00
277133fa1a Clean up services (#103)
* LanguageService
* TokenStorageService
2021-05-17 19:55:10 +02:00
abd0e08566 Introduce @ghostfolio/common lib (#102) 2021-05-16 22:11:14 +02:00
561d8dbc70 Feature/rename account link (#101)
* Rename account link

* Update changelog
2021-05-16 21:22:03 +02:00
c973ffd3ba Feature/reorganize helper lib (#100)
Reorganize helper lib (Move interfaces and types)
* InfoItem
* PortfolioItem
* PortfolioOverview
* PortfolioPerformance
* Position
* PortfolioPosition
* PortfolioReport
* PortfolioReportRule
* User
* UserSettings
* DateRange
* AdminData
* AccessWithGranteeUser
* OrderWithAccount
* Granularity
* UserWithSettings
* RequestWithUser
2021-05-16 21:20:59 +02:00
368de7dedc Extend unit tests (#99) 2021-05-16 21:19:14 +02:00
e56514629f Refactor postgres variables (#98)
Co-Authored-By: Valentin Zickner <valentin@coderworks.de>
2021-05-16 09:31:49 +02:00
7a8a25c4c0 Feature/filter by year in transaction table (#97)
* Filter by year

Co-Authored-By: Valentin Zickner <valentin@coderworks.de>
2021-05-16 09:31:28 +02:00
5d36d3a6bb remove database dependencies for test execution (#96)
Co-Authored-By: Valentin Zickner <valentin@coderworks.de>
2021-05-15 18:57:27 +02:00
0ef35fd31f Feature/hide unknown exchange (#95)
* Hide unknown exchange

* Update changelog
2021-05-15 17:50:28 +02:00
c1c22c195d Release 1.3.0 (#94) 2021-05-15 10:15:29 +02:00
111d8d8e3c Feature/rename share to allocation in columns of positions table (#93)
* Rename columns

* Initial Share -> Initial Allocation
* Current Share -> Current Allocation

* Update changelog
2021-05-15 10:12:12 +02:00
b0a24e4fc0 Desaturate background color (#92) 2021-05-15 10:09:07 +02:00
694b9b8991 Feature/refactor active menu item state (#91)
* Refactor active menu item state

* Update changelog
2021-05-15 10:05:03 +02:00
fada347aa5 Fix pricing page link (#90) 2021-05-15 08:36:13 +02:00
127 changed files with 734 additions and 611 deletions

2
.env
View File

@ -11,6 +11,6 @@ POSTGRES_DB=ghostfolio-db
ACCESS_TOKEN_SALT=GHOSTFOLIO
ALPHA_VANTAGE_API_KEY=
DATABASE_URL=postgresql://user:password@localhost:5432/ghostfolio-db?sslmode=prefer
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer
JWT_SECRET_KEY=123456
PORT=3333

View File

@ -7,3 +7,4 @@ before_script:
- yarn
script:
- yarn format:check
- yarn test

View File

@ -5,6 +5,35 @@ 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.4.0 - 20.05.2021
### Added
- Added filtering by year in the transaction filtering component
### Changed
- Renamed _Ghostfolio Account_ to _My Ghostfolio_
- Hid unknown exchange in the position overview
- Disable the base currency selector for the demo user
- Refactored the portfolio unit tests to work without database
- Refactored the search functionality of the data management (aligned with data source)
- Renamed shared helper to `@ghostfolio/common/helper`
- Moved shared interfaces to `@ghostfolio/common/interfaces`
- Moved shared types to `@ghostfolio/common/types`
## 1.3.0 - 15.05.2021
### Changed
- Refactored the active menu item state by parsing the current url
- Used a desaturated background color for unknown types in pie charts
- Renamed the columns _Initial Share_ and _Current Share_ to _Initial Allocation_ and _Current Allocation_ in the positions table
### Fixed
- Fixed the link to the pricing page
## 1.2.1 - 14.05.2021
### Changed

View File

@ -208,22 +208,22 @@
}
}
},
"helper": {
"root": "libs/helper",
"sourceRoot": "libs/helper/src",
"common": {
"root": "libs/common",
"sourceRoot": "libs/common/src",
"projectType": "library",
"architect": {
"lint": {
"builder": "@nrwl/linter:eslint",
"options": {
"lintFilePatterns": ["libs/helper/**/*.ts"]
"lintFilePatterns": ["libs/common/**/*.ts"]
}
},
"test": {
"builder": "@nrwl/jest:jest",
"outputs": ["coverage/libs/helper"],
"outputs": ["coverage/libs/common"],
"options": {
"jestConfig": "libs/helper/jest.config.js",
"jestConfig": "libs/common/jest.config.js",
"passWithNoTests": true
}
}

View File

@ -1,10 +1,10 @@
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
import { Access } from '@ghostfolio/common/interfaces';
import { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { AccessService } from './access.service';
import { Access } from './interfaces/access.interface';
@Controller('access')
export class AccessController {

View File

@ -1,9 +1,8 @@
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { AccessWithGranteeUser } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { AccessWithGranteeUser } from './interfaces/access-with-grantee-user.type';
@Injectable()
export class AccessService {
public constructor(private prisma: PrismaService) {}

View File

@ -1,7 +1,11 @@
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { getPermissions, hasPermission, permissions } from '@ghostfolio/helper';
import {
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
@ -17,7 +21,7 @@ import {
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Account as AccountModel, Order } from '@prisma/client';
import { Account as AccountModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccountService } from './account.service';

View File

@ -1,6 +1,11 @@
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { getPermissions, hasPermission, permissions } from '@ghostfolio/helper';
import { AdminData } from '@ghostfolio/common/interfaces';
import {
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
Get,
@ -14,7 +19,6 @@ import { AuthGuard } from '@nestjs/passport';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AdminService } from './admin.service';
import { AdminData } from './interfaces/admin-data.interface';
@Controller('admin')
export class AdminController {

View File

@ -1,10 +1,9 @@
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { AdminData } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { Currency } from '@prisma/client';
import { AdminData } from './interfaces/admin-data.interface';
@Injectable()
export class AdminService {
public constructor(

View File

@ -1,5 +1,5 @@
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
import { Controller, Inject, Param, Post, UseGuards } from '@nestjs/common';
import { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Inject, Post, UseGuards } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';

View File

@ -1,9 +1,6 @@
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
import {
baseCurrency,
benchmarks,
isApiTokenAuthorized
} from '@ghostfolio/helper';
import { baseCurrency, benchmarks } from '@ghostfolio/common/config';
import { isApiTokenAuthorized } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,

View File

@ -3,11 +3,11 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider.serv
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { RulesService } from '@ghostfolio/api/services/rules.service';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { Currency, Type } from '@prisma/client';
import { parseISO } from 'date-fns';
import { OrderWithAccount } from '../order/interfaces/order-with-account.type';
import { CreateOrderDto } from './create-order.dto';
import { Data } from './interfaces/data.interface';

View File

@ -1,7 +1,7 @@
import { InfoItem } from '@ghostfolio/common/interfaces';
import { Controller, Get } from '@nestjs/common';
import { InfoService } from './info.service';
import { InfoItem } from './interfaces/info-item.interface';
@Controller('info')
export class InfoController {

View File

@ -1,12 +1,11 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { permissions } from '@ghostfolio/helper';
import { InfoItem } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Currency } from '@prisma/client';
import { InfoItem } from './interfaces/info-item.interface';
@Injectable()
export class InfoService {
private static DEMO_USER_ID = '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f';

View File

@ -1,7 +1,11 @@
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { getPermissions, hasPermission, permissions } from '@ghostfolio/helper';
import {
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,

View File

@ -1,11 +1,11 @@
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { Order, Prisma } from '@prisma/client';
import { CacheService } from '../cache/cache.service';
import { RedisCacheService } from '../redis-cache/redis-cache.service';
import { OrderWithAccount } from './interfaces/order-with-account.type';
@Injectable()
export class OrderService {

View File

@ -4,7 +4,19 @@ import {
} from '@ghostfolio/api/helper/object.helper';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { getPermissions, hasPermission, permissions } from '@ghostfolio/helper';
import {
PortfolioItem,
PortfolioOverview,
PortfolioPerformance,
PortfolioPosition,
PortfolioReport
} from '@ghostfolio/common/interfaces';
import {
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
Get,
@ -21,16 +33,10 @@ import { AuthGuard } from '@nestjs/passport';
import { Response } from 'express';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { RequestWithUser } from '../interfaces/request-with-user.type';
import { PortfolioItem } from './interfaces/portfolio-item.interface';
import { PortfolioOverview } from './interfaces/portfolio-overview.interface';
import { PortfolioPerformance } from './interfaces/portfolio-performance.interface';
import {
HistoricalDataItem,
PortfolioPositionDetail
} from './interfaces/portfolio-position-detail.interface';
import { PortfolioPosition } from './interfaces/portfolio-position.interface';
import { PortfolioReport } from './interfaces/portfolio-report.interface';
import { PortfolioService } from './portfolio.service';
@Controller('portfolio')

View File

@ -1,10 +1,14 @@
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
import { Portfolio } from '@ghostfolio/api/models/portfolio';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { IOrder } from '@ghostfolio/api/services/interfaces/interfaces';
import { RulesService } from '@ghostfolio/api/services/rules.service';
import {
PortfolioItem,
PortfolioOverview
} from '@ghostfolio/common/interfaces';
import { DateRange, RequestWithUser } from '@ghostfolio/common/types';
import { Inject, Injectable } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import {
@ -26,9 +30,6 @@ import * as roundTo from 'round-to';
import { OrderService } from '../order/order.service';
import { RedisCacheService } from '../redis-cache/redis-cache.service';
import { UserService } from '../user/user.service';
import { DateRange } from './interfaces/date-range.type';
import { PortfolioItem } from './interfaces/portfolio-item.interface';
import { PortfolioOverview } from './interfaces/portfolio-overview.interface';
import {
HistoricalDataItem,
PortfolioPositionDetail

View File

@ -1,4 +1,7 @@
import { DataSource } from '@prisma/client';
export interface LookupItem {
dataSource: DataSource;
name: string;
symbol: string;
}

View File

@ -1,4 +1,4 @@
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
Get,
@ -28,9 +28,12 @@ export class SymbolController {
*/
@Get('lookup')
@UseGuards(AuthGuard('jwt'))
public async lookupSymbol(@Query() { query }): Promise<LookupItem[]> {
public async lookupSymbol(
@Query() { query = '' }
): Promise<{ items: LookupItem[] }> {
try {
return this.symbolService.lookup(query);
const encodedQuery = encodeURIComponent(query.toLowerCase());
return this.symbolService.lookup(encodedQuery);
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),

View File

@ -1,10 +1,8 @@
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { convertFromYahooSymbol } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { Injectable } from '@nestjs/common';
import { Currency } from '@prisma/client';
import * as bent from 'bent';
import { Currency, DataSource } from '@prisma/client';
import { LookupItem } from './interfaces/lookup-item.interface';
import { SymbolItem } from './interfaces/symbol-item.interface';
@ -27,62 +25,30 @@ export class SymbolService {
};
}
public async lookup(aQuery = ''): Promise<LookupItem[]> {
const query = aQuery.toLowerCase();
const results: LookupItem[] = [];
public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> {
const results: { items: LookupItem[] } = { items: [] };
if (!query) {
if (!aQuery) {
return results;
}
const get = bent(
`https://query1.finance.yahoo.com/v1/finance/search?q=${query}&lang=en-US&region=US&quotesCount=8&newsCount=0&enableFuzzyQuery=false&quotesQueryId=tss_match_phrase_query&multiQuoteQueryId=multi_quote_single_token_query&newsQueryId=news_cie_vespa&enableCb=true&enableNavLinks=false&enableEnhancedTrivialQuery=true`,
'GET',
'json',
200
);
// Add custom symbols
const scraperConfigurations = await this.ghostfolioScraperApiService.getScraperConfigurations();
scraperConfigurations.forEach((scraperConfiguration) => {
if (scraperConfiguration.name.toLowerCase().startsWith(query)) {
results.push({
name: scraperConfiguration.name,
symbol: scraperConfiguration.symbol
});
}
});
try {
const { quotes } = await get();
const { items } = await this.dataProviderService.search(aQuery);
results.items = items;
const searchResult = quotes
.filter(({ isYahooFinance }) => {
return isYahooFinance;
})
.filter(({ quoteType }) => {
return (
quoteType === 'CRYPTOCURRENCY' ||
quoteType === 'EQUITY' ||
quoteType === 'ETF'
);
})
.filter(({ quoteType, symbol }) => {
if (quoteType === 'CRYPTOCURRENCY') {
// Only allow cryptocurrencies in USD
return symbol.includes('USD');
}
// Add custom symbols
const scraperConfigurations = await this.ghostfolioScraperApiService.getScraperConfigurations();
scraperConfigurations.forEach((scraperConfiguration) => {
if (scraperConfiguration.name.toLowerCase().startsWith(aQuery)) {
results.items.push({
dataSource: DataSource.GHOSTFOLIO,
name: scraperConfiguration.name,
symbol: scraperConfiguration.symbol
});
}
});
return true;
})
.map(({ longname, shortname, symbol }) => {
return {
name: longname || shortname,
symbol: convertFromYahooSymbol(symbol)
};
});
return results.concat(searchResult);
return results;
} catch (error) {
console.error(error);

View File

@ -1,5 +1,10 @@
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
import { getPermissions, hasPermission, permissions } from '@ghostfolio/helper';
import { User } from '@ghostfolio/common/interfaces';
import {
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
@ -20,7 +25,6 @@ import { User as UserModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { UserItem } from './interfaces/user-item.interface';
import { User } from './interfaces/user.interface';
import { UpdateUserSettingsDto } from './update-user-settings.dto';
import { UserService } from './user.service';

View File

@ -1,18 +1,13 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import {
getPermissions,
locale,
permissions,
resetHours
} from '@ghostfolio/helper';
import { locale } from '@ghostfolio/common/config';
import { resetHours } from '@ghostfolio/common/helper';
import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces';
import { getPermissions, permissions } from '@ghostfolio/common/permissions';
import { Injectable } from '@nestjs/common';
import { Currency, Prisma, Provider, User } from '@prisma/client';
import { add } from 'date-fns';
import { UserWithSettings } from '../interfaces/user-with-settings';
import { User as IUser } from './interfaces/user.interface';
const crypto = require('crypto');
@Injectable()

View File

@ -1,7 +1,4 @@
import {
PortfolioItem,
Position
} from '@ghostfolio/api/app/portfolio/interfaces/portfolio-item.interface';
import { PortfolioItem, Position } from '@ghostfolio/common/interfaces';
import { Order } from '../order';

View File

@ -1,4 +1,4 @@
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { EvaluationResult } from './evaluation-result.interface';

View File

@ -1,65 +1,95 @@
import { baseCurrency, getUtc, getYesterday } from '@ghostfolio/helper';
import { Test } from '@nestjs/testing';
import { UNKNOWN_KEY, baseCurrency } from '@ghostfolio/common/config';
import { getUtc, getYesterday } from '@ghostfolio/common/helper';
import { AccountType, Currency, DataSource, Role, Type } from '@prisma/client';
import { format } from 'date-fns';
import { ConfigurationService } from '../services/configuration.service';
import { DataProviderService } from '../services/data-provider.service';
import { AlphaVantageService } from '../services/data-provider/alpha-vantage/alpha-vantage.service';
import { GhostfolioScraperApiService } from '../services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '../services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '../services/data-provider/yahoo-finance/yahoo-finance.service';
import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
import { MarketState } from '../services/interfaces/interfaces';
import { PrismaService } from '../services/prisma.service';
import { RulesService } from '../services/rules.service';
import { Portfolio } from './portfolio';
jest.mock('../services/data-provider.service', () => {
return {
DataProviderService: jest.fn().mockImplementation(() => {
const today = format(new Date(), 'yyyy-MM-dd');
const yesterday = format(getYesterday(), 'yyyy-MM-dd');
return {
get: () => {
return Promise.resolve({
BTCUSD: {
currency: Currency.USD,
dataSource: DataSource.YAHOO,
exchange: UNKNOWN_KEY,
marketPrice: 57973.008,
marketState: MarketState.open,
name: 'Bitcoin USD',
type: 'Cryptocurrency'
},
ETHUSD: {
currency: Currency.USD,
dataSource: DataSource.YAHOO,
exchange: UNKNOWN_KEY,
marketPrice: 3915.337,
marketState: MarketState.open,
name: 'Ethereum USD',
type: 'Cryptocurrency'
}
});
},
getHistorical: () => {
return Promise.resolve({
BTCUSD: {
[yesterday]: 56710.122,
[today]: 57973.008
},
ETHUSD: {
[yesterday]: 3641.984,
[today]: 3915.337
}
});
}
};
})
};
});
jest.mock('../services/exchange-rate-data.service', () => {
return {
ExchangeRateDataService: jest.fn().mockImplementation(() => {
return {
initialize: () => Promise.resolve(),
toCurrency: (value: number) => value
};
})
};
});
jest.mock('../services/data-provider.service');
jest.mock('../services/exchange-rate-data.service');
jest.mock('../services/rules.service');
const DEFAULT_ACCOUNT_ID = '693a834b-eb89-42c9-ae47-35196c25d269';
const USER_ID = 'ca6ce867-5d31-495a-bce9-5942bbca9237';
describe('Portfolio', () => {
let alphaVantageService: AlphaVantageService;
let configurationService: ConfigurationService;
let dataProviderService: DataProviderService;
let exchangeRateDataService: ExchangeRateDataService;
let ghostfolioScraperApiService: GhostfolioScraperApiService;
let portfolio: Portfolio;
let prismaService: PrismaService;
let rakutenRapidApiService: RakutenRapidApiService;
let rulesService: RulesService;
let yahooFinanceService: YahooFinanceService;
beforeAll(async () => {
const app = await Test.createTestingModule({
imports: [],
providers: [
AlphaVantageService,
ConfigurationService,
DataProviderService,
ExchangeRateDataService,
GhostfolioScraperApiService,
PrismaService,
RakutenRapidApiService,
RulesService,
YahooFinanceService
]
}).compile();
alphaVantageService = app.get<AlphaVantageService>(AlphaVantageService);
configurationService = app.get<ConfigurationService>(ConfigurationService);
dataProviderService = app.get<DataProviderService>(DataProviderService);
exchangeRateDataService = app.get<ExchangeRateDataService>(
ExchangeRateDataService
dataProviderService = new DataProviderService(
null,
null,
null,
null,
null,
null
);
ghostfolioScraperApiService = app.get<GhostfolioScraperApiService>(
GhostfolioScraperApiService
);
prismaService = app.get<PrismaService>(PrismaService);
rakutenRapidApiService = app.get<RakutenRapidApiService>(
RakutenRapidApiService
);
rulesService = app.get<RulesService>(RulesService);
yahooFinanceService = app.get<YahooFinanceService>(YahooFinanceService);
exchangeRateDataService = new ExchangeRateDataService(null);
rulesService = new RulesService();
await exchangeRateDataService.initialize();
@ -170,7 +200,7 @@ describe('Portfolio', () => {
expect(details).toMatchObject({
BTCUSD: {
accounts: {
Other: {
[UNKNOWN_KEY]: {
/*current: exchangeRateDataService.toCurrency(
1 * 49631.24,
Currency.USD,
@ -183,8 +213,10 @@ describe('Portfolio', () => {
)
}
},
allocationCurrent: 1,
allocationInvestment: 1,
currency: Currency.USD,
exchange: 'Other',
exchange: UNKNOWN_KEY,
grossPerformance: 0,
grossPerformancePercent: 0,
investment: exchangeRateDataService.toCurrency(
@ -192,12 +224,10 @@ describe('Portfolio', () => {
Currency.USD,
baseCurrency
),
// marketPrice: 57973.008,
marketPrice: 57973.008,
marketState: MarketState.open,
name: 'Bitcoin USD',
quantity: 1,
// shareCurrent: 0.9999999559148652,
shareInvestment: 1,
symbol: 'BTCUSD',
transactionCount: 1,
type: 'Cryptocurrency'
@ -271,7 +301,7 @@ describe('Portfolio', () => {
expect(details).toMatchObject({
ETHUSD: {
accounts: {
Other: {
[UNKNOWN_KEY]: {
/*current: exchangeRateDataService.toCurrency(
0.2 * 991.49,
Currency.USD,
@ -284,8 +314,10 @@ describe('Portfolio', () => {
)
}
},
// allocationCurrent: 1,
allocationInvestment: 1,
currency: Currency.USD,
exchange: 'Other',
exchange: UNKNOWN_KEY,
// grossPerformance: 0,
// grossPerformancePercent: 0,
investment: exchangeRateDataService.toCurrency(
@ -293,11 +325,9 @@ describe('Portfolio', () => {
Currency.USD,
baseCurrency
),
// marketPrice: 57973.008,
marketPrice: 3915.337,
name: 'Ethereum USD',
quantity: 0.2,
// shareCurrent: 1,
shareInvestment: 1,
transactionCount: 1,
symbol: 'ETHUSD',
type: 'Cryptocurrency'
@ -326,7 +356,7 @@ describe('Portfolio', () => {
baseCurrency
),
investmentInOriginalCurrency: 0.2 * 991.49,
// marketPrice: 0,
// marketPrice: 3915.337,
quantity: 0.2
}
});
@ -402,7 +432,7 @@ describe('Portfolio', () => {
baseCurrency
),
investmentInOriginalCurrency: 0.2 * 991.49 + 0.3 * 1050,
// marketPrice: 0,
// marketPrice: 3641.984,
quantity: 0.5
}
});
@ -551,8 +581,7 @@ describe('Portfolio', () => {
}
]);
// TODO: Fix
/*expect(portfolio.getCommittedFunds()).toEqual(
expect(portfolio.getCommittedFunds()).toEqual(
exchangeRateDataService.toCurrency(
0.2 * 991.49,
Currency.USD,
@ -568,7 +597,7 @@ describe('Portfolio', () => {
Currency.USD,
baseCurrency
)
);*/
);
expect(portfolio.getFees()).toEqual(
exchangeRateDataService.toCurrency(3, Currency.USD, baseCurrency)
@ -580,12 +609,11 @@ describe('Portfolio', () => {
(0.2 * 991.49 - 0.1 * 1050 + 0.2 * 1050) / (0.2 - 0.1 + 0.2),
currency: Currency.USD,
firstBuyDate: '2018-01-05T00:00:00.000Z',
// TODO: Fix
/*investment: exchangeRateDataService.toCurrency(
investment: exchangeRateDataService.toCurrency(
0.2 * 991.49 - 0.1 * 1050 + 0.2 * 1050,
Currency.USD,
baseCurrency
),*/
),
investmentInOriginalCurrency: 0.2 * 991.49 - 0.1 * 1050 + 0.2 * 1050,
// marketPrice: 0,
quantity: 0.2 - 0.1 + 0.2
@ -595,8 +623,4 @@ describe('Portfolio', () => {
expect(portfolio.getSymbols(getYesterday())).toEqual(['ETHUSD']);
});
});
afterAll(async () => {
prismaService.$disconnect();
});
});

View File

@ -1,8 +1,14 @@
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { getToday, getYesterday, resetHours } from '@ghostfolio/common/helper';
import {
PortfolioItem,
Position
} from '@ghostfolio/api/app/portfolio/interfaces/portfolio-item.interface';
import { getToday, getYesterday, resetHours } from '@ghostfolio/helper';
PortfolioPerformance,
PortfolioPosition,
PortfolioReport,
Position,
UserWithSettings
} from '@ghostfolio/common/interfaces';
import { DateRange, OrderWithAccount } from '@ghostfolio/common/types';
import {
add,
format,
@ -22,18 +28,13 @@ import {
import { cloneDeep, isEmpty } from 'lodash';
import * as roundTo from 'round-to';
import { UserWithSettings } from '../app/interfaces/user-with-settings';
import { OrderWithAccount } from '../app/order/interfaces/order-with-account.type';
import { DateRange } from '../app/portfolio/interfaces/date-range.type';
import { PortfolioPerformance } from '../app/portfolio/interfaces/portfolio-performance.interface';
import { PortfolioPosition } from '../app/portfolio/interfaces/portfolio-position.interface';
import { PortfolioReport } from '../app/portfolio/interfaces/portfolio-report.interface';
import { DataProviderService } from '../services/data-provider.service';
import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
import { IOrder } from '../services/interfaces/interfaces';
import { RulesService } from '../services/rules.service';
import { PortfolioInterface } from './interfaces/portfolio.interface';
import { Order } from './order';
import { OrderType } from './order-type';
import { AccountClusterRiskCurrentInvestment } from './rules/account-cluster-risk/current-investment';
import { AccountClusterRiskInitialInvestment } from './rules/account-cluster-risk/initial-investment';
import { AccountClusterRiskSingleAccount } from './rules/account-cluster-risk/single-account';
@ -227,15 +228,17 @@ export class Portfolio implements PortfolioInterface {
originalValueOfSymbol *= -1;
}
if (accounts[orderOfSymbol.getAccount()?.name || 'Other']?.current) {
if (
accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY]?.current
) {
accounts[
orderOfSymbol.getAccount()?.name || 'Other'
orderOfSymbol.getAccount()?.name || UNKNOWN_KEY
].current += currentValueOfSymbol;
accounts[
orderOfSymbol.getAccount()?.name || 'Other'
orderOfSymbol.getAccount()?.name || UNKNOWN_KEY
].original += originalValueOfSymbol;
} else {
accounts[orderOfSymbol.getAccount()?.name || 'Other'] = {
accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY] = {
current: currentValueOfSymbol,
original: originalValueOfSymbol
};
@ -278,6 +281,14 @@ export class Portfolio implements PortfolioInterface {
...data[symbol],
accounts,
symbol,
allocationCurrent:
this.exchangeRateDataService.toCurrency(
portfolioItem.positions[symbol].quantity * now,
data[symbol]?.currency,
this.user.Settings.currency
) / value,
allocationInvestment:
portfolioItem.positions[symbol].investment / investment,
grossPerformance: roundTo(
portfolioItemsNow.positions[symbol].quantity * (now - before),
2
@ -285,14 +296,6 @@ export class Portfolio implements PortfolioInterface {
grossPerformancePercent: roundTo((now - before) / before, 4),
investment: portfolioItem.positions[symbol].investment,
quantity: portfolioItem.positions[symbol].quantity,
shareCurrent:
this.exchangeRateDataService.toCurrency(
portfolioItem.positions[symbol].quantity * now,
data[symbol]?.currency,
this.user.Settings.currency
) / value,
shareInvestment:
portfolioItem.positions[symbol].investment / investment,
transactionCount: portfolioItem.positions[symbol].transactionCount
};
});
@ -530,12 +533,12 @@ export class Portfolio implements PortfolioInterface {
this.orders.push(
new Order({
account: order.Account,
currency: <any>order.currency,
currency: order.currency,
date: order.date.toISOString(),
fee: order.fee,
quantity: order.quantity,
symbol: order.symbol,
type: <any>order.type,
type: <OrderType>order.type,
unitPrice: order.unitPrice
})
);

View File

@ -1,7 +1,7 @@
import { groupBy } from '@ghostfolio/helper';
import { groupBy } from '@ghostfolio/common/helper';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { Currency } from '@prisma/client';
import { PortfolioPosition } from '../app/portfolio/interfaces/portfolio-position.interface';
import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
import { EvaluationResult } from './interfaces/evaluation-result.interface';
import { RuleInterface } from './interfaces/rule.interface';

View File

@ -1,5 +1,5 @@
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule';

View File

@ -1,4 +1,4 @@
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule';

View File

@ -1,4 +1,4 @@
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule';

View File

@ -1,5 +1,5 @@
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule';

View File

@ -1,4 +1,4 @@
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule';

View File

@ -1,4 +1,4 @@
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule';

View File

@ -1,4 +1,4 @@
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule';

View File

@ -1,4 +1,4 @@
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule';

View File

@ -1,7 +1,8 @@
import { Injectable } from '@nestjs/common';
import { bool, cleanEnv, num, port, str } from 'envalid';
import { bool, cleanEnv, json, num, port, str } from 'envalid';
import { Environment } from './interfaces/environment.interface';
import { DataSource } from '.prisma/client';
@Injectable()
export class ConfigurationService {
@ -12,6 +13,7 @@ export class ConfigurationService {
ACCESS_TOKEN_SALT: str(),
ALPHA_VANTAGE_API_KEY: str({ default: '' }),
CACHE_TTL: num({ default: 1 }),
DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }),
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),

View File

@ -1,10 +1,9 @@
import { benchmarks, currencyPairs } from '@ghostfolio/common/config';
import {
benchmarks,
currencyPairs,
getUtc,
isGhostfolioScraperApiSymbol,
resetHours
} from '@ghostfolio/helper';
} from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common';
import {
differenceInHours,

View File

@ -1,10 +1,12 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import {
isCrypto,
isGhostfolioScraperApiSymbol,
isRakutenRapidApiSymbol
} from '@ghostfolio/helper';
} from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { MarketData } from '@prisma/client';
import { DataSource, MarketData } from '@prisma/client';
import { format } from 'date-fns';
import { ConfigurationService } from './configuration.service';
@ -13,7 +15,6 @@ import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-
import { RakutenRapidApiService } from './data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from './data-provider/yahoo-finance/yahoo-finance.service';
import { DataProviderInterface } from './interfaces/data-provider.interface';
import { Granularity } from './interfaces/granularity.type';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse
@ -184,4 +185,19 @@ export class DataProviderService implements DataProviderInterface {
return dataOfYahoo;
}
public async search(aSymbol: string) {
return this.getDataProvider().search(aSymbol);
}
private getDataProvider() {
switch (this.configurationService.get('DATA_SOURCES')[0]) {
case DataSource.ALPHA_VANTAGE:
return this.alphaVantageService;
case DataSource.YAHOO:
return this.yahooFinanceService;
default:
throw new Error('No data provider has been found.');
}
}
}

View File

@ -1,9 +1,11 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { isAfter, isBefore, parse } from 'date-fns';
import { ConfigurationService } from '../../configuration.service';
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
import { Granularity } from '../../interfaces/granularity.type';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse
@ -77,7 +79,17 @@ export class AlphaVantageService implements DataProviderInterface {
}
}
public search(aSymbol: string) {
return this.alphaVantage.data.search(aSymbol);
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
const result = await this.alphaVantage.data.search(aSymbol);
return {
items: result?.bestMatches?.map((bestMatch) => {
return {
dataSource: DataSource.ALPHA_VANTAGE,
name: bestMatch['2. name'],
symbol: bestMatch['1. symbol']
};
})
};
}
}

View File

@ -1,4 +1,5 @@
import { getYesterday } from '@ghostfolio/helper';
import { getYesterday } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import * as bent from 'bent';
@ -6,7 +7,6 @@ import * as cheerio from 'cheerio';
import { format } from 'date-fns';
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
import { Granularity } from '../../interfaces/granularity.type';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse,
@ -117,6 +117,10 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
return [];
}
public async search(aSymbol: string) {
return { items: [] };
}
private extractNumberFromString(aString: string): number {
try {
const [numberString] = aString.match(

View File

@ -1,4 +1,5 @@
import { getToday, getYesterday } from '@ghostfolio/helper';
import { getToday, getYesterday } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import * as bent from 'bent';
@ -6,7 +7,6 @@ import { format, subMonths, subWeeks, subYears } from 'date-fns';
import { ConfigurationService } from '../../configuration.service';
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
import { Granularity } from '../../interfaces/granularity.type';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse,
@ -117,6 +117,14 @@ export class RakutenRapidApiService implements DataProviderInterface {
return {};
}
public async search(aSymbol: string) {
return { items: [] };
}
public setPrisma(aPrismaService: PrismaService) {
this.prisma = aPrismaService;
}
private async getFearAndGreedIndex(): Promise<{
now: { value: number; valueText: string };
previousClose: { value: number; valueText: string };
@ -147,8 +155,4 @@ export class RakutenRapidApiService implements DataProviderInterface {
return undefined;
}
}
public setPrisma(aPrismaService: PrismaService) {
this.prisma = aPrismaService;
}
}

View File

@ -1,24 +0,0 @@
/*
import { Test } from '@nestjs/testing';
import { YahooFinanceService } from './yahoo-finance.service';
describe('AppService', () => {
let service: YahooFinanceService;
beforeAll(async () => {
const app = await Test.createTestingModule({
imports: [],
providers: [YahooFinanceService]
}).compile();
service = app.get<YahooFinanceService>(YahooFinanceService);
});
describe('get', () => {
it('should return data for USDCHF', () => {
expect(service.get(['USDCHF'])).toEqual('{}');
});
});
});
*/

View File

@ -1,11 +1,14 @@
import { isCrypto, isCurrency, parseCurrency } from '@ghostfolio/helper';
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { isCrypto, isCurrency, parseCurrency } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import * as bent from 'bent';
import { format } from 'date-fns';
import * as yahooFinance from 'yahoo-finance';
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
import { Granularity } from '../../interfaces/granularity.type';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse,
@ -21,6 +24,8 @@ import {
@Injectable()
export class YahooFinanceService implements DataProviderInterface {
private yahooFinanceHostname = 'https://query1.finance.yahoo.com';
public constructor() {}
public async get(
@ -135,6 +140,49 @@ export class YahooFinanceService implements DataProviderInterface {
}
}
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
let items = [];
try {
const get = bent(
`${this.yahooFinanceHostname}/v1/finance/search?q=${aSymbol}&lang=en-US&region=US&quotesCount=8&newsCount=0&enableFuzzyQuery=false&quotesQueryId=tss_match_phrase_query&multiQuoteQueryId=multi_quote_single_token_query&newsQueryId=news_cie_vespa&enableCb=true&enableNavLinks=false&enableEnhancedTrivialQuery=true`,
'GET',
'json',
200
);
const result = await get();
items = result.quotes
.filter((quote) => {
return quote.isYahooFinance;
})
.filter(({ quoteType }) => {
return (
quoteType === 'CRYPTOCURRENCY' ||
quoteType === 'EQUITY' ||
quoteType === 'ETF'
);
})
.filter(({ quoteType, symbol }) => {
if (quoteType === 'CRYPTOCURRENCY') {
// Only allow cryptocurrencies in USD
return symbol.includes('USD');
}
return true;
})
.map(({ longname, shortname, symbol }) => {
return {
dataSource: DataSource.YAHOO,
name: longname || shortname,
symbol: convertFromYahooSymbol(symbol)
};
});
} catch {}
return { items };
}
/**
* Converts a symbol to a Yahoo symbol
*
@ -170,7 +218,7 @@ export class YahooFinanceService implements DataProviderInterface {
private parseExchange(aString: string): string {
if (aString?.toLowerCase() === 'ccc') {
return 'Other';
return UNKNOWN_KEY;
}
return aString;
@ -200,7 +248,7 @@ export class YahooFinanceService implements DataProviderInterface {
return Industry.Software;
}
return Industry.Other;
return Industry.Unknown;
}
private parseSector(aString: string): Sector {
@ -222,7 +270,7 @@ export class YahooFinanceService implements DataProviderInterface {
return Sector.Technology;
}
return Sector.Other;
return Sector.Unknown;
}
private parseType(aString: string): Type {
@ -234,7 +282,7 @@ export class YahooFinanceService implements DataProviderInterface {
return Type.Stock;
}
return Type.Other;
return Type.Unknown;
}
}

View File

@ -1,4 +1,4 @@
import { getYesterday } from '@ghostfolio/helper';
import { getYesterday } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common';
import { Currency } from '@prisma/client';
import { format } from 'date-fns';

View File

@ -1,4 +1,6 @@
import { Granularity } from './granularity.type';
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { Granularity } from '@ghostfolio/common/types';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse
@ -15,4 +17,6 @@ export interface DataProviderInterface {
): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}>;
search(aSymbol: string): Promise<{ items: LookupItem[] }>;
}

View File

@ -4,6 +4,7 @@ export interface Environment extends CleanedEnvAccessors {
ACCESS_TOKEN_SALT: string;
ALPHA_VANTAGE_API_KEY: string;
CACHE_TTL: number;
DATA_SOURCES: string | string[]; // string is not correct, error in envalid?
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
ENABLE_FEATURE_SOCIAL_LOGIN: boolean;

View File

@ -1,4 +1,5 @@
import { Account, Currency, DataSource, Platform } from '@prisma/client';
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { Account, Currency, DataSource } from '@prisma/client';
import { OrderType } from '../../models/order-type';
@ -7,9 +8,9 @@ export const Industry = {
Biotechnology: 'Biotechnology',
Food: 'Food',
Internet: 'Internet',
Other: 'Other',
Pharmaceutical: 'Pharmaceutical',
Software: 'Software'
Software: 'Software',
Unknown: UNKNOWN_KEY
};
export const MarketState = {
@ -21,15 +22,15 @@ export const MarketState = {
export const Sector = {
Consumer: 'Consumer',
Healthcare: 'Healthcare',
Other: 'Other',
Technology: 'Technology'
Technology: 'Technology',
Unknown: UNKNOWN_KEY
};
export const Type = {
Cryptocurrency: 'Cryptocurrency',
ETF: 'ETF',
Other: 'Other',
Stock: 'Stock'
Stock: 'Stock',
Unknown: UNKNOWN_KEY
};
export interface IOrder {

View File

@ -1,7 +1,7 @@
import {
DEFAULT_DATE_FORMAT,
DEFAULT_DATE_FORMAT_MONTH_YEAR
} from '@ghostfolio/helper';
} from '@ghostfolio/common/config';
export const DateFormats = {
display: {

View File

@ -82,7 +82,7 @@ const routes: Routes = [
// wildcard, if requested url doesn't match any paths for routes defined
// earlier
path: '**',
redirectTo: '/home',
redirectTo: 'home',
pathMatch: 'full'
}
];

View File

@ -5,15 +5,10 @@ import {
OnDestroy,
OnInit
} from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
import { InfoItem } from '@ghostfolio/api/app/info/interfaces/info-item.interface';
import { User } from '@ghostfolio/api/app/user/interfaces/user.interface';
import {
hasPermission,
permissions,
primaryColorHex,
secondaryColorHex
} from '@ghostfolio/helper';
import { NavigationEnd, PRIMARY_OUTLET, Router } from '@angular/router';
import { primaryColorHex, secondaryColorHex } from '@ghostfolio/common/config';
import { InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { MaterialCssVarsService } from 'angular-material-css-vars';
import { Subject } from 'rxjs';
import { filter, takeUntil } from 'rxjs/operators';
@ -58,7 +53,10 @@ export class AppComponent implements OnDestroy, OnInit {
this.router.events
.pipe(filter((event) => event instanceof NavigationEnd))
.subscribe(() => {
this.currentRoute = this.router.url.toString().substring(1);
const urlTree = this.router.parseUrl(this.router.url);
const urlSegmentGroup = urlTree.root.children[PRIMARY_OUTLET];
const urlSegments = urlSegmentGroup.segments;
this.currentRoute = urlSegments[0].path;
});
this.tokenStorageService
@ -84,6 +82,16 @@ export class AppComponent implements OnDestroy, OnInit {
});
}
public onCreateAccount() {
this.tokenStorageService.signOut();
window.location.reload();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private initializeTheme() {
this.materialCssVarsService.setDarkTheme(
window.matchMedia('(prefers-color-scheme: dark)').matches
@ -96,14 +104,4 @@ export class AppComponent implements OnDestroy, OnInit {
this.materialCssVarsService.setPrimaryColor(primaryColorHex);
this.materialCssVarsService.setAccentColor(secondaryColorHex);
}
public onCreateAccount() {
this.tokenStorageService.signOut();
window.location.reload();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -22,7 +22,7 @@ import { AppComponent } from './app.component';
import { GfHeaderModule } from './components/header/header.module';
import { authInterceptorProviders } from './core/auth.interceptor';
import { httpResponseInterceptorProviders } from './core/http-response.interceptor';
import { LanguageManager } from './core/language-manager.service';
import { LanguageService } from './core/language.service';
@NgModule({
declarations: [AppComponent],
@ -46,11 +46,11 @@ import { LanguageManager } from './core/language-manager.service';
providers: [
authInterceptorProviders,
httpResponseInterceptorProviders,
LanguageManager,
LanguageService,
{
provide: DateAdapter,
useClass: CustomDateAdapter,
deps: [LanguageManager, MAT_DATE_LOCALE, Platform]
deps: [LanguageService, MAT_DATE_LOCALE, Platform]
},
{ provide: MAT_DATE_FORMATS, useValue: DateFormats }
],

View File

@ -6,7 +6,7 @@ import {
OnInit
} from '@angular/core';
import { MatTableDataSource } from '@angular/material/table';
import { Access } from '@ghostfolio/api/app/access/interfaces/access.interface';
import { Access } from '@ghostfolio/common/interfaces';
@Component({
selector: 'gf-access-table',

View File

@ -5,7 +5,7 @@ import {
OnChanges,
OnInit
} from '@angular/core';
import { resolveFearAndGreedIndex } from '@ghostfolio/helper';
import { resolveFearAndGreedIndex } from '@ghostfolio/common/helper';
@Component({
selector: 'gf-fear-and-greed-index',

View File

@ -8,7 +8,7 @@
class="d-none d-sm-block"
i18n
mat-flat-button
[color]="currentRoute?.startsWith('home') ? 'primary' : null"
[color]="currentRoute === 'home' ? 'primary' : null"
[routerLink]="['/']"
>Overview</a
>
@ -16,7 +16,7 @@
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[color]="currentRoute?.startsWith('analysis') ? 'primary' : null"
[color]="currentRoute === 'analysis' ? 'primary' : null"
[routerLink]="['/analysis']"
>Analysis</a
>
@ -24,7 +24,7 @@
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[color]="currentRoute?.startsWith('report') ? 'primary' : null"
[color]="currentRoute === 'report' ? 'primary' : null"
[routerLink]="['/report']"
>X-ray</a
>
@ -32,7 +32,7 @@
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[color]="currentRoute?.startsWith('transactions') ? 'primary' : null"
[color]="currentRoute === 'transactions' ? 'primary' : null"
[routerLink]="['/transactions']"
>Transactions</a
>
@ -40,7 +40,7 @@
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[color]="currentRoute?.startsWith('accounts') ? 'primary' : null"
[color]="currentRoute === 'accounts' ? 'primary' : null"
[routerLink]="['/accounts']"
>Accounts</a
>
@ -49,7 +49,7 @@
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[color]="currentRoute?.startsWith('admin') ? 'primary' : null"
[color]="currentRoute === 'admin' ? 'primary' : null"
[routerLink]="['/admin']"
>Admin Control</a
>
@ -57,7 +57,7 @@
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[color]="currentRoute?.startsWith('resources') ? 'primary' : null"
[color]="currentRoute === 'resources' ? 'primary' : null"
[routerLink]="['/resources']"
>Resources</a
>
@ -66,7 +66,7 @@
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[color]="currentRoute?.startsWith('pricing') ? 'primary' : null"
[color]="currentRoute === 'pricing' ? 'primary' : null"
[routerLink]="['/pricing']"
>Pricing</a
>
@ -74,7 +74,7 @@
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[color]="currentRoute?.startsWith('about') ? 'primary' : null"
[color]="currentRoute === 'about' ? 'primary' : null"
[routerLink]="['/about']"
>About</a
>
@ -138,7 +138,7 @@
class="d-block d-sm-none"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute?.startsWith('analysis') }"
[ngClass]="{ 'font-weight-bold': currentRoute === 'analysis' }"
[routerLink]="['/analysis']"
>Analysis</a
>
@ -146,7 +146,7 @@
class="d-block d-sm-none"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute?.startsWith('report') }"
[ngClass]="{ 'font-weight-bold': currentRoute === 'report' }"
[routerLink]="['/report']"
>X-ray</a
>
@ -155,7 +155,7 @@
i18n
mat-menu-item
[ngClass]="{
'font-weight-bold': currentRoute?.startsWith('transactions')
'font-weight-bold': currentRoute === 'transactions'
}"
[routerLink]="['/transactions']"
>Transactions</a
@ -164,7 +164,7 @@
class="d-block d-sm-none"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute?.startsWith('accounts') }"
[ngClass]="{ 'font-weight-bold': currentRoute === 'accounts' }"
[routerLink]="['/accounts']"
>Accounts</a
>
@ -172,16 +172,16 @@
class="align-items-center d-flex"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute?.startsWith('account') }"
[ngClass]="{ 'font-weight-bold': currentRoute === 'account' }"
[routerLink]="['/account']"
>Ghostfolio Account</a
>My Ghostfolio</a
>
<a
*ngIf="hasPermissionToAccessAdminControl"
class="d-block d-sm-none"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute?.startsWith('admin') }"
[ngClass]="{ 'font-weight-bold': currentRoute === 'admin' }"
[routerLink]="['/admin']"
>Admin Control</a
>
@ -191,7 +191,7 @@
i18n
mat-menu-item
[ngClass]="{
'font-weight-bold': currentRoute?.startsWith('resources')
'font-weight-bold': currentRoute === 'resources'
}"
[routerLink]="['/resources']"
>Resources</a
@ -201,7 +201,7 @@
class="d-block d-sm-none"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute?.startsWith('pricing') }"
[ngClass]="{ 'font-weight-bold': currentRoute === 'pricing' }"
[routerLink]="['/pricing']"
>Pricing</a
>
@ -209,7 +209,7 @@
class="d-block d-sm-none"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute?.startsWith('about') }"
[ngClass]="{ 'font-weight-bold': currentRoute === 'about' }"
[routerLink]="['/about']"
>About Ghostfolio</a
>
@ -227,11 +227,19 @@
<gf-logo></gf-logo>
</a>
<span class="spacer"></span>
<a
*ngIf="hasPermissionForSubscription"
i18n
mat-flat-button
[color]="currentRoute === 'pricing' ? 'primary' : null"
[routerLink]="['/pricing']"
>Pricing</a
>
<a
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[color]="currentRoute?.startsWith('about') ? 'primary' : null"
[color]="currentRoute === 'about' ? 'primary' : null"
[routerLink]="['/about']"
>About</a
>

View File

@ -6,13 +6,12 @@ import {
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { Router } from '@angular/router';
import { InfoItem } from '@ghostfolio/api/app/info/interfaces/info-item.interface';
import { User } from '@ghostfolio/api/app/user/interfaces/user.interface';
import { LoginWithAccessTokenDialog } from '@ghostfolio/client/pages/login/login-with-access-token-dialog/login-with-access-token-dialog.component';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { hasPermission, permissions } from '@ghostfolio/helper';
import { InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { EMPTY, Subject } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators';

View File

@ -9,8 +9,8 @@ import {
OnInit,
ViewChild
} from '@angular/core';
import { PortfolioItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-item.interface';
import { primaryColorRgb } from '@ghostfolio/helper';
import { primaryColorRgb } from '@ghostfolio/common/config';
import { PortfolioItem } from '@ghostfolio/common/interfaces';
import {
LineController,
LineElement,

View File

@ -9,7 +9,8 @@ import {
OnInit,
ViewChild
} from '@angular/core';
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/helper';
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config';
import { getBackgroundColor } from '@ghostfolio/common/helper';
import {
Chart,
Filler,
@ -62,6 +63,10 @@ export class LineChartComponent implements OnChanges, OnDestroy, OnInit {
}
}
public ngOnDestroy() {
this.chart?.destroy();
}
private initialize() {
this.isLoading = true;
const benchmarkPrices = [];
@ -76,7 +81,7 @@ export class LineChartComponent implements OnChanges, OnDestroy, OnInit {
const canvas = document.getElementById('chartCanvas');
var gradient = this.chartCanvas?.nativeElement
const gradient = this.chartCanvas?.nativeElement
?.getContext('2d')
.createLinearGradient(
0,
@ -88,14 +93,7 @@ export class LineChartComponent implements OnChanges, OnDestroy, OnInit {
0,
`rgba(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b}, 0.01)`
);
gradient.addColorStop(
1,
getComputedStyle(document.documentElement).getPropertyValue(
window.matchMedia('(prefers-color-scheme: dark)').matches
? '--dark-background'
: '--light-background'
)
);
gradient.addColorStop(1, getBackgroundColor());
const data = {
labels,
@ -181,8 +179,4 @@ export class LineChartComponent implements OnChanges, OnDestroy, OnInit {
this.isLoading = false;
}
public ngOnDestroy() {
this.chart?.destroy();
}
}

View File

@ -5,7 +5,7 @@ import {
OnChanges,
OnInit
} from '@angular/core';
import { PortfolioOverview } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-overview.interface';
import { PortfolioOverview } from '@ghostfolio/common/interfaces';
import { Currency } from '@prisma/client';
@Component({

View File

@ -7,7 +7,7 @@ import {
OnInit,
ViewChild
} from '@angular/core';
import { PortfolioPerformance } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-performance.interface';
import { PortfolioPerformance } from '@ghostfolio/common/interfaces';
import { Currency } from '@prisma/client';
import { CountUp } from 'countup.js';
import { isNumber } from 'lodash';

View File

@ -4,7 +4,7 @@ import {
Input,
OnInit
} from '@angular/core';
import { PortfolioPerformance } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-performance.interface';
import { PortfolioPerformance } from '@ghostfolio/common/interfaces';
import { Currency } from '@prisma/client';
@Component({

View File

@ -7,7 +7,7 @@ import {
OnChanges,
OnInit
} from '@angular/core';
import { PortfolioItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-item.interface';
import { PortfolioItem } from '@ghostfolio/common/interfaces';
import { endOfDay, parseISO, startOfDay } from 'date-fns';
@Component({

View File

@ -7,7 +7,9 @@ import {
OnInit,
ViewChild
} from '@angular/core';
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { getCssVariable, getTextColor } from '@ghostfolio/common/helper';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { Currency } from '@prisma/client';
import { Tooltip } from 'chart.js';
import { LinearScale } from 'chart.js';
@ -38,7 +40,11 @@ export class PortfolioProportionChartComponent
private colorMap: {
[symbol: string]: string;
} = {};
} = {
[UNKNOWN_KEY]: `rgba(${getTextColor()}, ${getCssVariable(
'--palette-foreground-divider-alpha'
)})`
};
public constructor() {
Chart.register(ArcElement, DoughnutController, LinearScale, Tooltip);
@ -74,10 +80,10 @@ export class PortfolioProportionChartComponent
};
}
} else {
if (chartData['Other']) {
chartData['Other'].value += this.positions[symbol].value;
if (chartData[UNKNOWN_KEY]) {
chartData[UNKNOWN_KEY].value += this.positions[symbol].value;
} else {
chartData['Other'] = {
chartData[UNKNOWN_KEY] = {
value: this.positions[symbol].value
};
}
@ -134,7 +140,8 @@ export class PortfolioProportionChartComponent
tooltip: {
callbacks: {
label: (context) => {
const label = context.label;
const label =
context.label === UNKNOWN_KEY ? 'Other' : context.label;
if (this.isInPercent) {
const value = 100 * <number>context.raw;

View File

@ -35,7 +35,9 @@
<div class="h6 m-0 text-truncate">{{ position?.name }}</div>
<div class="d-flex">
<span>{{ position?.symbol | gfSymbol }}</span>
<span *ngIf="position?.exchange" class="ml-2 text-muted"
<span
*ngIf="position?.exchange && position?.exchange !== unknownKey"
class="ml-2 text-muted"
>({{ position.exchange }})</span
>
</div>

View File

@ -7,8 +7,9 @@ import {
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
import { Subject, Subscription } from 'rxjs';
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { PositionDetailDialog } from './position-detail-dialog/position-detail-dialog.component';
@ -27,7 +28,7 @@ export class PositionComponent implements OnDestroy, OnInit {
@Input() position: PortfolioPosition;
@Input() range: string;
public routeQueryParams: Subscription;
public unknownKey = UNKNOWN_KEY;
private unsubscribeSubject = new Subject<void>();
@ -36,7 +37,7 @@ export class PositionComponent implements OnDestroy, OnInit {
private route: ActivatedRoute,
private router: Router
) {
this.routeQueryParams = route.queryParams
route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (

View File

@ -1,7 +1,7 @@
<table
class="gf-table w-100"
matSort
matSortActive="shareCurrent"
matSortActive="allocationCurrent"
matSortDirection="desc"
mat-table
[dataSource]="dataSource"
@ -36,7 +36,7 @@
</td>
</ng-container>
<ng-container matColumnDef="shareInvestment">
<ng-container matColumnDef="allocationInvestment">
<th
*matHeaderCellDef
class="justify-content-end px-1"
@ -44,20 +44,20 @@
mat-header-cell
mat-sort-header
>
Initial Share
Initial Allocation
</th>
<td mat-cell *matCellDef="let element">
<div class="d-flex justify-content-end px-1">
<gf-value
[isPercent]="true"
[locale]="locale"
[value]="isLoading ? undefined : element.shareInvestment"
[value]="isLoading ? undefined : element.allocationInvestment"
></gf-value>
</div>
</td>
</ng-container>
<ng-container matColumnDef="shareCurrent">
<ng-container matColumnDef="allocationCurrent">
<th
*matHeaderCellDef
class="justify-content-end px-1"
@ -65,14 +65,14 @@
mat-header-cell
mat-sort-header
>
Current Share
Current Allocation
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex justify-content-end">
<gf-value
[isPercent]="true"
[locale]="locale"
[value]="isLoading ? undefined : element.shareCurrent"
[value]="isLoading ? undefined : element.allocationCurrent"
></gf-value>
</div>
</td>

View File

@ -13,7 +13,7 @@ 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 { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { Order as OrderModel } from '@prisma/client';
import { Subject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -73,8 +73,8 @@ export class PositionsTableComponent implements OnChanges, OnInit {
this.displayedColumns = [
'symbol',
'performance',
'shareInvestment',
'shareCurrent'
'allocationInvestment',
'allocationCurrent'
];
this.isLoading = true;

View File

@ -5,8 +5,8 @@ import {
OnChanges,
OnInit
} from '@angular/core';
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
import { PortfolioPosition } from '@ghostfolio/common/interfaces/portfolio-position.interface';
@Component({
selector: 'gf-positions',

View File

@ -4,7 +4,7 @@ import {
Input,
OnInit
} from '@angular/core';
import { PortfolioReportRule } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-report.interface';
import { PortfolioReportRule } from '@ghostfolio/common/interfaces';
@Component({
selector: 'gf-rule',

View File

@ -1,5 +1,5 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { PortfolioReportRule } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-report.interface';
import { PortfolioReportRule } from '@ghostfolio/common/interfaces';
@Component({
selector: 'gf-rules',

View File

@ -21,8 +21,9 @@ import { MatDialog } from '@angular/material/dialog';
import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router';
import { OrderWithAccount } from '@ghostfolio/api/app/order/interfaces/order-with-account.type';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/helper';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { format } from 'date-fns';
import { BehaviorSubject, Observable, Subject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -258,7 +259,21 @@ export class TransactionsTableComponent
.filter((item) => {
return item !== undefined;
})
.sort();
.sort((a, b) => {
const aFirstChar = a.charAt(0);
const bFirstChar = b.charAt(0);
const isANumber = aFirstChar >= '0' && aFirstChar <= '9';
const isBNumber = bFirstChar >= '0' && bFirstChar <= '9';
// Sort priority: text, followed by numbers
if (isANumber && !isBNumber) {
return 1;
} else if (!isANumber && isBNumber) {
return -1;
} else {
return a.toLowerCase() < b.toLowerCase() ? -1 : 1;
}
});
}
private getFilterableValues(
@ -270,6 +285,7 @@ export class TransactionsTableComponent
fieldValues.add(transaction.type);
fieldValues.add(transaction.Account?.name);
fieldValues.add(transaction.Account?.Platform?.name);
fieldValues.add(format(transaction.date, 'yyyy'));
return [...fieldValues].filter((item) => {
return item !== undefined;

View File

@ -5,7 +5,7 @@ import {
OnChanges,
OnInit
} from '@angular/core';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/helper';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
import { format, isDate } from 'date-fns';
import { isNumber } from 'lodash';

View File

@ -6,16 +6,25 @@ import {
RouterStateSnapshot
} from '@angular/router';
import { SettingsStorageService } from '../services/settings-storage.service';
import { TokenStorageService } from '../services/token-storage.service';
@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate {
constructor(
private router: Router,
private settingsStorageService: SettingsStorageService,
private tokenStorageService: TokenStorageService
) {}
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
if (route.queryParams?.utm_source) {
this.settingsStorageService.setSetting(
'utm_source',
route.queryParams?.utm_source
);
}
const isLoggedIn = !!this.tokenStorageService.getToken();
if (isLoggedIn) {

View File

@ -1,8 +1,4 @@
import {
HTTP_INTERCEPTORS,
HttpErrorResponse,
HttpEvent
} from '@angular/common/http';
import { HTTP_INTERCEPTORS, HttpEvent } from '@angular/common/http';
import {
HttpHandler,
HttpInterceptor,
@ -11,7 +7,6 @@ import {
import { Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { ImpersonationStorageService } from '../services/impersonation-storage.service';
import { TokenStorageService } from '../services/token-storage.service';

View File

@ -1,126 +0,0 @@
import { Injectable } from '@angular/core';
import * as deDateFnsLocale from 'date-fns/locale/de/index';
import * as frDateFnsLocale from 'date-fns/locale/fr/index';
import { BehaviorSubject } from 'rxjs';
// TODO: Rename to language service
/**
* Service that distributes onLanguageChanged events
*/
@Injectable()
export class LanguageManager {
private static readonly AVALABLE_LANGUAGES = ['de', 'fr'];
private static readonly LANGUAGE_LABELS = {
de: 'Deutsch',
fr: 'Français'
};
private currentLanguage: string;
private changeLoadLanguageStateSubject = new BehaviorSubject(false);
/**
* @constructor
*/
public constructor() {} // private translate: TranslateService // private dataLoaderManager: DataLoaderManager,
/**
* Emits an event that the language has changed
*/
public changeLanguage(aLanguage: string) {
if (aLanguage && aLanguage !== this.currentLanguage) {
this.currentLanguage = aLanguage;
this.changeLoadLanguageStateSubject.next(true);
// this.translate.use(this.currentLanguage);
/*this.dataLoaderManager.changeLanguage(this.currentLanguage).then(() => {
// Emit an event that loading has finished
this.changeLoadLanguageStateSubject.next(false);
});*/
}
}
/**
* Returns a list of available languages for admin
*/
public getAvailableLanguages() {
return LanguageManager.AVALABLE_LANGUAGES;
}
/**
* Get the current language
*/
public getCurrentLanguage(aReturnFullLocale = false) {
// Check if the full locale is needed (e.g. for angular pipes like
// '| percentage')
if (aReturnFullLocale) {
if (this.currentLanguage) {
if (this.currentLanguage.match(/^de/)) {
return 'de-CH';
}
if (this.currentLanguage.match(/^fr/)) {
return 'fr-CH';
}
}
// Default
return 'de-CH';
}
if (this.currentLanguage) {
return this.currentLanguage;
}
// Default
return 'de';
}
/**
* Gets the locale module of date-fns in the current language
*/
public getDateFnsLocale() {
let currentDateFnsLocale = null;
switch (this.getCurrentLanguage()) {
case 'de':
currentDateFnsLocale = deDateFnsLocale;
break;
case 'fr':
currentDateFnsLocale = frDateFnsLocale;
break;
default:
currentDateFnsLocale = deDateFnsLocale;
}
return currentDateFnsLocale;
}
/**
* Returns the default language
*/
public getDefaultLanguage() {
// return globals.defaultLanguage;
return 'de';
}
/**
* Returns a pretty label of the given language
*/
public getLanguageLabel(aLanguage: string) {
if (LanguageManager.LANGUAGE_LABELS[aLanguage]) {
return LanguageManager.LANGUAGE_LABELS[aLanguage];
}
return aLanguage;
}
/**
* Returns an observable that emits true when loading is in progress and false
* when loading is finished
*/
public onChangeLoadLanguageState() {
return this.changeLoadLanguageStateSubject.asObservable();
}
}

View File

@ -0,0 +1,6 @@
import { Injectable } from '@angular/core';
@Injectable()
export class LanguageService {
public constructor() {}
}

View File

@ -1,8 +1,8 @@
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { User } from '@ghostfolio/api/app/user/interfaces/user.interface';
import { DataService } from '@ghostfolio/client/services/data.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { baseCurrency } from '@ghostfolio/helper';
import { baseCurrency } from '@ghostfolio/common/config';
import { User } from '@ghostfolio/common/interfaces';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';

View File

@ -1,13 +1,9 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { Access } from '@ghostfolio/api/app/access/interfaces/access.interface';
import { User } from '@ghostfolio/api/app/user/interfaces/user.interface';
import { DataService } from '@ghostfolio/client/services/data.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import {
DEFAULT_DATE_FORMAT,
hasPermission,
permissions
} from '@ghostfolio/helper';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
import { Access, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Currency } from '@prisma/client';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -23,6 +19,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
public currencies: Currency[] = [];
public defaultDateFormat = DEFAULT_DATE_FORMAT;
public hasPermissionForSubscription: boolean;
public hasPermissionToUpdateUserSettings: boolean;
public user: User;
private unsubscribeSubject = new Subject<void>();
@ -54,6 +51,11 @@ export class AccountPageComponent implements OnDestroy, OnInit {
this.dataService.fetchUser().subscribe((user) => {
this.user = user;
this.hasPermissionToUpdateUserSettings = hasPermission(
this.user.permissions,
permissions.updateUserSettings
);
this.cd.markForCheck();
});
});

View File

@ -35,6 +35,7 @@
<mat-label i18n>Base Currency</mat-label>
<mat-select
name="baseCurrency"
[disabled]="!hasPermissionToUpdateUserSettings"
[value]="user.settings.baseCurrency"
(selectionChange)="onChangeBaseCurrency($event)"
>

View File

@ -3,11 +3,11 @@ import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
import { User } from '@ghostfolio/api/app/user/interfaces/user.interface';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { hasPermission, permissions } from '@ghostfolio/helper';
import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Account as AccountModel, AccountType } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, Subscription } from 'rxjs';

View File

@ -1,11 +1,10 @@
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { AdminData } from '@ghostfolio/api/app/admin/interfaces/admin-data.interface';
import { User } from '@ghostfolio/api/app/user/interfaces/user.interface';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { CacheService } from '@ghostfolio/client/services/cache.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/helper';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
import { AdminData, User } from '@ghostfolio/common/interfaces';
import { formatDistanceToNow, isValid, parseISO, sub } from 'date-fns';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';

View File

@ -1,11 +1,13 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { PortfolioItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-item.interface';
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
import { User } from '@ghostfolio/api/app/user/interfaces/user.interface';
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 { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import {
PortfolioItem,
PortfolioPosition,
User
} from '@ghostfolio/common/interfaces';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -108,8 +110,8 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
type: position.type,
value:
aPeriod === 'original'
? position.shareInvestment
: position.shareCurrent
? position.allocationInvestment
: position.allocationCurrent
};
this.positionsArray.push(position);

View File

@ -2,12 +2,15 @@
<div class="mb-5 row">
<div class="col">
<h3 class="d-flex justify-content-center mb-3" i18n>Analysis</h3>
<gf-positions-table
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[locale]="user?.settings?.locale"
[positions]="positionsArray"
></gf-positions-table>
<div class="mb-4">
<h4 class="m-0" i18n>Positions</h4>
<gf-positions-table
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[locale]="user?.settings?.locale"
[positions]="positionsArray"
></gf-positions-table>
</div>
</div>
</div>
<div class="proportion-charts row">
@ -157,7 +160,7 @@
</div>
<div i18n>
You can find more charts on your desktop:
<a href="https://ghostfol.io" target="_blank">www.ghostfol.io</a>
<a href="https://ghostfol.io" target="_blank">Ghostfol.io</a>
</div>
</div>
</mat-card-content>

View File

@ -1,11 +1,6 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { DateRange } from '@ghostfolio/api/app/portfolio/interfaces/date-range.type';
import { PortfolioOverview } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-overview.interface';
import { PortfolioPerformance } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-performance.interface';
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
import { User } from '@ghostfolio/api/app/user/interfaces/user.interface';
import { LineChartItem } from '@ghostfolio/client/components/line-chart/interfaces/line-chart.interface';
import { PerformanceChartDialog } from '@ghostfolio/client/components/performance-chart-dialog/performance-chart-dialog.component';
import { ToggleOption } from '@ghostfolio/client/components/toggle/interfaces/toggle-option.type';
@ -16,7 +11,14 @@ import {
SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { hasPermission, permissions } from '@ghostfolio/helper';
import {
PortfolioOverview,
PortfolioPerformance,
PortfolioPosition,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -66,7 +68,7 @@ export class HomePageComponent implements OnDestroy, OnInit {
private settingsStorageService: SettingsStorageService,
private tokenStorageService: TokenStorageService
) {
this.routeQueryParams = route.queryParams
this.routeQueryParams = this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (params['performanceChartDialog']) {

View File

@ -37,9 +37,6 @@ export class LoginPageComponent implements OnDestroy, OnInit {
* Initializes the controller
*/
public ngOnInit() {
// Remove all tokens (e.g. impersonationId)
window.localStorage.clear();
this.dataService.fetchInfo().subscribe(({ demoAuthToken }) => {
this.demoAuthToken = demoAuthToken;

View File

@ -1,8 +1,8 @@
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { User } from '@ghostfolio/api/app/user/interfaces/user.interface';
import { DataService } from '@ghostfolio/client/services/data.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { baseCurrency } from '@ghostfolio/helper';
import { baseCurrency } from '@ghostfolio/common/config';
import { User } from '@ghostfolio/common/interfaces';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';

View File

@ -1,6 +1,6 @@
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { PortfolioReportRule } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-report.interface';
import { DataService } from '@ghostfolio/client/services/data.service';
import { PortfolioReportRule } from '@ghostfolio/common/interfaces';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';

View File

@ -3,11 +3,11 @@ import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { User } from '@ghostfolio/api/app/user/interfaces/user.interface';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { hasPermission, permissions } from '@ghostfolio/helper';
import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Order as OrderModel } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, Subscription } from 'rxjs';

View File

@ -1,5 +1,5 @@
import { Pipe, PipeTransform } from '@angular/core';
import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/helper';
import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config';
@Pipe({ name: 'gfSymbol' })
export class SymbolPipe implements PipeTransform {

View File

@ -1,28 +1,36 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Access } from '@ghostfolio/api/app/access/interfaces/access.interface';
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
import { AdminData } from '@ghostfolio/api/app/admin/interfaces/admin-data.interface';
import { InfoItem } from '@ghostfolio/api/app/info/interfaces/info-item.interface';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { PortfolioItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-item.interface';
import { PortfolioOverview } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-overview.interface';
import { PortfolioPerformance } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-performance.interface';
import {
HistoricalDataItem,
PortfolioPositionDetail
} from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
import { PortfolioReport } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-report.interface';
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { SymbolItem } from '@ghostfolio/api/app/symbol/interfaces/symbol-item.interface';
import { UserItem } from '@ghostfolio/api/app/user/interfaces/user-item.interface';
import { User } from '@ghostfolio/api/app/user/interfaces/user.interface';
import { UpdateUserSettingsDto } from '@ghostfolio/api/app/user/update-user-settings.dto';
import {
Access,
AdminData,
InfoItem,
PortfolioItem,
PortfolioOverview,
PortfolioPerformance,
PortfolioPosition,
PortfolioReport,
User
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import { Order as OrderModel } from '@prisma/client';
import { Account as AccountModel } from '@prisma/client';
import { parseISO } from 'date-fns';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { SettingsStorageService } from './settings-storage.service';
@Injectable({
providedIn: 'root'
@ -30,7 +38,10 @@ import { Account as AccountModel } from '@prisma/client';
export class DataService {
private info: InfoItem;
public constructor(private http: HttpClient) {}
public constructor(
private http: HttpClient,
private settingsStorageService: SettingsStorageService
) {}
public fetchAccounts() {
return this.http.get<AccountModel[]>('/api/account');
@ -70,7 +81,20 @@ export class DataService {
}
*/
return this.http.get<InfoItem>('/api/info');
return this.http.get<InfoItem>('/api/info').pipe(
map((data) => {
if (
this.settingsStorageService.getSetting('utm_source') ===
'trusted-web-activity'
) {
data.globalPermissions = data.globalPermissions.filter(
(permission) => permission !== permissions.enableSubscription
);
}
return data;
})
);
}
public fetchSymbolItem(aSymbol: string) {
@ -78,11 +102,25 @@ export class DataService {
}
public fetchSymbols(aQuery: string) {
return this.http.get<LookupItem[]>(`/api/symbol/lookup?query=${aQuery}`);
return this.http
.get<{ items: LookupItem[] }>(`/api/symbol/lookup?query=${aQuery}`)
.pipe(
map((respose) => {
return respose.items;
})
);
}
public fetchOrders() {
return this.http.get<OrderModel[]>('/api/order');
public fetchOrders(): Observable<OrderModel[]> {
return this.http.get<any[]>('/api/order').pipe(
map((data) => {
for (const item of data) {
item.createdAt = parseISO(item.createdAt);
item.date = parseISO(item.date);
}
return data;
})
);
}
public fetchPortfolio() {

View File

@ -2,7 +2,6 @@ import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
const TOKEN_KEY = 'auth-token';
// const USER_KEY = 'auth-user';
@Injectable({
providedIn: 'root'
@ -12,10 +11,12 @@ export class TokenStorageService {
public constructor() {}
public signOut(): void {
window.localStorage.clear();
public getToken(): string {
return window.localStorage.getItem(TOKEN_KEY);
}
this.hasTokenChangeSubject.next();
public onChangeHasToken() {
return this.hasTokenChangeSubject.asObservable();
}
public saveToken(token: string): void {
@ -25,20 +26,15 @@ export class TokenStorageService {
this.hasTokenChangeSubject.next();
}
public getToken(): string {
return window.localStorage.getItem(TOKEN_KEY);
}
public signOut(): void {
const utmSource = window.localStorage.getItem('utm_source');
public onChangeHasToken() {
return this.hasTokenChangeSubject.asObservable();
}
window.localStorage.clear();
/*public saveUser(user): void {
window.localStorage.removeItem(USER_KEY);
window.localStorage.setItem(USER_KEY, JSON.stringify(user));
}
if (utmSource) {
window.localStorage.setItem('utm_source', utmSource);
}
public getUser(): any {
return JSON.parse(localStorage.getItem(USER_KEY));
}*/
this.hasTokenChangeSubject.next();
}
}

View File

@ -2,6 +2,6 @@ module.exports = {
projects: [
'<rootDir>/apps/api',
'<rootDir>/apps/client',
'<rootDir>/libs/helper'
'<rootDir>/libs/common'
]
};

View File

@ -5,7 +5,7 @@
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"parserOptions": {
"project": ["libs/helper/tsconfig.*?.json"]
"project": ["libs/common/tsconfig.*?.json"]
},
"rules": {}
},

View File

@ -1,7 +1,7 @@
# helper
# @ghostfolio/common
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test helper` to execute the unit tests via [Jest](https://jestjs.io).
Run `nx test common` to execute the unit tests via [Jest](https://jestjs.io).

View File

@ -1,5 +1,5 @@
module.exports = {
displayName: 'helper',
displayName: 'common',
preset: '../../jest.preset.js',
globals: {
'ts-jest': {
@ -10,5 +10,5 @@ module.exports = {
'^.+\\.[tj]sx?$': 'ts-jest'
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
coverageDirectory: '../../coverage/libs/helper'
coverageDirectory: '../../coverage/libs/common'
};

View File

@ -30,3 +30,5 @@ export const secondaryColorRgb = {
export const DEFAULT_DATE_FORMAT = 'dd.MM.yyyy';
export const DEFAULT_DATE_FORMAT_MONTH_YEAR = 'MMM yyyy';
export const UNKNOWN_KEY = 'UNKNOWN';

View File

@ -11,6 +11,28 @@ export function capitalize(aString: string) {
return aString.charAt(0).toUpperCase() + aString.slice(1).toLowerCase();
}
export function getBackgroundColor() {
return getCssVariable(
window.matchMedia('(prefers-color-scheme: dark)').matches
? '--dark-background'
: '--light-background'
);
}
export function getCssVariable(aCssVariable: string) {
return getComputedStyle(document.documentElement).getPropertyValue(
aCssVariable
);
}
export function getTextColor() {
return getCssVariable(
window.matchMedia('(prefers-color-scheme: dark)').matches
? '--light-primary-text'
: '--dark-primary-text'
);
}
export function getToday() {
const year = getYear(new Date());
const month = getMonth(new Date());

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