@ -1,6 +1,8 @@
import { McpService } from '@ghostfolio/api/app/endpoints/mcp/mcp.service' ;
import { CallerFacingError } from '@ghostfolio/api/errors/caller-facing.error' ;
import { encodeDataSource } from '@ghostfolio/api/helper/data-source.helper' ;
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service' ;
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service' ;
import { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.service' ;
import { PropertyService } from '@ghostfolio/api/services/property/property.service' ;
import {
@ -10,6 +12,7 @@ import {
PROPERTY_OPENROUTER_MODEL_AGENT ,
PROPERTY_OPENROUTER_MODEL_VISION
} from '@ghostfolio/common/config' ;
import { SubscriptionType } from '@ghostfolio/common/enums' ;
import { isValidCustomAssetProfileSymbol } from '@ghostfolio/common/helper' ;
import type { UserWithSettings } from '@ghostfolio/common/types' ;
@ -64,6 +67,7 @@ describe('AiService', () => {
let aiService : AiService ;
let chat : jest.Mock ;
let dataProviderService : DataProviderService ;
let mcpService : McpService ;
let properties : Record < string , string > ;
@ -93,10 +97,20 @@ describe('AiService', () => {
[ PROPERTY_OPENROUTER_MODEL_VISION ] : 'vision-model'
} ;
dataProviderService = {
getDataSources : jest
. fn ( )
. mockResolvedValue ( [ DataSource . MANUAL , DataSource . YAHOO ] ) ,
isPremiumRequired : jest.fn ( ) . mockReturnValue ( false )
} as unknown as DataProviderService ;
mcpService = {
getAccounts : jest.fn ( ) . mockResolvedValue ( {
content : [ { text : '## Accounts' , type : 'text' } ]
} ) ,
getPortfolio : jest.fn ( ) . mockResolvedValue ( {
content : [ { text : '## Holdings' , type : 'text' } ]
} ) ,
searchAssetProfiles : jest.fn ( ) . mockResolvedValue ( {
content : [ { text : '{ "assetProfiles": [] }' , type : 'text' } ]
} )
@ -109,7 +123,8 @@ describe('AiService', () => {
} as unknown as PropertyService ;
aiService = new AiService (
{ } as ConfigurationService ,
{ get : jest . fn ( ) } as unknown as ConfigurationService ,
dataProviderService ,
mcpService ,
{ } as PortfolioTableService ,
propertyService
@ -342,7 +357,8 @@ describe('AiService', () => {
const user = {
accounts : [ { currency : 'CHF' , id : 'account-id' , name : 'Brokerage' } ] ,
id : 'user-id' ,
settings : { settings : { baseCurrency : 'EUR' , locale : 'de-CH' } }
settings : { settings : { baseCurrency : 'EUR' , locale : 'de-CH' } } ,
subscription : { type : SubscriptionType . Basic }
} as unknown as UserWithSettings ;
it ( 'Reads the rows of the file and matches them with the accounts, the base currency and the id of the user' , async ( ) = > {
@ -386,6 +402,7 @@ describe('AiService', () => {
rows ,
accounts : user.accounts ,
broker : 'Swissquote' ,
subscription : user.subscription ,
userCurrency : 'EUR' ,
userId : 'user-id'
} ) ;
@ -522,6 +539,29 @@ describe('AiService', () => {
} ) ;
}
function mockHoldings ( {
dataSource = 'YAHOO'
} : { dataSource? : string } = { } ) {
jest . mocked ( mcpService . getPortfolio ) . mockResolvedValue ( {
content : [
{
text : [
'## Holdings' ,
'' ,
'| Name | ISIN | Symbol | Data Source | Currency | Asset Class | Asset Sub Class | Date of First Activity | Activities Count | Allocation in Percentage |' ,
'| :-------------------------------- | :----------- | :------ | :---------- | :------- | :---------- | :-------------- | :--------------------- | ---------------: | -----------------------: |' ,
'| Vanguard FTSE All-World UCITS ETF | IE00BK5BQT80 | VWRA.SW | YAHOO | USD | Equity | ETF | 2021-03-15 | 4 | 70.000% |' ,
'| Apple Inc. | | AAPL | YAHOO | USD | Equity | Stock | 2022-06-01 | 2 | 20.000% |' ,
'| US Dollar | | USD | | USD | Liquidity | Cash | 2021-03-15 | 1 | 10.000% |'
]
. join ( '\n' )
. replaceAll ( '| YAHOO |' , ` | ${ dataSource . padEnd ( 11 ) } | ` ) ,
type : 'text'
}
]
} ) ;
}
it ( 'Gives an activity for each row whose asset matches an asset profile' , async ( ) = > {
const row = createActivityRow ( { currency : null } ) ;
@ -673,7 +713,7 @@ describe('AiService', () => {
) ;
expect ( Logger . prototype . warn ) . toHaveBeenCalledWith (
'Left out 1 asset matches which the search of their asset did not gi ve'
'Left out 1 asset matches which neither the search of their asset nor the holdings ga ve'
) ;
} ) ;
@ -707,10 +747,343 @@ describe('AiService', () => {
} ) ;
expect ( Logger . prototype . warn ) . toHaveBeenCalledWith (
'Left out 1 asset matches which the search of their asset did not gi ve'
'Left out 1 asset matches which neither the search of their asset nor the holdings ga ve'
) ;
} ) ;
it ( 'Gives an activity for a row whose asset matches a holding which no search gave' , async ( ) = > {
const row = createActivityRow ( {
accountName : null ,
currency : 'CHF' ,
isin : 'IE00BK5BQT80' ,
name : 'Vanguard FTSE All-World UCITS ETF USD Accumulating' ,
symbol : null
} ) ;
mockRowMatches ( {
assetProfilesOfSearch : [ ] ,
assets : [
{ currency : 'USD' , dataSource : 'YAHOO' , id : 0 , symbol : 'VWRA.SW' }
]
} ) ;
mockHoldings ( ) ;
await expect (
aiService . getActivitiesFromRows ( {
accounts ,
userCurrency ,
userId ,
rows : [ row ]
} )
) . resolves . toEqual ( {
activities : [
expect . objectContaining ( {
currency : 'CHF' ,
dataSource : 'YAHOO' ,
symbol : 'VWRA.SW'
} )
] ,
rowsWithoutMatch : [ ]
} ) ;
} ) ;
it ( 'Gives an activity for a row whose asset matches a holding with the same ISIN with spaces and in lower case' , async ( ) = > {
const row = createActivityRow ( {
accountName : null ,
isin : 'ie00 bk5b qt80' ,
name : 'Vanguard FTSE All-World UCITS ETF USD Accumulating' ,
symbol : null
} ) ;
mockRowMatches ( {
assetProfilesOfSearch : [ ] ,
assets : [
{ currency : 'USD' , dataSource : 'YAHOO' , id : 0 , symbol : 'VWRA.SW' }
]
} ) ;
mockHoldings ( ) ;
await expect (
aiService . getActivitiesFromRows ( {
accounts ,
userCurrency ,
userId ,
rows : [ row ]
} )
) . resolves . toEqual ( {
activities : [
expect . objectContaining ( { dataSource : 'YAHOO' , symbol : 'VWRA.SW' } )
] ,
rowsWithoutMatch : [ ]
} ) ;
} ) ;
it ( 'Gives an activity for a row whose asset matches a holding without an ISIN which no search gave' , async ( ) = > {
const row = createActivityRow ( {
accountName : null ,
name : 'APPLE INC' ,
symbol : null
} ) ;
mockRowMatches ( {
assetProfilesOfSearch : [ ] ,
assets : [
{ currency : 'USD' , dataSource : 'YAHOO' , id : 0 , symbol : 'AAPL' }
]
} ) ;
mockHoldings ( ) ;
await expect (
aiService . getActivitiesFromRows ( {
accounts ,
userCurrency ,
userId ,
rows : [ row ]
} )
) . resolves . toEqual ( {
activities : [
expect . objectContaining ( { dataSource : 'YAHOO' , symbol : 'AAPL' } )
] ,
rowsWithoutMatch : [ ]
} ) ;
} ) ;
it ( 'Gives a row whose asset match has the symbol of a holding but another data source as a row without a match' , async ( ) = > {
jest . spyOn ( Logger . prototype , 'warn' ) . mockImplementation ( ) ;
const row = createActivityRow ( {
accountName : null ,
isin : 'IE00BK5BQT80'
} ) ;
mockRowMatches ( {
assetProfilesOfSearch : [ ] ,
assets : [
{ currency : 'USD' , dataSource : 'MANUAL' , id : 0 , symbol : 'VWRA.SW' }
]
} ) ;
mockHoldings ( ) ;
await expect (
aiService . getActivitiesFromRows ( {
accounts ,
userCurrency ,
userId ,
rows : [ row ]
} )
) . resolves . toEqual ( { activities : [ ] , rowsWithoutMatch : [ row ] } ) ;
} ) ;
it ( 'Gives a row whose asset match has the symbol in the currency column of a holding as a row without a match' , async ( ) = > {
jest . spyOn ( Logger . prototype , 'warn' ) . mockImplementation ( ) ;
const row = createActivityRow ( {
accountName : null ,
isin : 'IE00BK5BQT80'
} ) ;
mockRowMatches ( {
assetProfilesOfSearch : [ ] ,
assets : [ { currency : 'USD' , dataSource : 'YAHOO' , id : 0 , symbol : 'USD' } ]
} ) ;
mockHoldings ( ) ;
await expect (
aiService . getActivitiesFromRows ( {
accounts ,
userCurrency ,
userId ,
rows : [ row ]
} )
) . resolves . toEqual ( { activities : [ ] , rowsWithoutMatch : [ row ] } ) ;
} ) ;
it ( 'Gives a row whose asset match is a holding with another ISIN as a row without a match and logs the left out match' , async ( ) = > {
jest . spyOn ( Logger . prototype , 'warn' ) . mockImplementation ( ) ;
const row = createActivityRow ( {
accountName : null ,
isin : 'IE00B3RBWM25' ,
name : 'Vanguard FTSE All-World UCITS ETF USD Distributing' ,
symbol : null
} ) ;
mockRowMatches ( {
assetProfilesOfSearch : [ ] ,
assets : [
{ currency : 'USD' , dataSource : 'YAHOO' , id : 0 , symbol : 'VWRA.SW' }
]
} ) ;
mockHoldings ( ) ;
await expect (
aiService . getActivitiesFromRows ( {
accounts ,
userCurrency ,
userId ,
rows : [ row ]
} )
) . resolves . toEqual ( { activities : [ ] , rowsWithoutMatch : [ row ] } ) ;
expect ( Logger . prototype . warn ) . toHaveBeenCalledWith (
'Left out 1 asset matches which neither the search of their asset nor the holdings gave'
) ;
} ) ;
it ( 'Gives an activity for a row whose asset matches a holding with an encoded data source and checks the decoded data source' , async ( ) = > {
const encodedDataSource = encodeDataSource ( DataSource . YAHOO ) ;
const row = createActivityRow ( {
accountName : null ,
isin : 'IE00BK5BQT80' ,
symbol : null
} ) ;
mockRowMatches ( {
assetProfilesOfSearch : [ ] ,
assets : [
{
currency : 'USD' ,
dataSource : encodedDataSource ,
id : 0 ,
symbol : 'VWRA.SW'
}
]
} ) ;
mockHoldings ( { dataSource : encodedDataSource } ) ;
await expect (
aiService . getActivitiesFromRows ( {
accounts ,
userCurrency ,
userId ,
rows : [ row ]
} )
) . resolves . toEqual ( {
activities : [
expect . objectContaining ( {
dataSource : encodedDataSource ,
symbol : 'VWRA.SW'
} )
] ,
rowsWithoutMatch : [ ]
} ) ;
expect ( dataProviderService . isPremiumRequired ) . toHaveBeenCalledWith ( {
dataSource : DataSource.YAHOO ,
subscription : undefined
} ) ;
} ) ;
it ( 'Passes no holding with a data source which requires Premium to the model and gives a row whose asset match is such a holding as a row without a match' , async ( ) = > {
jest . spyOn ( Logger . prototype , 'warn' ) . mockImplementation ( ) ;
jest . mocked ( dataProviderService . isPremiumRequired ) . mockReturnValue ( true ) ;
const row = createActivityRow ( {
accountName : null ,
isin : 'IE00BK5BQT80' ,
symbol : null
} ) ;
const subscription = {
type : SubscriptionType . Basic
} as UserWithSettings [ 'subscription' ] ;
mockRowMatches ( {
assetProfilesOfSearch : [ ] ,
assets : [
{ currency : 'USD' , dataSource : 'YAHOO' , id : 0 , symbol : 'VWRA.SW' }
]
} ) ;
mockHoldings ( ) ;
await expect (
aiService . getActivitiesFromRows ( {
accounts ,
subscription ,
userCurrency ,
userId ,
rows : [ row ]
} )
) . resolves . toEqual ( { activities : [ ] , rowsWithoutMatch : [ row ] } ) ;
expect ( dataProviderService . isPremiumRequired ) . toHaveBeenCalledWith ( {
subscription ,
dataSource : DataSource.YAHOO
} ) ;
const { prompt } = jest . mocked ( generateText ) . mock . calls [ 0 ] [ 0 ] ;
expect ( JSON . parse ( prompt as string ) ) . toHaveProperty ( 'holdings' , [ ] ) ;
} ) ;
it ( 'Passes no holding with a data source which is not configured to the model and gives a row whose asset match is such a holding as a row without a match' , async ( ) = > {
jest . spyOn ( Logger . prototype , 'warn' ) . mockImplementation ( ) ;
jest
. mocked ( dataProviderService . getDataSources )
. mockResolvedValue ( [ DataSource . MANUAL ] ) ;
const row = createActivityRow ( {
accountName : null ,
isin : 'IE00BK5BQT80' ,
symbol : null
} ) ;
mockRowMatches ( {
assetProfilesOfSearch : [ ] ,
assets : [
{ currency : 'USD' , dataSource : 'YAHOO' , id : 0 , symbol : 'VWRA.SW' }
]
} ) ;
mockHoldings ( ) ;
await expect (
aiService . getActivitiesFromRows ( {
accounts ,
userCurrency ,
userId ,
rows : [ row ]
} )
) . resolves . toEqual ( { activities : [ ] , rowsWithoutMatch : [ row ] } ) ;
const { prompt } = jest . mocked ( generateText ) . mock . calls [ 0 ] [ 0 ] ;
expect ( JSON . parse ( prompt as string ) ) . toHaveProperty ( 'holdings' , [ ] ) ;
} ) ;
it ( 'Gives a row whose asset match is a cash position of the holdings as a row without a match' , async ( ) = > {
jest . spyOn ( Logger . prototype , 'warn' ) . mockImplementation ( ) ;
const row = createActivityRow ( { accountName : null } ) ;
mockRowMatches ( {
assetProfilesOfSearch : [ ] ,
assets : [ { currency : 'USD' , dataSource : '' , id : 0 , symbol : 'USD' } ]
} ) ;
mockHoldings ( ) ;
await expect (
aiService . getActivitiesFromRows ( {
accounts ,
userCurrency ,
userId ,
rows : [ row ]
} )
) . resolves . toEqual ( { activities : [ ] , rowsWithoutMatch : [ row ] } ) ;
} ) ;
it ( 'Matches a row without an account name by the broker of the file' , async ( ) = > {
mockRowMatches ( {
accounts : [ { accountId : 'account-id' , id : 0 } ] ,
@ -895,7 +1268,8 @@ describe('AiService', () => {
name : 'Apple Inc.' ,
symbol : 'AAPL'
}
]
] ,
holdings : [ ]
} ) ;
} ) ;
@ -1117,6 +1491,182 @@ describe('AiService', () => {
) ;
} ) ;
it ( 'Gets the holdings with the tool of the server of the Model Context Protocol without values' , async ( ) = > {
mockRowMatches ( { } ) ;
await aiService . getActivitiesFromRows ( {
accounts ,
userCurrency ,
userId ,
rows : [ createActivityRow ( ) ]
} ) ;
expect ( mcpService . getPortfolio ) . toHaveBeenCalledWith ( {
userId ,
withValues : false
} ) ;
} ) ;
it ( 'Passes the currency, the data source, the ISIN, the name and the symbol of each holding without the cash position to the model' , async ( ) = > {
mockRowMatches ( { } ) ;
mockHoldings ( ) ;
await aiService . getActivitiesFromRows ( {
accounts ,
userCurrency ,
userId ,
rows : [ createActivityRow ( ) ]
} ) ;
const { prompt } = jest . mocked ( generateText ) . mock . calls [ 0 ] [ 0 ] ;
expect ( JSON . parse ( prompt as string ) ) . toHaveProperty ( 'holdings' , [
{
currency : 'USD' ,
dataSource : 'YAHOO' ,
isin : 'IE00BK5BQT80' ,
name : 'Vanguard FTSE All-World UCITS ETF' ,
symbol : 'VWRA.SW'
} ,
{
currency : 'USD' ,
dataSource : 'YAHOO' ,
isin : null ,
name : 'Apple Inc.' ,
symbol : 'AAPL'
}
] ) ;
} ) ;
it ( 'Restores an escaped pipe in a value of the holdings' , async ( ) = > {
mockRowMatches ( { } ) ;
jest . mocked ( mcpService . getPortfolio ) . mockResolvedValue ( {
content : [
{
text : [
'## Holdings' ,
'' ,
'| Name | ISIN | Symbol | Data Source | Currency |' ,
'| :---------- | :--- | :----- | :---------- | :------- |' ,
'| Fund A \\| B | | FAB | YAHOO | USD |'
] . join ( '\n' ) ,
type : 'text'
}
]
} ) ;
await aiService . getActivitiesFromRows ( {
accounts ,
userCurrency ,
userId ,
rows : [ createActivityRow ( ) ]
} ) ;
const { prompt } = jest . mocked ( generateText ) . mock . calls [ 0 ] [ 0 ] ;
expect ( JSON . parse ( prompt as string ) ) . toHaveProperty ( 'holdings' , [
{
currency : 'USD' ,
dataSource : 'YAHOO' ,
isin : null ,
name : 'Fund A | B' ,
symbol : 'FAB'
}
] ) ;
} ) ;
it ( 'Does not get the holdings without an asset' , async ( ) = > {
mockRowMatches ( { } ) ;
await aiService . getActivitiesFromRows ( {
accounts ,
userCurrency ,
userId ,
rows : [
createActivityRow ( {
fee : 5 ,
name : 'Custody fee' ,
quantity : 0 ,
type : ActivityType . FEE ,
unitPrice : 0
} )
]
} ) ;
expect ( mcpService . getPortfolio ) . not . toHaveBeenCalled ( ) ;
expect ( dataProviderService . getDataSources ) . not . toHaveBeenCalled ( ) ;
const { prompt } = jest . mocked ( generateText ) . mock . calls [ 0 ] [ 0 ] ;
expect ( JSON . parse ( prompt as string ) ) . not . toHaveProperty ( 'holdings' ) ;
} ) ;
it ( 'Passes no holdings to the model and continues if the holdings cannot be got' , async ( ) = > {
jest . spyOn ( Logger . prototype , 'warn' ) . mockImplementation ( ) ;
mockRowMatches ( {
assets : [
{ currency : 'USD' , dataSource : 'YAHOO' , id : 0 , symbol : 'AAPL' }
]
} ) ;
jest
. mocked ( mcpService . getPortfolio )
. mockRejectedValueOnce ( new Error ( 'Calculation failed' ) ) ;
const { activities } = await aiService . getActivitiesFromRows ( {
accounts ,
userCurrency ,
userId ,
rows : [ createActivityRow ( ) ]
} ) ;
expect ( activities ) . toEqual ( [ expect . objectContaining ( { symbol : 'AAPL' } ) ] ) ;
const { prompt } = jest . mocked ( generateText ) . mock . calls [ 0 ] [ 0 ] ;
expect ( JSON . parse ( prompt as string ) ) . not . toHaveProperty ( 'holdings' ) ;
expect ( Logger . prototype . warn ) . toHaveBeenCalledWith (
'Could not get the holdings: Calculation failed'
) ;
} ) ;
it ( 'Passes no holdings to the model and continues if the data sources cannot be got' , async ( ) = > {
jest . spyOn ( Logger . prototype , 'warn' ) . mockImplementation ( ) ;
mockRowMatches ( {
assets : [
{ currency : 'USD' , dataSource : 'YAHOO' , id : 0 , symbol : 'AAPL' }
]
} ) ;
mockHoldings ( ) ;
jest
. mocked ( dataProviderService . getDataSources )
. mockRejectedValueOnce ( new Error ( 'Database failed' ) ) ;
const { activities } = await aiService . getActivitiesFromRows ( {
accounts ,
userCurrency ,
userId ,
rows : [ createActivityRow ( ) ]
} ) ;
expect ( activities ) . toEqual ( [ expect . objectContaining ( { symbol : 'AAPL' } ) ] ) ;
const { prompt } = jest . mocked ( generateText ) . mock . calls [ 0 ] [ 0 ] ;
expect ( JSON . parse ( prompt as string ) ) . not . toHaveProperty ( 'holdings' ) ;
expect ( Logger . prototype . warn ) . toHaveBeenCalledWith (
'Could not get the holdings: Database failed'
) ;
} ) ;
it ( 'Restricts the routing to endpoints with zero data retention, disables the reasoning and gives the model no tools' , async ( ) = > {
mockRowMatches ( { } ) ;