mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
82 changed files with 7733 additions and 2643 deletions
@ -0,0 +1,15 @@ |
|||
import { Granularity } from '@ghostfolio/common/types'; |
|||
|
|||
import { IsIn, IsISO8601, IsOptional } from 'class-validator'; |
|||
|
|||
export class GetDividendsDto { |
|||
@IsISO8601() |
|||
from: string; |
|||
|
|||
@IsIn(['day', 'month'] as Granularity[]) |
|||
@IsOptional() |
|||
granularity: Granularity; |
|||
|
|||
@IsISO8601() |
|||
to: string; |
|||
} |
@ -0,0 +1,15 @@ |
|||
import { Granularity } from '@ghostfolio/common/types'; |
|||
|
|||
import { IsIn, IsISO8601, IsOptional } from 'class-validator'; |
|||
|
|||
export class GetHistoricalDto { |
|||
@IsISO8601() |
|||
from: string; |
|||
|
|||
@IsIn(['day', 'month'] as Granularity[]) |
|||
@IsOptional() |
|||
granularity: Granularity; |
|||
|
|||
@IsISO8601() |
|||
to: string; |
|||
} |
@ -0,0 +1,10 @@ |
|||
import { Transform } from 'class-transformer'; |
|||
import { IsString } from 'class-validator'; |
|||
|
|||
export class GetQuotesDto { |
|||
@IsString({ each: true }) |
|||
@Transform(({ value }) => |
|||
typeof value === 'string' ? value.split(',') : value |
|||
) |
|||
symbols: string[]; |
|||
} |
@ -0,0 +1,196 @@ |
|||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; |
|||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
|||
import { parseDate } from '@ghostfolio/common/helper'; |
|||
import { |
|||
DataProviderGhostfolioStatusResponse, |
|||
DividendsResponse, |
|||
HistoricalResponse, |
|||
LookupResponse, |
|||
QuotesResponse |
|||
} from '@ghostfolio/common/interfaces'; |
|||
import { permissions } from '@ghostfolio/common/permissions'; |
|||
import { RequestWithUser } from '@ghostfolio/common/types'; |
|||
|
|||
import { |
|||
Controller, |
|||
Get, |
|||
HttpException, |
|||
Inject, |
|||
Param, |
|||
Query, |
|||
UseGuards |
|||
} from '@nestjs/common'; |
|||
import { REQUEST } from '@nestjs/core'; |
|||
import { AuthGuard } from '@nestjs/passport'; |
|||
import { getReasonPhrase, StatusCodes } from 'http-status-codes'; |
|||
|
|||
import { GetDividendsDto } from './get-dividends.dto'; |
|||
import { GetHistoricalDto } from './get-historical.dto'; |
|||
import { GetQuotesDto } from './get-quotes.dto'; |
|||
import { GhostfolioService } from './ghostfolio.service'; |
|||
|
|||
@Controller('data-providers/ghostfolio') |
|||
export class GhostfolioController { |
|||
public constructor( |
|||
private readonly ghostfolioService: GhostfolioService, |
|||
@Inject(REQUEST) private readonly request: RequestWithUser |
|||
) {} |
|||
|
|||
@Get('dividends/:symbol') |
|||
@HasPermission(permissions.enableDataProviderGhostfolio) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async getDividends( |
|||
@Param('symbol') symbol: string, |
|||
@Query() query: GetDividendsDto |
|||
): Promise<DividendsResponse> { |
|||
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); |
|||
|
|||
if ( |
|||
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests |
|||
) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), |
|||
StatusCodes.TOO_MANY_REQUESTS |
|||
); |
|||
} |
|||
|
|||
try { |
|||
const dividends = await this.ghostfolioService.getDividends({ |
|||
symbol, |
|||
from: parseDate(query.from), |
|||
granularity: query.granularity, |
|||
to: parseDate(query.to) |
|||
}); |
|||
|
|||
await this.ghostfolioService.incrementDailyRequests({ |
|||
userId: this.request.user.id |
|||
}); |
|||
|
|||
return dividends; |
|||
} catch { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), |
|||
StatusCodes.INTERNAL_SERVER_ERROR |
|||
); |
|||
} |
|||
} |
|||
|
|||
@Get('historical/:symbol') |
|||
@HasPermission(permissions.enableDataProviderGhostfolio) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async getHistorical( |
|||
@Param('symbol') symbol: string, |
|||
@Query() query: GetHistoricalDto |
|||
): Promise<HistoricalResponse> { |
|||
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); |
|||
|
|||
if ( |
|||
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests |
|||
) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), |
|||
StatusCodes.TOO_MANY_REQUESTS |
|||
); |
|||
} |
|||
|
|||
try { |
|||
const historicalData = await this.ghostfolioService.getHistorical({ |
|||
symbol, |
|||
from: parseDate(query.from), |
|||
granularity: query.granularity, |
|||
to: parseDate(query.to) |
|||
}); |
|||
|
|||
await this.ghostfolioService.incrementDailyRequests({ |
|||
userId: this.request.user.id |
|||
}); |
|||
|
|||
return historicalData; |
|||
} catch { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), |
|||
StatusCodes.INTERNAL_SERVER_ERROR |
|||
); |
|||
} |
|||
} |
|||
|
|||
@Get('lookup') |
|||
@HasPermission(permissions.enableDataProviderGhostfolio) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async lookupSymbol( |
|||
@Query('includeIndices') includeIndicesParam = 'false', |
|||
@Query('query') query = '' |
|||
): Promise<LookupResponse> { |
|||
const includeIndices = includeIndicesParam === 'true'; |
|||
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); |
|||
|
|||
if ( |
|||
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests |
|||
) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), |
|||
StatusCodes.TOO_MANY_REQUESTS |
|||
); |
|||
} |
|||
|
|||
try { |
|||
const result = await this.ghostfolioService.lookup({ |
|||
includeIndices, |
|||
query: query.toLowerCase() |
|||
}); |
|||
|
|||
await this.ghostfolioService.incrementDailyRequests({ |
|||
userId: this.request.user.id |
|||
}); |
|||
|
|||
return result; |
|||
} catch { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), |
|||
StatusCodes.INTERNAL_SERVER_ERROR |
|||
); |
|||
} |
|||
} |
|||
|
|||
@Get('quotes') |
|||
@HasPermission(permissions.enableDataProviderGhostfolio) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async getQuotes( |
|||
@Query() query: GetQuotesDto |
|||
): Promise<QuotesResponse> { |
|||
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); |
|||
|
|||
if ( |
|||
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests |
|||
) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), |
|||
StatusCodes.TOO_MANY_REQUESTS |
|||
); |
|||
} |
|||
|
|||
try { |
|||
const quotes = await this.ghostfolioService.getQuotes({ |
|||
symbols: query.symbols |
|||
}); |
|||
|
|||
await this.ghostfolioService.incrementDailyRequests({ |
|||
userId: this.request.user.id |
|||
}); |
|||
|
|||
return quotes; |
|||
} catch { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), |
|||
StatusCodes.INTERNAL_SERVER_ERROR |
|||
); |
|||
} |
|||
} |
|||
|
|||
@Get('status') |
|||
@HasPermission(permissions.enableDataProviderGhostfolio) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async getStatus(): Promise<DataProviderGhostfolioStatusResponse> { |
|||
return this.ghostfolioService.getStatus({ user: this.request.user }); |
|||
} |
|||
} |
@ -0,0 +1,83 @@ |
|||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module'; |
|||
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service'; |
|||
import { CoinGeckoService } from '@ghostfolio/api/services/data-provider/coingecko/coingecko.service'; |
|||
import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service'; |
|||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; |
|||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; |
|||
import { EodHistoricalDataService } from '@ghostfolio/api/services/data-provider/eod-historical-data/eod-historical-data.service'; |
|||
import { FinancialModelingPrepService } from '@ghostfolio/api/services/data-provider/financial-modeling-prep/financial-modeling-prep.service'; |
|||
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service'; |
|||
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service'; |
|||
import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service'; |
|||
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service'; |
|||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; |
|||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
|||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; |
|||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; |
|||
|
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { GhostfolioController } from './ghostfolio.controller'; |
|||
import { GhostfolioService } from './ghostfolio.service'; |
|||
|
|||
@Module({ |
|||
controllers: [GhostfolioController], |
|||
imports: [ |
|||
CryptocurrencyModule, |
|||
DataProviderModule, |
|||
MarketDataModule, |
|||
PrismaModule, |
|||
PropertyModule, |
|||
RedisCacheModule, |
|||
SymbolProfileModule |
|||
], |
|||
providers: [ |
|||
AlphaVantageService, |
|||
CoinGeckoService, |
|||
ConfigurationService, |
|||
DataProviderService, |
|||
EodHistoricalDataService, |
|||
FinancialModelingPrepService, |
|||
GhostfolioService, |
|||
GoogleSheetsService, |
|||
ManualService, |
|||
RapidApiService, |
|||
YahooFinanceService, |
|||
YahooFinanceDataEnhancerService, |
|||
{ |
|||
inject: [ |
|||
AlphaVantageService, |
|||
CoinGeckoService, |
|||
EodHistoricalDataService, |
|||
FinancialModelingPrepService, |
|||
GoogleSheetsService, |
|||
ManualService, |
|||
RapidApiService, |
|||
YahooFinanceService |
|||
], |
|||
provide: 'DataProviderInterfaces', |
|||
useFactory: ( |
|||
alphaVantageService, |
|||
coinGeckoService, |
|||
eodHistoricalDataService, |
|||
financialModelingPrepService, |
|||
googleSheetsService, |
|||
manualService, |
|||
rapidApiService, |
|||
yahooFinanceService |
|||
) => [ |
|||
alphaVantageService, |
|||
coinGeckoService, |
|||
eodHistoricalDataService, |
|||
financialModelingPrepService, |
|||
googleSheetsService, |
|||
manualService, |
|||
rapidApiService, |
|||
yahooFinanceService |
|||
] |
|||
} |
|||
] |
|||
}) |
|||
export class GhostfolioModule {} |
@ -0,0 +1,304 @@ |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; |
|||
import { |
|||
GetDividendsParams, |
|||
GetHistoricalParams, |
|||
GetQuotesParams, |
|||
GetSearchParams |
|||
} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; |
|||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; |
|||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
|||
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; |
|||
import { |
|||
DEFAULT_CURRENCY, |
|||
DERIVED_CURRENCIES |
|||
} from '@ghostfolio/common/config'; |
|||
import { PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS } from '@ghostfolio/common/config'; |
|||
import { |
|||
DataProviderInfo, |
|||
DividendsResponse, |
|||
HistoricalResponse, |
|||
LookupItem, |
|||
LookupResponse, |
|||
QuotesResponse |
|||
} from '@ghostfolio/common/interfaces'; |
|||
import { UserWithSettings } from '@ghostfolio/common/types'; |
|||
|
|||
import { Injectable, Logger } from '@nestjs/common'; |
|||
import { DataSource } from '@prisma/client'; |
|||
import { Big } from 'big.js'; |
|||
|
|||
@Injectable() |
|||
export class GhostfolioService { |
|||
public constructor( |
|||
private readonly configurationService: ConfigurationService, |
|||
private readonly dataProviderService: DataProviderService, |
|||
private readonly prismaService: PrismaService, |
|||
private readonly propertyService: PropertyService |
|||
) {} |
|||
|
|||
public async getDividends({ |
|||
from, |
|||
granularity, |
|||
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'), |
|||
symbol, |
|||
to |
|||
}: GetDividendsParams) { |
|||
const result: DividendsResponse = { dividends: {} }; |
|||
|
|||
try { |
|||
const promises: Promise<{ |
|||
[date: string]: IDataProviderHistoricalResponse; |
|||
}>[] = []; |
|||
|
|||
for (const dataProviderService of this.getDataProviderServices()) { |
|||
promises.push( |
|||
dataProviderService |
|||
.getDividends({ |
|||
from, |
|||
granularity, |
|||
requestTimeout, |
|||
symbol, |
|||
to |
|||
}) |
|||
.then((dividends) => { |
|||
result.dividends = dividends; |
|||
|
|||
return dividends; |
|||
}) |
|||
); |
|||
} |
|||
|
|||
await Promise.all(promises); |
|||
|
|||
return result; |
|||
} catch (error) { |
|||
Logger.error(error, 'GhostfolioService'); |
|||
|
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
public async getHistorical({ |
|||
from, |
|||
granularity, |
|||
requestTimeout, |
|||
to, |
|||
symbol |
|||
}: GetHistoricalParams) { |
|||
const result: HistoricalResponse = { historicalData: {} }; |
|||
|
|||
try { |
|||
const promises: Promise<{ |
|||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; |
|||
}>[] = []; |
|||
|
|||
for (const dataProviderService of this.getDataProviderServices()) { |
|||
promises.push( |
|||
dataProviderService |
|||
.getHistorical({ |
|||
from, |
|||
granularity, |
|||
requestTimeout, |
|||
symbol, |
|||
to |
|||
}) |
|||
.then((historicalData) => { |
|||
result.historicalData = historicalData[symbol]; |
|||
|
|||
return historicalData; |
|||
}) |
|||
); |
|||
} |
|||
|
|||
await Promise.all(promises); |
|||
|
|||
return result; |
|||
} catch (error) { |
|||
Logger.error(error, 'GhostfolioService'); |
|||
|
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
public async getMaxDailyRequests() { |
|||
return parseInt( |
|||
((await this.propertyService.getByKey( |
|||
PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS |
|||
)) as string) || '0', |
|||
10 |
|||
); |
|||
} |
|||
|
|||
public async getQuotes({ requestTimeout, symbols }: GetQuotesParams) { |
|||
const results: QuotesResponse = { quotes: {} }; |
|||
|
|||
try { |
|||
const promises: Promise<any>[] = []; |
|||
|
|||
for (const dataProvider of this.getDataProviderServices()) { |
|||
const maximumNumberOfSymbolsPerRequest = |
|||
dataProvider.getMaxNumberOfSymbolsPerRequest?.() ?? |
|||
Number.MAX_SAFE_INTEGER; |
|||
|
|||
for ( |
|||
let i = 0; |
|||
i < symbols.length; |
|||
i += maximumNumberOfSymbolsPerRequest |
|||
) { |
|||
const symbolsChunk = symbols.slice( |
|||
i, |
|||
i + maximumNumberOfSymbolsPerRequest |
|||
); |
|||
|
|||
const promise = Promise.resolve( |
|||
dataProvider.getQuotes({ requestTimeout, symbols: symbolsChunk }) |
|||
); |
|||
|
|||
promises.push( |
|||
promise.then(async (result) => { |
|||
for (const [symbol, dataProviderResponse] of Object.entries( |
|||
result |
|||
)) { |
|||
dataProviderResponse.dataSource = 'GHOSTFOLIO'; |
|||
|
|||
if ( |
|||
[ |
|||
...DERIVED_CURRENCIES.map(({ currency }) => { |
|||
return `${DEFAULT_CURRENCY}${currency}`; |
|||
}), |
|||
`${DEFAULT_CURRENCY}USX` |
|||
].includes(symbol) |
|||
) { |
|||
continue; |
|||
} |
|||
|
|||
results.quotes[symbol] = dataProviderResponse; |
|||
|
|||
for (const { |
|||
currency, |
|||
factor, |
|||
rootCurrency |
|||
} of DERIVED_CURRENCIES) { |
|||
if (symbol === `${DEFAULT_CURRENCY}${rootCurrency}`) { |
|||
results.quotes[`${DEFAULT_CURRENCY}${currency}`] = { |
|||
...dataProviderResponse, |
|||
currency, |
|||
marketPrice: new Big( |
|||
result[`${DEFAULT_CURRENCY}${rootCurrency}`].marketPrice |
|||
) |
|||
.mul(factor) |
|||
.toNumber(), |
|||
marketState: 'open' |
|||
}; |
|||
} |
|||
} |
|||
} |
|||
}) |
|||
); |
|||
} |
|||
|
|||
await Promise.all(promises); |
|||
} |
|||
|
|||
return results; |
|||
} catch (error) { |
|||
Logger.error(error, 'GhostfolioService'); |
|||
|
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
public async getStatus({ user }: { user: UserWithSettings }) { |
|||
return { |
|||
dailyRequests: user.dataProviderGhostfolioDailyRequests, |
|||
dailyRequestsMax: await this.getMaxDailyRequests(), |
|||
subscription: user.subscription |
|||
}; |
|||
} |
|||
|
|||
public async incrementDailyRequests({ userId }: { userId: string }) { |
|||
await this.prismaService.analytics.update({ |
|||
data: { |
|||
dataProviderGhostfolioDailyRequests: { increment: 1 }, |
|||
lastRequestAt: new Date() |
|||
}, |
|||
where: { userId } |
|||
}); |
|||
} |
|||
|
|||
public async lookup({ |
|||
includeIndices = false, |
|||
query |
|||
}: GetSearchParams): Promise<LookupResponse> { |
|||
const results: LookupResponse = { items: [] }; |
|||
|
|||
if (!query) { |
|||
return results; |
|||
} |
|||
|
|||
try { |
|||
let lookupItems: LookupItem[] = []; |
|||
const promises: Promise<{ items: LookupItem[] }>[] = []; |
|||
|
|||
if (query?.length < 2) { |
|||
return { items: lookupItems }; |
|||
} |
|||
|
|||
for (const dataProviderService of this.getDataProviderServices()) { |
|||
promises.push( |
|||
dataProviderService.search({ |
|||
includeIndices, |
|||
query |
|||
}) |
|||
); |
|||
} |
|||
|
|||
const searchResults = await Promise.all(promises); |
|||
|
|||
for (const { items } of searchResults) { |
|||
if (items?.length > 0) { |
|||
lookupItems = lookupItems.concat(items); |
|||
} |
|||
} |
|||
|
|||
const filteredItems = lookupItems |
|||
.filter(({ currency }) => { |
|||
// Only allow symbols with supported currency
|
|||
return currency ? true : false; |
|||
}) |
|||
.sort(({ name: name1 }, { name: name2 }) => { |
|||
return name1?.toLowerCase().localeCompare(name2?.toLowerCase()); |
|||
}) |
|||
.map((lookupItem) => { |
|||
lookupItem.dataProviderInfo = this.getDataProviderInfo(); |
|||
lookupItem.dataSource = 'GHOSTFOLIO'; |
|||
|
|||
return lookupItem; |
|||
}); |
|||
|
|||
results.items = filteredItems; |
|||
return results; |
|||
} catch (error) { |
|||
Logger.error(error, 'GhostfolioService'); |
|||
|
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
private getDataProviderInfo(): DataProviderInfo { |
|||
return { |
|||
isPremium: false, |
|||
name: 'Ghostfolio Premium', |
|||
url: 'https://ghostfol.io' |
|||
}; |
|||
} |
|||
|
|||
private getDataProviderServices() { |
|||
return this.configurationService |
|||
.get('DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER') |
|||
.map((dataSource) => { |
|||
return this.dataProviderService.getDataProvider(DataSource[dataSource]); |
|||
}); |
|||
} |
|||
} |
File diff suppressed because it is too large
@ -0,0 +1,284 @@ |
|||
import { environment } from '@ghostfolio/api/environments/environment'; |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
import { |
|||
DataProviderInterface, |
|||
GetDividendsParams, |
|||
GetHistoricalParams, |
|||
GetQuotesParams, |
|||
GetSearchParams |
|||
} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; |
|||
import { |
|||
IDataProviderHistoricalResponse, |
|||
IDataProviderResponse |
|||
} from '@ghostfolio/api/services/interfaces/interfaces'; |
|||
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; |
|||
import { |
|||
HEADER_KEY_TOKEN, |
|||
PROPERTY_API_KEY_GHOSTFOLIO |
|||
} from '@ghostfolio/common/config'; |
|||
import { DATE_FORMAT } from '@ghostfolio/common/helper'; |
|||
import { |
|||
DataProviderInfo, |
|||
DividendsResponse, |
|||
HistoricalResponse, |
|||
LookupResponse, |
|||
QuotesResponse |
|||
} from '@ghostfolio/common/interfaces'; |
|||
|
|||
import { Injectable, Logger } from '@nestjs/common'; |
|||
import { DataSource, SymbolProfile } from '@prisma/client'; |
|||
import { format } from 'date-fns'; |
|||
import got from 'got'; |
|||
import { StatusCodes } from 'http-status-codes'; |
|||
|
|||
@Injectable() |
|||
export class GhostfolioService implements DataProviderInterface { |
|||
private readonly URL = environment.production |
|||
? 'https://ghostfol.io/api' |
|||
: `${this.configurationService.get('ROOT_URL')}/api`; |
|||
|
|||
public constructor( |
|||
private readonly configurationService: ConfigurationService, |
|||
private readonly propertyService: PropertyService |
|||
) {} |
|||
|
|||
public canHandle() { |
|||
return true; |
|||
} |
|||
|
|||
public async getAssetProfile({ |
|||
symbol |
|||
}: { |
|||
symbol: string; |
|||
}): Promise<Partial<SymbolProfile>> { |
|||
const { items } = await this.search({ query: symbol }); |
|||
const searchResult = items?.[0]; |
|||
|
|||
return { |
|||
symbol, |
|||
assetClass: searchResult?.assetClass, |
|||
assetSubClass: searchResult?.assetSubClass, |
|||
currency: searchResult?.currency, |
|||
dataSource: this.getName(), |
|||
name: searchResult?.name |
|||
}; |
|||
} |
|||
|
|||
public getDataProviderInfo(): DataProviderInfo { |
|||
return { |
|||
isPremium: true, |
|||
name: 'Ghostfolio', |
|||
url: 'https://ghostfo.io' |
|||
}; |
|||
} |
|||
|
|||
public async getDividends({ |
|||
from, |
|||
granularity = 'day', |
|||
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'), |
|||
symbol, |
|||
to |
|||
}: GetDividendsParams): Promise<{ |
|||
[date: string]: IDataProviderHistoricalResponse; |
|||
}> { |
|||
let response: { |
|||
[date: string]: IDataProviderHistoricalResponse; |
|||
} = {}; |
|||
|
|||
try { |
|||
const abortController = new AbortController(); |
|||
|
|||
setTimeout(() => { |
|||
abortController.abort(); |
|||
}, requestTimeout); |
|||
|
|||
const { dividends } = await got( |
|||
`${this.URL}/v1/data-providers/ghostfolio/dividends/${symbol}?from=${format(from, DATE_FORMAT)}&granularity=${granularity}&to=${format( |
|||
to, |
|||
DATE_FORMAT |
|||
)}`,
|
|||
{ |
|||
headers: await this.getRequestHeaders(), |
|||
// @ts-ignore
|
|||
signal: abortController.signal |
|||
} |
|||
).json<DividendsResponse>(); |
|||
|
|||
response = dividends; |
|||
} catch (error) { |
|||
let message = error; |
|||
|
|||
if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) { |
|||
message = 'RequestError: The daily request limit has been exceeded'; |
|||
} else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) { |
|||
message = |
|||
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.'; |
|||
} |
|||
|
|||
Logger.error(message, 'GhostfolioService'); |
|||
} |
|||
|
|||
return response; |
|||
} |
|||
|
|||
public async getHistorical({ |
|||
from, |
|||
granularity = 'day', |
|||
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'), |
|||
symbol, |
|||
to |
|||
}: GetHistoricalParams): Promise<{ |
|||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; |
|||
}> { |
|||
try { |
|||
const abortController = new AbortController(); |
|||
|
|||
setTimeout(() => { |
|||
abortController.abort(); |
|||
}, requestTimeout); |
|||
|
|||
const { historicalData } = await got( |
|||
`${this.URL}/v1/data-providers/ghostfolio/historical/${symbol}?from=${format(from, DATE_FORMAT)}&granularity=${granularity}&to=${format( |
|||
to, |
|||
DATE_FORMAT |
|||
)}`,
|
|||
{ |
|||
headers: await this.getRequestHeaders(), |
|||
// @ts-ignore
|
|||
signal: abortController.signal |
|||
} |
|||
).json<HistoricalResponse>(); |
|||
|
|||
return { |
|||
[symbol]: historicalData |
|||
}; |
|||
} catch (error) { |
|||
let message = error; |
|||
|
|||
if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) { |
|||
message = 'RequestError: The daily request limit has been exceeded'; |
|||
} else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) { |
|||
message = |
|||
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.'; |
|||
} |
|||
|
|||
Logger.error(message, 'GhostfolioService'); |
|||
|
|||
throw new Error( |
|||
`Could not get historical market data for ${symbol} (${this.getName()}) from ${format( |
|||
from, |
|||
DATE_FORMAT |
|||
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}` |
|||
); |
|||
} |
|||
} |
|||
|
|||
public getMaxNumberOfSymbolsPerRequest() { |
|||
return 20; |
|||
} |
|||
|
|||
public getName(): DataSource { |
|||
return DataSource.GHOSTFOLIO; |
|||
} |
|||
|
|||
public async getQuotes({ |
|||
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'), |
|||
symbols |
|||
}: GetQuotesParams): Promise<{ |
|||
[symbol: string]: IDataProviderResponse; |
|||
}> { |
|||
let response: { [symbol: string]: IDataProviderResponse } = {}; |
|||
|
|||
if (symbols.length <= 0) { |
|||
return response; |
|||
} |
|||
|
|||
try { |
|||
const abortController = new AbortController(); |
|||
|
|||
setTimeout(() => { |
|||
abortController.abort(); |
|||
}, requestTimeout); |
|||
|
|||
const { quotes } = await got( |
|||
`${this.URL}/v1/data-providers/ghostfolio/quotes?symbols=${symbols.join(',')}`, |
|||
{ |
|||
headers: await this.getRequestHeaders(), |
|||
// @ts-ignore
|
|||
signal: abortController.signal |
|||
} |
|||
).json<QuotesResponse>(); |
|||
|
|||
response = quotes; |
|||
} catch (error) { |
|||
let message = error; |
|||
|
|||
if (error?.code === 'ABORT_ERR') { |
|||
message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${( |
|||
this.configurationService.get('REQUEST_TIMEOUT') / 1000 |
|||
).toFixed(3)} seconds`;
|
|||
} else if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) { |
|||
message = 'RequestError: The daily request limit has been exceeded'; |
|||
} else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) { |
|||
message = |
|||
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.'; |
|||
} |
|||
|
|||
Logger.error(message, 'GhostfolioService'); |
|||
} |
|||
|
|||
return response; |
|||
} |
|||
|
|||
public getTestSymbol() { |
|||
return 'AAPL.US'; |
|||
} |
|||
|
|||
public async search({ query }: GetSearchParams): Promise<LookupResponse> { |
|||
let searchResult: LookupResponse = { items: [] }; |
|||
|
|||
try { |
|||
const abortController = new AbortController(); |
|||
|
|||
setTimeout(() => { |
|||
abortController.abort(); |
|||
}, this.configurationService.get('REQUEST_TIMEOUT')); |
|||
|
|||
searchResult = await got( |
|||
`${this.URL}/v1/data-providers/ghostfolio/lookup?query=${query}`, |
|||
{ |
|||
headers: await this.getRequestHeaders(), |
|||
// @ts-ignore
|
|||
signal: abortController.signal |
|||
} |
|||
).json<LookupResponse>(); |
|||
} catch (error) { |
|||
let message = error; |
|||
|
|||
if (error?.code === 'ABORT_ERR') { |
|||
message = `RequestError: The operation to search for ${query} was aborted because the request to the data provider took more than ${( |
|||
this.configurationService.get('REQUEST_TIMEOUT') / 1000 |
|||
).toFixed(3)} seconds`;
|
|||
} else if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) { |
|||
message = 'RequestError: The daily request limit has been exceeded'; |
|||
} else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) { |
|||
message = |
|||
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.'; |
|||
} |
|||
|
|||
Logger.error(message, 'GhostfolioService'); |
|||
} |
|||
|
|||
return searchResult; |
|||
} |
|||
|
|||
private async getRequestHeaders() { |
|||
const apiKey = (await this.propertyService.getByKey( |
|||
PROPERTY_API_KEY_GHOSTFOLIO |
|||
)) as string; |
|||
|
|||
return { |
|||
[HEADER_KEY_TOKEN]: `Bearer ${apiKey}` |
|||
}; |
|||
} |
|||
} |
@ -1,2 +1,5 @@ |
|||
:host { |
|||
label { |
|||
margin-bottom: 0; |
|||
} |
|||
} |
|||
|
@ -0,0 +1,131 @@ |
|||
import { DATE_FORMAT } from '@ghostfolio/common/helper'; |
|||
import { |
|||
DataProviderGhostfolioStatusResponse, |
|||
DividendsResponse, |
|||
HistoricalResponse, |
|||
LookupResponse, |
|||
QuotesResponse |
|||
} from '@ghostfolio/common/interfaces'; |
|||
|
|||
import { CommonModule } from '@angular/common'; |
|||
import { HttpClient, HttpParams } from '@angular/common/http'; |
|||
import { Component, OnInit } from '@angular/core'; |
|||
import { format, startOfYear } from 'date-fns'; |
|||
import { map, Observable, Subject, takeUntil } from 'rxjs'; |
|||
|
|||
@Component({ |
|||
host: { class: 'page' }, |
|||
imports: [CommonModule], |
|||
selector: 'gf-api-page', |
|||
standalone: true, |
|||
styleUrls: ['./api-page.scss'], |
|||
templateUrl: './api-page.html' |
|||
}) |
|||
export class GfApiPageComponent implements OnInit { |
|||
public dividends$: Observable<DividendsResponse['dividends']>; |
|||
public historicalData$: Observable<HistoricalResponse['historicalData']>; |
|||
public quotes$: Observable<QuotesResponse['quotes']>; |
|||
public status$: Observable<DataProviderGhostfolioStatusResponse>; |
|||
public symbols$: Observable<LookupResponse['items']>; |
|||
|
|||
private unsubscribeSubject = new Subject<void>(); |
|||
|
|||
public constructor(private http: HttpClient) {} |
|||
|
|||
public ngOnInit() { |
|||
this.dividends$ = this.fetchDividends({ symbol: 'KO' }); |
|||
this.historicalData$ = this.fetchHistoricalData({ symbol: 'AAPL.US' }); |
|||
this.quotes$ = this.fetchQuotes({ symbols: ['AAPL.US', 'VOO.US'] }); |
|||
this.status$ = this.fetchStatus(); |
|||
this.symbols$ = this.fetchSymbols({ query: 'apple' }); |
|||
} |
|||
|
|||
public ngOnDestroy() { |
|||
this.unsubscribeSubject.next(); |
|||
this.unsubscribeSubject.complete(); |
|||
} |
|||
|
|||
private fetchDividends({ symbol }: { symbol: string }) { |
|||
const params = new HttpParams() |
|||
.set('from', format(startOfYear(new Date()), DATE_FORMAT)) |
|||
.set('to', format(new Date(), DATE_FORMAT)); |
|||
|
|||
return this.http |
|||
.get<DividendsResponse>( |
|||
`/api/v1/data-providers/ghostfolio/dividends/${symbol}`, |
|||
{ params } |
|||
) |
|||
.pipe( |
|||
map(({ dividends }) => { |
|||
return dividends; |
|||
}), |
|||
takeUntil(this.unsubscribeSubject) |
|||
); |
|||
} |
|||
|
|||
private fetchHistoricalData({ symbol }: { symbol: string }) { |
|||
const params = new HttpParams() |
|||
.set('from', format(startOfYear(new Date()), DATE_FORMAT)) |
|||
.set('to', format(new Date(), DATE_FORMAT)); |
|||
|
|||
return this.http |
|||
.get<HistoricalResponse>( |
|||
`/api/v1/data-providers/ghostfolio/historical/${symbol}`, |
|||
{ params } |
|||
) |
|||
.pipe( |
|||
map(({ historicalData }) => { |
|||
return historicalData; |
|||
}), |
|||
takeUntil(this.unsubscribeSubject) |
|||
); |
|||
} |
|||
|
|||
private fetchQuotes({ symbols }: { symbols: string[] }) { |
|||
const params = new HttpParams().set('symbols', symbols.join(',')); |
|||
|
|||
return this.http |
|||
.get<QuotesResponse>('/api/v1/data-providers/ghostfolio/quotes', { |
|||
params |
|||
}) |
|||
.pipe( |
|||
map(({ quotes }) => { |
|||
return quotes; |
|||
}), |
|||
takeUntil(this.unsubscribeSubject) |
|||
); |
|||
} |
|||
|
|||
private fetchStatus() { |
|||
return this.http |
|||
.get<DataProviderGhostfolioStatusResponse>( |
|||
'/api/v1/data-providers/ghostfolio/status' |
|||
) |
|||
.pipe(takeUntil(this.unsubscribeSubject)); |
|||
} |
|||
|
|||
private fetchSymbols({ |
|||
includeIndices = false, |
|||
query |
|||
}: { |
|||
includeIndices?: boolean; |
|||
query: string; |
|||
}) { |
|||
let params = new HttpParams().set('query', query); |
|||
|
|||
if (includeIndices) { |
|||
params = params.append('includeIndices', includeIndices); |
|||
} |
|||
|
|||
return this.http |
|||
.get<LookupResponse>('/api/v1/data-providers/ghostfolio/lookup', { |
|||
params |
|||
}) |
|||
.pipe( |
|||
map(({ items }) => { |
|||
return items; |
|||
}), |
|||
takeUntil(this.unsubscribeSubject) |
|||
); |
|||
} |
|||
} |
@ -0,0 +1,62 @@ |
|||
<div class="container"> |
|||
<div class="mb-3"> |
|||
<h2 class="text-center">Status</h2> |
|||
<div>{{ status$ | async | json }}</div> |
|||
</div> |
|||
<div class="mb-3"> |
|||
<h2 class="text-center">Lookup</h2> |
|||
@if (symbols$) { |
|||
@let symbols = symbols$ | async; |
|||
<ul> |
|||
@for (item of symbols; track item.symbol) { |
|||
<li>{{ item.name }} ({{ item.symbol }})</li> |
|||
} |
|||
</ul> |
|||
} |
|||
</div> |
|||
<div> |
|||
<h2 class="text-center">Quotes</h2> |
|||
@if (quotes$) { |
|||
@let quotes = quotes$ | async; |
|||
<ul> |
|||
@for (quote of quotes | keyvalue; track quote) { |
|||
<li> |
|||
{{ quote.key }}: {{ quote.value.marketPrice }} |
|||
{{ quote.value.currency }} |
|||
</li> |
|||
} |
|||
</ul> |
|||
} |
|||
</div> |
|||
<div> |
|||
<h2 class="text-center">Historical</h2> |
|||
@if (historicalData$) { |
|||
@let historicalData = historicalData$ | async; |
|||
<ul> |
|||
@for ( |
|||
historicalDataItem of historicalData | keyvalue; |
|||
track historicalDataItem |
|||
) { |
|||
<li> |
|||
{{ historicalDataItem.key }}: |
|||
{{ historicalDataItem.value.marketPrice }} |
|||
</li> |
|||
} |
|||
</ul> |
|||
} |
|||
</div> |
|||
<div> |
|||
<h2 class="text-center">Dividends</h2> |
|||
@if (dividends$) { |
|||
@let dividends = dividends$ | async; |
|||
<ul> |
|||
@for (dividend of dividends | keyvalue; track dividend) { |
|||
<li> |
|||
{{ dividend.key }}: |
|||
{{ dividend.value.marketPrice }} |
|||
</li> |
|||
} |
|||
</ul> |
|||
} |
|||
</div> |
|||
</div> |
@ -0,0 +1,3 @@ |
|||
:host { |
|||
display: block; |
|||
} |
@ -0,0 +1,17 @@ |
|||
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; |
|||
|
|||
import { Component } from '@angular/core'; |
|||
import { MatButtonModule } from '@angular/material/button'; |
|||
import { RouterModule } from '@angular/router'; |
|||
|
|||
@Component({ |
|||
host: { class: 'page' }, |
|||
imports: [GfPremiumIndicatorComponent, MatButtonModule, RouterModule], |
|||
selector: 'gf-black-weeks-2024-page', |
|||
standalone: true, |
|||
templateUrl: './black-weeks-2024-page.html' |
|||
}) |
|||
export class BlackWeeks2024PageComponent { |
|||
public routerLinkFeatures = ['/' + $localize`:snake-case:features`]; |
|||
public routerLinkPricing = ['/' + $localize`:snake-case:pricing`]; |
|||
} |
@ -0,0 +1,180 @@ |
|||
<div class="blog container"> |
|||
<div class="row"> |
|||
<div class="col-md-8 offset-md-2"> |
|||
<article> |
|||
<div class="mb-4 text-center"> |
|||
<h1 class="mb-1">Black Weeks 2024</h1> |
|||
<div class="mb-3 text-muted"><small>2024-11-16</small></div> |
|||
<img |
|||
alt="Black Week 2024 Teaser" |
|||
class="rounded w-100" |
|||
src="../assets/images/blog/black-weeks-2024.jpg" |
|||
title="Black Weeks 2024" |
|||
/> |
|||
</div> |
|||
<section class="mb-4"> |
|||
<p> |
|||
Take advantage of our exclusive <strong>Black Weeks</strong> offer |
|||
and save <strong>25%</strong> on your annual |
|||
<span class="align-items-center d-inline-flex" |
|||
>Ghostfolio Premium |
|||
<gf-premium-indicator |
|||
class="d-inline-block ml-1" |
|||
[enableLink]="false" |
|||
/> |
|||
</span> |
|||
subscription, plus get <strong>3 months extra</strong> for free! |
|||
</p> |
|||
</section> |
|||
<section class="mb-4"> |
|||
<p> |
|||
<a |
|||
href="https://ghostfol.io" |
|||
title="Open Source Wealth Management Software" |
|||
>Ghostfolio</a |
|||
> |
|||
is a powerful personal finance dashboard, designed to simplify your |
|||
investment journey. With this Open Source Software (OSS) platform, |
|||
you can: |
|||
</p> |
|||
<ul class="list-unstyled"> |
|||
<li> |
|||
<strong>Unify your assets</strong>: Track your financial |
|||
portfolio, including stocks, ETFs, cryptocurrencies, etc. |
|||
</li> |
|||
<li> |
|||
<strong>Gain deeper insights</strong>: Access real-time analytics |
|||
and data-driven insights. |
|||
</li> |
|||
<li> |
|||
<strong>Make informed decisions</strong>: Empower yourself with |
|||
actionable information. |
|||
</li> |
|||
</ul> |
|||
</section> |
|||
<section class="mb-4"> |
|||
<p> |
|||
Don’t miss this limited-time offer to optimize your financial |
|||
future. |
|||
</p> |
|||
<p class="text-center"> |
|||
<a color="primary" mat-flat-button [routerLink]="routerLinkPricing" |
|||
>Get the Deal</a |
|||
> |
|||
</p> |
|||
<p class="mt-5"> |
|||
For more information, visit our |
|||
<a [routerLink]="routerLinkPricing">pricing page</a>. |
|||
</p> |
|||
</section> |
|||
<section class="mb-4"> |
|||
<ul class="list-inline"> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">2024</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">Black Friday</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">Black Weeks</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">Cryptocurrency</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">Dashboard</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">Deal</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">DeFi</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">ETF</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">Finance</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">Fintech</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">Ghostfolio</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">Ghostfolio Premium</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">Hosting</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">Investment</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">Open Source</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">OSS</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">Personal Finance</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">Portfolio</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">Portfolio Tracker</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">Pricing</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">Promotion</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">SaaS</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">Sale</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">Software</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">Stock</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">Subscription</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">Wealth</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">Wealth Management</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">Web3</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">Web 3.0</span> |
|||
</li> |
|||
</ul> |
|||
</section> |
|||
<nav aria-label="breadcrumb"> |
|||
<ol class="breadcrumb"> |
|||
<li class="breadcrumb-item"> |
|||
<a i18n [routerLink]="['/blog']">Blog</a> |
|||
</li> |
|||
<li |
|||
aria-current="page" |
|||
class="active breadcrumb-item text-truncate" |
|||
> |
|||
Black Weeks 2024 |
|||
</li> |
|||
</ol> |
|||
</nav> |
|||
</article> |
|||
</div> |
|||
</div> |
|||
</div> |
After Width: | Height: | Size: 304 KiB |
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
@ -0,0 +1,5 @@ |
|||
import { Holding } from './holding.interface'; |
|||
|
|||
export interface HoldingWithParents extends Holding { |
|||
parents?: Holding[]; |
|||
} |
@ -0,0 +1,7 @@ |
|||
import { UserWithSettings } from '@ghostfolio/common/types'; |
|||
|
|||
export interface DataProviderGhostfolioStatusResponse { |
|||
dailyRequests: number; |
|||
dailyRequestsMax: number; |
|||
subscription: UserWithSettings['subscription']; |
|||
} |
@ -0,0 +1,7 @@ |
|||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; |
|||
|
|||
export interface DividendsResponse { |
|||
dividends: { |
|||
[date: string]: IDataProviderHistoricalResponse; |
|||
}; |
|||
} |
@ -0,0 +1,7 @@ |
|||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; |
|||
|
|||
export interface HistoricalResponse { |
|||
historicalData: { |
|||
[date: string]: IDataProviderHistoricalResponse; |
|||
}; |
|||
} |
@ -0,0 +1,5 @@ |
|||
import { IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces'; |
|||
|
|||
export interface QuotesResponse { |
|||
quotes: { [symbol: string]: IDataProviderResponse }; |
|||
} |
File diff suppressed because it is too large
@ -0,0 +1,2 @@ |
|||
-- AlterEnum |
|||
ALTER TYPE "DataSource" ADD VALUE 'GHOSTFOLIO'; |
@ -0,0 +1,5 @@ |
|||
-- AlterTable |
|||
ALTER TABLE "_OrderToTag" ADD CONSTRAINT "_OrderToTag_AB_pkey" PRIMARY KEY ("A", "B"); |
|||
|
|||
-- DropIndex |
|||
DROP INDEX "_OrderToTag_AB_unique"; |
@ -0,0 +1,19 @@ |
|||
-- CreateTable |
|||
CREATE TABLE "ApiKey" ( |
|||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, |
|||
"hashedKey" TEXT NOT NULL, |
|||
"id" TEXT NOT NULL, |
|||
"updatedAt" TIMESTAMP(3) NOT NULL, |
|||
"userId" TEXT NOT NULL, |
|||
|
|||
CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id") |
|||
); |
|||
|
|||
-- CreateIndex |
|||
CREATE INDEX "ApiKey_hashedKey_idx" ON "ApiKey"("hashedKey"); |
|||
|
|||
-- CreateIndex |
|||
CREATE INDEX "ApiKey_userId_idx" ON "ApiKey"("userId"); |
|||
|
|||
-- AddForeignKey |
|||
ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; |
Loading…
Reference in new issue