mirror of https://github.com/ghostfolio/ghostfolio
134 changed files with 6972 additions and 7700 deletions
@ -0,0 +1,274 @@ |
|||
import { AccountService } from '@ghostfolio/api/app/account/account.service'; |
|||
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface'; |
|||
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; |
|||
import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; |
|||
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; |
|||
import { UserService } from '@ghostfolio/api/app/user/user.service'; |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; |
|||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
|||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; |
|||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; |
|||
import { parseDate } from '@ghostfolio/common/helper'; |
|||
|
|||
import { Account, DataSource } from '@prisma/client'; |
|||
import { Big } from 'big.js'; |
|||
import { randomUUID } from 'node:crypto'; |
|||
|
|||
import { PortfolioService } from './portfolio.service'; |
|||
|
|||
describe('PortfolioService', () => { |
|||
let accountService: AccountService; |
|||
let activitiesService: ActivitiesService; |
|||
let configurationService: ConfigurationService; |
|||
let dataProviderService: DataProviderService; |
|||
let exchangeRateDataService: ExchangeRateDataService; |
|||
let impersonationService: ImpersonationService; |
|||
let portfolioCalculatorFactory: PortfolioCalculatorFactory; |
|||
let portfolioService: PortfolioService; |
|||
let symbolProfileService: SymbolProfileService; |
|||
let userService: UserService; |
|||
|
|||
beforeEach(() => { |
|||
configurationService = new ConfigurationService(); |
|||
|
|||
dataProviderService = new DataProviderService( |
|||
configurationService, |
|||
null, |
|||
null, |
|||
null, |
|||
null, |
|||
null |
|||
); |
|||
|
|||
exchangeRateDataService = new ExchangeRateDataService( |
|||
null, |
|||
null, |
|||
null, |
|||
null |
|||
); |
|||
|
|||
accountService = new AccountService( |
|||
null, |
|||
null, |
|||
exchangeRateDataService, |
|||
null |
|||
); |
|||
|
|||
activitiesService = new ActivitiesService( |
|||
null, |
|||
accountService, |
|||
null, |
|||
null, |
|||
dataProviderService, |
|||
null, |
|||
exchangeRateDataService, |
|||
null, |
|||
null, |
|||
null |
|||
); |
|||
|
|||
impersonationService = new ImpersonationService(null, null); |
|||
|
|||
portfolioCalculatorFactory = new PortfolioCalculatorFactory( |
|||
configurationService, |
|||
null, |
|||
exchangeRateDataService, |
|||
null, |
|||
null |
|||
); |
|||
|
|||
symbolProfileService = new SymbolProfileService(null); |
|||
|
|||
userService = new UserService( |
|||
null, |
|||
null, |
|||
null, |
|||
null, |
|||
null, |
|||
null, |
|||
null, |
|||
null |
|||
); |
|||
|
|||
portfolioService = new PortfolioService( |
|||
null, |
|||
accountService, |
|||
activitiesService, |
|||
null, |
|||
portfolioCalculatorFactory, |
|||
dataProviderService, |
|||
exchangeRateDataService, |
|||
null, |
|||
impersonationService, |
|||
null, |
|||
null, |
|||
symbolProfileService, |
|||
userService |
|||
); |
|||
}); |
|||
|
|||
describe('getCashSymbolProfiles', () => { |
|||
it('should use the exchange-rate data source so the symbol-profile join in getDetails matches the calculator positions', () => { |
|||
jest |
|||
.spyOn(dataProviderService, 'getDataSourceForExchangeRates') |
|||
.mockReturnValue(DataSource.YAHOO); |
|||
|
|||
const cashDetails: CashDetails = { |
|||
accounts: [ |
|||
{ |
|||
balance: 2000, |
|||
comment: null, |
|||
createdAt: parseDate('2024-01-01'), |
|||
currency: 'USD', |
|||
id: randomUUID(), |
|||
isExcluded: false, |
|||
name: 'USD', |
|||
platformId: null, |
|||
updatedAt: parseDate('2024-01-01'), |
|||
userId: userDummyData.id |
|||
} |
|||
], |
|||
balanceInBaseCurrency: 1820 |
|||
}; |
|||
|
|||
const assetProfiles = ( |
|||
portfolioService as unknown as { |
|||
getCashSymbolProfiles: ( |
|||
aCashDetails: CashDetails |
|||
) => { dataSource: DataSource; symbol: string }[]; |
|||
} |
|||
).getCashSymbolProfiles(cashDetails); |
|||
|
|||
expect(assetProfiles).toHaveLength(1); |
|||
expect(assetProfiles[0].dataSource).toBe(DataSource.YAHOO); |
|||
expect(assetProfiles[0].symbol).toBe('USD'); |
|||
}); |
|||
}); |
|||
|
|||
describe('getDetails', () => { |
|||
it('should return cash holdings when the calculator emits cash positions with the exchange-rate data source', async () => { |
|||
const accountId = randomUUID(); |
|||
|
|||
const cashAccount: Account = { |
|||
balance: 2000, |
|||
comment: null, |
|||
createdAt: parseDate('2024-01-01'), |
|||
currency: 'USD', |
|||
id: accountId, |
|||
isExcluded: false, |
|||
name: 'USD', |
|||
platformId: null, |
|||
updatedAt: parseDate('2024-01-01'), |
|||
userId: userDummyData.id |
|||
}; |
|||
|
|||
jest.spyOn(accountService, 'getCashDetails').mockResolvedValue({ |
|||
accounts: [cashAccount], |
|||
balanceInBaseCurrency: 1820 |
|||
}); |
|||
|
|||
jest |
|||
.spyOn(activitiesService, 'getActivitiesForPortfolioCalculator') |
|||
.mockResolvedValue({ activities: [], count: 0 }); |
|||
|
|||
jest |
|||
.spyOn(dataProviderService, 'getDataSourceForExchangeRates') |
|||
.mockReturnValue(DataSource.YAHOO); |
|||
|
|||
jest |
|||
.spyOn(impersonationService, 'validateImpersonationId') |
|||
.mockResolvedValue(null); |
|||
|
|||
jest |
|||
.spyOn(symbolProfileService, 'getSymbolProfiles') |
|||
.mockResolvedValue([]); |
|||
|
|||
jest.spyOn(userService, 'user').mockResolvedValue({ |
|||
accessesGet: [], |
|||
accounts: [], |
|||
activityCount: 0, |
|||
dataProviderGhostfolioDailyRequests: 0, |
|||
id: userDummyData.id, |
|||
settings: { |
|||
settings: { |
|||
baseCurrency: 'CHF' |
|||
} |
|||
} |
|||
} as unknown as Awaited<ReturnType<typeof userService.user>>); |
|||
|
|||
const usdPosition = { |
|||
activitiesCount: 1, |
|||
averagePrice: new Big(1), |
|||
currency: 'USD', |
|||
dataSource: DataSource.YAHOO, |
|||
dateOfFirstActivity: '2024-01-01', |
|||
dividend: new Big(0), |
|||
dividendInBaseCurrency: new Big(0), |
|||
fee: new Big(0), |
|||
feeInBaseCurrency: new Big(0), |
|||
grossPerformance: new Big(0), |
|||
grossPerformancePercentage: new Big(0), |
|||
grossPerformancePercentageWithCurrencyEffect: new Big(0), |
|||
grossPerformanceWithCurrencyEffect: new Big(0), |
|||
investment: new Big(1820), |
|||
investmentWithCurrencyEffect: new Big(1820), |
|||
marketPrice: 1, |
|||
marketPriceInBaseCurrency: 0.91, |
|||
netPerformance: new Big(0), |
|||
netPerformancePercentage: new Big(0), |
|||
netPerformancePercentageWithCurrencyEffectMap: {}, |
|||
netPerformanceWithCurrencyEffectMap: {}, |
|||
quantity: new Big(2000), |
|||
symbol: 'USD', |
|||
tags: [], |
|||
timeWeightedInvestment: new Big(0), |
|||
timeWeightedInvestmentWithCurrencyEffect: new Big(0), |
|||
valueInBaseCurrency: new Big(1820) |
|||
}; |
|||
|
|||
jest |
|||
.spyOn(portfolioCalculatorFactory, 'createCalculator') |
|||
.mockReturnValue({ |
|||
getSnapshot: jest.fn().mockResolvedValue({ |
|||
activitiesCount: 1, |
|||
createdAt: parseDate('2024-01-01'), |
|||
currentValueInBaseCurrency: new Big(1820), |
|||
errors: [], |
|||
hasErrors: false, |
|||
historicalData: [], |
|||
positions: [usdPosition], |
|||
totalFeesWithCurrencyEffect: new Big(0), |
|||
totalInterestWithCurrencyEffect: new Big(0), |
|||
totalInvestment: new Big(1820), |
|||
totalInvestmentWithCurrencyEffect: new Big(1820), |
|||
totalLiabilitiesWithCurrencyEffect: new Big(0) |
|||
}) |
|||
} as unknown as ReturnType< |
|||
typeof portfolioCalculatorFactory.createCalculator |
|||
>); |
|||
|
|||
jest |
|||
.spyOn( |
|||
portfolioService as unknown as { |
|||
getValueOfAccountsAndPlatforms: () => Promise<{ |
|||
accounts: object; |
|||
platforms: object; |
|||
}>; |
|||
}, |
|||
'getValueOfAccountsAndPlatforms' |
|||
) |
|||
.mockResolvedValue({ accounts: {}, platforms: {} }); |
|||
|
|||
const { holdings } = await portfolioService.getDetails({ |
|||
filters: [], |
|||
impersonationId: userDummyData.id, |
|||
userId: userDummyData.id |
|||
}); |
|||
|
|||
expect(holdings['USD']).toBeDefined(); |
|||
expect(holdings['USD'].assetProfile.dataSource).toBe(DataSource.YAHOO); |
|||
expect(holdings['USD'].assetProfile.symbol).toBe('USD'); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,17 @@ |
|||
import { countries } from 'countries-list'; |
|||
|
|||
export function getCountryCodeByName({ |
|||
aliases = {}, |
|||
name |
|||
}: { |
|||
aliases?: Record<string, string>; |
|||
name: string; |
|||
}): string { |
|||
for (const [code, country] of Object.entries(countries)) { |
|||
if (country.name === name || country.name === aliases[name]) { |
|||
return code; |
|||
} |
|||
} |
|||
|
|||
return undefined; |
|||
} |
|||
@ -0,0 +1,28 @@ |
|||
import { SECTORS } from '@ghostfolio/common/config'; |
|||
import { SectorName } from '@ghostfolio/common/types'; |
|||
|
|||
import { Logger } from '@nestjs/common'; |
|||
|
|||
export function getSectorName({ |
|||
aliases = {}, |
|||
name |
|||
}: { |
|||
aliases?: Record<string, SectorName>; |
|||
name: string; |
|||
}): SectorName { |
|||
if (aliases[name]) { |
|||
return aliases[name]; |
|||
} |
|||
|
|||
if ((SECTORS as readonly string[]).includes(name)) { |
|||
return name as SectorName; |
|||
} |
|||
|
|||
if (name) { |
|||
const logger = new Logger('getSectorName'); |
|||
|
|||
logger.warn(`Could not map the sector "${name}" to the ontology`); |
|||
} |
|||
|
|||
return 'Other'; |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service'; |
|||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; |
|||
|
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
@Module({ |
|||
exports: [FetchService], |
|||
imports: [PropertyModule], |
|||
providers: [FetchService] |
|||
}) |
|||
export class FetchModule {} |
|||
@ -0,0 +1,199 @@ |
|||
import { redactPaths } from '@ghostfolio/api/helper/object.helper'; |
|||
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; |
|||
import { |
|||
PROPERTY_API_KEY_OPENROUTER, |
|||
PROPERTY_OPENROUTER_MODEL, |
|||
PROPERTY_WEB_FETCH_ROUTES |
|||
} from '@ghostfolio/common/config'; |
|||
|
|||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; |
|||
import { createOpenRouter } from '@openrouter/ai-sdk-provider'; |
|||
import { generateText, jsonSchema, tool } from 'ai'; |
|||
import ms from 'ms'; |
|||
|
|||
import { WebFetchRoute } from './interfaces/web-fetch-route.interface'; |
|||
|
|||
@Injectable() |
|||
export class FetchService implements OnModuleInit { |
|||
private readonly logger = new Logger(FetchService.name); |
|||
|
|||
private static readonly REDACTED_QUERY_PARAM_NAMES = ['apikey', 'api_token']; |
|||
private static readonly WEB_FETCH_TIMEOUT = ms('30 seconds'); |
|||
|
|||
private webFetchRoutes: WebFetchRoute[] = []; |
|||
|
|||
public constructor(private readonly propertyService: PropertyService) {} |
|||
|
|||
public async onModuleInit() { |
|||
this.webFetchRoutes = |
|||
(await this.propertyService.getByKey<WebFetchRoute[]>( |
|||
PROPERTY_WEB_FETCH_ROUTES |
|||
)) ?? []; |
|||
} |
|||
|
|||
public async fetch(input: RequestInfo | URL, init?: RequestInit) { |
|||
const method = ( |
|||
init?.method ?? |
|||
(input instanceof Request ? input.method : undefined) ?? |
|||
'GET' |
|||
).toUpperCase(); |
|||
|
|||
const url = input instanceof Request ? input.url : input.toString(); |
|||
const urlRedacted = this.redactUrl(url); |
|||
|
|||
this.logger.debug(`${method} ${urlRedacted}`); |
|||
|
|||
if (method === 'GET') { |
|||
const webFetchRoute = this.getMatchingWebFetchRoute(url); |
|||
|
|||
if (webFetchRoute) { |
|||
const response = await this.fetchViaWebFetchTool({ |
|||
url, |
|||
webFetchRoute |
|||
}); |
|||
|
|||
if (response) { |
|||
return response; |
|||
} |
|||
} |
|||
} |
|||
|
|||
try { |
|||
return await globalThis.fetch(input, init); |
|||
} catch (error) { |
|||
if (error instanceof Error) { |
|||
this.logger.error( |
|||
`${method} ${urlRedacted} failed: [${error.name}] ${error.message}` |
|||
); |
|||
} else { |
|||
this.logger.error(`${method} ${urlRedacted} failed: ${String(error)}`); |
|||
} |
|||
|
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
private async fetchViaWebFetchTool({ |
|||
url, |
|||
webFetchRoute |
|||
}: { |
|||
url: string; |
|||
webFetchRoute: WebFetchRoute; |
|||
}) { |
|||
const [openRouterApiKey, openRouterModel] = await Promise.all([ |
|||
this.propertyService.getByKey<string>(PROPERTY_API_KEY_OPENROUTER), |
|||
this.propertyService.getByKey<string>(PROPERTY_OPENROUTER_MODEL) |
|||
]); |
|||
|
|||
if (!openRouterApiKey || !openRouterModel) { |
|||
return undefined; |
|||
} |
|||
|
|||
try { |
|||
const openRouterService = createOpenRouter({ apiKey: openRouterApiKey }); |
|||
|
|||
const { sources, text } = await generateText({ |
|||
model: openRouterService.chat(openRouterModel), |
|||
prompt: [ |
|||
'You have access to a web_fetch tool. You MUST call it to retrieve the URL below, do not answer from prior knowledge.', |
|||
'Return the fetched response body exactly as received: raw body only, no commentary, no Markdown, and no code fences.', |
|||
`URL: ${url}` |
|||
].join('\n'), |
|||
timeout: FetchService.WEB_FETCH_TIMEOUT, |
|||
tools: { |
|||
// Provider-defined tool: lets OpenRouter perform the actual web
|
|||
// request server-side via its `web_fetch` engine. `id` and `args`
|
|||
// are the OpenRouter-specific identifiers; the input schema is left
|
|||
// open as the arguments are supplied by the model.
|
|||
web_fetch: tool({ |
|||
args: { engine: 'openrouter' }, |
|||
id: 'openrouter.web_fetch', |
|||
inputSchema: jsonSchema({ |
|||
additionalProperties: true, |
|||
type: 'object' |
|||
}), |
|||
type: 'provider' |
|||
}) |
|||
} |
|||
}); |
|||
|
|||
const candidates = [ |
|||
...(sources ?? []).map((source) => { |
|||
return source.providerMetadata?.openrouter?.content; |
|||
}), |
|||
text |
|||
]; |
|||
|
|||
for (const candidate of candidates) { |
|||
if (typeof candidate !== 'string') { |
|||
continue; |
|||
} |
|||
|
|||
const body = candidate.trim(); |
|||
|
|||
if (!body) { |
|||
continue; |
|||
} |
|||
|
|||
if (webFetchRoute.responseContentType?.includes('application/json')) { |
|||
try { |
|||
JSON.parse(body); |
|||
} catch { |
|||
continue; |
|||
} |
|||
} |
|||
|
|||
this.logger.debug(`Routed ${this.redactUrl(url)} via web fetch tool`); |
|||
|
|||
return new Response(body, { |
|||
headers: webFetchRoute.responseContentType |
|||
? { 'content-type': webFetchRoute.responseContentType } |
|||
: undefined |
|||
}); |
|||
} |
|||
|
|||
return undefined; |
|||
} catch (error) { |
|||
this.logger.error( |
|||
`Web fetch tool failed for ${this.redactUrl(url)}: ${ |
|||
error instanceof Error ? error.message : String(error) |
|||
}` |
|||
); |
|||
|
|||
return undefined; |
|||
} |
|||
} |
|||
|
|||
private getMatchingWebFetchRoute(url: string) { |
|||
try { |
|||
const { hostname } = new URL(url); |
|||
|
|||
return this.webFetchRoutes.find(({ domain }) => { |
|||
return hostname === domain || hostname.endsWith(`.${domain}`); |
|||
}); |
|||
} catch { |
|||
return undefined; |
|||
} |
|||
} |
|||
|
|||
private redactUrl(rawUrl: string): string { |
|||
try { |
|||
const url = new URL(rawUrl); |
|||
|
|||
const redacted = redactPaths({ |
|||
object: Object.fromEntries(url.searchParams), |
|||
paths: FetchService.REDACTED_QUERY_PARAM_NAMES |
|||
}); |
|||
|
|||
for (const [key, value] of Object.entries(redacted)) { |
|||
if (value === null) { |
|||
url.searchParams.set(key, '*******'); |
|||
} |
|||
} |
|||
|
|||
return url.toString(); |
|||
} catch { |
|||
return rawUrl; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,19 @@ |
|||
/** |
|||
* Routes outgoing GET requests for a given domain through the OpenRouter |
|||
* `web_fetch` tool instead of a direct network request. |
|||
* |
|||
* Configured via the `WEB_FETCH_ROUTES` property as a JSON array, e.g. |
|||
* |
|||
* [ |
|||
* { |
|||
* "domain": "example.com", |
|||
* "responseContentType": "application/json" |
|||
* } |
|||
* ] |
|||
* |
|||
* Matches the domain itself and its subdomains (e.g. `api.example.com`). |
|||
*/ |
|||
export interface WebFetchRoute { |
|||
domain: string; |
|||
responseContentType?: string; |
|||
} |
|||
@ -1,28 +1,34 @@ |
|||
import { Directive, EventEmitter, HostListener, Output } from '@angular/core'; |
|||
import { Directive, output } from '@angular/core'; |
|||
|
|||
@Directive({ |
|||
host: { |
|||
'(dragenter)': 'onDragEnter($event)', |
|||
'(dragover)': 'onDragOver($event)', |
|||
'(drop)': 'onDrop($event)' |
|||
}, |
|||
selector: '[gfFileDrop]' |
|||
}) |
|||
export class GfFileDropDirective { |
|||
@Output() filesDropped = new EventEmitter<FileList>(); |
|||
public readonly filesDropped = output<FileList>(); |
|||
|
|||
@HostListener('dragenter', ['$event']) onDragEnter(event: DragEvent) { |
|||
public onDragEnter(event: DragEvent) { |
|||
event.preventDefault(); |
|||
event.stopPropagation(); |
|||
} |
|||
|
|||
@HostListener('dragover', ['$event']) onDragOver(event: DragEvent) { |
|||
public onDragOver(event: DragEvent) { |
|||
event.preventDefault(); |
|||
event.stopPropagation(); |
|||
} |
|||
|
|||
@HostListener('drop', ['$event']) onDrop(event: DragEvent) { |
|||
public onDrop(event: DragEvent) { |
|||
event.preventDefault(); |
|||
event.stopPropagation(); |
|||
|
|||
// Prevent the browser's default behavior for handling the file drop
|
|||
event.dataTransfer.dropEffect = 'copy'; |
|||
|
|||
this.filesDropped.emit(event.dataTransfer.files); |
|||
if (event.dataTransfer) { |
|||
// Prevent the browser's default behavior for handling the file drop
|
|||
event.dataTransfer.dropEffect = 'copy'; |
|||
this.filesDropped.emit(event.dataTransfer.files); |
|||
} |
|||
} |
|||
} |
|||
|
|||
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
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue