Compare commits

..

12 Commits

Author SHA1 Message Date
420f331be9 Release 2.101.0 (#3632) 2024-08-03 16:58:08 +02:00
e0068c4d5d Feature/harden container security following OWASP best practices (#3614)
* Harden container security

* Update changelog
2024-08-03 16:55:18 +02:00
85661884a6 Release 2.100.0 (#3631) 2024-08-03 15:47:52 +02:00
8f6203d296 Feature/manage tags of holdings (#3630)
* Manage tags of holdings

* Update changelog
2024-08-03 15:46:01 +02:00
2fa723dc3c Bugfix/fix language selector of user account settings (#3613)
Fix value of Català
2024-08-03 15:42:21 +02:00
a500fb72c5 Feature/refactor Angular Material theme (#3629) 2024-08-02 20:57:03 +02:00
02db0db733 Feature/persist view mode of holdings tab on home page (#3624)
* Persist view mode of holdings in user settings

* Update changelog
2024-08-02 20:27:58 +02:00
c87b08ca8b Feature/improve language localization for es (#3625)
* Update translations

* Update changelog
2024-08-01 20:28:42 +02:00
fcc2ab1a48 Feature/change color assignment by annualized performance in treemap chart (#3617)
* Change color assignment to annualized performance

* Update changelog
2024-07-31 19:18:50 +02:00
7efda2f890 Feature/improve language localization for Catalan (#3598)
* Update translations

* Update changelog
2024-07-30 14:30:08 +02:00
3794a61d2d Release 2.99.0 (#3618) 2024-07-29 20:10:26 +02:00
c1d1ea9dde Feature/migrate from Yarn 1 (Classic) to npm (#3601)
* Migrate from yarn to npm
2024-07-29 20:08:43 +02:00
40 changed files with 34814 additions and 21980 deletions

View File

@ -24,16 +24,16 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node_version }}
cache: 'yarn'
cache: 'npm'
- name: Install dependencies
run: yarn install --frozen-lockfile
run: npm ci
- name: Check formatting
run: yarn format:check
run: npm run format:check
- name: Execute tests
run: yarn test
run: npm test
- name: Build application
run: yarn build:production
run: npm run build:production

4
.gitignore vendored
View File

@ -5,8 +5,8 @@
/tmp
# dependencies
/.yarn
/node_modules
npm-debug.log
# IDEs and editors
/.idea
@ -34,10 +34,8 @@
/coverage
/dist
/libpeerconnection.log
npm-debug.log
testem.log
/typings
yarn-error.log
# System Files
.DS_Store

View File

@ -1 +0,0 @@
network-timeout 600000

View File

@ -5,6 +5,33 @@ 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
- Migrated the usage of `yarn` to `npm`
- Upgraded `storybook` from version `7.0.9` to `8.2.5`
- Downgraded `marked` from version `13.0.0` to `12.0.2`
## 2.98.0 - 2024-07-27
### Added

View File

@ -30,26 +30,26 @@ Use `@if (user?.settings?.isExperimentalFeatures) {}` in HTML template
#### Upgrade
1. Run `yarn nx migrate latest`
1. Make sure `package.json` changes make sense and then run `yarn install`
1. Run `yarn nx migrate --run-migrations` (Run `YARN_NODE_LINKER="node-modules" NX_MIGRATE_SKIP_INSTALL=1 yarn nx migrate --run-migrations` due to https://github.com/nrwl/nx/issues/16338)
1. Run `npx nx migrate latest`
1. Make sure `package.json` changes make sense and then run `npm install`
1. Run `npx nx migrate --run-migrations`
### Prisma
#### Access database via GUI
Run `yarn database:gui`
Run `npm run database:gui`
https://www.prisma.io/studio
#### Synchronize schema with database for prototyping
Run `yarn database:push`
Run `npm run database:push`
https://www.prisma.io/docs/concepts/components/prisma-migrate/db-push
#### Create schema migration
Run `yarn prisma migrate dev --name added_job_title`
Run `npm run prisma migrate dev --name added_job_title`
https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate

View File

@ -1,4 +1,4 @@
FROM --platform=$BUILDPLATFORM node:20-slim as builder
FROM --platform=$BUILDPLATFORM node:20-slim AS builder
# Build application and add additional files
WORKDIR /ghostfolio
@ -8,18 +8,17 @@ WORKDIR /ghostfolio
COPY ./CHANGELOG.md CHANGELOG.md
COPY ./LICENSE LICENSE
COPY ./package.json package.json
COPY ./yarn.lock yarn.lock
COPY ./.yarnrc .yarnrc
COPY ./package-lock.json package-lock.json
COPY ./prisma/schema.prisma prisma/schema.prisma
RUN apt update && apt install -y \
g++ \
git \
make \
openssl \
python3 \
&& rm -rf /var/lib/apt/lists/*
RUN yarn install
RUN apt-get update && apt-get install -y --no-install-suggests \
g++ \
git \
make \
openssl \
python3 \
&& rm -rf /var/lib/apt/lists/*
RUN npm install
# See https://github.com/nrwl/nx/issues/6586 for further details
COPY ./decorate-angular-cli.js decorate-angular-cli.js
@ -33,34 +32,36 @@ COPY ./tsconfig.base.json tsconfig.base.json
COPY ./libs libs
COPY ./apps apps
RUN yarn build:production
RUN npm run build:production
# Prepare the dist image with additional node_modules
WORKDIR /ghostfolio/dist/apps/api
# package.json was generated by the build process, however the original
# yarn.lock needs to be used to ensure the same versions
COPY ./yarn.lock /ghostfolio/dist/apps/api/yarn.lock
# package-lock.json needs to be used to ensure the same versions
COPY ./package-lock.json /ghostfolio/dist/apps/api/package-lock.json
RUN yarn
RUN npm install
COPY prisma /ghostfolio/dist/apps/api/prisma
# Overwrite the generated package.json with the original one to ensure having
# all the scripts
COPY package.json /ghostfolio/dist/apps/api
RUN yarn database:generate-typings
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 \
curl \
openssl \
&& rm -rf /var/lib/apt/lists/*
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" ]

View File

@ -150,15 +150,14 @@ Ghostfolio is available for various home server systems, including [CasaOS](http
- [Docker](https://www.docker.com/products/docker-desktop)
- [Node.js](https://nodejs.org/en/download) (version 20+)
- [Yarn](https://yarnpkg.com/en/docs/install)
- Create a local copy of this Git repository (clone)
- Copy the file `.env.dev` to `.env` and populate it with your data (`cp .env.dev .env`)
### Setup
1. Run `yarn install`
1. Run `npm install`
1. Run `docker compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
1. Run `yarn database:setup` to initialize the database schema
1. Run `npm run database:setup` to initialize the database schema
1. Run `git config core.hooksPath ./git-hooks/` to setup git hooks
1. Start the server and the client (see [_Development_](#Development))
1. Open https://localhost:4200/en in your browser
@ -168,31 +167,31 @@ Ghostfolio is available for various home server systems, including [CasaOS](http
#### Debug
Run `yarn watch:server` and click _Debug API_ in [Visual Studio Code](https://code.visualstudio.com)
Run `npm run watch:server` and click _Debug API_ in [Visual Studio Code](https://code.visualstudio.com)
#### Serve
Run `yarn start:server`
Run `npm run start:server`
### Start Client
Run `yarn start:client` and open https://localhost:4200/en in your browser
Run `npm run start:client` and open https://localhost:4200/en in your browser
### Start _Storybook_
Run `yarn start:storybook`
Run `npm run start:storybook`
### Migrate Database
With the following command you can keep your database schema in sync:
```bash
yarn database:push
npm run database:push
```
## Testing
Run `yarn test`
Run `npm test`
## Public API

View File

@ -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;

View File

@ -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
});
}
}

View File

@ -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);
});
});
});

View File

@ -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

View File

@ -0,0 +1,7 @@
import { Tag } from '@prisma/client';
import { IsArray } from 'class-validator';
export class UpdateHoldingTagsDto {
@IsArray()
tags: Tag[];
}

View File

@ -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;

View File

@ -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);

View File

@ -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',

View File

@ -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);
});
}
}

View File

@ -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>

View File

@ -9,6 +9,7 @@ export interface HoldingDetailDialogParams {
deviceType: string;
hasImpersonationId: boolean;
hasPermissionToReportDataGlitch: boolean;
hasPermissionToUpdateOrder: boolean;
locale: string;
symbol: string;
}

View File

@ -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;

View File

@ -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

View File

@ -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);
});
}

View File

@ -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>

View File

@ -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

View File

@ -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>

View File

@ -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;

View File

@ -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]

View File

@ -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:

View 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]

View File

@ -1,11 +1,11 @@
#!/bin/bash
# Will check if "yarn format" is run before executing.
# Will check if "npm run format" is run before executing.
# Called by "git commit" with no arguments. The hook should
# exit with non-zero status after issuing an appropriate message if
# it wants to stop the commit.
echo "Running yarn format"
echo "Running npm run format"
# Run the command and loop over its output
FILES_TO_STAGE=""
@ -14,13 +14,13 @@ while IFS= read -r line; do
# Process each line here
((i++))
if [ $i -le 2 ]; then
continue
continue
fi
if [[ $line == Done* ]]; then
break
break
fi
FILES_TO_STAGE="$FILES_TO_STAGE $line"
done < <(yarn format )
done < <(npm run format)
git add $FILES_TO_STAGE
echo "Files formatted. Committing..."

View 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);
});
});
});

View 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);
}

View File

@ -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;

View File

@ -1 +0,0 @@
export type HoldingViewMode = 'CHART' | 'TABLE';

View File

@ -0,0 +1 @@
export type HoldingsViewMode = 'CHART' | 'TABLE';

View File

@ -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,

View File

@ -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];

34039
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "2.98.0",
"version": "2.101.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio",
@ -15,7 +15,7 @@
"affected:test": "nx affected:test",
"analyze:client": "nx run client:build:production --stats-json && webpack-bundle-analyzer -p 1234 dist/apps/client/en/stats.json",
"angular": "node --max_old_space_size=32768 ./node_modules/@angular/cli/bin/ng",
"build:production": "nx run api:copy-assets && nx run api:build:production && nx run client:copy-assets && nx run client:build:production && yarn replace-placeholders-in-build",
"build:production": "nx run api:copy-assets && nx run api:build:production && nx run client:copy-assets && nx run client:build:production && npm run replace-placeholders-in-build",
"build:storybook": "nx run ui:build-storybook",
"database:format-schema": "prisma format",
"database:generate-typings": "prisma generate",
@ -24,7 +24,7 @@
"database:migrate": "prisma migrate deploy",
"database:push": "prisma db push",
"database:seed": "prisma db seed",
"database:setup": "yarn database:push && yarn database:seed",
"database:setup": "npm run database:push && npm run database:seed",
"database:validate-schema": "prisma validate",
"dep-graph": "nx dep-graph",
"e2e": "ng e2e",
@ -40,10 +40,10 @@
"replace-placeholders-in-build": "node ./replace.build.js",
"start": "node dist/apps/api/main",
"start:client": "nx run client:copy-assets && nx run client:serve --configuration=development-en --hmr -o",
"start:production": "yarn database:migrate && yarn database:seed && node main",
"start:production": "npm run database:migrate && npm run database:seed && node main",
"start:server": "nx run api:copy-assets && nx run api:serve --watch",
"start:storybook": "nx run ui:storybook",
"test": "yarn test:api && yarn test:common",
"test": "npm run test:api && npm run test:common",
"test:api": "npx dotenv-cli -e .env.example -- nx test api",
"test:common": "npx dotenv-cli -e .env.example -- nx test common",
"test:single": "nx run api:test --test-file portfolio-calculator-novn-buy-and-sell.spec.ts",
@ -116,7 +116,7 @@
"ionicons": "7.4.0",
"jsonpath": "1.1.1",
"lodash": "4.17.21",
"marked": "13.0.0",
"marked": "12.0.2",
"ms": "3.0.0-canary.1",
"ng-extract-i18n-merge": "2.12.0",
"ngx-device-detector": "8.0.0",
@ -164,9 +164,9 @@
"@nx/workspace": "19.5.1",
"@schematics/angular": "18.1.1",
"@simplewebauthn/types": "9.0.1",
"@storybook/addon-essentials": "7.6.5",
"@storybook/angular": "7.6.5",
"@storybook/core-server": "7.6.5",
"@storybook/addon-essentials": "8.2.6",
"@storybook/angular": "8.2.6",
"@storybook/core-server": "8.2.6",
"@trivago/prettier-plugin-sort-imports": "4.3.0",
"@types/big.js": "6.2.2",
"@types/body-parser": "1.19.5",
@ -182,13 +182,13 @@
"@typescript-eslint/parser": "6.21.0",
"codelyzer": "6.0.1",
"cypress": "6.2.1",
"eslint": "8.56.0",
"eslint": "8.57.0",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-cypress": "2.15.1",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-storybook": "0.6.15",
"jest": "29.4.3",
"jest-environment-jsdom": "29.4.3",
"jest": "29.7.0",
"jest-environment-jsdom": "29.7.0",
"jest-preset-angular": "14.1.0",
"nx": "19.5.1",
"prettier": "3.3.3",
@ -197,7 +197,7 @@
"react-dom": "18.2.0",
"replace-in-file": "7.0.1",
"shx": "0.3.4",
"storybook": "7.0.9",
"storybook": "8.2.6",
"ts-jest": "29.1.0",
"ts-node": "10.9.2",
"tslib": "2.6.0",

21549
yarn.lock

File diff suppressed because it is too large Load Diff