Compare commits
19 Commits
Author | SHA1 | Date | |
---|---|---|---|
ffa020ee2a | |||
80a3668aa9 | |||
7378900050 | |||
9be457943c | |||
93454c6c15 | |||
fccbd76993 | |||
922876a893 | |||
654446f068 | |||
947460abdd | |||
c5635b0050 | |||
8a3a6308a3 | |||
290a07fe79 | |||
4c907d56f0 | |||
56b437ca74 | |||
e23ff33e6f | |||
f2d206262e | |||
1ed5690b33 | |||
4451514ec5 | |||
8f73f85276 |
61
CHANGELOG.md
61
CHANGELOG.md
@ -5,6 +5,67 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## 1.257.0 - 2023-04-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Introduced the allocations by ETF provider chart on the allocations page
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue in the global heat map component caused by manipulating an input property
|
||||||
|
- Fixed an issue with the currency inconsistency in the _EOD Historical Data_ service (convert from `GBX` to `GBp`)
|
||||||
|
|
||||||
|
## 1.256.0 - 2023-04-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the _Yahoo Finance_ data enhancer for countries, sectors and urls
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Enabled the configuration to immediately remove queue jobs on complete
|
||||||
|
- Refactored the implementation of removing queue jobs
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the unique job ids of the gather asset profile process
|
||||||
|
- Fixed the style of the button to fetch the current market price
|
||||||
|
|
||||||
|
## 1.255.0 - 2023-04-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Made the system message expandable
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Skipped creating queue jobs for asset profiles with `MANUAL` data source not having a scraper configuration
|
||||||
|
- Reduced the execution interval of the data gathering to every hour
|
||||||
|
- Upgraded `prisma` from version `4.11.0` to `4.12.0`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Improved the style of the system message
|
||||||
|
|
||||||
|
## 1.254.0 - 2023-04-14
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the queue jobs implementation by adding in bulk
|
||||||
|
- Improved the queue jobs implementation by introducing unique job ids
|
||||||
|
- Reverted the execution interval of the data gathering from every 12 hours to every 4 hours
|
||||||
|
|
||||||
|
## 1.253.0 - 2023-04-14
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Reduced the execution interval of the data gathering to every 12 hours
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the background color of dialogs in dark mode
|
||||||
|
|
||||||
## 1.252.2 - 2023-04-11
|
## 1.252.2 - 2023-04-11
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
@ -100,16 +100,21 @@ export class AdminController {
|
|||||||
|
|
||||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||||
|
|
||||||
for (const { dataSource, symbol } of uniqueAssets) {
|
await this.dataGatheringService.addJobsToQueue(
|
||||||
await this.dataGatheringService.addJobToQueue(
|
uniqueAssets.map(({ dataSource, symbol }) => {
|
||||||
GATHER_ASSET_PROFILE_PROCESS,
|
return {
|
||||||
{
|
data: {
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
},
|
},
|
||||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||||
);
|
opts: {
|
||||||
}
|
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||||
|
jobId: `${dataSource}-${symbol}`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
this.dataGatheringService.gatherMax();
|
this.dataGatheringService.gatherMax();
|
||||||
}
|
}
|
||||||
@ -131,16 +136,21 @@ export class AdminController {
|
|||||||
|
|
||||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||||
|
|
||||||
for (const { dataSource, symbol } of uniqueAssets) {
|
await this.dataGatheringService.addJobsToQueue(
|
||||||
await this.dataGatheringService.addJobToQueue(
|
uniqueAssets.map(({ dataSource, symbol }) => {
|
||||||
GATHER_ASSET_PROFILE_PROCESS,
|
return {
|
||||||
{
|
data: {
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
},
|
},
|
||||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||||
);
|
opts: {
|
||||||
}
|
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||||
|
jobId: `${dataSource}-${symbol}`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('gather/profile-data/:dataSource/:symbol')
|
@Post('gather/profile-data/:dataSource/:symbol')
|
||||||
@ -161,14 +171,17 @@ export class AdminController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.dataGatheringService.addJobToQueue(
|
await this.dataGatheringService.addJobToQueue({
|
||||||
GATHER_ASSET_PROFILE_PROCESS,
|
data: {
|
||||||
{
|
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
},
|
},
|
||||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||||
);
|
opts: {
|
||||||
|
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||||
|
jobId: `${dataSource}-${symbol}`
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('gather/:dataSource/:symbol')
|
@Post('gather/:dataSource/:symbol')
|
||||||
|
@ -4,7 +4,7 @@ import {
|
|||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { AdminJobs } from '@ghostfolio/common/interfaces';
|
import { AdminJobs } from '@ghostfolio/common/interfaces';
|
||||||
import { InjectQueue } from '@nestjs/bull';
|
import { InjectQueue } from '@nestjs/bull';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { JobStatus, Queue } from 'bull';
|
import { JobStatus, Queue } from 'bull';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -23,14 +23,11 @@ export class QueueService {
|
|||||||
}: {
|
}: {
|
||||||
status?: JobStatus[];
|
status?: JobStatus[];
|
||||||
}) {
|
}) {
|
||||||
const jobs = await this.dataGatheringQueue.getJobs(status);
|
for (const statusItem of status) {
|
||||||
|
await this.dataGatheringQueue.clean(
|
||||||
for (const job of jobs) {
|
300,
|
||||||
try {
|
statusItem === 'waiting' ? 'wait' : statusItem
|
||||||
await job.remove();
|
);
|
||||||
} catch (error) {
|
|
||||||
Logger.warn(error, 'QueueService');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -44,18 +41,23 @@ export class QueueService {
|
|||||||
const jobs = await this.dataGatheringQueue.getJobs(status);
|
const jobs = await this.dataGatheringQueue.getJobs(status);
|
||||||
|
|
||||||
const jobsWithState = await Promise.all(
|
const jobsWithState = await Promise.all(
|
||||||
jobs.slice(0, limit).map(async (job) => {
|
jobs
|
||||||
return {
|
.filter((job) => {
|
||||||
attemptsMade: job.attemptsMade + 1,
|
return job;
|
||||||
data: job.data,
|
})
|
||||||
finishedOn: job.finishedOn,
|
.slice(0, limit)
|
||||||
id: job.id,
|
.map(async (job) => {
|
||||||
name: job.name,
|
return {
|
||||||
stacktrace: job.stacktrace,
|
attemptsMade: job.attemptsMade + 1,
|
||||||
state: await job.getState(),
|
data: job.data,
|
||||||
timestamp: job.timestamp
|
finishedOn: job.finishedOn,
|
||||||
};
|
id: job.id,
|
||||||
})
|
name: job.name,
|
||||||
|
stacktrace: job.stacktrace,
|
||||||
|
state: await job.getState(),
|
||||||
|
timestamp: job.timestamp
|
||||||
|
};
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -112,14 +112,17 @@ export class OrderService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.dataGatheringService.addJobToQueue(
|
await this.dataGatheringService.addJobToQueue({
|
||||||
GATHER_ASSET_PROFILE_PROCESS,
|
data: {
|
||||||
{
|
|
||||||
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
||||||
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
||||||
},
|
},
|
||||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||||
);
|
opts: {
|
||||||
|
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||||
|
jobId: `${data.SymbolProfile.connectOrCreate.create.dataSource}-${data.SymbolProfile.connectOrCreate.create.symbol}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const isDraft = isAfter(data.date as Date, endOfToday());
|
const isDraft = isAfter(data.date as Date, endOfToday());
|
||||||
|
|
||||||
|
@ -722,7 +722,7 @@ export class PortfolioCalculator {
|
|||||||
);
|
);
|
||||||
} else if (!currentPosition.quantity.eq(0)) {
|
} else if (!currentPosition.quantity.eq(0)) {
|
||||||
Logger.warn(
|
Logger.warn(
|
||||||
`Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`,
|
`Missing historical market data for symbol ${currentPosition.symbol}`,
|
||||||
'PortfolioCalculator'
|
'PortfolioCalculator'
|
||||||
);
|
);
|
||||||
hasErrors = true;
|
hasErrors = true;
|
||||||
|
@ -19,8 +19,8 @@ export class CronService {
|
|||||||
private readonly twitterBotService: TwitterBotService
|
private readonly twitterBotService: TwitterBotService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Cron(CronExpression.EVERY_4_HOURS)
|
@Cron(CronExpression.EVERY_HOUR)
|
||||||
public async runEveryFourHours() {
|
public async runEveryHour() {
|
||||||
await this.dataGatheringService.gather7Days();
|
await this.dataGatheringService.gather7Days();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -38,15 +38,20 @@ export class CronService {
|
|||||||
public async runEverySundayAtTwelvePm() {
|
public async runEverySundayAtTwelvePm() {
|
||||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||||
|
|
||||||
for (const { dataSource, symbol } of uniqueAssets) {
|
await this.dataGatheringService.addJobsToQueue(
|
||||||
await this.dataGatheringService.addJobToQueue(
|
uniqueAssets.map(({ dataSource, symbol }) => {
|
||||||
GATHER_ASSET_PROFILE_PROCESS,
|
return {
|
||||||
{
|
data: {
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
},
|
},
|
||||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||||
);
|
opts: {
|
||||||
}
|
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||||
|
jobId: `${dataSource}-${symbol}`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,8 +2,7 @@ import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.se
|
|||||||
import {
|
import {
|
||||||
DATA_GATHERING_QUEUE,
|
DATA_GATHERING_QUEUE,
|
||||||
GATHER_HISTORICAL_MARKET_DATA_PROCESS,
|
GATHER_HISTORICAL_MARKET_DATA_PROCESS,
|
||||||
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
|
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS
|
||||||
QUEUE_JOB_STATUS_LIST
|
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper';
|
||||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
@ -12,6 +11,7 @@ import { Inject, Injectable, Logger } from '@nestjs/common';
|
|||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
import { JobOptions, Queue } from 'bull';
|
import { JobOptions, Queue } from 'bull';
|
||||||
import { format, min, subDays, subYears } from 'date-fns';
|
import { format, min, subDays, subYears } from 'date-fns';
|
||||||
|
import { isEmpty } from 'lodash';
|
||||||
|
|
||||||
import { DataProviderService } from './data-provider/data-provider.service';
|
import { DataProviderService } from './data-provider/data-provider.service';
|
||||||
import { DataEnhancerInterface } from './data-provider/interfaces/data-enhancer.interface';
|
import { DataEnhancerInterface } from './data-provider/interfaces/data-enhancer.interface';
|
||||||
@ -34,17 +34,22 @@ export class DataGatheringService {
|
|||||||
private readonly symbolProfileService: SymbolProfileService
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async addJobToQueue(name: string, data: any, options?: JobOptions) {
|
public async addJobToQueue({
|
||||||
const hasJob = await this.hasJob(name, data);
|
data,
|
||||||
|
name,
|
||||||
|
opts
|
||||||
|
}: {
|
||||||
|
data: any;
|
||||||
|
name: string;
|
||||||
|
opts?: JobOptions;
|
||||||
|
}) {
|
||||||
|
return this.dataGatheringQueue.add(name, data, opts);
|
||||||
|
}
|
||||||
|
|
||||||
if (hasJob) {
|
public async addJobsToQueue(
|
||||||
Logger.log(
|
jobs: { data: any; name: string; opts?: JobOptions }[]
|
||||||
`Job ${name} with data ${JSON.stringify(data)} already exists.`,
|
) {
|
||||||
'DataGatheringService'
|
return this.dataGatheringQueue.addBulk(jobs);
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return this.dataGatheringQueue.add(name, data, options);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async gather7Days() {
|
public async gather7Days() {
|
||||||
@ -209,59 +214,22 @@ export class DataGatheringService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
|
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
|
||||||
for (const { dataSource, date, symbol } of aSymbolsWithStartDate) {
|
await this.addJobsToQueue(
|
||||||
await this.addJobToQueue(
|
aSymbolsWithStartDate.map(({ dataSource, date, symbol }) => {
|
||||||
GATHER_HISTORICAL_MARKET_DATA_PROCESS,
|
|
||||||
{
|
|
||||||
dataSource,
|
|
||||||
date,
|
|
||||||
symbol
|
|
||||||
},
|
|
||||||
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getSymbolsMax(): Promise<IDataGatheringItem[]> {
|
|
||||||
const startDate =
|
|
||||||
(
|
|
||||||
await this.prismaService.order.findFirst({
|
|
||||||
orderBy: [{ date: 'asc' }]
|
|
||||||
})
|
|
||||||
)?.date ?? new Date();
|
|
||||||
|
|
||||||
const currencyPairsToGather = this.exchangeRateDataService
|
|
||||||
.getCurrencyPairs()
|
|
||||||
.map(({ dataSource, symbol }) => {
|
|
||||||
return {
|
return {
|
||||||
dataSource,
|
data: {
|
||||||
symbol,
|
dataSource,
|
||||||
date: min([startDate, subYears(new Date(), 10)])
|
date,
|
||||||
};
|
symbol
|
||||||
});
|
|
||||||
|
|
||||||
const symbolProfilesToGather = (
|
|
||||||
await this.prismaService.symbolProfile.findMany({
|
|
||||||
orderBy: [{ symbol: 'asc' }],
|
|
||||||
select: {
|
|
||||||
dataSource: true,
|
|
||||||
Order: {
|
|
||||||
orderBy: [{ date: 'asc' }],
|
|
||||||
select: { date: true },
|
|
||||||
take: 1
|
|
||||||
},
|
},
|
||||||
scraperConfiguration: true,
|
name: GATHER_HISTORICAL_MARKET_DATA_PROCESS,
|
||||||
symbol: true
|
opts: {
|
||||||
}
|
...GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
|
||||||
|
jobId: `${dataSource}-${symbol}-${format(date, DATE_FORMAT)}`
|
||||||
|
}
|
||||||
|
};
|
||||||
})
|
})
|
||||||
).map((symbolProfile) => {
|
);
|
||||||
return {
|
|
||||||
...symbolProfile,
|
|
||||||
date: symbolProfile.Order?.[0]?.date ?? startDate
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return [...currencyPairsToGather, ...symbolProfilesToGather];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getUniqueAssets(): Promise<UniqueAsset[]> {
|
public async getUniqueAssets(): Promise<UniqueAsset[]> {
|
||||||
@ -298,7 +266,7 @@ export class DataGatheringService {
|
|||||||
|
|
||||||
// Only consider symbols with incomplete market data for the last
|
// Only consider symbols with incomplete market data for the last
|
||||||
// 7 days
|
// 7 days
|
||||||
const symbolsNotToGather = (
|
const symbolsWithCompleteMarketData = (
|
||||||
await this.prismaService.marketData.groupBy({
|
await this.prismaService.marketData.groupBy({
|
||||||
_count: true,
|
_count: true,
|
||||||
by: ['symbol'],
|
by: ['symbol'],
|
||||||
@ -316,8 +284,14 @@ export class DataGatheringService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const symbolProfilesToGather = symbolProfiles
|
const symbolProfilesToGather = symbolProfiles
|
||||||
.filter(({ symbol }) => {
|
.filter(({ dataSource, scraperConfiguration, symbol }) => {
|
||||||
return !symbolsNotToGather.includes(symbol);
|
const manualDataSourceWithScraperConfiguration =
|
||||||
|
dataSource === 'MANUAL' && !isEmpty(scraperConfiguration);
|
||||||
|
|
||||||
|
return (
|
||||||
|
!symbolsWithCompleteMarketData.includes(symbol) &&
|
||||||
|
(dataSource !== 'MANUAL' || manualDataSourceWithScraperConfiguration)
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.map((symbolProfile) => {
|
.map((symbolProfile) => {
|
||||||
return {
|
return {
|
||||||
@ -329,7 +303,7 @@ export class DataGatheringService {
|
|||||||
const currencyPairsToGather = this.exchangeRateDataService
|
const currencyPairsToGather = this.exchangeRateDataService
|
||||||
.getCurrencyPairs()
|
.getCurrencyPairs()
|
||||||
.filter(({ symbol }) => {
|
.filter(({ symbol }) => {
|
||||||
return !symbolsNotToGather.includes(symbol);
|
return !symbolsWithCompleteMarketData.includes(symbol);
|
||||||
})
|
})
|
||||||
.map(({ dataSource, symbol }) => {
|
.map(({ dataSource, symbol }) => {
|
||||||
return {
|
return {
|
||||||
@ -342,17 +316,56 @@ export class DataGatheringService {
|
|||||||
return [...currencyPairsToGather, ...symbolProfilesToGather];
|
return [...currencyPairsToGather, ...symbolProfilesToGather];
|
||||||
}
|
}
|
||||||
|
|
||||||
private async hasJob(name: string, data: any) {
|
private async getSymbolsMax(): Promise<IDataGatheringItem[]> {
|
||||||
const jobs = await this.dataGatheringQueue.getJobs(
|
const startDate =
|
||||||
QUEUE_JOB_STATUS_LIST.filter((status) => {
|
(
|
||||||
return status !== 'completed';
|
await this.prismaService.order.findFirst({
|
||||||
})
|
orderBy: [{ date: 'asc' }]
|
||||||
);
|
})
|
||||||
|
)?.date ?? new Date();
|
||||||
|
|
||||||
return jobs.some((job) => {
|
const currencyPairsToGather = this.exchangeRateDataService
|
||||||
return (
|
.getCurrencyPairs()
|
||||||
job.name === name && JSON.stringify(job.data) === JSON.stringify(data)
|
.map(({ dataSource, symbol }) => {
|
||||||
);
|
return {
|
||||||
});
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
date: min([startDate, subYears(new Date(), 10)])
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const symbolProfilesToGather = (
|
||||||
|
await this.prismaService.symbolProfile.findMany({
|
||||||
|
orderBy: [{ symbol: 'asc' }],
|
||||||
|
select: {
|
||||||
|
dataSource: true,
|
||||||
|
Order: {
|
||||||
|
orderBy: [{ date: 'asc' }],
|
||||||
|
select: { date: true },
|
||||||
|
take: 1
|
||||||
|
},
|
||||||
|
scraperConfiguration: true,
|
||||||
|
symbol: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.filter((symbolProfile) => {
|
||||||
|
const manualDataSourceWithScraperConfiguration =
|
||||||
|
symbolProfile.dataSource === 'MANUAL' &&
|
||||||
|
!isEmpty(symbolProfile.scraperConfiguration);
|
||||||
|
|
||||||
|
return (
|
||||||
|
symbolProfile.dataSource !== 'MANUAL' ||
|
||||||
|
manualDataSourceWithScraperConfiguration
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map((symbolProfile) => {
|
||||||
|
return {
|
||||||
|
...symbolProfile,
|
||||||
|
date: symbolProfile.Order?.[0]?.date ?? startDate
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...currencyPairsToGather, ...symbolProfilesToGather];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -33,7 +33,8 @@ export class AlphaVantageService implements DataProviderInterface {
|
|||||||
aSymbol: string
|
aSymbol: string
|
||||||
): Promise<Partial<SymbolProfile>> {
|
): Promise<Partial<SymbolProfile>> {
|
||||||
return {
|
return {
|
||||||
dataSource: this.getName()
|
dataSource: this.getName(),
|
||||||
|
symbol: aSymbol
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,15 +1,27 @@
|
|||||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
|
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
|
||||||
import { TrackinsightDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/trackinsight/trackinsight.service';
|
import { TrackinsightDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/trackinsight/trackinsight.service';
|
||||||
|
import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
exports: ['DataEnhancers', TrackinsightDataEnhancerService],
|
exports: [
|
||||||
|
'DataEnhancers',
|
||||||
|
TrackinsightDataEnhancerService,
|
||||||
|
YahooFinanceDataEnhancerService
|
||||||
|
],
|
||||||
|
imports: [ConfigurationModule, CryptocurrencyModule],
|
||||||
providers: [
|
providers: [
|
||||||
|
TrackinsightDataEnhancerService,
|
||||||
|
YahooFinanceDataEnhancerService,
|
||||||
{
|
{
|
||||||
inject: [TrackinsightDataEnhancerService],
|
inject: [
|
||||||
|
TrackinsightDataEnhancerService,
|
||||||
|
YahooFinanceDataEnhancerService
|
||||||
|
],
|
||||||
provide: 'DataEnhancers',
|
provide: 'DataEnhancers',
|
||||||
useFactory: (trackinsight) => [trackinsight]
|
useFactory: (trackinsight, yahooFinance) => [trackinsight, yahooFinance]
|
||||||
},
|
}
|
||||||
TrackinsightDataEnhancerService
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class DataEnhancerModule {}
|
export class DataEnhancerModule {}
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
|
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
|
||||||
import { Country } from '@ghostfolio/common/interfaces/country.interface';
|
import { Country } from '@ghostfolio/common/interfaces/country.interface';
|
||||||
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
|
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
import { SymbolProfile } from '@prisma/client';
|
import { SymbolProfile } from '@prisma/client';
|
||||||
import bent from 'bent';
|
import bent from 'bent';
|
||||||
|
|
||||||
const getJSON = bent('json');
|
const getJSON = bent('json');
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
||||||
private static baseUrl = 'https://data.trackinsight.com';
|
private static baseUrl = 'https://data.trackinsight.com';
|
||||||
private static countries = require('countries-list/dist/countries.json');
|
private static countries = require('countries-list/dist/countries.json');
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
|
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
|
||||||
|
|
||||||
import { YahooFinanceService } from './yahoo-finance.service';
|
import { YahooFinanceDataEnhancerService } from './yahoo-finance.service';
|
||||||
|
|
||||||
jest.mock(
|
jest.mock(
|
||||||
'@ghostfolio/api/services/cryptocurrency/cryptocurrency.service',
|
'@ghostfolio/api/services/cryptocurrency/cryptocurrency.service',
|
||||||
@ -25,16 +25,16 @@ jest.mock(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
describe('YahooFinanceService', () => {
|
describe('YahooFinanceDataEnhancerService', () => {
|
||||||
let configurationService: ConfigurationService;
|
let configurationService: ConfigurationService;
|
||||||
let cryptocurrencyService: CryptocurrencyService;
|
let cryptocurrencyService: CryptocurrencyService;
|
||||||
let yahooFinanceService: YahooFinanceService;
|
let yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
configurationService = new ConfigurationService();
|
configurationService = new ConfigurationService();
|
||||||
cryptocurrencyService = new CryptocurrencyService();
|
cryptocurrencyService = new CryptocurrencyService();
|
||||||
|
|
||||||
yahooFinanceService = new YahooFinanceService(
|
yahooFinanceDataEnhancerService = new YahooFinanceDataEnhancerService(
|
||||||
configurationService,
|
configurationService,
|
||||||
cryptocurrencyService
|
cryptocurrencyService
|
||||||
);
|
);
|
||||||
@ -42,25 +42,37 @@ describe('YahooFinanceService', () => {
|
|||||||
|
|
||||||
it('convertFromYahooFinanceSymbol', async () => {
|
it('convertFromYahooFinanceSymbol', async () => {
|
||||||
expect(
|
expect(
|
||||||
await yahooFinanceService.convertFromYahooFinanceSymbol('BRK-B')
|
await yahooFinanceDataEnhancerService.convertFromYahooFinanceSymbol(
|
||||||
|
'BRK-B'
|
||||||
|
)
|
||||||
).toEqual('BRK-B');
|
).toEqual('BRK-B');
|
||||||
expect(
|
expect(
|
||||||
await yahooFinanceService.convertFromYahooFinanceSymbol('BTC-USD')
|
await yahooFinanceDataEnhancerService.convertFromYahooFinanceSymbol(
|
||||||
|
'BTC-USD'
|
||||||
|
)
|
||||||
).toEqual('BTCUSD');
|
).toEqual('BTCUSD');
|
||||||
expect(
|
expect(
|
||||||
await yahooFinanceService.convertFromYahooFinanceSymbol('EURUSD=X')
|
await yahooFinanceDataEnhancerService.convertFromYahooFinanceSymbol(
|
||||||
|
'EURUSD=X'
|
||||||
|
)
|
||||||
).toEqual('EURUSD');
|
).toEqual('EURUSD');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('convertToYahooFinanceSymbol', async () => {
|
it('convertToYahooFinanceSymbol', async () => {
|
||||||
expect(
|
expect(
|
||||||
await yahooFinanceService.convertToYahooFinanceSymbol('BTCUSD')
|
await yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(
|
||||||
|
'BTCUSD'
|
||||||
|
)
|
||||||
).toEqual('BTC-USD');
|
).toEqual('BTC-USD');
|
||||||
expect(
|
expect(
|
||||||
await yahooFinanceService.convertToYahooFinanceSymbol('DOGEUSD')
|
await yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(
|
||||||
|
'DOGEUSD'
|
||||||
|
)
|
||||||
).toEqual('DOGE-USD');
|
).toEqual('DOGE-USD');
|
||||||
expect(
|
expect(
|
||||||
await yahooFinanceService.convertToYahooFinanceSymbol('USDCHF')
|
await yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(
|
||||||
|
'USDCHF'
|
||||||
|
)
|
||||||
).toEqual('USDCHF=X');
|
).toEqual('USDCHF=X');
|
||||||
});
|
});
|
||||||
});
|
});
|
@ -0,0 +1,325 @@
|
|||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
|
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
|
||||||
|
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
|
||||||
|
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||||
|
import { isCurrency } from '@ghostfolio/common/helper';
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
AssetClass,
|
||||||
|
AssetSubClass,
|
||||||
|
DataSource,
|
||||||
|
SymbolProfile
|
||||||
|
} from '@prisma/client';
|
||||||
|
import { countries } from 'countries-list';
|
||||||
|
import yahooFinance from 'yahoo-finance2';
|
||||||
|
import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-iface';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
|
||||||
|
private baseCurrency: string;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly configurationService: ConfigurationService,
|
||||||
|
private readonly cryptocurrencyService: CryptocurrencyService
|
||||||
|
) {
|
||||||
|
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
||||||
|
}
|
||||||
|
|
||||||
|
public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) {
|
||||||
|
let symbol = aYahooFinanceSymbol.replace(
|
||||||
|
new RegExp(`-${this.baseCurrency}$`),
|
||||||
|
this.baseCurrency
|
||||||
|
);
|
||||||
|
|
||||||
|
if (symbol.includes('=X') && !symbol.includes(this.baseCurrency)) {
|
||||||
|
symbol = `${this.baseCurrency}${symbol}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return symbol.replace('=X', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a symbol to a Yahoo Finance symbol
|
||||||
|
*
|
||||||
|
* Currency: USDCHF -> USDCHF=X
|
||||||
|
* Cryptocurrency: BTCUSD -> BTC-USD
|
||||||
|
* DOGEUSD -> DOGE-USD
|
||||||
|
*/
|
||||||
|
public convertToYahooFinanceSymbol(aSymbol: string) {
|
||||||
|
if (
|
||||||
|
aSymbol.includes(this.baseCurrency) &&
|
||||||
|
aSymbol.length > this.baseCurrency.length
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
isCurrency(
|
||||||
|
aSymbol.substring(0, aSymbol.length - this.baseCurrency.length)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return `${aSymbol}=X`;
|
||||||
|
} else if (
|
||||||
|
this.cryptocurrencyService.isCryptocurrency(
|
||||||
|
aSymbol.replace(
|
||||||
|
new RegExp(`-${this.baseCurrency}$`),
|
||||||
|
this.baseCurrency
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
// Add a dash before the last three characters
|
||||||
|
// BTCUSD -> BTC-USD
|
||||||
|
// DOGEUSD -> DOGE-USD
|
||||||
|
// SOL1USD -> SOL1-USD
|
||||||
|
return aSymbol.replace(
|
||||||
|
new RegExp(`-?${this.baseCurrency}$`),
|
||||||
|
`-${this.baseCurrency}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return aSymbol;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async enhance({
|
||||||
|
response,
|
||||||
|
symbol
|
||||||
|
}: {
|
||||||
|
response: Partial<SymbolProfile>;
|
||||||
|
symbol: string;
|
||||||
|
}): Promise<Partial<SymbolProfile>> {
|
||||||
|
if (response.dataSource !== 'YAHOO' && !response.isin) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let yahooSymbol: string;
|
||||||
|
|
||||||
|
if (response.dataSource === 'YAHOO') {
|
||||||
|
yahooSymbol = symbol;
|
||||||
|
} else {
|
||||||
|
const { quotes } = await yahooFinance.search(response.isin);
|
||||||
|
yahooSymbol = quotes[0].symbol;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { countries, sectors, url } = await this.getAssetProfile(
|
||||||
|
yahooSymbol
|
||||||
|
);
|
||||||
|
|
||||||
|
if (countries) {
|
||||||
|
response.countries = countries;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sectors) {
|
||||||
|
response.sectors = sectors;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
response.url = url;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error, 'YahooFinanceDataEnhancerService');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public formatName({
|
||||||
|
longName,
|
||||||
|
quoteType,
|
||||||
|
shortName,
|
||||||
|
symbol
|
||||||
|
}: {
|
||||||
|
longName: Price['longName'];
|
||||||
|
quoteType: Price['quoteType'];
|
||||||
|
shortName: Price['shortName'];
|
||||||
|
symbol: Price['symbol'];
|
||||||
|
}) {
|
||||||
|
let name = longName;
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
name = name.replace('Amundi Index Solutions - ', '');
|
||||||
|
name = name.replace('iShares ETF (CH) - ', '');
|
||||||
|
name = name.replace('iShares III Public Limited Company - ', '');
|
||||||
|
name = name.replace('iShares V PLC - ', '');
|
||||||
|
name = name.replace('iShares VI Public Limited Company - ', '');
|
||||||
|
name = name.replace('iShares VII PLC - ', '');
|
||||||
|
name = name.replace('Multi Units Luxembourg - ', '');
|
||||||
|
name = name.replace('VanEck ETFs N.V. - ', '');
|
||||||
|
name = name.replace('Vaneck Vectors Ucits Etfs Plc - ', '');
|
||||||
|
name = name.replace('Vanguard Funds Public Limited Company - ', '');
|
||||||
|
name = name.replace('Vanguard Index Funds - ', '');
|
||||||
|
name = name.replace('Xtrackers (IE) Plc - ', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quoteType === 'FUTURE') {
|
||||||
|
// "Gold Jun 22" -> "Gold"
|
||||||
|
name = shortName?.slice(0, -7);
|
||||||
|
}
|
||||||
|
|
||||||
|
return name || shortName || symbol;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAssetProfile(
|
||||||
|
aSymbol: string
|
||||||
|
): Promise<Partial<SymbolProfile>> {
|
||||||
|
const response: Partial<SymbolProfile> = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const symbol = this.convertToYahooFinanceSymbol(aSymbol);
|
||||||
|
const assetProfile = await yahooFinance.quoteSummary(symbol, {
|
||||||
|
modules: ['price', 'summaryProfile', 'topHoldings']
|
||||||
|
});
|
||||||
|
|
||||||
|
const { assetClass, assetSubClass } = this.parseAssetClass({
|
||||||
|
quoteType: assetProfile.price.quoteType,
|
||||||
|
shortName: assetProfile.price.shortName
|
||||||
|
});
|
||||||
|
|
||||||
|
response.assetClass = assetClass;
|
||||||
|
response.assetSubClass = assetSubClass;
|
||||||
|
response.currency = assetProfile.price.currency;
|
||||||
|
response.dataSource = this.getName();
|
||||||
|
response.name = this.formatName({
|
||||||
|
longName: assetProfile.price.longName,
|
||||||
|
quoteType: assetProfile.price.quoteType,
|
||||||
|
shortName: assetProfile.price.shortName,
|
||||||
|
symbol: assetProfile.price.symbol
|
||||||
|
});
|
||||||
|
response.symbol = aSymbol;
|
||||||
|
|
||||||
|
if (assetSubClass === AssetSubClass.MUTUALFUND) {
|
||||||
|
response.sectors = [];
|
||||||
|
|
||||||
|
for (const sectorWeighting of assetProfile.topHoldings
|
||||||
|
?.sectorWeightings ?? []) {
|
||||||
|
for (const [sector, weight] of Object.entries(sectorWeighting)) {
|
||||||
|
response.sectors.push({ weight, name: this.parseSector(sector) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
assetSubClass === AssetSubClass.STOCK &&
|
||||||
|
assetProfile.summaryProfile?.country
|
||||||
|
) {
|
||||||
|
// Add country if asset is stock and country available
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [code] = Object.entries(countries).find(([, country]) => {
|
||||||
|
return country.name === assetProfile.summaryProfile?.country;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (code) {
|
||||||
|
response.countries = [{ code, weight: 1 }];
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
if (assetProfile.summaryProfile?.sector) {
|
||||||
|
response.sectors = [
|
||||||
|
{ name: assetProfile.summaryProfile?.sector, weight: 1 }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = assetProfile.summaryProfile?.website;
|
||||||
|
if (url) {
|
||||||
|
response.url = url;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error, 'YahooFinanceService');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getName() {
|
||||||
|
return DataSource.YAHOO;
|
||||||
|
}
|
||||||
|
|
||||||
|
public parseAssetClass({
|
||||||
|
quoteType,
|
||||||
|
shortName
|
||||||
|
}: {
|
||||||
|
quoteType: string;
|
||||||
|
shortName: string;
|
||||||
|
}): {
|
||||||
|
assetClass: AssetClass;
|
||||||
|
assetSubClass: AssetSubClass;
|
||||||
|
} {
|
||||||
|
let assetClass: AssetClass;
|
||||||
|
let assetSubClass: AssetSubClass;
|
||||||
|
|
||||||
|
switch (quoteType?.toLowerCase()) {
|
||||||
|
case 'cryptocurrency':
|
||||||
|
assetClass = AssetClass.CASH;
|
||||||
|
assetSubClass = AssetSubClass.CRYPTOCURRENCY;
|
||||||
|
break;
|
||||||
|
case 'equity':
|
||||||
|
assetClass = AssetClass.EQUITY;
|
||||||
|
assetSubClass = AssetSubClass.STOCK;
|
||||||
|
break;
|
||||||
|
case 'etf':
|
||||||
|
assetClass = AssetClass.EQUITY;
|
||||||
|
assetSubClass = AssetSubClass.ETF;
|
||||||
|
break;
|
||||||
|
case 'future':
|
||||||
|
assetClass = AssetClass.COMMODITY;
|
||||||
|
assetSubClass = AssetSubClass.COMMODITY;
|
||||||
|
|
||||||
|
if (
|
||||||
|
shortName?.toLowerCase()?.startsWith('gold') ||
|
||||||
|
shortName?.toLowerCase()?.startsWith('palladium') ||
|
||||||
|
shortName?.toLowerCase()?.startsWith('platinum') ||
|
||||||
|
shortName?.toLowerCase()?.startsWith('silver')
|
||||||
|
) {
|
||||||
|
assetSubClass = AssetSubClass.PRECIOUS_METAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
case 'mutualfund':
|
||||||
|
assetClass = AssetClass.EQUITY;
|
||||||
|
assetSubClass = AssetSubClass.MUTUALFUND;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { assetClass, assetSubClass };
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseSector(aString: string): string {
|
||||||
|
let sector = UNKNOWN_KEY;
|
||||||
|
|
||||||
|
switch (aString) {
|
||||||
|
case 'basic_materials':
|
||||||
|
sector = 'Basic Materials';
|
||||||
|
break;
|
||||||
|
case 'communication_services':
|
||||||
|
sector = 'Communication Services';
|
||||||
|
break;
|
||||||
|
case 'consumer_cyclical':
|
||||||
|
sector = 'Consumer Cyclical';
|
||||||
|
break;
|
||||||
|
case 'consumer_defensive':
|
||||||
|
sector = 'Consumer Staples';
|
||||||
|
break;
|
||||||
|
case 'energy':
|
||||||
|
sector = 'Energy';
|
||||||
|
break;
|
||||||
|
case 'financial_services':
|
||||||
|
sector = 'Financial Services';
|
||||||
|
break;
|
||||||
|
case 'healthcare':
|
||||||
|
sector = 'Healthcare';
|
||||||
|
break;
|
||||||
|
case 'industrials':
|
||||||
|
sector = 'Industrials';
|
||||||
|
break;
|
||||||
|
case 'realestate':
|
||||||
|
sector = 'Real Estate';
|
||||||
|
break;
|
||||||
|
case 'technology':
|
||||||
|
sector = 'Technology';
|
||||||
|
break;
|
||||||
|
case 'utilities':
|
||||||
|
sector = 'Utilities';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sector;
|
||||||
|
}
|
||||||
|
}
|
@ -11,12 +11,15 @@ import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
|||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { DataEnhancerModule } from './data-enhancer/data-enhancer.module';
|
||||||
|
import { YahooFinanceDataEnhancerService } from './data-enhancer/yahoo-finance/yahoo-finance.service';
|
||||||
import { DataProviderService } from './data-provider.service';
|
import { DataProviderService } from './data-provider.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
CryptocurrencyModule,
|
CryptocurrencyModule,
|
||||||
|
DataEnhancerModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
SymbolProfileModule
|
SymbolProfileModule
|
||||||
],
|
],
|
||||||
@ -57,7 +60,8 @@ import { DataProviderService } from './data-provider.service';
|
|||||||
rapidApiService,
|
rapidApiService,
|
||||||
yahooFinanceService
|
yahooFinanceService
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
YahooFinanceDataEnhancerService
|
||||||
],
|
],
|
||||||
exports: [DataProviderService, YahooFinanceService]
|
exports: [DataProviderService, YahooFinanceService]
|
||||||
})
|
})
|
||||||
|
@ -40,10 +40,11 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
return {
|
return {
|
||||||
assetClass: searchResult?.assetClass,
|
assetClass: searchResult?.assetClass,
|
||||||
assetSubClass: searchResult?.assetSubClass,
|
assetSubClass: searchResult?.assetSubClass,
|
||||||
currency: searchResult?.currency,
|
currency: this.convertCurrency(searchResult?.currency),
|
||||||
dataSource: this.getName(),
|
dataSource: this.getName(),
|
||||||
isin: searchResult?.isin,
|
isin: searchResult?.isin,
|
||||||
name: searchResult?.name
|
name: searchResult?.name,
|
||||||
|
symbol: aSymbol
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -146,7 +147,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
{ close, code, timestamp }
|
{ close, code, timestamp }
|
||||||
) => {
|
) => {
|
||||||
result[code] = {
|
result[code] = {
|
||||||
currency: searchResponse?.items[0]?.currency,
|
currency: this.convertCurrency(searchResponse?.items[0]?.currency),
|
||||||
dataSource: DataSource.EOD_HISTORICAL_DATA,
|
dataSource: DataSource.EOD_HISTORICAL_DATA,
|
||||||
marketPrice: close,
|
marketPrice: close,
|
||||||
marketState: isToday(new Date(timestamp * 1000)) ? 'open' : 'closed'
|
marketState: isToday(new Date(timestamp * 1000)) ? 'open' : 'closed'
|
||||||
@ -183,16 +184,26 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
return {
|
return {
|
||||||
assetClass,
|
assetClass,
|
||||||
assetSubClass,
|
assetSubClass,
|
||||||
currency,
|
|
||||||
dataSource,
|
dataSource,
|
||||||
name,
|
name,
|
||||||
symbol
|
symbol,
|
||||||
|
currency: this.convertCurrency(currency)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private convertCurrency(aCurrency: string) {
|
||||||
|
let currency = aCurrency;
|
||||||
|
|
||||||
|
if (currency === 'GBX') {
|
||||||
|
currency = 'GBp';
|
||||||
|
}
|
||||||
|
|
||||||
|
return currency;
|
||||||
|
}
|
||||||
|
|
||||||
private async getSearchResult(aQuery: string): Promise<
|
private async getSearchResult(aQuery: string): Promise<
|
||||||
(LookupItem & {
|
(LookupItem & {
|
||||||
assetClass: AssetClass;
|
assetClass: AssetClass;
|
||||||
@ -212,14 +223,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
const response = await get();
|
const response = await get();
|
||||||
|
|
||||||
searchResult = response.map(
|
searchResult = response.map(
|
||||||
({
|
({ Code, Currency, Exchange, ISIN: isin, Name: name, Type }) => {
|
||||||
Code,
|
|
||||||
Currency: currency,
|
|
||||||
Exchange,
|
|
||||||
ISIN: isin,
|
|
||||||
Name: name,
|
|
||||||
Type
|
|
||||||
}) => {
|
|
||||||
const { assetClass, assetSubClass } = this.parseAssetClass({
|
const { assetClass, assetSubClass } = this.parseAssetClass({
|
||||||
Exchange,
|
Exchange,
|
||||||
Type
|
Type
|
||||||
@ -228,9 +232,9 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
return {
|
return {
|
||||||
assetClass,
|
assetClass,
|
||||||
assetSubClass,
|
assetSubClass,
|
||||||
currency,
|
|
||||||
isin,
|
isin,
|
||||||
name,
|
name,
|
||||||
|
currency: this.convertCurrency(Currency),
|
||||||
dataSource: this.getName(),
|
dataSource: this.getName(),
|
||||||
symbol: `${Code}.${Exchange}`
|
symbol: `${Code}.${Exchange}`
|
||||||
};
|
};
|
||||||
|
@ -30,7 +30,8 @@ export class GoogleSheetsService implements DataProviderInterface {
|
|||||||
aSymbol: string
|
aSymbol: string
|
||||||
): Promise<Partial<SymbolProfile>> {
|
): Promise<Partial<SymbolProfile>> {
|
||||||
return {
|
return {
|
||||||
dataSource: this.getName()
|
dataSource: this.getName(),
|
||||||
|
symbol: aSymbol
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -34,7 +34,8 @@ export class ManualService implements DataProviderInterface {
|
|||||||
aSymbol: string
|
aSymbol: string
|
||||||
): Promise<Partial<SymbolProfile>> {
|
): Promise<Partial<SymbolProfile>> {
|
||||||
return {
|
return {
|
||||||
dataSource: this.getName()
|
dataSource: this.getName(),
|
||||||
|
symbol: aSymbol
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -27,7 +27,8 @@ export class RapidApiService implements DataProviderInterface {
|
|||||||
aSymbol: string
|
aSymbol: string
|
||||||
): Promise<Partial<SymbolProfile>> {
|
): Promise<Partial<SymbolProfile>> {
|
||||||
return {
|
return {
|
||||||
dataSource: this.getName()
|
dataSource: this.getName(),
|
||||||
|
symbol: aSymbol
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,26 +1,19 @@
|
|||||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
|
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
|
||||||
|
import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service';
|
||||||
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
||||||
import {
|
import {
|
||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
IDataProviderResponse
|
IDataProviderResponse
|
||||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
|
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import {
|
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||||
AssetClass,
|
|
||||||
AssetSubClass,
|
|
||||||
DataSource,
|
|
||||||
SymbolProfile
|
|
||||||
} from '@prisma/client';
|
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
import { countries } from 'countries-list';
|
|
||||||
import { addDays, format, isSameDay } from 'date-fns';
|
import { addDays, format, isSameDay } from 'date-fns';
|
||||||
import yahooFinance from 'yahoo-finance2';
|
import yahooFinance from 'yahoo-finance2';
|
||||||
import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-iface';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class YahooFinanceService implements DataProviderInterface {
|
export class YahooFinanceService implements DataProviderInterface {
|
||||||
@ -28,7 +21,8 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly cryptocurrencyService: CryptocurrencyService
|
private readonly cryptocurrencyService: CryptocurrencyService,
|
||||||
|
private readonly yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService
|
||||||
) {
|
) {
|
||||||
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
||||||
}
|
}
|
||||||
@ -37,128 +31,20 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) {
|
|
||||||
let symbol = aYahooFinanceSymbol.replace(
|
|
||||||
new RegExp(`-${this.baseCurrency}$`),
|
|
||||||
this.baseCurrency
|
|
||||||
);
|
|
||||||
|
|
||||||
if (symbol.includes('=X') && !symbol.includes(this.baseCurrency)) {
|
|
||||||
symbol = `${this.baseCurrency}${symbol}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return symbol.replace('=X', '');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts a symbol to a Yahoo Finance symbol
|
|
||||||
*
|
|
||||||
* Currency: USDCHF -> USDCHF=X
|
|
||||||
* Cryptocurrency: BTCUSD -> BTC-USD
|
|
||||||
* DOGEUSD -> DOGE-USD
|
|
||||||
*/
|
|
||||||
public convertToYahooFinanceSymbol(aSymbol: string) {
|
|
||||||
if (
|
|
||||||
aSymbol.includes(this.baseCurrency) &&
|
|
||||||
aSymbol.length > this.baseCurrency.length
|
|
||||||
) {
|
|
||||||
if (
|
|
||||||
isCurrency(
|
|
||||||
aSymbol.substring(0, aSymbol.length - this.baseCurrency.length)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return `${aSymbol}=X`;
|
|
||||||
} else if (
|
|
||||||
this.cryptocurrencyService.isCryptocurrency(
|
|
||||||
aSymbol.replace(
|
|
||||||
new RegExp(`-${this.baseCurrency}$`),
|
|
||||||
this.baseCurrency
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
// Add a dash before the last three characters
|
|
||||||
// BTCUSD -> BTC-USD
|
|
||||||
// DOGEUSD -> DOGE-USD
|
|
||||||
// SOL1USD -> SOL1-USD
|
|
||||||
return aSymbol.replace(
|
|
||||||
new RegExp(`-?${this.baseCurrency}$`),
|
|
||||||
`-${this.baseCurrency}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return aSymbol;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getAssetProfile(
|
public async getAssetProfile(
|
||||||
aSymbol: string
|
aSymbol: string
|
||||||
): Promise<Partial<SymbolProfile>> {
|
): Promise<Partial<SymbolProfile>> {
|
||||||
const response: Partial<SymbolProfile> = {};
|
const { assetClass, assetSubClass, currency, name } =
|
||||||
|
await this.yahooFinanceDataEnhancerService.getAssetProfile(aSymbol);
|
||||||
|
|
||||||
try {
|
return {
|
||||||
const symbol = this.convertToYahooFinanceSymbol(aSymbol);
|
assetClass,
|
||||||
const assetProfile = await yahooFinance.quoteSummary(symbol, {
|
assetSubClass,
|
||||||
modules: ['price', 'summaryProfile', 'topHoldings']
|
currency,
|
||||||
});
|
name,
|
||||||
|
dataSource: this.getName(),
|
||||||
const { assetClass, assetSubClass } = this.parseAssetClass({
|
symbol: aSymbol
|
||||||
quoteType: assetProfile.price.quoteType,
|
};
|
||||||
shortName: assetProfile.price.shortName
|
|
||||||
});
|
|
||||||
|
|
||||||
response.assetClass = assetClass;
|
|
||||||
response.assetSubClass = assetSubClass;
|
|
||||||
response.currency = assetProfile.price.currency;
|
|
||||||
response.dataSource = this.getName();
|
|
||||||
response.name = this.formatName({
|
|
||||||
longName: assetProfile.price.longName,
|
|
||||||
quoteType: assetProfile.price.quoteType,
|
|
||||||
shortName: assetProfile.price.shortName,
|
|
||||||
symbol: assetProfile.price.symbol
|
|
||||||
});
|
|
||||||
response.symbol = aSymbol;
|
|
||||||
|
|
||||||
if (assetSubClass === AssetSubClass.MUTUALFUND) {
|
|
||||||
response.sectors = [];
|
|
||||||
|
|
||||||
for (const sectorWeighting of assetProfile.topHoldings
|
|
||||||
?.sectorWeightings ?? []) {
|
|
||||||
for (const [sector, weight] of Object.entries(sectorWeighting)) {
|
|
||||||
response.sectors.push({ weight, name: this.parseSector(sector) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (
|
|
||||||
assetSubClass === AssetSubClass.STOCK &&
|
|
||||||
assetProfile.summaryProfile?.country
|
|
||||||
) {
|
|
||||||
// Add country if asset is stock and country available
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [code] = Object.entries(countries).find(([, country]) => {
|
|
||||||
return country.name === assetProfile.summaryProfile?.country;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (code) {
|
|
||||||
response.countries = [{ code, weight: 1 }];
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
if (assetProfile.summaryProfile?.sector) {
|
|
||||||
response.sectors = [
|
|
||||||
{ name: assetProfile.summaryProfile?.sector, weight: 1 }
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = assetProfile.summaryProfile?.website;
|
|
||||||
if (url) {
|
|
||||||
response.url = url;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
Logger.error(error, 'YahooFinanceService');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getDividends({
|
public async getDividends({
|
||||||
@ -178,7 +64,9 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const historicalResult = await yahooFinance.historical(
|
const historicalResult = await yahooFinance.historical(
|
||||||
this.convertToYahooFinanceSymbol(symbol),
|
this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(
|
||||||
|
symbol
|
||||||
|
),
|
||||||
{
|
{
|
||||||
events: 'dividends',
|
events: 'dividends',
|
||||||
interval: granularity === 'month' ? '1mo' : '1d',
|
interval: granularity === 'month' ? '1mo' : '1d',
|
||||||
@ -228,7 +116,9 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const historicalResult = await yahooFinance.historical(
|
const historicalResult = await yahooFinance.historical(
|
||||||
this.convertToYahooFinanceSymbol(aSymbol),
|
this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(
|
||||||
|
aSymbol
|
||||||
|
),
|
||||||
{
|
{
|
||||||
interval: '1d',
|
interval: '1d',
|
||||||
period1: format(from, DATE_FORMAT),
|
period1: format(from, DATE_FORMAT),
|
||||||
@ -278,7 +168,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
const yahooFinanceSymbols = aSymbols.map((symbol) =>
|
const yahooFinanceSymbols = aSymbols.map((symbol) =>
|
||||||
this.convertToYahooFinanceSymbol(symbol)
|
this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(symbol)
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -288,7 +178,10 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
|
|
||||||
for (const quote of quotes) {
|
for (const quote of quotes) {
|
||||||
// Convert symbols back
|
// Convert symbols back
|
||||||
const symbol = this.convertFromYahooFinanceSymbol(quote.symbol);
|
const symbol =
|
||||||
|
this.yahooFinanceDataEnhancerService.convertFromYahooFinanceSymbol(
|
||||||
|
quote.symbol
|
||||||
|
);
|
||||||
|
|
||||||
response[symbol] = {
|
response[symbol] = {
|
||||||
currency: quote.currency,
|
currency: quote.currency,
|
||||||
@ -405,14 +298,16 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
return currentQuote.symbol === marketDataItem.symbol;
|
return currentQuote.symbol === marketDataItem.symbol;
|
||||||
});
|
});
|
||||||
|
|
||||||
const symbol = this.convertFromYahooFinanceSymbol(
|
const symbol =
|
||||||
marketDataItem.symbol
|
this.yahooFinanceDataEnhancerService.convertFromYahooFinanceSymbol(
|
||||||
);
|
marketDataItem.symbol
|
||||||
|
);
|
||||||
|
|
||||||
const { assetClass, assetSubClass } = this.parseAssetClass({
|
const { assetClass, assetSubClass } =
|
||||||
quoteType: quote.quoteType,
|
this.yahooFinanceDataEnhancerService.parseAssetClass({
|
||||||
shortName: quote.shortname
|
quoteType: quote.quoteType,
|
||||||
});
|
shortName: quote.shortname
|
||||||
|
});
|
||||||
|
|
||||||
items.push({
|
items.push({
|
||||||
assetClass,
|
assetClass,
|
||||||
@ -420,7 +315,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
symbol,
|
symbol,
|
||||||
currency: marketDataItem.currency,
|
currency: marketDataItem.currency,
|
||||||
dataSource: this.getName(),
|
dataSource: this.getName(),
|
||||||
name: this.formatName({
|
name: this.yahooFinanceDataEnhancerService.formatName({
|
||||||
longName: quote.longname,
|
longName: quote.longname,
|
||||||
quoteType: quote.quoteType,
|
quoteType: quote.quoteType,
|
||||||
shortName: quote.shortname,
|
shortName: quote.shortname,
|
||||||
@ -435,42 +330,6 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
return { items };
|
return { items };
|
||||||
}
|
}
|
||||||
|
|
||||||
private formatName({
|
|
||||||
longName,
|
|
||||||
quoteType,
|
|
||||||
shortName,
|
|
||||||
symbol
|
|
||||||
}: {
|
|
||||||
longName: Price['longName'];
|
|
||||||
quoteType: Price['quoteType'];
|
|
||||||
shortName: Price['shortName'];
|
|
||||||
symbol: Price['symbol'];
|
|
||||||
}) {
|
|
||||||
let name = longName;
|
|
||||||
|
|
||||||
if (name) {
|
|
||||||
name = name.replace('Amundi Index Solutions - ', '');
|
|
||||||
name = name.replace('iShares ETF (CH) - ', '');
|
|
||||||
name = name.replace('iShares III Public Limited Company - ', '');
|
|
||||||
name = name.replace('iShares V PLC - ', '');
|
|
||||||
name = name.replace('iShares VI Public Limited Company - ', '');
|
|
||||||
name = name.replace('iShares VII PLC - ', '');
|
|
||||||
name = name.replace('Multi Units Luxembourg - ', '');
|
|
||||||
name = name.replace('VanEck ETFs N.V. - ', '');
|
|
||||||
name = name.replace('Vaneck Vectors Ucits Etfs Plc - ', '');
|
|
||||||
name = name.replace('Vanguard Funds Public Limited Company - ', '');
|
|
||||||
name = name.replace('Vanguard Index Funds - ', '');
|
|
||||||
name = name.replace('Xtrackers (IE) Plc - ', '');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (quoteType === 'FUTURE') {
|
|
||||||
// "Gold Jun 22" -> "Gold"
|
|
||||||
name = shortName?.slice(0, -6);
|
|
||||||
}
|
|
||||||
|
|
||||||
return name || shortName || symbol;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getConvertedValue({
|
private getConvertedValue({
|
||||||
symbol,
|
symbol,
|
||||||
value
|
value
|
||||||
@ -491,95 +350,4 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
|
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseAssetClass({
|
|
||||||
quoteType,
|
|
||||||
shortName
|
|
||||||
}: {
|
|
||||||
quoteType: string;
|
|
||||||
shortName: string;
|
|
||||||
}): {
|
|
||||||
assetClass: AssetClass;
|
|
||||||
assetSubClass: AssetSubClass;
|
|
||||||
} {
|
|
||||||
let assetClass: AssetClass;
|
|
||||||
let assetSubClass: AssetSubClass;
|
|
||||||
|
|
||||||
switch (quoteType?.toLowerCase()) {
|
|
||||||
case 'cryptocurrency':
|
|
||||||
assetClass = AssetClass.CASH;
|
|
||||||
assetSubClass = AssetSubClass.CRYPTOCURRENCY;
|
|
||||||
break;
|
|
||||||
case 'equity':
|
|
||||||
assetClass = AssetClass.EQUITY;
|
|
||||||
assetSubClass = AssetSubClass.STOCK;
|
|
||||||
break;
|
|
||||||
case 'etf':
|
|
||||||
assetClass = AssetClass.EQUITY;
|
|
||||||
assetSubClass = AssetSubClass.ETF;
|
|
||||||
break;
|
|
||||||
case 'future':
|
|
||||||
assetClass = AssetClass.COMMODITY;
|
|
||||||
assetSubClass = AssetSubClass.COMMODITY;
|
|
||||||
|
|
||||||
if (
|
|
||||||
shortName?.toLowerCase()?.startsWith('gold') ||
|
|
||||||
shortName?.toLowerCase()?.startsWith('palladium') ||
|
|
||||||
shortName?.toLowerCase()?.startsWith('platinum') ||
|
|
||||||
shortName?.toLowerCase()?.startsWith('silver')
|
|
||||||
) {
|
|
||||||
assetSubClass = AssetSubClass.PRECIOUS_METAL;
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
case 'mutualfund':
|
|
||||||
assetClass = AssetClass.EQUITY;
|
|
||||||
assetSubClass = AssetSubClass.MUTUALFUND;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { assetClass, assetSubClass };
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseSector(aString: string): string {
|
|
||||||
let sector = UNKNOWN_KEY;
|
|
||||||
|
|
||||||
switch (aString) {
|
|
||||||
case 'basic_materials':
|
|
||||||
sector = 'Basic Materials';
|
|
||||||
break;
|
|
||||||
case 'communication_services':
|
|
||||||
sector = 'Communication Services';
|
|
||||||
break;
|
|
||||||
case 'consumer_cyclical':
|
|
||||||
sector = 'Consumer Cyclical';
|
|
||||||
break;
|
|
||||||
case 'consumer_defensive':
|
|
||||||
sector = 'Consumer Staples';
|
|
||||||
break;
|
|
||||||
case 'energy':
|
|
||||||
sector = 'Energy';
|
|
||||||
break;
|
|
||||||
case 'financial_services':
|
|
||||||
sector = 'Financial Services';
|
|
||||||
break;
|
|
||||||
case 'healthcare':
|
|
||||||
sector = 'Healthcare';
|
|
||||||
break;
|
|
||||||
case 'industrials':
|
|
||||||
sector = 'Industrials';
|
|
||||||
break;
|
|
||||||
case 'realestate':
|
|
||||||
sector = 'Real Estate';
|
|
||||||
break;
|
|
||||||
case 'technology':
|
|
||||||
sector = 'Technology';
|
|
||||||
break;
|
|
||||||
case 'utilities':
|
|
||||||
sector = 'Utilities';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return sector;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -31,7 +31,8 @@
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
*ngIf="!canCreateAccount && info?.systemMessage && user"
|
*ngIf="!canCreateAccount && info?.systemMessage && user"
|
||||||
class="d-inline-block info-message px-3 py-2"
|
class="cursor-pointer d-inline-block info-message px-3 py-2 text-truncate"
|
||||||
|
(click)="onShowSystemMessage()"
|
||||||
>
|
>
|
||||||
{{ info.systemMessage }}
|
{{ info.systemMessage }}
|
||||||
</div>
|
</div>
|
||||||
|
@ -13,9 +13,10 @@
|
|||||||
margin-top: -0.5rem;
|
margin-top: -0.5rem;
|
||||||
|
|
||||||
.info-message {
|
.info-message {
|
||||||
background-color: rgba(0, 0, 0, $alpha-hover);
|
background-color: rgba(var(--palette-foreground-text), 0.05);
|
||||||
border-radius: 2rem;
|
border-radius: 2rem;
|
||||||
font-size: 80%;
|
font-size: 80%;
|
||||||
|
max-width: 100%;
|
||||||
|
|
||||||
.a {
|
.a {
|
||||||
color: rgba(var(--palette-primary-500), 1);
|
color: rgba(var(--palette-primary-500), 1);
|
||||||
@ -30,3 +31,13 @@
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:host-context(.is-dark-theme) {
|
||||||
|
main {
|
||||||
|
.info-message-container {
|
||||||
|
.info-message {
|
||||||
|
background-color: rgba(var(--palette-foreground-text-dark), 0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -100,6 +100,10 @@ export class AppComponent implements OnDestroy, OnInit {
|
|||||||
this.tokenStorageService.signOut();
|
this.tokenStorageService.signOut();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onShowSystemMessage() {
|
||||||
|
alert(this.info.systemMessage);
|
||||||
|
}
|
||||||
|
|
||||||
public onSignOut() {
|
public onSignOut() {
|
||||||
this.tokenStorageService.signOut();
|
this.tokenStorageService.signOut();
|
||||||
this.userService.remove();
|
this.userService.remove();
|
||||||
|
@ -33,7 +33,7 @@
|
|||||||
<span class="ml-2" matTextSuffix>{{ data.currency }}</span>
|
<span class="ml-2" matTextSuffix>{{ data.currency }}</span>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<button
|
<button
|
||||||
class="apply-current-market-price ml-2 no-min-width"
|
class="ml-2 mt-1 no-min-width"
|
||||||
mat-button
|
mat-button
|
||||||
title="Fetch market price"
|
title="Fetch market price"
|
||||||
(click)="onFetchSymbolForDate()"
|
(click)="onFetchSymbolForDate()"
|
||||||
|
@ -3,11 +3,5 @@
|
|||||||
|
|
||||||
.mat-mdc-dialog-content {
|
.mat-mdc-dialog-content {
|
||||||
max-height: unset;
|
max-height: unset;
|
||||||
|
|
||||||
.mat-mdc-button {
|
|
||||||
&.apply-current-market-price {
|
|
||||||
height: 56px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -30,6 +30,9 @@ export class WorldMapChartComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
public ngOnInit() {}
|
public ngOnInit() {}
|
||||||
|
|
||||||
public ngOnChanges() {
|
public ngOnChanges() {
|
||||||
|
// Create a copy before manipulating countries object
|
||||||
|
this.countries = structuredClone(this.countries);
|
||||||
|
|
||||||
if (this.countries) {
|
if (this.countries) {
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
|
|
||||||
|
@ -153,7 +153,7 @@
|
|||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<button
|
<button
|
||||||
*ngIf="currentMarketPrice && (data.activity.type === 'BUY' || data.activity.type === 'SELL')"
|
*ngIf="currentMarketPrice && (data.activity.type === 'BUY' || data.activity.type === 'SELL')"
|
||||||
class="apply-current-market-price ml-2 no-min-width"
|
class="ml-2 mt-1 no-min-width"
|
||||||
mat-button
|
mat-button
|
||||||
title="Apply current market price"
|
title="Apply current market price"
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -15,12 +15,6 @@
|
|||||||
color: var(--dark-primary-text);
|
color: var(--dark-primary-text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.mat-mdc-button {
|
|
||||||
&.apply-current-market-price {
|
|
||||||
height: 56px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -65,7 +65,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
|||||||
| 'exchange'
|
| 'exchange'
|
||||||
| 'name'
|
| 'name'
|
||||||
| 'value'
|
| 'value'
|
||||||
>;
|
> & { etfProvider: string };
|
||||||
};
|
};
|
||||||
public sectors: {
|
public sectors: {
|
||||||
[name: string]: { name: string; value: number };
|
[name: string]: { name: string; value: number };
|
||||||
@ -249,7 +249,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
|||||||
public initializeAnalysisData() {
|
public initializeAnalysisData() {
|
||||||
this.initialize();
|
this.initialize();
|
||||||
|
|
||||||
for (const [id, { current, name, original }] of Object.entries(
|
for (const [id, { current, name }] of Object.entries(
|
||||||
this.portfolioDetails.accounts
|
this.portfolioDetails.accounts
|
||||||
)) {
|
)) {
|
||||||
this.accounts[id] = {
|
this.accounts[id] = {
|
||||||
@ -275,6 +275,10 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
|||||||
assetClass: position.assetClass,
|
assetClass: position.assetClass,
|
||||||
assetSubClass: position.assetSubClass,
|
assetSubClass: position.assetSubClass,
|
||||||
currency: position.currency,
|
currency: position.currency,
|
||||||
|
etfProvider: this.extractEtfProvider({
|
||||||
|
assetSubClass: position.assetSubClass,
|
||||||
|
name: position.name
|
||||||
|
}),
|
||||||
exchange: position.exchange,
|
exchange: position.exchange,
|
||||||
name: position.name
|
name: position.name
|
||||||
};
|
};
|
||||||
@ -452,4 +456,19 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extractEtfProvider({
|
||||||
|
assetSubClass,
|
||||||
|
name
|
||||||
|
}: {
|
||||||
|
assetSubClass: PortfolioPosition['assetSubClass'];
|
||||||
|
name: string;
|
||||||
|
}) {
|
||||||
|
if (assetSubClass === 'ETF') {
|
||||||
|
const [firstWord] = name.split(' ');
|
||||||
|
return firstWord;
|
||||||
|
}
|
||||||
|
|
||||||
|
return UNKNOWN_KEY;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -249,4 +249,29 @@
|
|||||||
</mat-card>
|
</mat-card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<mat-card appearance="outlined" class="mb-3">
|
||||||
|
<mat-card-header class="overflow-hidden w-100">
|
||||||
|
<mat-card-title class="align-items-center d-flex text-truncate"
|
||||||
|
><span i18n>By ETF Provider</span
|
||||||
|
><gf-premium-indicator
|
||||||
|
*ngIf="user?.subscription?.type === 'Basic'"
|
||||||
|
class="ml-1"
|
||||||
|
></gf-premium-indicator
|
||||||
|
></mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<gf-portfolio-proportion-chart
|
||||||
|
[baseCurrency]="user?.settings?.baseCurrency"
|
||||||
|
[colorScheme]="user?.settings?.colorScheme"
|
||||||
|
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
|
||||||
|
[keys]="['etfProvider']"
|
||||||
|
[locale]="user?.settings?.locale"
|
||||||
|
[positions]="positions"
|
||||||
|
></gf-portfolio-proportion-chart>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -237,7 +237,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mat-mdc-dialog-container {
|
.mat-mdc-dialog-container {
|
||||||
background: var(--dark-background);
|
--mdc-dialog-container-color: var(--dark-background);
|
||||||
|
|
||||||
.mdc-dialog__content {
|
.mdc-dialog__content {
|
||||||
--mdc-dialog-supporting-text-color: rgba(var(--light-primary-text));
|
--mdc-dialog-supporting-text-color: rgba(var(--light-primary-text));
|
||||||
@ -464,7 +464,7 @@ ngx-skeleton-loader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.with-info-message {
|
.with-info-message {
|
||||||
height: calc(100vh - 5rem - 3.5rem) !important;
|
height: calc(100vh - 5rem - 3.5rem + 0.5rem) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.with-placeholder-as-option {
|
.with-placeholder-as-option {
|
||||||
|
@ -49,9 +49,7 @@ export const GATHER_ASSET_PROFILE_PROCESS_OPTIONS: JobOptions = {
|
|||||||
type: 'exponential'
|
type: 'exponential'
|
||||||
},
|
},
|
||||||
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH,
|
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH,
|
||||||
removeOnComplete: {
|
removeOnComplete: true
|
||||||
age: ms('2 weeks') / 1000
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
export const GATHER_HISTORICAL_MARKET_DATA_PROCESS =
|
export const GATHER_HISTORICAL_MARKET_DATA_PROCESS =
|
||||||
'GATHER_HISTORICAL_MARKET_DATA';
|
'GATHER_HISTORICAL_MARKET_DATA';
|
||||||
@ -62,9 +60,7 @@ export const GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS: JobOptions = {
|
|||||||
type: 'exponential'
|
type: 'exponential'
|
||||||
},
|
},
|
||||||
priority: DATA_GATHERING_QUEUE_PRIORITY_LOW,
|
priority: DATA_GATHERING_QUEUE_PRIORITY_LOW,
|
||||||
removeOnComplete: {
|
removeOnComplete: true
|
||||||
age: ms('2 weeks') / 1000
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const HEADER_KEY_IMPERSONATION = 'Impersonation-Id';
|
export const HEADER_KEY_IMPERSONATION = 'Impersonation-Id';
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ghostfolio",
|
"name": "ghostfolio",
|
||||||
"version": "1.252.2",
|
"version": "1.257.0",
|
||||||
"homepage": "https://ghostfol.io",
|
"homepage": "https://ghostfol.io",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -80,7 +80,7 @@
|
|||||||
"@nestjs/schedule": "2.1.0",
|
"@nestjs/schedule": "2.1.0",
|
||||||
"@nestjs/serve-static": "3.0.0",
|
"@nestjs/serve-static": "3.0.0",
|
||||||
"@nrwl/angular": "15.9.2",
|
"@nrwl/angular": "15.9.2",
|
||||||
"@prisma/client": "4.11.0",
|
"@prisma/client": "4.12.0",
|
||||||
"@simplewebauthn/browser": "5.2.1",
|
"@simplewebauthn/browser": "5.2.1",
|
||||||
"@simplewebauthn/server": "5.2.1",
|
"@simplewebauthn/server": "5.2.1",
|
||||||
"@stripe/stripe-js": "1.47.0",
|
"@stripe/stripe-js": "1.47.0",
|
||||||
@ -120,7 +120,7 @@
|
|||||||
"passport": "0.6.0",
|
"passport": "0.6.0",
|
||||||
"passport-google-oauth20": "2.0.0",
|
"passport-google-oauth20": "2.0.0",
|
||||||
"passport-jwt": "4.0.0",
|
"passport-jwt": "4.0.0",
|
||||||
"prisma": "4.11.0",
|
"prisma": "4.12.0",
|
||||||
"reflect-metadata": "0.1.13",
|
"reflect-metadata": "0.1.13",
|
||||||
"rxjs": "7.5.6",
|
"rxjs": "7.5.6",
|
||||||
"stripe": "11.12.0",
|
"stripe": "11.12.0",
|
||||||
|
36
yarn.lock
36
yarn.lock
@ -4887,22 +4887,22 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
esquery "^1.0.1"
|
esquery "^1.0.1"
|
||||||
|
|
||||||
"@prisma/client@4.11.0":
|
"@prisma/client@4.12.0":
|
||||||
version "4.11.0"
|
version "4.12.0"
|
||||||
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-4.11.0.tgz#41d5664dea4172c954190a432f70b86d3e2e629b"
|
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-4.12.0.tgz#119b692888b1fe0fd3305c7d0e0ac48520aa6839"
|
||||||
integrity sha512-0INHYkQIqgAjrt7NzhYpeDQi8x3Nvylc2uDngKyFDDj1tTRQ4uV1HnVmd1sQEraeVAN63SOK0dgCKQHlvjL0KA==
|
integrity sha512-j9/ighfWwux97J2dS15nqhl60tYoH8V0IuSsgZDb6bCFcQD3fXbXmxjYC8GHhIgOk3lB7Pq+8CwElz2MiDpsSg==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@prisma/engines-version" "4.11.0-57.8fde8fef4033376662cad983758335009d522acb"
|
"@prisma/engines-version" "4.12.0-67.659ef412370fa3b41cd7bf6e94587c1dfb7f67e7"
|
||||||
|
|
||||||
"@prisma/engines-version@4.11.0-57.8fde8fef4033376662cad983758335009d522acb":
|
"@prisma/engines-version@4.12.0-67.659ef412370fa3b41cd7bf6e94587c1dfb7f67e7":
|
||||||
version "4.11.0-57.8fde8fef4033376662cad983758335009d522acb"
|
version "4.12.0-67.659ef412370fa3b41cd7bf6e94587c1dfb7f67e7"
|
||||||
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-4.11.0-57.8fde8fef4033376662cad983758335009d522acb.tgz#74af5ff56170c78e93ce46c56510160f58cd3c01"
|
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-4.12.0-67.659ef412370fa3b41cd7bf6e94587c1dfb7f67e7.tgz#51a1cc5c886564b542acde64a873645d0dee2566"
|
||||||
integrity sha512-3Vd8Qq06d5xD8Ch5WauWcUUrsVPdMC6Ge8ILji8RFfyhUpqon6qSyGM0apvr1O8n8qH8cKkEFqRPsYjuz5r83g==
|
integrity sha512-JIHNj5jlXb9mcaJwakM0vpgRYJIAurxTUqM0iX0tfEQA5XLZ9ONkIckkhuAKdAzocZ+80GYg7QSsfpjg7OxbOA==
|
||||||
|
|
||||||
"@prisma/engines@4.11.0":
|
"@prisma/engines@4.12.0":
|
||||||
version "4.11.0"
|
version "4.12.0"
|
||||||
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-4.11.0.tgz#c99749bfe20f58e8f4d2b5e04fee0785eba440e1"
|
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-4.12.0.tgz#68d99078b70b2d9c339d0e8cbf2e99f00b72aa8c"
|
||||||
integrity sha512-0AEBi2HXGV02cf6ASsBPhfsVIbVSDC9nbQed4iiY5eHttW9ZtMxHThuKZE1pnESbr8HRdgmFSa/Kn4OSNYuibg==
|
integrity sha512-0alKtnxhNB5hYU+ymESBlGI4b9XrGGSdv7Ud+8TE/fBNOEhIud0XQsAR+TrvUZgS4na5czubiMsODw0TUrgkIA==
|
||||||
|
|
||||||
"@samverschueren/stream-to-observable@^0.3.0":
|
"@samverschueren/stream-to-observable@^0.3.0":
|
||||||
version "0.3.1"
|
version "0.3.1"
|
||||||
@ -18083,12 +18083,12 @@ pretty-hrtime@^1.0.3:
|
|||||||
resolved "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz"
|
resolved "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz"
|
||||||
integrity sha512-66hKPCr+72mlfiSjlEB1+45IjXSqvVAIy6mocupoww4tBFE9R9IhwwUGoI4G++Tc9Aq+2rxOt0RFU6gPcrte0A==
|
integrity sha512-66hKPCr+72mlfiSjlEB1+45IjXSqvVAIy6mocupoww4tBFE9R9IhwwUGoI4G++Tc9Aq+2rxOt0RFU6gPcrte0A==
|
||||||
|
|
||||||
prisma@4.11.0:
|
prisma@4.12.0:
|
||||||
version "4.11.0"
|
version "4.12.0"
|
||||||
resolved "https://registry.yarnpkg.com/prisma/-/prisma-4.11.0.tgz#9695ba4129a43eab3e76b5f7a033c6c020377725"
|
resolved "https://registry.yarnpkg.com/prisma/-/prisma-4.12.0.tgz#1080eda951928cb3b0274ad29da9ae4f93143d68"
|
||||||
integrity sha512-4zZmBXssPUEiX+GeL0MUq/Yyie4ltiKmGu7jCJFnYMamNrrulTBc+D+QwAQSJ01tyzeGHlD13kOnqPwRipnlNw==
|
integrity sha512-xqVper4mbwl32BWzLpdznHAYvYDWQQWK2tBfXjdUD397XaveRyAP7SkBZ6kFlIg8kKayF4hvuaVtYwXd9BodAg==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@prisma/engines" "4.11.0"
|
"@prisma/engines" "4.12.0"
|
||||||
|
|
||||||
prismjs@^1.28.0:
|
prismjs@^1.28.0:
|
||||||
version "1.28.0"
|
version "1.28.0"
|
||||||
|
Reference in New Issue
Block a user