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/),
|
||||
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
|
||||
|
||||
### Changed
|
||||
|
@ -11,7 +11,7 @@ COPY ./package.json package.json
|
||||
COPY ./package-lock.json package-lock.json
|
||||
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++ \
|
||||
git \
|
||||
make \
|
||||
@ -50,16 +50,18 @@ RUN npm run database:generate-typings
|
||||
|
||||
# Image to run, copy everything needed from builder
|
||||
FROM node:20-slim
|
||||
|
||||
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 \
|
||||
openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
||||
COPY ./docker/entrypoint.sh /ghostfolio/entrypoint.sh
|
||||
RUN chown -R node:node /ghostfolio
|
||||
WORKDIR /ghostfolio/apps/api
|
||||
EXPOSE ${PORT:-3333}
|
||||
USER node
|
||||
CMD [ "/ghostfolio/entrypoint.sh" ]
|
||||
|
@ -46,6 +46,39 @@ export class OrderService {
|
||||
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(
|
||||
data: Prisma.OrderCreateInput & {
|
||||
accountId?: string;
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { AccessService } from '@ghostfolio/api/app/access/access.service';
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.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 {
|
||||
hasNotDefinedValuesInObject,
|
||||
@ -29,7 +30,8 @@ import {
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
hasReadRestrictedAccessPermission,
|
||||
isRestrictedView
|
||||
isRestrictedView,
|
||||
permissions
|
||||
} from '@ghostfolio/common/permissions';
|
||||
import type {
|
||||
DateRange,
|
||||
@ -38,12 +40,14 @@ import type {
|
||||
} from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Headers,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
@ -51,12 +55,13 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { AssetClass, AssetSubClass } from '@prisma/client';
|
||||
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
|
||||
import { Big } from 'big.js';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface';
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
import { UpdateHoldingTagsDto } from './update-holding-tags.dto';
|
||||
|
||||
@Controller('portfolio')
|
||||
export class PortfolioController {
|
||||
@ -566,23 +571,23 @@ export class PortfolioController {
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getPosition(
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Param('dataSource') dataSource,
|
||||
@Param('symbol') symbol
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<PortfolioHoldingDetail> {
|
||||
const position = await this.portfolioService.getPosition(
|
||||
const holding = await this.portfolioService.getPosition(
|
||||
dataSource,
|
||||
impersonationId,
|
||||
symbol
|
||||
);
|
||||
|
||||
if (position) {
|
||||
return position;
|
||||
if (!holding) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
return holding;
|
||||
}
|
||||
|
||||
@Get('report')
|
||||
@ -605,4 +610,36 @@ export class PortfolioController {
|
||||
|
||||
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 { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||
import { getAnnualizedPerformancePercent } from '@ghostfolio/common/calculation-helper';
|
||||
import {
|
||||
DEFAULT_CURRENCY,
|
||||
EMERGENCY_FUND_TAG_ID,
|
||||
@ -58,7 +59,8 @@ import {
|
||||
DataSource,
|
||||
Order,
|
||||
Platform,
|
||||
Prisma
|
||||
Prisma,
|
||||
Tag
|
||||
} from '@prisma/client';
|
||||
import { Big } from 'big.js';
|
||||
import {
|
||||
@ -70,7 +72,7 @@ import {
|
||||
parseISO,
|
||||
set
|
||||
} from 'date-fns';
|
||||
import { isEmpty, isNumber, last, uniq, uniqBy } from 'lodash';
|
||||
import { isEmpty, uniq, uniqBy } from 'lodash';
|
||||
|
||||
import { PortfolioCalculator } from './calculator/portfolio-calculator';
|
||||
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({
|
||||
activities,
|
||||
groupBy
|
||||
@ -713,7 +697,7 @@ export class PortfolioService {
|
||||
return Account;
|
||||
});
|
||||
|
||||
const dividendYieldPercent = this.getAnnualizedPerformancePercent({
|
||||
const dividendYieldPercent = getAnnualizedPerformancePercent({
|
||||
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
|
||||
netPerformancePercentage: timeWeightedInvestment.eq(0)
|
||||
? new Big(0)
|
||||
@ -721,7 +705,7 @@ export class PortfolioService {
|
||||
});
|
||||
|
||||
const dividendYieldPercentWithCurrencyEffect =
|
||||
this.getAnnualizedPerformancePercent({
|
||||
getAnnualizedPerformancePercent({
|
||||
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
|
||||
netPerformancePercentage: timeWeightedInvestmentWithCurrencyEffect.eq(
|
||||
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({
|
||||
cashDetails,
|
||||
userCurrency,
|
||||
@ -1724,13 +1726,13 @@ export class PortfolioService {
|
||||
|
||||
const daysInMarket = differenceInDays(new Date(), firstOrderDate);
|
||||
|
||||
const annualizedPerformancePercent = this.getAnnualizedPerformancePercent({
|
||||
const annualizedPerformancePercent = getAnnualizedPerformancePercent({
|
||||
daysInMarket,
|
||||
netPerformancePercentage: new Big(netPerformancePercentage)
|
||||
})?.toNumber();
|
||||
|
||||
const annualizedPerformancePercentWithCurrencyEffect =
|
||||
this.getAnnualizedPerformancePercent({
|
||||
getAnnualizedPerformancePercent({
|
||||
daysInMarket,
|
||||
netPerformancePercentage: new Big(
|
||||
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 {
|
||||
ColorScheme,
|
||||
DateRange,
|
||||
HoldingsViewMode,
|
||||
ViewMode
|
||||
} from '@ghostfolio/common/types';
|
||||
|
||||
@ -66,6 +67,10 @@ export class UpdateUserSettingDto {
|
||||
@IsOptional()
|
||||
'filters.tags'?: string[];
|
||||
|
||||
@IsIn(<HoldingsViewMode[]>['CHART', 'TABLE'])
|
||||
@IsOptional()
|
||||
holdingsViewMode?: HoldingsViewMode;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isExperimentalFeatures?: boolean;
|
||||
|
@ -190,7 +190,7 @@ export class UserService {
|
||||
(user.Settings.settings as UserSettings).dateRange =
|
||||
(user.Settings.settings as UserSettings).viewMode === 'ZEN'
|
||||
? 'max'
|
||||
: (user.Settings.settings as UserSettings)?.dateRange ?? 'max';
|
||||
: ((user.Settings.settings as UserSettings)?.dateRange ?? 'max');
|
||||
|
||||
// Set default value for view mode
|
||||
if (!(user.Settings.settings as UserSettings).viewMode) {
|
||||
@ -243,6 +243,9 @@ export class UserService {
|
||||
|
||||
// Reset benchmark
|
||||
user.Settings.settings.benchmark = undefined;
|
||||
|
||||
// Reset holdings view mode
|
||||
user.Settings.settings.holdingsViewMode = undefined;
|
||||
} else if (user.subscription?.type === 'Premium') {
|
||||
currentPermissions.push(permissions.reportDataGlitch);
|
||||
|
||||
|
@ -259,6 +259,10 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
this.user?.permissions,
|
||||
permissions.reportDataGlitch
|
||||
),
|
||||
hasPermissionToUpdateOrder:
|
||||
!this.hasImpersonationId &&
|
||||
hasPermission(this.user?.permissions, permissions.updateOrder) &&
|
||||
!user?.settings?.isRestrictedView,
|
||||
locale: this.user?.settings?.locale
|
||||
},
|
||||
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 { GfValueComponent } from '@ghostfolio/ui/value';
|
||||
|
||||
import { COMMA, ENTER } from '@angular/cdk/keycodes';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
CUSTOM_ELEMENTS_SCHEMA,
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
ElementRef,
|
||||
Inject,
|
||||
OnDestroy,
|
||||
OnInit
|
||||
OnInit,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { FormBuilder, FormGroup } from '@angular/forms';
|
||||
import {
|
||||
MatAutocompleteModule,
|
||||
MatAutocompleteSelectedEvent
|
||||
} from '@angular/material/autocomplete';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import {
|
||||
@ -36,14 +44,15 @@ import {
|
||||
MatDialogModule,
|
||||
MatDialogRef
|
||||
} from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { SortDirection } from '@angular/material/sort';
|
||||
import { MatTableDataSource } from '@angular/material/table';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { Account, Tag } from '@prisma/client';
|
||||
import { format, isSameMonth, isToday, parseISO } from 'date-fns';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
import { Observable, of, Subject } from 'rxjs';
|
||||
import { map, startWith, takeUntil } from 'rxjs/operators';
|
||||
|
||||
import { HoldingDetailDialogParams } from './interfaces/interfaces';
|
||||
|
||||
@ -60,9 +69,11 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces';
|
||||
GfLineChartComponent,
|
||||
GfPortfolioProportionChartComponent,
|
||||
GfValueComponent,
|
||||
MatAutocompleteModule,
|
||||
MatButtonModule,
|
||||
MatChipsModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatTabsModule,
|
||||
NgxSkeletonLoaderModule
|
||||
],
|
||||
@ -73,6 +84,9 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces';
|
||||
templateUrl: 'holding-detail-dialog.html'
|
||||
})
|
||||
export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
||||
@ViewChild('tagInput') tagInput: ElementRef<HTMLInputElement>;
|
||||
|
||||
public activityForm: FormGroup;
|
||||
public accounts: Account[];
|
||||
public activities: Activity[];
|
||||
public assetClass: string;
|
||||
@ -88,6 +102,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
||||
public dividendInBaseCurrencyPrecision = 2;
|
||||
public dividendYieldPercentWithCurrencyEffect: number;
|
||||
public feeInBaseCurrency: number;
|
||||
public filteredTagsObservable: Observable<Tag[]> = of([]);
|
||||
public firstBuyDate: string;
|
||||
public historicalDataItems: LineChartItem[];
|
||||
public investment: number;
|
||||
@ -107,10 +122,12 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
||||
public sectors: {
|
||||
[name: string]: { name: string; value: number };
|
||||
};
|
||||
public separatorKeysCodes: number[] = [COMMA, ENTER];
|
||||
public sortColumn = 'date';
|
||||
public sortDirection: SortDirection = 'desc';
|
||||
public SymbolProfile: EnhancedSymbolProfile;
|
||||
public tags: Tag[];
|
||||
public tagsAvailable: Tag[];
|
||||
public totalItems: number;
|
||||
public transactionCount: number;
|
||||
public user: User;
|
||||
@ -123,10 +140,38 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
||||
private dataService: DataService,
|
||||
public dialogRef: MatDialogRef<GfHoldingDetailDialogComponent>,
|
||||
@Inject(MAT_DIALOG_DATA) public data: HoldingDetailDialogParams,
|
||||
private formBuilder: FormBuilder,
|
||||
private userService: UserService
|
||||
) {}
|
||||
|
||||
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
|
||||
.fetchHoldingDetail({
|
||||
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.sectors = {};
|
||||
this.SymbolProfile = SymbolProfile;
|
||||
|
||||
this.tags = tags.map(({ id, name }) => {
|
||||
return {
|
||||
id,
|
||||
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.totalItems = transactionCount;
|
||||
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() {
|
||||
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() {
|
||||
this.unsubscribeSubject.next();
|
||||
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
|
||||
animationDuration="0"
|
||||
class="mb-3"
|
||||
class="mb-5"
|
||||
[mat-stretch-tabs]="false"
|
||||
[ngClass]="{ 'd-none': !activities?.length }"
|
||||
>
|
||||
@ -375,7 +375,49 @@
|
||||
</mat-tab>
|
||||
</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="col">
|
||||
<div class="h5" i18n>Tags</div>
|
||||
|
@ -9,6 +9,7 @@ export interface HoldingDetailDialogParams {
|
||||
deviceType: string;
|
||||
hasImpersonationId: boolean;
|
||||
hasPermissionToReportDataGlitch: boolean;
|
||||
hasPermissionToUpdateOrder: boolean;
|
||||
locale: string;
|
||||
symbol: string;
|
||||
}
|
||||
|
@ -9,7 +9,7 @@ import {
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import {
|
||||
HoldingType,
|
||||
HoldingViewMode,
|
||||
HoldingsViewMode,
|
||||
ToggleOption
|
||||
} from '@ghostfolio/common/types';
|
||||
|
||||
@ -18,7 +18,7 @@ import { FormControl } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
import { skip, takeUntil } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
selector: 'gf-home-holdings',
|
||||
@ -26,6 +26,8 @@ import { takeUntil } from 'rxjs/operators';
|
||||
templateUrl: './home-holdings.html'
|
||||
})
|
||||
export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||
public static DEFAULT_HOLDINGS_VIEW_MODE: HoldingsViewMode = 'TABLE';
|
||||
|
||||
public deviceType: string;
|
||||
public hasImpersonationId: boolean;
|
||||
public hasPermissionToAccessHoldingsChart: boolean;
|
||||
@ -37,7 +39,9 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||
{ label: $localize`Closed`, value: 'CLOSED' }
|
||||
];
|
||||
public user: User;
|
||||
public viewModeFormControl = new FormControl<HoldingViewMode>('TABLE');
|
||||
public viewModeFormControl = new FormControl<HoldingsViewMode>(
|
||||
HomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE
|
||||
);
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
@ -81,6 +85,21 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||
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) {
|
||||
@ -122,9 +141,20 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||
this.hasPermissionToAccessHoldingsChart &&
|
||||
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') {
|
||||
this.viewModeFormControl.setValue('TABLE');
|
||||
this.viewModeFormControl.setValue(
|
||||
HomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE,
|
||||
{ emitEvent: false }
|
||||
);
|
||||
}
|
||||
|
||||
this.holdings = undefined;
|
||||
|
@ -70,16 +70,16 @@
|
||||
"
|
||||
>
|
||||
<mat-option [value]="null" />
|
||||
<mat-option value="de">Deutsch</mat-option>
|
||||
<mat-option value="en">English</mat-option>
|
||||
@if (user?.settings?.isExperimentalFeatures) {
|
||||
<!--
|
||||
<mat-option value="de"
|
||||
<mat-option value="ca"
|
||||
>Català (<ng-container i18n>Community</ng-container
|
||||
>)</mat-option
|
||||
>
|
||||
-->
|
||||
}
|
||||
<mat-option value="de">Deutsch</mat-option>
|
||||
<mat-option value="en">English</mat-option>
|
||||
@if (user?.settings?.isExperimentalFeatures) {
|
||||
<mat-option value="zh"
|
||||
>Chinese (<ng-container i18n>Community</ng-container
|
||||
|
@ -53,8 +53,8 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
public isToday = isToday;
|
||||
public mode: 'create' | 'update';
|
||||
public platforms: { id: string; name: string }[];
|
||||
public separatorKeysCodes: number[] = [ENTER, COMMA];
|
||||
public tags: Tag[] = [];
|
||||
public separatorKeysCodes: number[] = [COMMA, ENTER];
|
||||
public tagsAvailable: Tag[] = [];
|
||||
public total = 0;
|
||||
public typesTranslationMap = new Map<Type, string>();
|
||||
public Validators = Validators;
|
||||
@ -81,7 +81,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
this.currencies = currencies;
|
||||
this.defaultDateFormat = getDateFormatString(this.locale);
|
||||
this.platforms = platforms;
|
||||
this.tags = tags.map(({ id, name }) => {
|
||||
this.tagsAvailable = tags.map(({ id, name }) => {
|
||||
return {
|
||||
id,
|
||||
name: translate(name)
|
||||
@ -287,7 +287,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
].valueChanges.pipe(
|
||||
startWith(this.activityForm.get('tags').value),
|
||||
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) {
|
||||
this.activityForm.get('tags').setValue([
|
||||
...(this.activityForm.get('tags').value ?? []),
|
||||
this.tags.find(({ id }) => {
|
||||
this.tagsAvailable.find(({ id }) => {
|
||||
return id === event.option.value;
|
||||
})
|
||||
]);
|
||||
|
||||
this.tagInput.nativeElement.value = '';
|
||||
}
|
||||
|
||||
@ -518,12 +519,12 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
}
|
||||
|
||||
private filterTags(aTags: Tag[]) {
|
||||
const tagIds = aTags.map((tag) => {
|
||||
return tag.id;
|
||||
const tagIds = aTags.map(({ id }) => {
|
||||
return id;
|
||||
});
|
||||
|
||||
return this.tags.filter((tag) => {
|
||||
return !tagIds.includes(tag.id);
|
||||
return this.tagsAvailable.filter(({ id }) => {
|
||||
return !tagIds.includes(id);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -378,11 +378,11 @@
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</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-label i18n>Tags</mat-label>
|
||||
<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
|
||||
matChipRemove
|
||||
[removable]="true"
|
||||
@ -404,7 +404,7 @@
|
||||
#autocompleteTags="matAutocomplete"
|
||||
(optionSelected)="onAddTag($event)"
|
||||
>
|
||||
@for (tag of filteredTagsObservable | async; track tag) {
|
||||
@for (tag of filteredTagsObservable | async; track tag.id) {
|
||||
<mat-option [value]="tag.id">
|
||||
{{ tag.name }}
|
||||
</mat-option>
|
||||
|
@ -47,7 +47,8 @@ import { SortDirection } from '@angular/material/sort';
|
||||
import {
|
||||
AccountBalance,
|
||||
DataSource,
|
||||
Order as OrderModel
|
||||
Order as OrderModel,
|
||||
Tag
|
||||
} from '@prisma/client';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { cloneDeep, groupBy, isNumber } from 'lodash';
|
||||
@ -649,6 +650,17 @@ export class DataService {
|
||||
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) {
|
||||
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 id="f799e268a8ec6ceb427287d7aa53fa9cc790a085" datatype="html">
|
||||
<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 context-type="sourcefile">apps/client/src/app/pages/pricing/pricing-page.html</context>
|
||||
<context context-type="linenumber">152</context>
|
||||
@ -3664,7 +3664,7 @@
|
||||
</trans-unit>
|
||||
<trans-unit id="5afce9034d974ae1e794d437885bf17b0ebf5a0c" datatype="html">
|
||||
<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 context-type="sourcefile">apps/client/src/app/pages/pricing/pricing-page.html</context>
|
||||
<context context-type="linenumber">184</context>
|
||||
@ -3672,7 +3672,7 @@
|
||||
</trans-unit>
|
||||
<trans-unit id="a9eaad1a5cb1b7a8d154ecb75fc613e3bd5adbf3" datatype="html">
|
||||
<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 context-type="sourcefile">apps/client/src/app/pages/pricing/pricing-page.html</context>
|
||||
<context context-type="linenumber">280</context>
|
||||
@ -3780,7 +3780,7 @@
|
||||
</trans-unit>
|
||||
<trans-unit id="3298117765569632011" datatype="html">
|
||||
<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 context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
|
||||
<context context-type="linenumber">10</context>
|
||||
@ -3788,7 +3788,7 @@
|
||||
</trans-unit>
|
||||
<trans-unit id="1921273115613254799" datatype="html">
|
||||
<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 context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
|
||||
<context context-type="linenumber">12</context>
|
||||
@ -3796,7 +3796,7 @@
|
||||
</trans-unit>
|
||||
<trans-unit id="b74406fd93207c23bb840732ad2760ce0efaa2c5" datatype="html">
|
||||
<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 context-type="sourcefile">libs/ui/src/lib/data-provider-credits/data-provider-credits.component.html</context>
|
||||
<context context-type="linenumber">2</context>
|
||||
|
@ -73,40 +73,40 @@ $gf-secondary: (
|
||||
|
||||
$gf-typography: mat.m2-define-typography-config();
|
||||
|
||||
@include mat.core();
|
||||
|
||||
// Create default theme
|
||||
$gf-theme-default: mat.m2-define-light-theme(
|
||||
(
|
||||
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,
|
||||
typography: $gf-typography
|
||||
)
|
||||
);
|
||||
|
||||
@include mat.all-component-themes($gf-theme-default);
|
||||
@include mat.button-density(0);
|
||||
@include mat.table-density(-1);
|
||||
|
||||
// Create dark theme
|
||||
$gf-theme-dark: mat.m2-define-dark-theme(
|
||||
(
|
||||
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,
|
||||
typography: $gf-typography
|
||||
)
|
||||
);
|
||||
|
||||
.is-dark-theme {
|
||||
@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 {
|
||||
--gf-theme-alpha-hover: 0.04;
|
||||
--gf-theme-primary-500: #36cfcc;
|
||||
|
@ -6,7 +6,6 @@ services:
|
||||
- ../.env
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
|
||||
NODE_ENV: production
|
||||
REDIS_HOST: redis
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
||||
ports:
|
||||
@ -21,8 +20,9 @@ services:
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
postgres:
|
||||
image: postgres:15
|
||||
image: docker.io/library/postgres:15
|
||||
env_file:
|
||||
- ../.env
|
||||
healthcheck:
|
||||
@ -32,8 +32,9 @@ services:
|
||||
retries: 5
|
||||
volumes:
|
||||
- postgres:/var/lib/postgresql/data
|
||||
|
||||
redis:
|
||||
image: redis:alpine
|
||||
image: docker.io/library/redis:alpine
|
||||
env_file:
|
||||
- ../.env
|
||||
command: ['redis-server', '--requirepass', $REDIS_PASSWORD]
|
||||
|
@ -1,6 +1,6 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
image: docker.io/library/postgres:15
|
||||
container_name: postgres
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
@ -9,8 +9,9 @@ services:
|
||||
- ${POSTGRES_PORT:-5432}:5432
|
||||
volumes:
|
||||
- postgres:/var/lib/postgresql/data
|
||||
|
||||
redis:
|
||||
image: redis:alpine
|
||||
image: docker.io/library/redis:alpine
|
||||
container_name: redis
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
|
@ -1,12 +1,16 @@
|
||||
services:
|
||||
ghostfolio:
|
||||
image: ghostfolio/ghostfolio:latest
|
||||
image: docker.io/ghostfolio/ghostfolio:latest
|
||||
init: true
|
||||
read_only: true
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
env_file:
|
||||
- ../.env
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
|
||||
NODE_ENV: production
|
||||
REDIS_HOST: redis
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
||||
ports:
|
||||
@ -21,8 +25,19 @@ services:
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
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
|
||||
healthcheck:
|
||||
@ -32,8 +47,14 @@ services:
|
||||
retries: 5
|
||||
volumes:
|
||||
- postgres:/var/lib/postgresql/data
|
||||
|
||||
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
|
||||
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 {
|
||||
annualInterestRate?: number;
|
||||
@ -9,6 +14,7 @@ export interface UserSettings {
|
||||
emergencyFund?: number;
|
||||
'filters.accounts'?: string[];
|
||||
'filters.tags'?: string[];
|
||||
holdingsViewMode?: HoldingsViewMode;
|
||||
isExperimentalFeatures?: boolean;
|
||||
isRestrictedView?: boolean;
|
||||
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 { GroupBy } from './group-by.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 { MarketDataPreset } from './market-data-preset.type';
|
||||
import type { MarketState } from './market-state.type';
|
||||
@ -31,7 +31,7 @@ export type {
|
||||
Granularity,
|
||||
GroupBy,
|
||||
HoldingType,
|
||||
HoldingViewMode,
|
||||
HoldingsViewMode,
|
||||
Market,
|
||||
MarketAdvanced,
|
||||
MarketDataPreset,
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { getAnnualizedPerformancePercent } from '@ghostfolio/common/calculation-helper';
|
||||
import { PortfolioPosition, UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { CommonModule } from '@angular/common';
|
||||
@ -14,10 +15,12 @@ import {
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { Big } from 'big.js';
|
||||
import { ChartConfiguration } from 'chart.js';
|
||||
import { LinearScale } from 'chart.js';
|
||||
import { Chart } from 'chart.js';
|
||||
import { TreemapController, TreemapElement } from 'chartjs-chart-treemap';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { orderBy } from 'lodash';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
|
||||
@ -41,6 +44,8 @@ export class GfTreemapChartComponent
|
||||
|
||||
@ViewChild('chartCanvas') chartCanvas: ElementRef<HTMLCanvasElement>;
|
||||
|
||||
public static readonly HEAT_MULTIPLIER = 5;
|
||||
|
||||
public chart: Chart<'treemap'>;
|
||||
public isLoading = true;
|
||||
|
||||
@ -71,24 +76,52 @@ export class GfTreemapChartComponent
|
||||
datasets: [
|
||||
{
|
||||
backgroundColor(ctx) {
|
||||
const netPerformancePercentWithCurrencyEffect =
|
||||
ctx.raw._data.netPerformancePercentWithCurrencyEffect;
|
||||
const annualizedNetPerformancePercentWithCurrencyEffect =
|
||||
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];
|
||||
} else if (netPerformancePercentWithCurrencyEffect > 0.02) {
|
||||
} else if (
|
||||
annualizedNetPerformancePercentWithCurrencyEffect >
|
||||
0.02 * GfTreemapChartComponent.HEAT_MULTIPLIER
|
||||
) {
|
||||
return green[7];
|
||||
} else if (netPerformancePercentWithCurrencyEffect > 0.01) {
|
||||
} else if (
|
||||
annualizedNetPerformancePercentWithCurrencyEffect >
|
||||
0.01 * GfTreemapChartComponent.HEAT_MULTIPLIER
|
||||
) {
|
||||
return green[5];
|
||||
} else if (netPerformancePercentWithCurrencyEffect > 0) {
|
||||
} else if (annualizedNetPerformancePercentWithCurrencyEffect > 0) {
|
||||
return green[3];
|
||||
} else if (netPerformancePercentWithCurrencyEffect === 0) {
|
||||
} else if (
|
||||
annualizedNetPerformancePercentWithCurrencyEffect === 0
|
||||
) {
|
||||
return gray[3];
|
||||
} else if (netPerformancePercentWithCurrencyEffect > -0.01) {
|
||||
} else if (
|
||||
annualizedNetPerformancePercentWithCurrencyEffect >
|
||||
-0.01 * GfTreemapChartComponent.HEAT_MULTIPLIER
|
||||
) {
|
||||
return red[3];
|
||||
} else if (netPerformancePercentWithCurrencyEffect > -0.02) {
|
||||
} else if (
|
||||
annualizedNetPerformancePercentWithCurrencyEffect >
|
||||
-0.02 * GfTreemapChartComponent.HEAT_MULTIPLIER
|
||||
) {
|
||||
return red[5];
|
||||
} else if (netPerformancePercentWithCurrencyEffect > -0.03) {
|
||||
} else if (
|
||||
annualizedNetPerformancePercentWithCurrencyEffect >
|
||||
-0.03 * GfTreemapChartComponent.HEAT_MULTIPLIER
|
||||
) {
|
||||
return red[7];
|
||||
} else {
|
||||
return red[9];
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ghostfolio",
|
||||
"version": "2.99.0",
|
||||
"version": "2.101.0",
|
||||
"homepage": "https://ghostfol.io",
|
||||
"license": "AGPL-3.0",
|
||||
"repository": "https://github.com/ghostfolio/ghostfolio",
|
||||
|
Reference in New Issue
Block a user