mirror of https://github.com/ghostfolio/ghostfolio
75 changed files with 1752 additions and 1271 deletions
@ -0,0 +1,272 @@ |
|||
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, |
|||
dataProviderService, |
|||
null, |
|||
exchangeRateDataService, |
|||
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,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,205 @@ |
|||
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 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); |
|||
|
|||
Logger.debug(`${method} ${urlRedacted}`, 'FetchService'); |
|||
|
|||
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) { |
|||
Logger.error( |
|||
`${method} ${urlRedacted} failed: [${error.name}] ${error.message}`, |
|||
'FetchService' |
|||
); |
|||
} else { |
|||
Logger.error( |
|||
`${method} ${urlRedacted} failed: ${String(error)}`, |
|||
'FetchService' |
|||
); |
|||
} |
|||
|
|||
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; |
|||
} |
|||
} |
|||
|
|||
Logger.debug( |
|||
`Routed ${this.redactUrl(url)} via web fetch tool`, |
|||
'FetchService' |
|||
); |
|||
|
|||
return new Response(body, { |
|||
headers: webFetchRoute.responseContentType |
|||
? { 'content-type': webFetchRoute.responseContentType } |
|||
: undefined |
|||
}); |
|||
} |
|||
|
|||
return undefined; |
|||
} catch (error) { |
|||
Logger.error( |
|||
`Web fetch tool failed for ${this.redactUrl(url)}: ${ |
|||
error instanceof Error ? error.message : String(error) |
|||
}`,
|
|||
'FetchService' |
|||
); |
|||
|
|||
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,5 +1,5 @@ |
|||
import { Access } from '@ghostfolio/common/interfaces'; |
|||
|
|||
export interface CreateOrUpdateAccessDialogParams { |
|||
access: Access; |
|||
access?: Access; |
|||
} |
|||
|
|||
@ -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); |
|||
} |
|||
} |
|||
} |
|||
|
|||
@ -0,0 +1,9 @@ |
|||
<a |
|||
class="align-items-center d-flex justify-content-center" |
|||
color="primary" |
|||
mat-fab |
|||
[queryParams]="queryParams()" |
|||
[routerLink]="[]" |
|||
> |
|||
<ion-icon name="add-outline" size="large" /> |
|||
</a> |
|||
@ -0,0 +1,14 @@ |
|||
:host { |
|||
bottom: calc(constant(safe-area-inset-bottom) + 2rem); |
|||
bottom: calc(env(safe-area-inset-bottom) + 2rem); |
|||
position: fixed; |
|||
right: 2rem; |
|||
z-index: 999; |
|||
} |
|||
|
|||
:host-context(gf-page-tabs) { |
|||
@media (max-width: 575.98px) { |
|||
bottom: calc(constant(safe-area-inset-bottom) + 5rem); |
|||
bottom: calc(env(safe-area-inset-bottom) + 5rem); |
|||
} |
|||
} |
|||
@ -0,0 +1,21 @@ |
|||
import { ChangeDetectionStrategy, Component, input } from '@angular/core'; |
|||
import { MatButtonModule } from '@angular/material/button'; |
|||
import { Params, RouterModule } from '@angular/router'; |
|||
import { IonIcon } from '@ionic/angular/standalone'; |
|||
import { addIcons } from 'ionicons'; |
|||
import { addOutline } from 'ionicons/icons'; |
|||
|
|||
@Component({ |
|||
changeDetection: ChangeDetectionStrategy.OnPush, |
|||
imports: [IonIcon, MatButtonModule, RouterModule], |
|||
selector: 'gf-fab', |
|||
styleUrls: ['./fab.component.scss'], |
|||
templateUrl: './fab.component.html' |
|||
}) |
|||
export class GfFabComponent { |
|||
public readonly queryParams = input.required<Params>(); |
|||
|
|||
public constructor() { |
|||
addIcons({ addOutline }); |
|||
} |
|||
} |
|||
@ -0,0 +1 @@ |
|||
export * from './fab.component'; |
|||
File diff suppressed because it is too large
@ -0,0 +1,4 @@ |
|||
import { config } from 'dotenv'; |
|||
import { expand } from 'dotenv-expand'; |
|||
|
|||
expand(config({ path: process.env.GHOSTFOLIO_ENV_FILE, quiet: true })); |
|||
Loading…
Reference in new issue