Compare commits
10 Commits
Author | SHA1 | Date | |
---|---|---|---|
420f331be9 | |||
e0068c4d5d | |||
85661884a6 | |||
8f6203d296 | |||
2fa723dc3c | |||
a500fb72c5 | |||
02db0db733 | |||
c87b08ca8b | |||
fcc2ab1a48 | |||
7efda2f890 |
19
CHANGELOG.md
19
CHANGELOG.md
@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## 2.101.0 - 2024-08-03
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Hardened container security by switching to a non-root user, setting the filesystem to read-only, and dropping unnecessary capabilities
|
||||||
|
|
||||||
|
## 2.100.0 - 2024-08-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support to manage tags of holdings in the holding detail dialog
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the color assignment in the chart of the holdings tab on the home page (experimental)
|
||||||
|
- Persisted the view mode of the holdings tab on the home page (experimental)
|
||||||
|
- Improved the language localization for Catalan (`ca`)
|
||||||
|
- Improved the language localization for Spanish (`es`)
|
||||||
|
|
||||||
## 2.99.0 - 2024-07-29
|
## 2.99.0 - 2024-07-29
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
@ -11,7 +11,7 @@ COPY ./package.json package.json
|
|||||||
COPY ./package-lock.json package-lock.json
|
COPY ./package-lock.json package-lock.json
|
||||||
COPY ./prisma/schema.prisma prisma/schema.prisma
|
COPY ./prisma/schema.prisma prisma/schema.prisma
|
||||||
|
|
||||||
RUN apt update && apt install -y \
|
RUN apt-get update && apt-get install -y --no-install-suggests \
|
||||||
g++ \
|
g++ \
|
||||||
git \
|
git \
|
||||||
make \
|
make \
|
||||||
@ -50,16 +50,18 @@ RUN npm run database:generate-typings
|
|||||||
|
|
||||||
# Image to run, copy everything needed from builder
|
# Image to run, copy everything needed from builder
|
||||||
FROM node:20-slim
|
FROM node:20-slim
|
||||||
|
|
||||||
LABEL org.opencontainers.image.source="https://github.com/ghostfolio/ghostfolio"
|
LABEL org.opencontainers.image.source="https://github.com/ghostfolio/ghostfolio"
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
RUN apt update && apt install -y \
|
RUN apt-get update && apt-get install -y --no-install-suggests \
|
||||||
curl \
|
curl \
|
||||||
openssl \
|
openssl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
||||||
COPY ./docker/entrypoint.sh /ghostfolio/entrypoint.sh
|
COPY ./docker/entrypoint.sh /ghostfolio/entrypoint.sh
|
||||||
|
RUN chown -R node:node /ghostfolio
|
||||||
WORKDIR /ghostfolio/apps/api
|
WORKDIR /ghostfolio/apps/api
|
||||||
EXPOSE ${PORT:-3333}
|
EXPOSE ${PORT:-3333}
|
||||||
|
USER node
|
||||||
CMD [ "/ghostfolio/entrypoint.sh" ]
|
CMD [ "/ghostfolio/entrypoint.sh" ]
|
||||||
|
@ -46,6 +46,39 @@ export class OrderService {
|
|||||||
private readonly symbolProfileService: SymbolProfileService
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
public async assignTags({
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
tags,
|
||||||
|
userId
|
||||||
|
}: { tags: Tag[]; userId: string } & UniqueAsset) {
|
||||||
|
const orders = await this.prismaService.order.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
SymbolProfile: {
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
orders.map(({ id }) =>
|
||||||
|
this.prismaService.order.update({
|
||||||
|
data: {
|
||||||
|
tags: {
|
||||||
|
// The set operation replaces all existing connections with the provided ones
|
||||||
|
set: tags.map(({ id }) => {
|
||||||
|
return { id };
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
where: { id }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public async createOrder(
|
public async createOrder(
|
||||||
data: Prisma.OrderCreateInput & {
|
data: Prisma.OrderCreateInput & {
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { AccessService } from '@ghostfolio/api/app/access/access.service';
|
import { AccessService } from '@ghostfolio/api/app/access/access.service';
|
||||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import {
|
import {
|
||||||
hasNotDefinedValuesInObject,
|
hasNotDefinedValuesInObject,
|
||||||
@ -29,7 +30,8 @@ import {
|
|||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import {
|
import {
|
||||||
hasReadRestrictedAccessPermission,
|
hasReadRestrictedAccessPermission,
|
||||||
isRestrictedView
|
isRestrictedView,
|
||||||
|
permissions
|
||||||
} from '@ghostfolio/common/permissions';
|
} from '@ghostfolio/common/permissions';
|
||||||
import type {
|
import type {
|
||||||
DateRange,
|
DateRange,
|
||||||
@ -38,12 +40,14 @@ import type {
|
|||||||
} from '@ghostfolio/common/types';
|
} from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
Headers,
|
Headers,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
Param,
|
Param,
|
||||||
|
Put,
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
@ -51,12 +55,13 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { AssetClass, AssetSubClass } from '@prisma/client';
|
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
|
||||||
import { Big } from 'big.js';
|
import { Big } from 'big.js';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface';
|
import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface';
|
||||||
import { PortfolioService } from './portfolio.service';
|
import { PortfolioService } from './portfolio.service';
|
||||||
|
import { UpdateHoldingTagsDto } from './update-holding-tags.dto';
|
||||||
|
|
||||||
@Controller('portfolio')
|
@Controller('portfolio')
|
||||||
export class PortfolioController {
|
export class PortfolioController {
|
||||||
@ -566,25 +571,25 @@ export class PortfolioController {
|
|||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async getPosition(
|
public async getPosition(
|
||||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||||
@Param('dataSource') dataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('symbol') symbol
|
@Param('symbol') symbol: string
|
||||||
): Promise<PortfolioHoldingDetail> {
|
): Promise<PortfolioHoldingDetail> {
|
||||||
const position = await this.portfolioService.getPosition(
|
const holding = await this.portfolioService.getPosition(
|
||||||
dataSource,
|
dataSource,
|
||||||
impersonationId,
|
impersonationId,
|
||||||
symbol
|
symbol
|
||||||
);
|
);
|
||||||
|
|
||||||
if (position) {
|
if (!holding) {
|
||||||
return position;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
StatusCodes.NOT_FOUND
|
StatusCodes.NOT_FOUND
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return holding;
|
||||||
|
}
|
||||||
|
|
||||||
@Get('report')
|
@Get('report')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async getReport(
|
public async getReport(
|
||||||
@ -605,4 +610,36 @@ export class PortfolioController {
|
|||||||
|
|
||||||
return report;
|
return report;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.updateOrder)
|
||||||
|
@Put('position/:dataSource/:symbol/tags')
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
public async updateHoldingTags(
|
||||||
|
@Body() data: UpdateHoldingTagsDto,
|
||||||
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
): Promise<void> {
|
||||||
|
const holding = await this.portfolioService.getPosition(
|
||||||
|
dataSource,
|
||||||
|
impersonationId,
|
||||||
|
symbol
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!holding) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
|
StatusCodes.NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.portfolioService.updateTags({
|
||||||
|
dataSource,
|
||||||
|
impersonationId,
|
||||||
|
symbol,
|
||||||
|
tags: data.tags,
|
||||||
|
userId: this.request.user.id
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,78 +0,0 @@
|
|||||||
import { Big } from 'big.js';
|
|
||||||
|
|
||||||
import { PortfolioService } from './portfolio.service';
|
|
||||||
|
|
||||||
describe('PortfolioService', () => {
|
|
||||||
let portfolioService: PortfolioService;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
portfolioService = new PortfolioService(
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('annualized performance percentage', () => {
|
|
||||||
it('Get annualized performance', async () => {
|
|
||||||
expect(
|
|
||||||
portfolioService
|
|
||||||
.getAnnualizedPerformancePercent({
|
|
||||||
daysInMarket: NaN, // differenceInDays of date-fns returns NaN for the same day
|
|
||||||
netPerformancePercentage: new Big(0)
|
|
||||||
})
|
|
||||||
.toNumber()
|
|
||||||
).toEqual(0);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
portfolioService
|
|
||||||
.getAnnualizedPerformancePercent({
|
|
||||||
daysInMarket: 0,
|
|
||||||
netPerformancePercentage: new Big(0)
|
|
||||||
})
|
|
||||||
.toNumber()
|
|
||||||
).toEqual(0);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Source: https://www.readyratios.com/reference/analysis/annualized_rate.html
|
|
||||||
*/
|
|
||||||
expect(
|
|
||||||
portfolioService
|
|
||||||
.getAnnualizedPerformancePercent({
|
|
||||||
daysInMarket: 65, // < 1 year
|
|
||||||
netPerformancePercentage: new Big(0.1025)
|
|
||||||
})
|
|
||||||
.toNumber()
|
|
||||||
).toBeCloseTo(0.729705);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
portfolioService
|
|
||||||
.getAnnualizedPerformancePercent({
|
|
||||||
daysInMarket: 365, // 1 year
|
|
||||||
netPerformancePercentage: 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(
|
|
||||||
portfolioService
|
|
||||||
.getAnnualizedPerformancePercent({
|
|
||||||
daysInMarket: 575, // > 1 year
|
|
||||||
netPerformancePercentage: new Big(0.2374)
|
|
||||||
})
|
|
||||||
.toNumber()
|
|
||||||
).toBeCloseTo(0.145);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@ -18,6 +18,7 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
|
|||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
|
import { getAnnualizedPerformancePercent } from '@ghostfolio/common/calculation-helper';
|
||||||
import {
|
import {
|
||||||
DEFAULT_CURRENCY,
|
DEFAULT_CURRENCY,
|
||||||
EMERGENCY_FUND_TAG_ID,
|
EMERGENCY_FUND_TAG_ID,
|
||||||
@ -58,7 +59,8 @@ import {
|
|||||||
DataSource,
|
DataSource,
|
||||||
Order,
|
Order,
|
||||||
Platform,
|
Platform,
|
||||||
Prisma
|
Prisma,
|
||||||
|
Tag
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
import { Big } from 'big.js';
|
import { Big } from 'big.js';
|
||||||
import {
|
import {
|
||||||
@ -70,7 +72,7 @@ import {
|
|||||||
parseISO,
|
parseISO,
|
||||||
set
|
set
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
import { isEmpty, isNumber, last, uniq, uniqBy } from 'lodash';
|
import { isEmpty, uniq, uniqBy } from 'lodash';
|
||||||
|
|
||||||
import { PortfolioCalculator } from './calculator/portfolio-calculator';
|
import { PortfolioCalculator } from './calculator/portfolio-calculator';
|
||||||
import {
|
import {
|
||||||
@ -206,24 +208,6 @@ export class PortfolioService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public getAnnualizedPerformancePercent({
|
|
||||||
daysInMarket,
|
|
||||||
netPerformancePercentage
|
|
||||||
}: {
|
|
||||||
daysInMarket: number;
|
|
||||||
netPerformancePercentage: Big;
|
|
||||||
}): Big {
|
|
||||||
if (isNumber(daysInMarket) && daysInMarket > 0) {
|
|
||||||
const exponent = new Big(365).div(daysInMarket).toNumber();
|
|
||||||
|
|
||||||
return new Big(
|
|
||||||
Math.pow(netPerformancePercentage.plus(1).toNumber(), exponent)
|
|
||||||
).minus(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Big(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getDividends({
|
public async getDividends({
|
||||||
activities,
|
activities,
|
||||||
groupBy
|
groupBy
|
||||||
@ -713,7 +697,7 @@ export class PortfolioService {
|
|||||||
return Account;
|
return Account;
|
||||||
});
|
});
|
||||||
|
|
||||||
const dividendYieldPercent = this.getAnnualizedPerformancePercent({
|
const dividendYieldPercent = getAnnualizedPerformancePercent({
|
||||||
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
|
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
|
||||||
netPerformancePercentage: timeWeightedInvestment.eq(0)
|
netPerformancePercentage: timeWeightedInvestment.eq(0)
|
||||||
? new Big(0)
|
? new Big(0)
|
||||||
@ -721,7 +705,7 @@ export class PortfolioService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const dividendYieldPercentWithCurrencyEffect =
|
const dividendYieldPercentWithCurrencyEffect =
|
||||||
this.getAnnualizedPerformancePercent({
|
getAnnualizedPerformancePercent({
|
||||||
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
|
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
|
||||||
netPerformancePercentage: timeWeightedInvestmentWithCurrencyEffect.eq(
|
netPerformancePercentage: timeWeightedInvestmentWithCurrencyEffect.eq(
|
||||||
0
|
0
|
||||||
@ -1321,6 +1305,24 @@ export class PortfolioService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async updateTags({
|
||||||
|
dataSource,
|
||||||
|
impersonationId,
|
||||||
|
symbol,
|
||||||
|
tags,
|
||||||
|
userId
|
||||||
|
}: {
|
||||||
|
dataSource: DataSource;
|
||||||
|
impersonationId: string;
|
||||||
|
symbol: string;
|
||||||
|
tags: Tag[];
|
||||||
|
userId: string;
|
||||||
|
}) {
|
||||||
|
userId = await this.getUserId(impersonationId, userId);
|
||||||
|
|
||||||
|
await this.orderService.assignTags({ dataSource, symbol, tags, userId });
|
||||||
|
}
|
||||||
|
|
||||||
private async getCashPositions({
|
private async getCashPositions({
|
||||||
cashDetails,
|
cashDetails,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
@ -1724,13 +1726,13 @@ export class PortfolioService {
|
|||||||
|
|
||||||
const daysInMarket = differenceInDays(new Date(), firstOrderDate);
|
const daysInMarket = differenceInDays(new Date(), firstOrderDate);
|
||||||
|
|
||||||
const annualizedPerformancePercent = this.getAnnualizedPerformancePercent({
|
const annualizedPerformancePercent = getAnnualizedPerformancePercent({
|
||||||
daysInMarket,
|
daysInMarket,
|
||||||
netPerformancePercentage: new Big(netPerformancePercentage)
|
netPerformancePercentage: new Big(netPerformancePercentage)
|
||||||
})?.toNumber();
|
})?.toNumber();
|
||||||
|
|
||||||
const annualizedPerformancePercentWithCurrencyEffect =
|
const annualizedPerformancePercentWithCurrencyEffect =
|
||||||
this.getAnnualizedPerformancePercent({
|
getAnnualizedPerformancePercent({
|
||||||
daysInMarket,
|
daysInMarket,
|
||||||
netPerformancePercentage: new Big(
|
netPerformancePercentage: new Big(
|
||||||
netPerformancePercentageWithCurrencyEffect
|
netPerformancePercentageWithCurrencyEffect
|
||||||
|
7
apps/api/src/app/portfolio/update-holding-tags.dto.ts
Normal file
7
apps/api/src/app/portfolio/update-holding-tags.dto.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { Tag } from '@prisma/client';
|
||||||
|
import { IsArray } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateHoldingTagsDto {
|
||||||
|
@IsArray()
|
||||||
|
tags: Tag[];
|
||||||
|
}
|
@ -2,6 +2,7 @@ import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
|
|||||||
import type {
|
import type {
|
||||||
ColorScheme,
|
ColorScheme,
|
||||||
DateRange,
|
DateRange,
|
||||||
|
HoldingsViewMode,
|
||||||
ViewMode
|
ViewMode
|
||||||
} from '@ghostfolio/common/types';
|
} from '@ghostfolio/common/types';
|
||||||
|
|
||||||
@ -66,6 +67,10 @@ export class UpdateUserSettingDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
'filters.tags'?: string[];
|
'filters.tags'?: string[];
|
||||||
|
|
||||||
|
@IsIn(<HoldingsViewMode[]>['CHART', 'TABLE'])
|
||||||
|
@IsOptional()
|
||||||
|
holdingsViewMode?: HoldingsViewMode;
|
||||||
|
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
isExperimentalFeatures?: boolean;
|
isExperimentalFeatures?: boolean;
|
||||||
|
@ -190,7 +190,7 @@ export class UserService {
|
|||||||
(user.Settings.settings as UserSettings).dateRange =
|
(user.Settings.settings as UserSettings).dateRange =
|
||||||
(user.Settings.settings as UserSettings).viewMode === 'ZEN'
|
(user.Settings.settings as UserSettings).viewMode === 'ZEN'
|
||||||
? 'max'
|
? 'max'
|
||||||
: (user.Settings.settings as UserSettings)?.dateRange ?? 'max';
|
: ((user.Settings.settings as UserSettings)?.dateRange ?? 'max');
|
||||||
|
|
||||||
// Set default value for view mode
|
// Set default value for view mode
|
||||||
if (!(user.Settings.settings as UserSettings).viewMode) {
|
if (!(user.Settings.settings as UserSettings).viewMode) {
|
||||||
@ -243,6 +243,9 @@ export class UserService {
|
|||||||
|
|
||||||
// Reset benchmark
|
// Reset benchmark
|
||||||
user.Settings.settings.benchmark = undefined;
|
user.Settings.settings.benchmark = undefined;
|
||||||
|
|
||||||
|
// Reset holdings view mode
|
||||||
|
user.Settings.settings.holdingsViewMode = undefined;
|
||||||
} else if (user.subscription?.type === 'Premium') {
|
} else if (user.subscription?.type === 'Premium') {
|
||||||
currentPermissions.push(permissions.reportDataGlitch);
|
currentPermissions.push(permissions.reportDataGlitch);
|
||||||
|
|
||||||
|
@ -259,6 +259,10 @@ export class AppComponent implements OnDestroy, OnInit {
|
|||||||
this.user?.permissions,
|
this.user?.permissions,
|
||||||
permissions.reportDataGlitch
|
permissions.reportDataGlitch
|
||||||
),
|
),
|
||||||
|
hasPermissionToUpdateOrder:
|
||||||
|
!this.hasImpersonationId &&
|
||||||
|
hasPermission(this.user?.permissions, permissions.updateOrder) &&
|
||||||
|
!user?.settings?.isRestrictedView,
|
||||||
locale: this.user?.settings?.locale
|
locale: this.user?.settings?.locale
|
||||||
},
|
},
|
||||||
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||||
|
@ -19,16 +19,24 @@ import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
|
|||||||
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart';
|
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart';
|
||||||
import { GfValueComponent } from '@ghostfolio/ui/value';
|
import { GfValueComponent } from '@ghostfolio/ui/value';
|
||||||
|
|
||||||
|
import { COMMA, ENTER } from '@angular/cdk/keycodes';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import {
|
import {
|
||||||
CUSTOM_ELEMENTS_SCHEMA,
|
CUSTOM_ELEMENTS_SCHEMA,
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
ChangeDetectorRef,
|
ChangeDetectorRef,
|
||||||
Component,
|
Component,
|
||||||
|
ElementRef,
|
||||||
Inject,
|
Inject,
|
||||||
OnDestroy,
|
OnDestroy,
|
||||||
OnInit
|
OnInit,
|
||||||
|
ViewChild
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { FormBuilder, FormGroup } from '@angular/forms';
|
||||||
|
import {
|
||||||
|
MatAutocompleteModule,
|
||||||
|
MatAutocompleteSelectedEvent
|
||||||
|
} from '@angular/material/autocomplete';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatChipsModule } from '@angular/material/chips';
|
import { MatChipsModule } from '@angular/material/chips';
|
||||||
import {
|
import {
|
||||||
@ -36,14 +44,15 @@ import {
|
|||||||
MatDialogModule,
|
MatDialogModule,
|
||||||
MatDialogRef
|
MatDialogRef
|
||||||
} from '@angular/material/dialog';
|
} from '@angular/material/dialog';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
import { SortDirection } from '@angular/material/sort';
|
import { SortDirection } from '@angular/material/sort';
|
||||||
import { MatTableDataSource } from '@angular/material/table';
|
import { MatTableDataSource } from '@angular/material/table';
|
||||||
import { MatTabsModule } from '@angular/material/tabs';
|
import { MatTabsModule } from '@angular/material/tabs';
|
||||||
import { Account, Tag } from '@prisma/client';
|
import { Account, Tag } from '@prisma/client';
|
||||||
import { format, isSameMonth, isToday, parseISO } from 'date-fns';
|
import { format, isSameMonth, isToday, parseISO } from 'date-fns';
|
||||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||||
import { Subject } from 'rxjs';
|
import { Observable, of, Subject } from 'rxjs';
|
||||||
import { takeUntil } from 'rxjs/operators';
|
import { map, startWith, takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
import { HoldingDetailDialogParams } from './interfaces/interfaces';
|
import { HoldingDetailDialogParams } from './interfaces/interfaces';
|
||||||
|
|
||||||
@ -60,9 +69,11 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces';
|
|||||||
GfLineChartComponent,
|
GfLineChartComponent,
|
||||||
GfPortfolioProportionChartComponent,
|
GfPortfolioProportionChartComponent,
|
||||||
GfValueComponent,
|
GfValueComponent,
|
||||||
|
MatAutocompleteModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatChipsModule,
|
MatChipsModule,
|
||||||
MatDialogModule,
|
MatDialogModule,
|
||||||
|
MatFormFieldModule,
|
||||||
MatTabsModule,
|
MatTabsModule,
|
||||||
NgxSkeletonLoaderModule
|
NgxSkeletonLoaderModule
|
||||||
],
|
],
|
||||||
@ -73,6 +84,9 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces';
|
|||||||
templateUrl: 'holding-detail-dialog.html'
|
templateUrl: 'holding-detail-dialog.html'
|
||||||
})
|
})
|
||||||
export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
||||||
|
@ViewChild('tagInput') tagInput: ElementRef<HTMLInputElement>;
|
||||||
|
|
||||||
|
public activityForm: FormGroup;
|
||||||
public accounts: Account[];
|
public accounts: Account[];
|
||||||
public activities: Activity[];
|
public activities: Activity[];
|
||||||
public assetClass: string;
|
public assetClass: string;
|
||||||
@ -88,6 +102,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
|||||||
public dividendInBaseCurrencyPrecision = 2;
|
public dividendInBaseCurrencyPrecision = 2;
|
||||||
public dividendYieldPercentWithCurrencyEffect: number;
|
public dividendYieldPercentWithCurrencyEffect: number;
|
||||||
public feeInBaseCurrency: number;
|
public feeInBaseCurrency: number;
|
||||||
|
public filteredTagsObservable: Observable<Tag[]> = of([]);
|
||||||
public firstBuyDate: string;
|
public firstBuyDate: string;
|
||||||
public historicalDataItems: LineChartItem[];
|
public historicalDataItems: LineChartItem[];
|
||||||
public investment: number;
|
public investment: number;
|
||||||
@ -107,10 +122,12 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
|||||||
public sectors: {
|
public sectors: {
|
||||||
[name: string]: { name: string; value: number };
|
[name: string]: { name: string; value: number };
|
||||||
};
|
};
|
||||||
|
public separatorKeysCodes: number[] = [COMMA, ENTER];
|
||||||
public sortColumn = 'date';
|
public sortColumn = 'date';
|
||||||
public sortDirection: SortDirection = 'desc';
|
public sortDirection: SortDirection = 'desc';
|
||||||
public SymbolProfile: EnhancedSymbolProfile;
|
public SymbolProfile: EnhancedSymbolProfile;
|
||||||
public tags: Tag[];
|
public tags: Tag[];
|
||||||
|
public tagsAvailable: Tag[];
|
||||||
public totalItems: number;
|
public totalItems: number;
|
||||||
public transactionCount: number;
|
public transactionCount: number;
|
||||||
public user: User;
|
public user: User;
|
||||||
@ -123,10 +140,38 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
|||||||
private dataService: DataService,
|
private dataService: DataService,
|
||||||
public dialogRef: MatDialogRef<GfHoldingDetailDialogComponent>,
|
public dialogRef: MatDialogRef<GfHoldingDetailDialogComponent>,
|
||||||
@Inject(MAT_DIALOG_DATA) public data: HoldingDetailDialogParams,
|
@Inject(MAT_DIALOG_DATA) public data: HoldingDetailDialogParams,
|
||||||
|
private formBuilder: FormBuilder,
|
||||||
private userService: UserService
|
private userService: UserService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public ngOnInit() {
|
public ngOnInit() {
|
||||||
|
const { tags } = this.dataService.fetchInfo();
|
||||||
|
|
||||||
|
this.activityForm = this.formBuilder.group({
|
||||||
|
tags: <string[]>[]
|
||||||
|
});
|
||||||
|
|
||||||
|
this.tagsAvailable = tags.map(({ id, name }) => {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: translate(name)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
this.activityForm
|
||||||
|
.get('tags')
|
||||||
|
.valueChanges.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((tags) => {
|
||||||
|
this.dataService
|
||||||
|
.putHoldingTags({
|
||||||
|
tags,
|
||||||
|
dataSource: this.data.dataSource,
|
||||||
|
symbol: this.data.symbol
|
||||||
|
})
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe();
|
||||||
|
});
|
||||||
|
|
||||||
this.dataService
|
this.dataService
|
||||||
.fetchHoldingDetail({
|
.fetchHoldingDetail({
|
||||||
dataSource: this.data.dataSource,
|
dataSource: this.data.dataSource,
|
||||||
@ -248,12 +293,27 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
|||||||
this.reportDataGlitchMail = `mailto:hi@ghostfol.io?Subject=Ghostfolio Data Glitch Report&body=Hello%0D%0DI would like to report a data glitch for%0D%0DSymbol: ${SymbolProfile?.symbol}%0DData Source: ${SymbolProfile?.dataSource}%0D%0DAdditional notes:%0D%0DCan you please take a look?%0D%0DKind regards`;
|
this.reportDataGlitchMail = `mailto:hi@ghostfol.io?Subject=Ghostfolio Data Glitch Report&body=Hello%0D%0DI would like to report a data glitch for%0D%0DSymbol: ${SymbolProfile?.symbol}%0DData Source: ${SymbolProfile?.dataSource}%0D%0DAdditional notes:%0D%0DCan you please take a look?%0D%0DKind regards`;
|
||||||
this.sectors = {};
|
this.sectors = {};
|
||||||
this.SymbolProfile = SymbolProfile;
|
this.SymbolProfile = SymbolProfile;
|
||||||
|
|
||||||
this.tags = tags.map(({ id, name }) => {
|
this.tags = tags.map(({ id, name }) => {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
name: translate(name)
|
name: translate(name)
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.activityForm.setValue({ tags: this.tags }, { emitEvent: false });
|
||||||
|
|
||||||
|
this.filteredTagsObservable = this.activityForm.controls[
|
||||||
|
'tags'
|
||||||
|
].valueChanges.pipe(
|
||||||
|
startWith(this.activityForm.get('tags').value),
|
||||||
|
map((aTags: Tag[] | null) => {
|
||||||
|
return aTags
|
||||||
|
? this.filterTags(aTags)
|
||||||
|
: this.tagsAvailable.slice();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
this.transactionCount = transactionCount;
|
this.transactionCount = transactionCount;
|
||||||
this.totalItems = transactionCount;
|
this.totalItems = transactionCount;
|
||||||
this.value = value;
|
this.value = value;
|
||||||
@ -353,6 +413,17 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onAddTag(event: MatAutocompleteSelectedEvent) {
|
||||||
|
this.activityForm.get('tags').setValue([
|
||||||
|
...(this.activityForm.get('tags').value ?? []),
|
||||||
|
this.tagsAvailable.find(({ id }) => {
|
||||||
|
return id === event.option.value;
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.tagInput.nativeElement.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
public onClose() {
|
public onClose() {
|
||||||
this.dialogRef.close();
|
this.dialogRef.close();
|
||||||
}
|
}
|
||||||
@ -377,8 +448,26 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onRemoveTag(aTag: Tag) {
|
||||||
|
this.activityForm.get('tags').setValue(
|
||||||
|
this.activityForm.get('tags').value.filter(({ id }) => {
|
||||||
|
return id !== aTag.id;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public ngOnDestroy() {
|
public ngOnDestroy() {
|
||||||
this.unsubscribeSubject.next();
|
this.unsubscribeSubject.next();
|
||||||
this.unsubscribeSubject.complete();
|
this.unsubscribeSubject.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private filterTags(aTags: Tag[]) {
|
||||||
|
const tagIds = aTags.map(({ id }) => {
|
||||||
|
return id;
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.tagsAvailable.filter(({ id }) => {
|
||||||
|
return !tagIds.includes(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -325,7 +325,7 @@
|
|||||||
|
|
||||||
<mat-tab-group
|
<mat-tab-group
|
||||||
animationDuration="0"
|
animationDuration="0"
|
||||||
class="mb-3"
|
class="mb-5"
|
||||||
[mat-stretch-tabs]="false"
|
[mat-stretch-tabs]="false"
|
||||||
[ngClass]="{ 'd-none': !activities?.length }"
|
[ngClass]="{ 'd-none': !activities?.length }"
|
||||||
>
|
>
|
||||||
@ -375,7 +375,49 @@
|
|||||||
</mat-tab>
|
</mat-tab>
|
||||||
</mat-tab-group>
|
</mat-tab-group>
|
||||||
|
|
||||||
@if (tags?.length > 0) {
|
<div
|
||||||
|
class="row"
|
||||||
|
[ngClass]="{
|
||||||
|
'd-none': !data.hasPermissionToUpdateOrder
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="col">
|
||||||
|
<mat-form-field appearance="outline" class="w-100 without-hint">
|
||||||
|
<mat-label i18n>Tags</mat-label>
|
||||||
|
<mat-chip-grid #tagsChipList>
|
||||||
|
@for (tag of activityForm.get('tags')?.value; track tag.id) {
|
||||||
|
<mat-chip-row
|
||||||
|
matChipRemove
|
||||||
|
[removable]="true"
|
||||||
|
(removed)="onRemoveTag(tag)"
|
||||||
|
>
|
||||||
|
{{ tag.name }}
|
||||||
|
<ion-icon class="ml-2" matPrefix name="close-outline" />
|
||||||
|
</mat-chip-row>
|
||||||
|
}
|
||||||
|
<input
|
||||||
|
#tagInput
|
||||||
|
name="close-outline"
|
||||||
|
[matAutocomplete]="autocompleteTags"
|
||||||
|
[matChipInputFor]="tagsChipList"
|
||||||
|
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
|
||||||
|
/>
|
||||||
|
</mat-chip-grid>
|
||||||
|
<mat-autocomplete
|
||||||
|
#autocompleteTags="matAutocomplete"
|
||||||
|
(optionSelected)="onAddTag($event)"
|
||||||
|
>
|
||||||
|
@for (tag of filteredTagsObservable | async; track tag.id) {
|
||||||
|
<mat-option [value]="tag.id">
|
||||||
|
{{ tag.name }}
|
||||||
|
</mat-option>
|
||||||
|
}
|
||||||
|
</mat-autocomplete>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!data.hasPermissionToUpdateOrder && tagsAvailable?.length > 0) {
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="h5" i18n>Tags</div>
|
<div class="h5" i18n>Tags</div>
|
||||||
|
@ -9,6 +9,7 @@ export interface HoldingDetailDialogParams {
|
|||||||
deviceType: string;
|
deviceType: string;
|
||||||
hasImpersonationId: boolean;
|
hasImpersonationId: boolean;
|
||||||
hasPermissionToReportDataGlitch: boolean;
|
hasPermissionToReportDataGlitch: boolean;
|
||||||
|
hasPermissionToUpdateOrder: boolean;
|
||||||
locale: string;
|
locale: string;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
}
|
}
|
||||||
|
@ -9,7 +9,7 @@ import {
|
|||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import {
|
import {
|
||||||
HoldingType,
|
HoldingType,
|
||||||
HoldingViewMode,
|
HoldingsViewMode,
|
||||||
ToggleOption
|
ToggleOption
|
||||||
} from '@ghostfolio/common/types';
|
} from '@ghostfolio/common/types';
|
||||||
|
|
||||||
@ -18,7 +18,7 @@ import { FormControl } from '@angular/forms';
|
|||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { takeUntil } from 'rxjs/operators';
|
import { skip, takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'gf-home-holdings',
|
selector: 'gf-home-holdings',
|
||||||
@ -26,6 +26,8 @@ import { takeUntil } from 'rxjs/operators';
|
|||||||
templateUrl: './home-holdings.html'
|
templateUrl: './home-holdings.html'
|
||||||
})
|
})
|
||||||
export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||||
|
public static DEFAULT_HOLDINGS_VIEW_MODE: HoldingsViewMode = 'TABLE';
|
||||||
|
|
||||||
public deviceType: string;
|
public deviceType: string;
|
||||||
public hasImpersonationId: boolean;
|
public hasImpersonationId: boolean;
|
||||||
public hasPermissionToAccessHoldingsChart: boolean;
|
public hasPermissionToAccessHoldingsChart: boolean;
|
||||||
@ -37,7 +39,9 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
|||||||
{ label: $localize`Closed`, value: 'CLOSED' }
|
{ label: $localize`Closed`, value: 'CLOSED' }
|
||||||
];
|
];
|
||||||
public user: User;
|
public user: User;
|
||||||
public viewModeFormControl = new FormControl<HoldingViewMode>('TABLE');
|
public viewModeFormControl = new FormControl<HoldingsViewMode>(
|
||||||
|
HomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE
|
||||||
|
);
|
||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
@ -81,6 +85,21 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
|||||||
this.changeDetectorRef.markForCheck();
|
this.changeDetectorRef.markForCheck();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.viewModeFormControl.valueChanges
|
||||||
|
.pipe(
|
||||||
|
// Skip inizialization: "new FormControl"
|
||||||
|
skip(1),
|
||||||
|
takeUntil(this.unsubscribeSubject)
|
||||||
|
)
|
||||||
|
.subscribe((holdingsViewMode) => {
|
||||||
|
this.dataService
|
||||||
|
.putUserSetting({ holdingsViewMode })
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
this.userService.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public onChangeHoldingType(aHoldingType: HoldingType) {
|
public onChangeHoldingType(aHoldingType: HoldingType) {
|
||||||
@ -122,9 +141,20 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
|||||||
this.hasPermissionToAccessHoldingsChart &&
|
this.hasPermissionToAccessHoldingsChart &&
|
||||||
this.holdingType === 'ACTIVE'
|
this.holdingType === 'ACTIVE'
|
||||||
) {
|
) {
|
||||||
this.viewModeFormControl.enable();
|
this.viewModeFormControl.enable({ emitEvent: false });
|
||||||
|
|
||||||
|
this.viewModeFormControl.setValue(
|
||||||
|
this.deviceType === 'mobile'
|
||||||
|
? HomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE
|
||||||
|
: this.user?.settings?.holdingsViewMode ||
|
||||||
|
HomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE,
|
||||||
|
{ emitEvent: false }
|
||||||
|
);
|
||||||
} else if (this.holdingType === 'CLOSED') {
|
} else if (this.holdingType === 'CLOSED') {
|
||||||
this.viewModeFormControl.setValue('TABLE');
|
this.viewModeFormControl.setValue(
|
||||||
|
HomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE,
|
||||||
|
{ emitEvent: false }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.holdings = undefined;
|
this.holdings = undefined;
|
||||||
|
@ -70,16 +70,16 @@
|
|||||||
"
|
"
|
||||||
>
|
>
|
||||||
<mat-option [value]="null" />
|
<mat-option [value]="null" />
|
||||||
|
<mat-option value="de">Deutsch</mat-option>
|
||||||
|
<mat-option value="en">English</mat-option>
|
||||||
@if (user?.settings?.isExperimentalFeatures) {
|
@if (user?.settings?.isExperimentalFeatures) {
|
||||||
<!--
|
<!--
|
||||||
<mat-option value="de"
|
<mat-option value="ca"
|
||||||
>Català (<ng-container i18n>Community</ng-container
|
>Català (<ng-container i18n>Community</ng-container
|
||||||
>)</mat-option
|
>)</mat-option
|
||||||
>
|
>
|
||||||
-->
|
-->
|
||||||
}
|
}
|
||||||
<mat-option value="de">Deutsch</mat-option>
|
|
||||||
<mat-option value="en">English</mat-option>
|
|
||||||
@if (user?.settings?.isExperimentalFeatures) {
|
@if (user?.settings?.isExperimentalFeatures) {
|
||||||
<mat-option value="zh"
|
<mat-option value="zh"
|
||||||
>Chinese (<ng-container i18n>Community</ng-container
|
>Chinese (<ng-container i18n>Community</ng-container
|
||||||
|
@ -53,8 +53,8 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
|||||||
public isToday = isToday;
|
public isToday = isToday;
|
||||||
public mode: 'create' | 'update';
|
public mode: 'create' | 'update';
|
||||||
public platforms: { id: string; name: string }[];
|
public platforms: { id: string; name: string }[];
|
||||||
public separatorKeysCodes: number[] = [ENTER, COMMA];
|
public separatorKeysCodes: number[] = [COMMA, ENTER];
|
||||||
public tags: Tag[] = [];
|
public tagsAvailable: Tag[] = [];
|
||||||
public total = 0;
|
public total = 0;
|
||||||
public typesTranslationMap = new Map<Type, string>();
|
public typesTranslationMap = new Map<Type, string>();
|
||||||
public Validators = Validators;
|
public Validators = Validators;
|
||||||
@ -81,7 +81,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
|||||||
this.currencies = currencies;
|
this.currencies = currencies;
|
||||||
this.defaultDateFormat = getDateFormatString(this.locale);
|
this.defaultDateFormat = getDateFormatString(this.locale);
|
||||||
this.platforms = platforms;
|
this.platforms = platforms;
|
||||||
this.tags = tags.map(({ id, name }) => {
|
this.tagsAvailable = tags.map(({ id, name }) => {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
name: translate(name)
|
name: translate(name)
|
||||||
@ -287,7 +287,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
|||||||
].valueChanges.pipe(
|
].valueChanges.pipe(
|
||||||
startWith(this.activityForm.get('tags').value),
|
startWith(this.activityForm.get('tags').value),
|
||||||
map((aTags: Tag[] | null) => {
|
map((aTags: Tag[] | null) => {
|
||||||
return aTags ? this.filterTags(aTags) : this.tags.slice();
|
return aTags ? this.filterTags(aTags) : this.tagsAvailable.slice();
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -441,10 +441,11 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
|||||||
public onAddTag(event: MatAutocompleteSelectedEvent) {
|
public onAddTag(event: MatAutocompleteSelectedEvent) {
|
||||||
this.activityForm.get('tags').setValue([
|
this.activityForm.get('tags').setValue([
|
||||||
...(this.activityForm.get('tags').value ?? []),
|
...(this.activityForm.get('tags').value ?? []),
|
||||||
this.tags.find(({ id }) => {
|
this.tagsAvailable.find(({ id }) => {
|
||||||
return id === event.option.value;
|
return id === event.option.value;
|
||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
this.tagInput.nativeElement.value = '';
|
this.tagInput.nativeElement.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -518,12 +519,12 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private filterTags(aTags: Tag[]) {
|
private filterTags(aTags: Tag[]) {
|
||||||
const tagIds = aTags.map((tag) => {
|
const tagIds = aTags.map(({ id }) => {
|
||||||
return tag.id;
|
return id;
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.tags.filter((tag) => {
|
return this.tagsAvailable.filter(({ id }) => {
|
||||||
return !tagIds.includes(tag.id);
|
return !tagIds.includes(id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -378,11 +378,11 @@
|
|||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3" [ngClass]="{ 'd-none': tags?.length < 1 }">
|
<div class="mb-3" [ngClass]="{ 'd-none': tagsAvailable?.length < 1 }">
|
||||||
<mat-form-field appearance="outline" class="w-100">
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
<mat-label i18n>Tags</mat-label>
|
<mat-label i18n>Tags</mat-label>
|
||||||
<mat-chip-grid #tagsChipList>
|
<mat-chip-grid #tagsChipList>
|
||||||
@for (tag of activityForm.get('tags')?.value; track tag) {
|
@for (tag of activityForm.get('tags')?.value; track tag.id) {
|
||||||
<mat-chip-row
|
<mat-chip-row
|
||||||
matChipRemove
|
matChipRemove
|
||||||
[removable]="true"
|
[removable]="true"
|
||||||
@ -404,7 +404,7 @@
|
|||||||
#autocompleteTags="matAutocomplete"
|
#autocompleteTags="matAutocomplete"
|
||||||
(optionSelected)="onAddTag($event)"
|
(optionSelected)="onAddTag($event)"
|
||||||
>
|
>
|
||||||
@for (tag of filteredTagsObservable | async; track tag) {
|
@for (tag of filteredTagsObservable | async; track tag.id) {
|
||||||
<mat-option [value]="tag.id">
|
<mat-option [value]="tag.id">
|
||||||
{{ tag.name }}
|
{{ tag.name }}
|
||||||
</mat-option>
|
</mat-option>
|
||||||
|
@ -47,7 +47,8 @@ import { SortDirection } from '@angular/material/sort';
|
|||||||
import {
|
import {
|
||||||
AccountBalance,
|
AccountBalance,
|
||||||
DataSource,
|
DataSource,
|
||||||
Order as OrderModel
|
Order as OrderModel,
|
||||||
|
Tag
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
import { format, parseISO } from 'date-fns';
|
import { format, parseISO } from 'date-fns';
|
||||||
import { cloneDeep, groupBy, isNumber } from 'lodash';
|
import { cloneDeep, groupBy, isNumber } from 'lodash';
|
||||||
@ -649,6 +650,17 @@ export class DataService {
|
|||||||
return this.http.put<void>(`/api/v1/admin/settings/${key}`, aData);
|
return this.http.put<void>(`/api/v1/admin/settings/${key}`, aData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public putHoldingTags({
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
tags
|
||||||
|
}: { tags: Tag[] } & UniqueAsset) {
|
||||||
|
return this.http.put<void>(
|
||||||
|
`/api/v1/portfolio/position/${dataSource}/${symbol}/tags`,
|
||||||
|
{ tags }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public putOrder(aOrder: UpdateOrderDto) {
|
public putOrder(aOrder: UpdateOrderDto) {
|
||||||
return this.http.put<UserItem>(`/api/v1/order/${aOrder.id}`, aOrder);
|
return this.http.put<UserItem>(`/api/v1/order/${aOrder.id}`, aOrder);
|
||||||
}
|
}
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -3652,7 +3652,7 @@
|
|||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="f799e268a8ec6ceb427287d7aa53fa9cc790a085" datatype="html">
|
<trans-unit id="f799e268a8ec6ceb427287d7aa53fa9cc790a085" datatype="html">
|
||||||
<source>Fully managed Ghostfolio cloud offering.</source>
|
<source>Fully managed Ghostfolio cloud offering.</source>
|
||||||
<target state="new">Fully managed Ghostfolio cloud offering.</target>
|
<target state="translated">Oferta en la nube de Ghostfolio totalmente administrada.</target>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">apps/client/src/app/pages/pricing/pricing-page.html</context>
|
<context context-type="sourcefile">apps/client/src/app/pages/pricing/pricing-page.html</context>
|
||||||
<context context-type="linenumber">152</context>
|
<context context-type="linenumber">152</context>
|
||||||
@ -3664,7 +3664,7 @@
|
|||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5afce9034d974ae1e794d437885bf17b0ebf5a0c" datatype="html">
|
<trans-unit id="5afce9034d974ae1e794d437885bf17b0ebf5a0c" datatype="html">
|
||||||
<source> For ambitious investors who need the full picture of their financial assets. </source>
|
<source> For ambitious investors who need the full picture of their financial assets. </source>
|
||||||
<target state="new"> For ambitious investors who need the full picture of their financial assets. </target>
|
<target state="translated"> Para inversores ambiciosos que necesitan una visión completa de sus activos financieros </target>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">apps/client/src/app/pages/pricing/pricing-page.html</context>
|
<context context-type="sourcefile">apps/client/src/app/pages/pricing/pricing-page.html</context>
|
||||||
<context context-type="linenumber">184</context>
|
<context context-type="linenumber">184</context>
|
||||||
@ -3672,7 +3672,7 @@
|
|||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="a9eaad1a5cb1b7a8d154ecb75fc613e3bd5adbf3" datatype="html">
|
<trans-unit id="a9eaad1a5cb1b7a8d154ecb75fc613e3bd5adbf3" datatype="html">
|
||||||
<source>One-time payment, no auto-renewal.</source>
|
<source>One-time payment, no auto-renewal.</source>
|
||||||
<target state="new">One-time payment, no auto-renewal.</target>
|
<target state="translated">Pago único, sin renovación automática.</target>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">apps/client/src/app/pages/pricing/pricing-page.html</context>
|
<context context-type="sourcefile">apps/client/src/app/pages/pricing/pricing-page.html</context>
|
||||||
<context context-type="linenumber">280</context>
|
<context context-type="linenumber">280</context>
|
||||||
@ -3780,7 +3780,7 @@
|
|||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3298117765569632011" datatype="html">
|
<trans-unit id="3298117765569632011" datatype="html">
|
||||||
<source>Switch to Ghostfolio Premium or Ghostfolio Open Source easily</source>
|
<source>Switch to Ghostfolio Premium or Ghostfolio Open Source easily</source>
|
||||||
<target state="new">Switch to Ghostfolio Premium or Ghostfolio Open Source easily</target>
|
<target state="translated">Cambie a Ghostfolio Premium o Ghostfolio Open Source fácilmente</target>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
|
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
|
||||||
<context context-type="linenumber">10</context>
|
<context context-type="linenumber">10</context>
|
||||||
@ -3788,7 +3788,7 @@
|
|||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1921273115613254799" datatype="html">
|
<trans-unit id="1921273115613254799" datatype="html">
|
||||||
<source>Switch to Ghostfolio Open Source or Ghostfolio Basic easily</source>
|
<source>Switch to Ghostfolio Open Source or Ghostfolio Basic easily</source>
|
||||||
<target state="new">Switch to Ghostfolio Open Source or Ghostfolio Basic easily</target>
|
<target state="translated">Cambie a Ghostfolio Open Source o Ghostfolio Basic fácilmente</target>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
|
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
|
||||||
<context context-type="linenumber">12</context>
|
<context context-type="linenumber">12</context>
|
||||||
@ -3796,7 +3796,7 @@
|
|||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="b74406fd93207c23bb840732ad2760ce0efaa2c5" datatype="html">
|
<trans-unit id="b74406fd93207c23bb840732ad2760ce0efaa2c5" datatype="html">
|
||||||
<source>Market data provided by</source>
|
<source>Market data provided by</source>
|
||||||
<target state="new">Market data provided by</target>
|
<target state="translated">Datos de mercado proporcionados por</target>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">libs/ui/src/lib/data-provider-credits/data-provider-credits.component.html</context>
|
<context context-type="sourcefile">libs/ui/src/lib/data-provider-credits/data-provider-credits.component.html</context>
|
||||||
<context context-type="linenumber">2</context>
|
<context context-type="linenumber">2</context>
|
||||||
|
@ -73,40 +73,40 @@ $gf-secondary: (
|
|||||||
|
|
||||||
$gf-typography: mat.m2-define-typography-config();
|
$gf-typography: mat.m2-define-typography-config();
|
||||||
|
|
||||||
@include mat.core();
|
|
||||||
|
|
||||||
// Create default theme
|
// Create default theme
|
||||||
$gf-theme-default: mat.m2-define-light-theme(
|
$gf-theme-default: mat.m2-define-light-theme(
|
||||||
(
|
(
|
||||||
color: (
|
color: (
|
||||||
primary: mat.m2-define-palette($gf-primary),
|
accent: mat.m2-define-palette($gf-secondary, 500, 900, A100),
|
||||||
accent: mat.m2-define-palette($gf-secondary, 500, 900, A100)
|
primary: mat.m2-define-palette($gf-primary)
|
||||||
),
|
),
|
||||||
density: -3,
|
density: -3,
|
||||||
typography: $gf-typography
|
typography: $gf-typography
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
@include mat.all-component-themes($gf-theme-default);
|
@include mat.all-component-themes($gf-theme-default);
|
||||||
@include mat.button-density(0);
|
|
||||||
@include mat.table-density(-1);
|
|
||||||
|
|
||||||
// Create dark theme
|
// Create dark theme
|
||||||
$gf-theme-dark: mat.m2-define-dark-theme(
|
$gf-theme-dark: mat.m2-define-dark-theme(
|
||||||
(
|
(
|
||||||
color: (
|
color: (
|
||||||
primary: mat.m2-define-palette($gf-primary),
|
accent: mat.m2-define-palette($gf-secondary, 500, 900, A100),
|
||||||
accent: mat.m2-define-palette($gf-secondary, 500, 900, A100)
|
primary: mat.m2-define-palette($gf-primary)
|
||||||
),
|
),
|
||||||
density: -3,
|
density: -3,
|
||||||
typography: $gf-typography
|
typography: $gf-typography
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
.is-dark-theme {
|
.is-dark-theme {
|
||||||
@include mat.all-component-colors($gf-theme-dark);
|
@include mat.all-component-colors($gf-theme-dark);
|
||||||
@include mat.button-density(0);
|
|
||||||
@include mat.table-density(-1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@include mat.button-density(0);
|
||||||
|
@include mat.core();
|
||||||
|
@include mat.table-density(-1);
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--gf-theme-alpha-hover: 0.04;
|
--gf-theme-alpha-hover: 0.04;
|
||||||
--gf-theme-primary-500: #36cfcc;
|
--gf-theme-primary-500: #36cfcc;
|
||||||
|
@ -6,7 +6,6 @@ services:
|
|||||||
- ../.env
|
- ../.env
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
|
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
|
||||||
NODE_ENV: production
|
|
||||||
REDIS_HOST: redis
|
REDIS_HOST: redis
|
||||||
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
||||||
ports:
|
ports:
|
||||||
@ -21,8 +20,9 @@ services:
|
|||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:15
|
image: docker.io/library/postgres:15
|
||||||
env_file:
|
env_file:
|
||||||
- ../.env
|
- ../.env
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@ -32,8 +32,9 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
volumes:
|
volumes:
|
||||||
- postgres:/var/lib/postgresql/data
|
- postgres:/var/lib/postgresql/data
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:alpine
|
image: docker.io/library/redis:alpine
|
||||||
env_file:
|
env_file:
|
||||||
- ../.env
|
- ../.env
|
||||||
command: ['redis-server', '--requirepass', $REDIS_PASSWORD]
|
command: ['redis-server', '--requirepass', $REDIS_PASSWORD]
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:15
|
image: docker.io/library/postgres:15
|
||||||
container_name: postgres
|
container_name: postgres
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
@ -9,8 +9,9 @@ services:
|
|||||||
- ${POSTGRES_PORT:-5432}:5432
|
- ${POSTGRES_PORT:-5432}:5432
|
||||||
volumes:
|
volumes:
|
||||||
- postgres:/var/lib/postgresql/data
|
- postgres:/var/lib/postgresql/data
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:alpine
|
image: docker.io/library/redis:alpine
|
||||||
container_name: redis
|
container_name: redis
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
|
@ -1,12 +1,16 @@
|
|||||||
services:
|
services:
|
||||||
ghostfolio:
|
ghostfolio:
|
||||||
image: ghostfolio/ghostfolio:latest
|
image: docker.io/ghostfolio/ghostfolio:latest
|
||||||
init: true
|
init: true
|
||||||
|
read_only: true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
env_file:
|
env_file:
|
||||||
- ../.env
|
- ../.env
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
|
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
|
||||||
NODE_ENV: production
|
|
||||||
REDIS_HOST: redis
|
REDIS_HOST: redis
|
||||||
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
||||||
ports:
|
ports:
|
||||||
@ -21,8 +25,19 @@ services:
|
|||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:15
|
image: docker.io/library/postgres:15
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
cap_add:
|
||||||
|
- CHOWN
|
||||||
|
- DAC_READ_SEARCH
|
||||||
|
- FOWNER
|
||||||
|
- SETGID
|
||||||
|
- SETUID
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
env_file:
|
env_file:
|
||||||
- ../.env
|
- ../.env
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@ -32,8 +47,14 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
volumes:
|
volumes:
|
||||||
- postgres:/var/lib/postgresql/data
|
- postgres:/var/lib/postgresql/data
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:alpine
|
image: docker.io/library/redis:alpine
|
||||||
|
user: '999:1000'
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
env_file:
|
env_file:
|
||||||
- ../.env
|
- ../.env
|
||||||
command: ['redis-server', '--requirepass', $REDIS_PASSWORD]
|
command: ['redis-server', '--requirepass', $REDIS_PASSWORD]
|
||||||
|
50
libs/common/src/lib/calculation-helper.spec.ts
Normal file
50
libs/common/src/lib/calculation-helper.spec.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { Big } from 'big.js';
|
||||||
|
|
||||||
|
import { getAnnualizedPerformancePercent } from './calculation-helper';
|
||||||
|
|
||||||
|
describe('CalculationHelper', () => {
|
||||||
|
describe('annualized performance percentage', () => {
|
||||||
|
it('Get annualized performance', async () => {
|
||||||
|
expect(
|
||||||
|
getAnnualizedPerformancePercent({
|
||||||
|
daysInMarket: NaN, // differenceInDays of date-fns returns NaN for the same day
|
||||||
|
netPerformancePercentage: new Big(0)
|
||||||
|
}).toNumber()
|
||||||
|
).toEqual(0);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getAnnualizedPerformancePercent({
|
||||||
|
daysInMarket: 0,
|
||||||
|
netPerformancePercentage: new Big(0)
|
||||||
|
}).toNumber()
|
||||||
|
).toEqual(0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Source: https://www.readyratios.com/reference/analysis/annualized_rate.html
|
||||||
|
*/
|
||||||
|
expect(
|
||||||
|
getAnnualizedPerformancePercent({
|
||||||
|
daysInMarket: 65, // < 1 year
|
||||||
|
netPerformancePercentage: new Big(0.1025)
|
||||||
|
}).toNumber()
|
||||||
|
).toBeCloseTo(0.729705);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getAnnualizedPerformancePercent({
|
||||||
|
daysInMarket: 365, // 1 year
|
||||||
|
netPerformancePercentage: 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(
|
||||||
|
getAnnualizedPerformancePercent({
|
||||||
|
daysInMarket: 575, // > 1 year
|
||||||
|
netPerformancePercentage: new Big(0.2374)
|
||||||
|
}).toNumber()
|
||||||
|
).toBeCloseTo(0.145);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
20
libs/common/src/lib/calculation-helper.ts
Normal file
20
libs/common/src/lib/calculation-helper.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { Big } from 'big.js';
|
||||||
|
import { isNumber } from 'lodash';
|
||||||
|
|
||||||
|
export function getAnnualizedPerformancePercent({
|
||||||
|
daysInMarket,
|
||||||
|
netPerformancePercentage
|
||||||
|
}: {
|
||||||
|
daysInMarket: number;
|
||||||
|
netPerformancePercentage: Big;
|
||||||
|
}): Big {
|
||||||
|
if (isNumber(daysInMarket) && daysInMarket > 0) {
|
||||||
|
const exponent = new Big(365).div(daysInMarket).toNumber();
|
||||||
|
|
||||||
|
return new Big(
|
||||||
|
Math.pow(netPerformancePercentage.plus(1).toNumber(), exponent)
|
||||||
|
).minus(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Big(0);
|
||||||
|
}
|
@ -1,4 +1,9 @@
|
|||||||
import { ColorScheme, DateRange, ViewMode } from '@ghostfolio/common/types';
|
import {
|
||||||
|
ColorScheme,
|
||||||
|
DateRange,
|
||||||
|
HoldingsViewMode,
|
||||||
|
ViewMode
|
||||||
|
} from '@ghostfolio/common/types';
|
||||||
|
|
||||||
export interface UserSettings {
|
export interface UserSettings {
|
||||||
annualInterestRate?: number;
|
annualInterestRate?: number;
|
||||||
@ -9,6 +14,7 @@ export interface UserSettings {
|
|||||||
emergencyFund?: number;
|
emergencyFund?: number;
|
||||||
'filters.accounts'?: string[];
|
'filters.accounts'?: string[];
|
||||||
'filters.tags'?: string[];
|
'filters.tags'?: string[];
|
||||||
|
holdingsViewMode?: HoldingsViewMode;
|
||||||
isExperimentalFeatures?: boolean;
|
isExperimentalFeatures?: boolean;
|
||||||
isRestrictedView?: boolean;
|
isRestrictedView?: boolean;
|
||||||
language?: string;
|
language?: string;
|
||||||
|
@ -1 +0,0 @@
|
|||||||
export type HoldingViewMode = 'CHART' | 'TABLE';
|
|
1
libs/common/src/lib/types/holdings-view-mode.type.ts
Normal file
1
libs/common/src/lib/types/holdings-view-mode.type.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export type HoldingsViewMode = 'CHART' | 'TABLE';
|
@ -8,7 +8,7 @@ import type { DateRange } from './date-range.type';
|
|||||||
import type { Granularity } from './granularity.type';
|
import type { Granularity } from './granularity.type';
|
||||||
import type { GroupBy } from './group-by.type';
|
import type { GroupBy } from './group-by.type';
|
||||||
import type { HoldingType } from './holding-type.type';
|
import type { HoldingType } from './holding-type.type';
|
||||||
import type { HoldingViewMode } from './holding-view-mode.type';
|
import type { HoldingsViewMode } from './holdings-view-mode.type';
|
||||||
import type { MarketAdvanced } from './market-advanced.type';
|
import type { MarketAdvanced } from './market-advanced.type';
|
||||||
import type { MarketDataPreset } from './market-data-preset.type';
|
import type { MarketDataPreset } from './market-data-preset.type';
|
||||||
import type { MarketState } from './market-state.type';
|
import type { MarketState } from './market-state.type';
|
||||||
@ -31,7 +31,7 @@ export type {
|
|||||||
Granularity,
|
Granularity,
|
||||||
GroupBy,
|
GroupBy,
|
||||||
HoldingType,
|
HoldingType,
|
||||||
HoldingViewMode,
|
HoldingsViewMode,
|
||||||
Market,
|
Market,
|
||||||
MarketAdvanced,
|
MarketAdvanced,
|
||||||
MarketDataPreset,
|
MarketDataPreset,
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { getAnnualizedPerformancePercent } from '@ghostfolio/common/calculation-helper';
|
||||||
import { PortfolioPosition, UniqueAsset } from '@ghostfolio/common/interfaces';
|
import { PortfolioPosition, UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
@ -14,10 +15,12 @@ import {
|
|||||||
ViewChild
|
ViewChild
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
|
import { Big } from 'big.js';
|
||||||
import { ChartConfiguration } from 'chart.js';
|
import { ChartConfiguration } from 'chart.js';
|
||||||
import { LinearScale } from 'chart.js';
|
import { LinearScale } from 'chart.js';
|
||||||
import { Chart } from 'chart.js';
|
import { Chart } from 'chart.js';
|
||||||
import { TreemapController, TreemapElement } from 'chartjs-chart-treemap';
|
import { TreemapController, TreemapElement } from 'chartjs-chart-treemap';
|
||||||
|
import { differenceInDays } from 'date-fns';
|
||||||
import { orderBy } from 'lodash';
|
import { orderBy } from 'lodash';
|
||||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||||
|
|
||||||
@ -41,6 +44,8 @@ export class GfTreemapChartComponent
|
|||||||
|
|
||||||
@ViewChild('chartCanvas') chartCanvas: ElementRef<HTMLCanvasElement>;
|
@ViewChild('chartCanvas') chartCanvas: ElementRef<HTMLCanvasElement>;
|
||||||
|
|
||||||
|
public static readonly HEAT_MULTIPLIER = 5;
|
||||||
|
|
||||||
public chart: Chart<'treemap'>;
|
public chart: Chart<'treemap'>;
|
||||||
public isLoading = true;
|
public isLoading = true;
|
||||||
|
|
||||||
@ -71,24 +76,52 @@ export class GfTreemapChartComponent
|
|||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
backgroundColor(ctx) {
|
backgroundColor(ctx) {
|
||||||
const netPerformancePercentWithCurrencyEffect =
|
const annualizedNetPerformancePercentWithCurrencyEffect =
|
||||||
ctx.raw._data.netPerformancePercentWithCurrencyEffect;
|
getAnnualizedPerformancePercent({
|
||||||
|
daysInMarket: differenceInDays(
|
||||||
|
new Date(),
|
||||||
|
ctx.raw._data.dateOfFirstActivity
|
||||||
|
),
|
||||||
|
netPerformancePercentage: new Big(
|
||||||
|
ctx.raw._data.netPerformancePercentWithCurrencyEffect
|
||||||
|
)
|
||||||
|
}).toNumber();
|
||||||
|
|
||||||
if (netPerformancePercentWithCurrencyEffect > 0.03) {
|
if (
|
||||||
|
annualizedNetPerformancePercentWithCurrencyEffect >
|
||||||
|
0.03 * GfTreemapChartComponent.HEAT_MULTIPLIER
|
||||||
|
) {
|
||||||
return green[9];
|
return green[9];
|
||||||
} else if (netPerformancePercentWithCurrencyEffect > 0.02) {
|
} else if (
|
||||||
|
annualizedNetPerformancePercentWithCurrencyEffect >
|
||||||
|
0.02 * GfTreemapChartComponent.HEAT_MULTIPLIER
|
||||||
|
) {
|
||||||
return green[7];
|
return green[7];
|
||||||
} else if (netPerformancePercentWithCurrencyEffect > 0.01) {
|
} else if (
|
||||||
|
annualizedNetPerformancePercentWithCurrencyEffect >
|
||||||
|
0.01 * GfTreemapChartComponent.HEAT_MULTIPLIER
|
||||||
|
) {
|
||||||
return green[5];
|
return green[5];
|
||||||
} else if (netPerformancePercentWithCurrencyEffect > 0) {
|
} else if (annualizedNetPerformancePercentWithCurrencyEffect > 0) {
|
||||||
return green[3];
|
return green[3];
|
||||||
} else if (netPerformancePercentWithCurrencyEffect === 0) {
|
} else if (
|
||||||
|
annualizedNetPerformancePercentWithCurrencyEffect === 0
|
||||||
|
) {
|
||||||
return gray[3];
|
return gray[3];
|
||||||
} else if (netPerformancePercentWithCurrencyEffect > -0.01) {
|
} else if (
|
||||||
|
annualizedNetPerformancePercentWithCurrencyEffect >
|
||||||
|
-0.01 * GfTreemapChartComponent.HEAT_MULTIPLIER
|
||||||
|
) {
|
||||||
return red[3];
|
return red[3];
|
||||||
} else if (netPerformancePercentWithCurrencyEffect > -0.02) {
|
} else if (
|
||||||
|
annualizedNetPerformancePercentWithCurrencyEffect >
|
||||||
|
-0.02 * GfTreemapChartComponent.HEAT_MULTIPLIER
|
||||||
|
) {
|
||||||
return red[5];
|
return red[5];
|
||||||
} else if (netPerformancePercentWithCurrencyEffect > -0.03) {
|
} else if (
|
||||||
|
annualizedNetPerformancePercentWithCurrencyEffect >
|
||||||
|
-0.03 * GfTreemapChartComponent.HEAT_MULTIPLIER
|
||||||
|
) {
|
||||||
return red[7];
|
return red[7];
|
||||||
} else {
|
} else {
|
||||||
return red[9];
|
return red[9];
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ghostfolio",
|
"name": "ghostfolio",
|
||||||
"version": "2.99.0",
|
"version": "2.101.0",
|
||||||
"homepage": "https://ghostfol.io",
|
"homepage": "https://ghostfol.io",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"repository": "https://github.com/ghostfolio/ghostfolio",
|
"repository": "https://github.com/ghostfolio/ghostfolio",
|
||||||
|
Reference in New Issue
Block a user