mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
30 changed files with 1370 additions and 1 deletions
@ -0,0 +1,13 @@ |
|||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; |
|||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; |
|||
|
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { MarketSentimentService } from './market-sentiment.service'; |
|||
|
|||
@Module({ |
|||
exports: [MarketSentimentService], |
|||
imports: [ConfigurationModule, RedisCacheModule], |
|||
providers: [MarketSentimentService] |
|||
}) |
|||
export class MarketSentimentModule {} |
|||
@ -0,0 +1,181 @@ |
|||
import { AssetClass, DataSource } from '@prisma/client'; |
|||
|
|||
import { MarketSentimentService } from './market-sentiment.service'; |
|||
|
|||
describe('MarketSentimentService', () => { |
|||
const configurationService = { |
|||
get: jest.fn((key: string) => { |
|||
const values = { |
|||
API_KEY_ADANOS: 'sk_live_test', |
|||
CACHE_MARKET_SENTIMENT_TTL: 1000, |
|||
REQUEST_TIMEOUT: 5000 |
|||
}; |
|||
|
|||
return values[key]; |
|||
}) |
|||
}; |
|||
const redisCacheService = { |
|||
get: jest.fn().mockResolvedValue(null), |
|||
set: jest.fn().mockResolvedValue(undefined) |
|||
}; |
|||
|
|||
let marketSentimentService: MarketSentimentService; |
|||
|
|||
beforeEach(() => { |
|||
jest.clearAllMocks(); |
|||
configurationService.get.mockImplementation((key: string) => { |
|||
const values = { |
|||
API_KEY_ADANOS: 'sk_live_test', |
|||
CACHE_MARKET_SENTIMENT_TTL: 1000, |
|||
REQUEST_TIMEOUT: 5000 |
|||
}; |
|||
|
|||
return values[key]; |
|||
}); |
|||
global.fetch = jest.fn().mockImplementation((url: URL | string) => { |
|||
const href = url.toString(); |
|||
|
|||
if (href.includes('/reddit/')) { |
|||
return Promise.resolve({ |
|||
ok: true, |
|||
json: async () => ({ |
|||
stocks: [ |
|||
{ |
|||
bullish_pct: 66, |
|||
buzz_score: 64.2, |
|||
mentions: 140, |
|||
ticker: 'AAPL', |
|||
trend: 'rising' |
|||
} |
|||
] |
|||
}), |
|||
status: 200, |
|||
statusText: 'OK' |
|||
}); |
|||
} |
|||
|
|||
if (href.includes('/x/')) { |
|||
return Promise.resolve({ |
|||
ok: true, |
|||
json: async () => ({ |
|||
stocks: [ |
|||
{ |
|||
bullish_pct: 61, |
|||
buzz_score: 58.1, |
|||
mentions: 210, |
|||
ticker: 'AAPL', |
|||
trend: 'stable' |
|||
} |
|||
] |
|||
}), |
|||
status: 200, |
|||
statusText: 'OK' |
|||
}); |
|||
} |
|||
|
|||
if (href.includes('/news/')) { |
|||
return Promise.resolve({ |
|||
ok: true, |
|||
json: async () => ({ |
|||
stocks: [ |
|||
{ |
|||
bullish_pct: 63, |
|||
buzz_score: 55.5, |
|||
mentions: 34, |
|||
ticker: 'AAPL', |
|||
trend: 'rising' |
|||
} |
|||
] |
|||
}), |
|||
status: 200, |
|||
statusText: 'OK' |
|||
}); |
|||
} |
|||
|
|||
return Promise.resolve({ |
|||
ok: true, |
|||
json: async () => ({ |
|||
stocks: [ |
|||
{ |
|||
bullish_pct: 68, |
|||
buzz_score: 49.8, |
|||
ticker: 'AAPL', |
|||
trade_count: 12, |
|||
trend: 'stable' |
|||
} |
|||
] |
|||
}), |
|||
status: 200, |
|||
statusText: 'OK' |
|||
}); |
|||
}) as typeof fetch; |
|||
|
|||
marketSentimentService = new MarketSentimentService( |
|||
configurationService as never, |
|||
redisCacheService as never |
|||
); |
|||
}); |
|||
|
|||
it('returns no data when the API key is missing', async () => { |
|||
configurationService.get.mockImplementation((key: string) => { |
|||
return key === 'API_KEY_ADANOS' ? '' : 1000; |
|||
}); |
|||
|
|||
expect( |
|||
await marketSentimentService.getWatchlistMarketSentiment([ |
|||
{ |
|||
assetClass: AssetClass.EQUITY, |
|||
dataSource: DataSource.YAHOO, |
|||
name: 'Apple', |
|||
symbol: 'AAPL' |
|||
} as never |
|||
]) |
|||
).toEqual([]); |
|||
}); |
|||
|
|||
it('returns no data for an empty holding profile', async () => { |
|||
await expect( |
|||
marketSentimentService.getHoldingMarketSentiment(undefined) |
|||
).resolves.toBeUndefined(); |
|||
}); |
|||
|
|||
it('aggregates per-source responses for supported watchlist items', async () => { |
|||
const [sentimentItem] = |
|||
await marketSentimentService.getWatchlistMarketSentiment([ |
|||
{ |
|||
assetClass: AssetClass.EQUITY, |
|||
dataSource: DataSource.YAHOO, |
|||
name: 'Apple', |
|||
symbol: 'AAPL' |
|||
} as never, |
|||
{ |
|||
assetClass: AssetClass.COMMODITY, |
|||
dataSource: DataSource.YAHOO, |
|||
name: 'Bitcoin', |
|||
symbol: 'BTC-USD' |
|||
} as never |
|||
]); |
|||
|
|||
expect(global.fetch).toHaveBeenCalledTimes(4); |
|||
expect(sentimentItem.symbol).toBe('AAPL'); |
|||
expect(sentimentItem.marketSentiment.averageBuzzScore).toBe(56.9); |
|||
expect(sentimentItem.marketSentiment.averageBullishPct).toBe(64.5); |
|||
expect(sentimentItem.marketSentiment.coverage).toBe(4); |
|||
expect(sentimentItem.marketSentiment.sourceAlignment).toBe('ALIGNED'); |
|||
}); |
|||
|
|||
it('splits large watchlists into compare batches of 10', async () => { |
|||
const items = Array.from({ length: 11 }).map((_, index) => { |
|||
return { |
|||
assetClass: AssetClass.EQUITY, |
|||
dataSource: DataSource.YAHOO, |
|||
name: `Stock ${index}`, |
|||
symbol: `A${index}` |
|||
}; |
|||
}); |
|||
|
|||
await marketSentimentService.getWatchlistMarketSentiment(items as never[]); |
|||
|
|||
expect(global.fetch).toHaveBeenCalledTimes(8); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,391 @@ |
|||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
import { |
|||
EnhancedSymbolProfile, |
|||
MarketSentiment, |
|||
MarketSentimentAlignment, |
|||
MarketSentimentSource, |
|||
MarketSentimentSourceName, |
|||
MarketSentimentTrend |
|||
} from '@ghostfolio/common/interfaces'; |
|||
|
|||
import { Injectable, Logger } from '@nestjs/common'; |
|||
import { AssetClass, DataSource } from '@prisma/client'; |
|||
import { createHash } from 'node:crypto'; |
|||
|
|||
type AdanosSource = 'news' | 'polymarket' | 'reddit' | 'x'; |
|||
|
|||
type AdanosCompareRow = { |
|||
bullish_pct?: number | null; |
|||
buzz_score?: number | null; |
|||
company_name?: string | null; |
|||
market_count?: number | null; |
|||
mentions?: number | null; |
|||
ticker: string; |
|||
trade_count?: number | null; |
|||
trend?: string | null; |
|||
unique_posts?: number | null; |
|||
unique_tweets?: number | null; |
|||
}; |
|||
|
|||
type SupportedWatchlistItem = Pick< |
|||
EnhancedSymbolProfile, |
|||
'assetClass' | 'dataSource' | 'name' | 'symbol' |
|||
>; |
|||
|
|||
const ADANOS_API_BASE_URL = 'https://api.adanos.org'; |
|||
const ADANOS_COMPARE_LOOKBACK_DAYS = 7; |
|||
const ADANOS_MAX_TICKERS_PER_REQUEST = 10; |
|||
const SUPPORTED_SOURCES: AdanosSource[] = ['reddit', 'x', 'news', 'polymarket']; |
|||
const SOURCE_ORDER: MarketSentimentSourceName[] = [ |
|||
'REDDIT', |
|||
'X', |
|||
'NEWS', |
|||
'POLYMARKET' |
|||
]; |
|||
|
|||
@Injectable() |
|||
export class MarketSentimentService { |
|||
public constructor( |
|||
private readonly configurationService: ConfigurationService, |
|||
private readonly redisCacheService: RedisCacheService |
|||
) {} |
|||
|
|||
public canHandle() { |
|||
return !!this.configurationService.get('API_KEY_ADANOS'); |
|||
} |
|||
|
|||
public async getHoldingMarketSentiment( |
|||
assetProfile?: SupportedWatchlistItem |
|||
): Promise<MarketSentiment> { |
|||
if (!assetProfile) { |
|||
return undefined; |
|||
} |
|||
|
|||
const [item] = await this.getWatchlistMarketSentiment([assetProfile]); |
|||
|
|||
return item?.marketSentiment; |
|||
} |
|||
|
|||
public async getWatchlistMarketSentiment(items: SupportedWatchlistItem[]) { |
|||
if (!this.canHandle()) { |
|||
return []; |
|||
} |
|||
|
|||
const supportedItems = items.filter( |
|||
(item): item is SupportedWatchlistItem => { |
|||
return !!item && this.isSupportedAsset(item); |
|||
} |
|||
); |
|||
|
|||
if (supportedItems.length === 0) { |
|||
return []; |
|||
} |
|||
|
|||
const tickers = [...new Set(supportedItems.map(({ symbol }) => symbol))]; |
|||
const rowsBySource = await Promise.all( |
|||
SUPPORTED_SOURCES.map(async (source) => { |
|||
return [ |
|||
source, |
|||
await this.fetchRowsForSource(source, tickers) |
|||
] as const; |
|||
}) |
|||
); |
|||
|
|||
const sentimentBySymbol = new Map<string, MarketSentiment>(); |
|||
|
|||
for (const item of supportedItems) { |
|||
const sourceMetrics: MarketSentimentSource[] = []; |
|||
|
|||
for (const [source, rows] of rowsBySource) { |
|||
const row = rows.find(({ ticker }) => { |
|||
return ticker === item.symbol; |
|||
}); |
|||
|
|||
const metric = this.mapRowToSourceMetric({ row, source }); |
|||
|
|||
if (metric) { |
|||
sourceMetrics.push(metric); |
|||
} |
|||
} |
|||
|
|||
const marketSentiment = this.aggregateSourceMetrics(sourceMetrics); |
|||
|
|||
if (marketSentiment) { |
|||
sentimentBySymbol.set(item.symbol, marketSentiment); |
|||
} |
|||
} |
|||
|
|||
return supportedItems |
|||
.map(({ dataSource, name, symbol }) => { |
|||
return { |
|||
dataSource, |
|||
marketSentiment: sentimentBySymbol.get(symbol), |
|||
name, |
|||
symbol |
|||
}; |
|||
}) |
|||
.filter(({ marketSentiment }) => { |
|||
return !!marketSentiment; |
|||
}); |
|||
} |
|||
|
|||
private aggregateSourceMetrics(sourceMetrics: MarketSentimentSource[]) { |
|||
if (sourceMetrics.length === 0) { |
|||
return undefined; |
|||
} |
|||
|
|||
const orderedSourceMetrics = [...sourceMetrics].sort((a, b) => { |
|||
return SOURCE_ORDER.indexOf(a.source) - SOURCE_ORDER.indexOf(b.source); |
|||
}); |
|||
|
|||
const buzzValues = orderedSourceMetrics.map(({ buzzScore }) => { |
|||
return buzzScore; |
|||
}); |
|||
const bullishValues = orderedSourceMetrics |
|||
.map(({ bullishPct }) => bullishPct) |
|||
.filter((value): value is number => { |
|||
return typeof value === 'number'; |
|||
}); |
|||
const trends = orderedSourceMetrics |
|||
.map(({ trend }) => trend) |
|||
.filter((trend): trend is Exclude<MarketSentimentTrend, 'MIXED'> => { |
|||
return !!trend; |
|||
}); |
|||
|
|||
return { |
|||
averageBullishPct: |
|||
bullishValues.length > 0 |
|||
? this.round( |
|||
bullishValues.reduce((sum, value) => sum + value, 0) / |
|||
bullishValues.length |
|||
) |
|||
: undefined, |
|||
averageBuzzScore: this.round( |
|||
buzzValues.reduce((sum, value) => sum + value, 0) / buzzValues.length |
|||
), |
|||
coverage: orderedSourceMetrics.length, |
|||
sourceAlignment: this.resolveSourceAlignment(bullishValues), |
|||
sourceMetrics: orderedSourceMetrics, |
|||
trend: this.resolveTrend(trends) |
|||
} satisfies MarketSentiment; |
|||
} |
|||
|
|||
private async fetchRowsForSource(source: AdanosSource, tickers: string[]) { |
|||
const rows: AdanosCompareRow[] = []; |
|||
|
|||
for ( |
|||
let index = 0; |
|||
index < tickers.length; |
|||
index += ADANOS_MAX_TICKERS_PER_REQUEST |
|||
) { |
|||
const chunk = tickers.slice( |
|||
index, |
|||
index + ADANOS_MAX_TICKERS_PER_REQUEST |
|||
); |
|||
rows.push(...(await this.fetchRowsForSourceChunk(source, chunk))); |
|||
} |
|||
|
|||
return rows; |
|||
} |
|||
|
|||
private async fetchRowsForSourceChunk( |
|||
source: AdanosSource, |
|||
tickers: string[] |
|||
): Promise<AdanosCompareRow[]> { |
|||
if (tickers.length === 0) { |
|||
return []; |
|||
} |
|||
|
|||
const cacheKey = this.getCacheKey({ source, tickers }); |
|||
const cached = await this.redisCacheService.get(cacheKey); |
|||
|
|||
if (cached) { |
|||
try { |
|||
return JSON.parse(cached) as AdanosCompareRow[]; |
|||
} catch {} |
|||
} |
|||
|
|||
const url = new URL(`${ADANOS_API_BASE_URL}/${source}/stocks/v1/compare`); |
|||
url.searchParams.set('days', ADANOS_COMPARE_LOOKBACK_DAYS.toString()); |
|||
url.searchParams.set('tickers', tickers.join(',')); |
|||
|
|||
try { |
|||
const response = await fetch(url, { |
|||
headers: { |
|||
'X-API-Key': this.configurationService.get('API_KEY_ADANOS') |
|||
}, |
|||
signal: AbortSignal.timeout( |
|||
this.configurationService.get('REQUEST_TIMEOUT') |
|||
) |
|||
}); |
|||
|
|||
if (!response.ok) { |
|||
throw new Error(`${response.status} ${response.statusText}`); |
|||
} |
|||
|
|||
const payload = await response.json(); |
|||
const rows = this.normalizeRows(payload); |
|||
|
|||
await this.redisCacheService.set( |
|||
cacheKey, |
|||
JSON.stringify(rows), |
|||
this.configurationService.get('CACHE_MARKET_SENTIMENT_TTL') |
|||
); |
|||
|
|||
return rows; |
|||
} catch (error) { |
|||
Logger.warn( |
|||
`Adanos ${source} sentiment request failed for ${tickers.join(',')}: ${error}`, |
|||
'MarketSentimentService' |
|||
); |
|||
|
|||
return []; |
|||
} |
|||
} |
|||
|
|||
private getCacheKey({ |
|||
source, |
|||
tickers |
|||
}: { |
|||
source: AdanosSource; |
|||
tickers: string[]; |
|||
}) { |
|||
const tickersHash = createHash('sha256') |
|||
.update(JSON.stringify([...tickers].sort())) |
|||
.digest('hex'); |
|||
|
|||
return `market-sentiment-${source}-${ADANOS_COMPARE_LOOKBACK_DAYS}-${tickersHash}`; |
|||
} |
|||
|
|||
private isSupportedAsset({ |
|||
assetClass, |
|||
dataSource, |
|||
symbol |
|||
}: SupportedWatchlistItem) { |
|||
return ( |
|||
assetClass === AssetClass.EQUITY && |
|||
dataSource === DataSource.YAHOO && |
|||
/^[A-Z][A-Z0-9.-]{0,9}$/.test(symbol) |
|||
); |
|||
} |
|||
|
|||
private mapRowToSourceMetric({ |
|||
row, |
|||
source |
|||
}: { |
|||
row?: AdanosCompareRow; |
|||
source: AdanosSource; |
|||
}): MarketSentimentSource { |
|||
if (!row) { |
|||
return undefined; |
|||
} |
|||
|
|||
const activityCount = Math.max( |
|||
0, |
|||
Number( |
|||
row.mentions ?? |
|||
row.trade_count ?? |
|||
row.unique_tweets ?? |
|||
row.unique_posts ?? |
|||
row.market_count ?? |
|||
0 |
|||
) |
|||
); |
|||
const buzzScore = this.round(Number(row.buzz_score ?? 0)); |
|||
|
|||
if (activityCount <= 0 && buzzScore <= 0) { |
|||
return undefined; |
|||
} |
|||
|
|||
return { |
|||
activityCount, |
|||
bullishPct: |
|||
typeof row.bullish_pct === 'number' |
|||
? this.round(row.bullish_pct) |
|||
: undefined, |
|||
buzzScore, |
|||
source: this.toSourceName(source), |
|||
trend: this.toTrend(row.trend) |
|||
}; |
|||
} |
|||
|
|||
private normalizeRows(payload: unknown): AdanosCompareRow[] { |
|||
if (Array.isArray(payload)) { |
|||
return payload as AdanosCompareRow[]; |
|||
} |
|||
|
|||
if ( |
|||
payload && |
|||
typeof payload === 'object' && |
|||
Array.isArray((payload as { stocks?: unknown[] }).stocks) |
|||
) { |
|||
return (payload as { stocks: AdanosCompareRow[] }).stocks; |
|||
} |
|||
|
|||
return []; |
|||
} |
|||
|
|||
private resolveSourceAlignment( |
|||
bullishValues: number[] |
|||
): MarketSentimentAlignment { |
|||
if (bullishValues.length <= 1) { |
|||
return 'SINGLE_SOURCE'; |
|||
} |
|||
|
|||
const min = Math.min(...bullishValues); |
|||
const max = Math.max(...bullishValues); |
|||
const hasBullishSignal = bullishValues.some((value) => value >= 55); |
|||
const hasBearishSignal = bullishValues.some((value) => value <= 45); |
|||
|
|||
if (hasBullishSignal && hasBearishSignal && max - min >= 15) { |
|||
return 'DIVERGENT'; |
|||
} |
|||
|
|||
if (max - min <= 10) { |
|||
return 'ALIGNED'; |
|||
} |
|||
|
|||
return 'MIXED'; |
|||
} |
|||
|
|||
private resolveTrend( |
|||
trends: Exclude<MarketSentimentTrend, 'MIXED'>[] |
|||
): MarketSentimentTrend { |
|||
if (trends.length === 0) { |
|||
return 'MIXED'; |
|||
} |
|||
|
|||
return trends.every((trend) => trend === trends[0]) ? trends[0] : 'MIXED'; |
|||
} |
|||
|
|||
private round(value: number) { |
|||
return Math.round(value * 10) / 10; |
|||
} |
|||
|
|||
private toSourceName(source: AdanosSource): MarketSentimentSourceName { |
|||
switch (source) { |
|||
case 'news': |
|||
return 'NEWS'; |
|||
case 'polymarket': |
|||
return 'POLYMARKET'; |
|||
case 'reddit': |
|||
return 'REDDIT'; |
|||
case 'x': |
|||
return 'X'; |
|||
} |
|||
} |
|||
|
|||
private toTrend( |
|||
trend?: string | null |
|||
): Exclude<MarketSentimentTrend, 'MIXED'> { |
|||
switch ((trend ?? '').toLowerCase()) { |
|||
case 'falling': |
|||
return 'FALLING'; |
|||
case 'rising': |
|||
return 'RISING'; |
|||
default: |
|||
return 'STABLE'; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,192 @@ |
|||
import { UserService } from '@ghostfolio/client/services/user/user.service'; |
|||
import { GfAccountsTableComponent } from '@ghostfolio/ui/accounts-table'; |
|||
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; |
|||
import { GfDataProviderCreditsComponent } from '@ghostfolio/ui/data-provider-credits'; |
|||
import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer'; |
|||
import { GfDialogHeaderComponent } from '@ghostfolio/ui/dialog-header'; |
|||
import { GfHistoricalMarketDataEditorComponent } from '@ghostfolio/ui/historical-market-data-editor'; |
|||
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart'; |
|||
import { GfMarketSentimentSummaryComponent } from '@ghostfolio/ui/market-sentiment-summary'; |
|||
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart'; |
|||
import { DataService } from '@ghostfolio/ui/services'; |
|||
import { GfTagsSelectorComponent } from '@ghostfolio/ui/tags-selector'; |
|||
import { GfValueComponent } from '@ghostfolio/ui/value'; |
|||
|
|||
import { Component, CUSTOM_ELEMENTS_SCHEMA, forwardRef } from '@angular/core'; |
|||
import { ComponentFixture, TestBed } from '@angular/core/testing'; |
|||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; |
|||
import { FormBuilder } from '@angular/forms'; |
|||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; |
|||
import { Router } from '@angular/router'; |
|||
import { IonIcon } from '@ionic/angular/standalone'; |
|||
import { of, Subject } from 'rxjs'; |
|||
|
|||
import { GfHoldingDetailDialogComponent } from './holding-detail-dialog.component'; |
|||
|
|||
jest.mock('@ionic/angular/standalone', () => { |
|||
class MockIonIconComponent {} |
|||
|
|||
return { IonIcon: MockIonIconComponent }; |
|||
}); |
|||
|
|||
jest.mock('color', () => { |
|||
return () => ({ |
|||
alpha: () => ({ |
|||
rgb: () => ({ |
|||
string: () => 'rgba(0,0,0,1)' |
|||
}) |
|||
}) |
|||
}); |
|||
}); |
|||
|
|||
@Component({ |
|||
providers: [ |
|||
{ |
|||
multi: true, |
|||
provide: NG_VALUE_ACCESSOR, |
|||
useExisting: forwardRef(() => MockTagsSelectorComponent) |
|||
} |
|||
], |
|||
selector: 'gf-tags-selector', |
|||
standalone: true, |
|||
template: '' |
|||
}) |
|||
class MockTagsSelectorComponent implements ControlValueAccessor { |
|||
public registerOnChange(fn: (value: unknown) => void) { |
|||
void fn; |
|||
} |
|||
|
|||
public registerOnTouched(fn: () => void) { |
|||
void fn; |
|||
} |
|||
|
|||
public setDisabledState(isDisabled: boolean) { |
|||
void isDisabled; |
|||
} |
|||
|
|||
public writeValue(value: unknown) { |
|||
void value; |
|||
} |
|||
} |
|||
|
|||
describe('GfHoldingDetailDialogComponent', () => { |
|||
let component: GfHoldingDetailDialogComponent; |
|||
let fixture: ComponentFixture<GfHoldingDetailDialogComponent>; |
|||
|
|||
const stateChanged = new Subject<any>(); |
|||
|
|||
beforeEach(async () => { |
|||
TestBed.overrideComponent(GfHoldingDetailDialogComponent, { |
|||
remove: { |
|||
imports: [ |
|||
GfAccountsTableComponent, |
|||
GfActivitiesTableComponent, |
|||
GfDataProviderCreditsComponent, |
|||
GfDialogFooterComponent, |
|||
GfDialogHeaderComponent, |
|||
GfHistoricalMarketDataEditorComponent, |
|||
GfLineChartComponent, |
|||
GfMarketSentimentSummaryComponent, |
|||
GfPortfolioProportionChartComponent, |
|||
GfTagsSelectorComponent, |
|||
GfValueComponent, |
|||
IonIcon |
|||
] |
|||
}, |
|||
add: { |
|||
imports: [MockTagsSelectorComponent] |
|||
} |
|||
}); |
|||
|
|||
await TestBed.configureTestingModule({ |
|||
imports: [GfHoldingDetailDialogComponent], |
|||
providers: [ |
|||
{ |
|||
provide: DataService, |
|||
useValue: { |
|||
fetchAccounts: jest.fn().mockReturnValue(of({ accounts: [] })), |
|||
fetchActivities: jest.fn().mockReturnValue(of({ activities: [] })), |
|||
fetchHoldingDetail: jest.fn().mockReturnValue( |
|||
of({ |
|||
activitiesCount: 1, |
|||
averagePrice: 100, |
|||
dataProviderInfo: undefined, |
|||
dateOfFirstActivity: '2024-01-02', |
|||
dividendInBaseCurrency: 0, |
|||
dividendYieldPercentWithCurrencyEffect: 0, |
|||
feeInBaseCurrency: 0, |
|||
historicalData: [], |
|||
investmentInBaseCurrencyWithCurrencyEffect: 1000, |
|||
marketPrice: 120, |
|||
marketPriceMax: 130, |
|||
marketPriceMin: 90, |
|||
marketSentiment: { |
|||
averageBullishPct: 58, |
|||
averageBuzzScore: 44, |
|||
coverage: 3, |
|||
sourceAlignment: 'MIXED', |
|||
sourceMetrics: [], |
|||
trend: 'RISING' |
|||
}, |
|||
netPerformance: 20, |
|||
netPerformancePercent: 0.2, |
|||
netPerformancePercentWithCurrencyEffect: 0.2, |
|||
netPerformanceWithCurrencyEffect: 20, |
|||
quantity: 5, |
|||
SymbolProfile: { |
|||
assetSubClass: 'STOCK', |
|||
countries: [], |
|||
currency: 'USD', |
|||
dataSource: 'YAHOO', |
|||
sectors: [], |
|||
symbol: 'AAPL' |
|||
}, |
|||
tags: [], |
|||
value: 600 |
|||
}) |
|||
), |
|||
fetchMarketData: jest.fn().mockReturnValue(of([])), |
|||
postActivity: jest.fn(), |
|||
postTag: jest.fn(), |
|||
putHoldingTags: jest.fn() |
|||
} |
|||
}, |
|||
{ provide: FormBuilder, useValue: new FormBuilder() }, |
|||
{ provide: MatDialogRef, useValue: { close: jest.fn() } }, |
|||
{ |
|||
provide: MAT_DIALOG_DATA, |
|||
useValue: { |
|||
baseCurrency: 'USD', |
|||
colorScheme: 'light', |
|||
dataSource: 'YAHOO', |
|||
deviceType: 'desktop', |
|||
locale: 'en', |
|||
symbol: 'AAPL' |
|||
} |
|||
}, |
|||
{ provide: Router, useValue: { navigate: jest.fn() } }, |
|||
{ |
|||
provide: UserService, |
|||
useValue: { |
|||
stateChanged, |
|||
get: jest.fn().mockReturnValue(of({})) |
|||
} |
|||
} |
|||
], |
|||
schemas: [CUSTOM_ELEMENTS_SCHEMA] |
|||
}).compileComponents(); |
|||
|
|||
fixture = TestBed.createComponent(GfHoldingDetailDialogComponent); |
|||
component = fixture.componentInstance; |
|||
component.user = { |
|||
permissions: [], |
|||
settings: { isExperimentalFeatures: false } |
|||
} as any; |
|||
fixture.detectChanges(); |
|||
}); |
|||
|
|||
it('should expose market sentiment from the holding detail response', () => { |
|||
expect(component.marketSentiment?.averageBuzzScore).toBe(44); |
|||
expect(component.marketSentiment?.coverage).toBe(3); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,134 @@ |
|||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; |
|||
import { UserService } from '@ghostfolio/client/services/user/user.service'; |
|||
import { Benchmark, User } from '@ghostfolio/common/interfaces'; |
|||
import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark'; |
|||
import { GfMarketSentimentSummaryComponent } from '@ghostfolio/ui/market-sentiment-summary'; |
|||
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; |
|||
import { DataService } from '@ghostfolio/ui/services'; |
|||
|
|||
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; |
|||
import { ComponentFixture, TestBed } from '@angular/core/testing'; |
|||
import { MatDialog } from '@angular/material/dialog'; |
|||
import { ActivatedRoute, Router } from '@angular/router'; |
|||
import { IonIcon } from '@ionic/angular/standalone'; |
|||
import { DeviceDetectorService } from 'ngx-device-detector'; |
|||
import { of, Subject } from 'rxjs'; |
|||
|
|||
import { GfHomeWatchlistComponent } from './home-watchlist.component'; |
|||
|
|||
jest.mock('@ionic/angular/standalone', () => { |
|||
class MockIonIconComponent {} |
|||
|
|||
return { IonIcon: MockIonIconComponent }; |
|||
}); |
|||
|
|||
describe('GfHomeWatchlistComponent', () => { |
|||
let component: GfHomeWatchlistComponent; |
|||
let fixture: ComponentFixture<GfHomeWatchlistComponent>; |
|||
|
|||
const stateChanged = new Subject<{ user: User }>(); |
|||
|
|||
beforeEach(async () => { |
|||
TestBed.overrideComponent(GfHomeWatchlistComponent, { |
|||
remove: { |
|||
imports: [ |
|||
GfBenchmarkComponent, |
|||
GfMarketSentimentSummaryComponent, |
|||
GfPremiumIndicatorComponent, |
|||
IonIcon |
|||
] |
|||
} |
|||
}); |
|||
|
|||
await TestBed.configureTestingModule({ |
|||
imports: [GfHomeWatchlistComponent], |
|||
providers: [ |
|||
{ |
|||
provide: DataService, |
|||
useValue: { |
|||
fetchWatchlist: jest.fn().mockReturnValue(of({ watchlist: [] })) |
|||
} |
|||
}, |
|||
{ |
|||
provide: DeviceDetectorService, |
|||
useValue: { |
|||
getDeviceInfo: () => ({ deviceType: 'desktop' }) |
|||
} |
|||
}, |
|||
{ provide: MatDialog, useValue: { open: jest.fn() } }, |
|||
{ |
|||
provide: ImpersonationStorageService, |
|||
useValue: { |
|||
onChangeHasImpersonation: () => of(null) |
|||
} |
|||
}, |
|||
{ |
|||
provide: ActivatedRoute, |
|||
useValue: { |
|||
queryParams: of({}) |
|||
} |
|||
}, |
|||
{ |
|||
provide: Router, |
|||
useValue: { navigate: jest.fn() } |
|||
}, |
|||
{ |
|||
provide: UserService, |
|||
useValue: { |
|||
stateChanged, |
|||
get: jest.fn().mockReturnValue(of({})) |
|||
} |
|||
} |
|||
], |
|||
schemas: [CUSTOM_ELEMENTS_SCHEMA] |
|||
}).compileComponents(); |
|||
|
|||
fixture = TestBed.createComponent(GfHomeWatchlistComponent); |
|||
component = fixture.componentInstance; |
|||
component.user = { |
|||
permissions: [], |
|||
settings: { locale: 'en' }, |
|||
subscription: { type: 'Basic' } |
|||
} as unknown as User; |
|||
fixture.detectChanges(); |
|||
}); |
|||
|
|||
it('should sort market sentiment cards by buzz score', () => { |
|||
component.watchlist = [ |
|||
{ |
|||
dataSource: 'YAHOO', |
|||
marketSentiment: { |
|||
averageBuzzScore: 31, |
|||
coverage: 2, |
|||
sourceAlignment: 'MIXED', |
|||
sourceMetrics: [], |
|||
trend: 'STABLE' |
|||
}, |
|||
name: 'AAPL', |
|||
symbol: 'AAPL' |
|||
}, |
|||
{ |
|||
dataSource: 'YAHOO', |
|||
marketSentiment: { |
|||
averageBuzzScore: 57, |
|||
coverage: 3, |
|||
sourceAlignment: 'ALIGNED', |
|||
sourceMetrics: [], |
|||
trend: 'RISING' |
|||
}, |
|||
name: 'TSLA', |
|||
symbol: 'TSLA' |
|||
}, |
|||
{ |
|||
dataSource: 'YAHOO', |
|||
marketSentiment: undefined, |
|||
name: 'BND', |
|||
symbol: 'BND' |
|||
} |
|||
] as Benchmark[]; |
|||
|
|||
expect( |
|||
component.watchlistWithMarketSentiment.map(({ symbol }) => symbol) |
|||
).toEqual(['TSLA', 'AAPL']); |
|||
}); |
|||
}); |
|||
@ -1,3 +1,13 @@ |
|||
:host { |
|||
display: block; |
|||
|
|||
.market-sentiment-card { |
|||
border-color: var(--oc-gray-2); |
|||
} |
|||
|
|||
.market-sentiment-section { |
|||
.card-body { |
|||
padding: 1rem; |
|||
} |
|||
} |
|||
} |
|||
|
|||
@ -1,3 +1,11 @@ |
|||
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'; |
|||
|
|||
setupZoneTestEnv(); |
|||
|
|||
Object.assign(globalThis, { |
|||
$localize: (messageParts: TemplateStringsArray, ...expressions: string[]) => { |
|||
return messageParts.reduce((result, part, index) => { |
|||
return result + part + (expressions[index] ?? ''); |
|||
}, ''); |
|||
} |
|||
}); |
|||
|
|||
@ -0,0 +1,26 @@ |
|||
export type MarketSentimentAlignment = |
|||
| 'ALIGNED' |
|||
| 'DIVERGENT' |
|||
| 'MIXED' |
|||
| 'SINGLE_SOURCE'; |
|||
|
|||
export type MarketSentimentSourceName = 'NEWS' | 'POLYMARKET' | 'REDDIT' | 'X'; |
|||
|
|||
export type MarketSentimentTrend = 'FALLING' | 'MIXED' | 'RISING' | 'STABLE'; |
|||
|
|||
export interface MarketSentimentSource { |
|||
activityCount: number; |
|||
bullishPct?: number; |
|||
buzzScore: number; |
|||
source: MarketSentimentSourceName; |
|||
trend?: Exclude<MarketSentimentTrend, 'MIXED'>; |
|||
} |
|||
|
|||
export interface MarketSentiment { |
|||
averageBullishPct?: number; |
|||
averageBuzzScore: number; |
|||
coverage: number; |
|||
sourceAlignment: MarketSentimentAlignment; |
|||
sourceMetrics: MarketSentimentSource[]; |
|||
trend: MarketSentimentTrend; |
|||
} |
|||
@ -0,0 +1 @@ |
|||
export * from './market-sentiment-summary.component'; |
|||
@ -0,0 +1,65 @@ |
|||
@if (marketSentiment) { |
|||
<div class="summary-grid"> |
|||
<div class="summary-stat"> |
|||
<small class="text-muted">Buzz</small> |
|||
<strong>{{ marketSentiment.averageBuzzScore | number: '1.1-1' }}</strong> |
|||
</div> |
|||
<div class="summary-stat"> |
|||
<small class="text-muted">Bullish</small> |
|||
<strong> |
|||
@if (marketSentiment.averageBullishPct !== undefined) { |
|||
{{ marketSentiment.averageBullishPct | number: '1.0-0' }}% |
|||
} @else { |
|||
<span class="text-muted">N/A</span> |
|||
} |
|||
</strong> |
|||
</div> |
|||
<div class="summary-stat"> |
|||
<small class="text-muted">Coverage</small> |
|||
<strong>{{ marketSentiment.coverage }}</strong> |
|||
</div> |
|||
<div class="summary-stat"> |
|||
<small class="text-muted">Alignment</small> |
|||
<strong class="summary-badge">{{ alignmentLabel }}</strong> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="trend-row mt-3"> |
|||
<small class="mr-2 text-muted">Trend</small> |
|||
<span class="summary-badge" [ngClass]="getTrendClass()">{{ |
|||
trendLabel |
|||
}}</span> |
|||
</div> |
|||
|
|||
@if (showSources && sortedSourceMetrics.length > 0) { |
|||
<div class="source-list mt-3"> |
|||
@for (sourceMetric of sortedSourceMetrics; track sourceMetric.source) { |
|||
<div class="source-row"> |
|||
<div class="source-label">{{ getSourceLabel(sourceMetric) }}</div> |
|||
<div class="source-values text-muted"> |
|||
<span>{{ sourceMetric.buzzScore | number: '1.1-1' }}</span> |
|||
<span> |
|||
@if (sourceMetric.bullishPct !== undefined) { |
|||
{{ sourceMetric.bullishPct | number: '1.0-0' }}% |
|||
} @else { |
|||
<ng-container>N/A</ng-container> |
|||
} |
|||
</span> |
|||
<span> |
|||
{{ getActivityLabel(sourceMetric) }}: |
|||
{{ sourceMetric.activityCount | number: '1.0-0' }} |
|||
</span> |
|||
@if (sourceMetric.trend) { |
|||
<span |
|||
class="summary-badge" |
|||
[ngClass]="getTrendClass(sourceMetric)" |
|||
> |
|||
{{ sourceMetric.trend.toLowerCase() }} |
|||
</span> |
|||
} |
|||
</div> |
|||
</div> |
|||
} |
|||
</div> |
|||
} |
|||
} |
|||
@ -0,0 +1,74 @@ |
|||
:host { |
|||
display: block; |
|||
|
|||
.source-list { |
|||
border-top: 1px solid var(--oc-gray-2); |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 0.75rem; |
|||
padding-top: 0.75rem; |
|||
} |
|||
|
|||
.source-label { |
|||
font-size: 0.875rem; |
|||
font-weight: 600; |
|||
} |
|||
|
|||
.source-row { |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 0.35rem; |
|||
} |
|||
|
|||
.source-values { |
|||
column-gap: 0.75rem; |
|||
display: flex; |
|||
flex-wrap: wrap; |
|||
font-size: 0.85rem; |
|||
row-gap: 0.35rem; |
|||
} |
|||
|
|||
.summary-badge { |
|||
background-color: var(--oc-gray-1); |
|||
border-radius: 999px; |
|||
display: inline-flex; |
|||
font-size: 0.75rem; |
|||
font-weight: 600; |
|||
line-height: 1; |
|||
padding: 0.25rem 0.5rem; |
|||
text-transform: capitalize; |
|||
} |
|||
|
|||
.summary-grid { |
|||
display: grid; |
|||
gap: 0.75rem; |
|||
grid-template-columns: repeat(2, minmax(0, 1fr)); |
|||
} |
|||
|
|||
.summary-stat { |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 0.25rem; |
|||
} |
|||
|
|||
.trend-row { |
|||
align-items: center; |
|||
display: flex; |
|||
flex-wrap: wrap; |
|||
} |
|||
|
|||
.is-falling { |
|||
background-color: rgba(240, 62, 62, 0.12); |
|||
color: var(--oc-red-7); |
|||
} |
|||
|
|||
.is-neutral { |
|||
background-color: var(--oc-gray-1); |
|||
color: var(--oc-gray-7); |
|||
} |
|||
|
|||
.is-rising { |
|||
background-color: rgba(47, 158, 68, 0.12); |
|||
color: var(--oc-green-7); |
|||
} |
|||
} |
|||
@ -0,0 +1,71 @@ |
|||
import { MarketSentiment } from '@ghostfolio/common/interfaces'; |
|||
|
|||
import { ComponentFixture, TestBed } from '@angular/core/testing'; |
|||
|
|||
import { GfMarketSentimentSummaryComponent } from './market-sentiment-summary.component'; |
|||
|
|||
describe('GfMarketSentimentSummaryComponent', () => { |
|||
let component: GfMarketSentimentSummaryComponent; |
|||
let fixture: ComponentFixture<GfMarketSentimentSummaryComponent>; |
|||
|
|||
const marketSentiment: MarketSentiment = { |
|||
averageBullishPct: 62.4, |
|||
averageBuzzScore: 48.7, |
|||
coverage: 3, |
|||
sourceAlignment: 'ALIGNED', |
|||
sourceMetrics: [ |
|||
{ |
|||
activityCount: 120, |
|||
bullishPct: 61, |
|||
buzzScore: 54.2, |
|||
source: 'REDDIT', |
|||
trend: 'RISING' |
|||
}, |
|||
{ |
|||
activityCount: 88, |
|||
bullishPct: 63, |
|||
buzzScore: 49.9, |
|||
source: 'X', |
|||
trend: 'RISING' |
|||
} |
|||
], |
|||
trend: 'RISING' |
|||
}; |
|||
|
|||
beforeEach(async () => { |
|||
await TestBed.configureTestingModule({ |
|||
imports: [GfMarketSentimentSummaryComponent] |
|||
}).compileComponents(); |
|||
|
|||
fixture = TestBed.createComponent(GfMarketSentimentSummaryComponent); |
|||
component = fixture.componentInstance; |
|||
component.marketSentiment = marketSentiment; |
|||
fixture.detectChanges(); |
|||
}); |
|||
|
|||
it('should create', () => { |
|||
expect(component).toBeTruthy(); |
|||
}); |
|||
|
|||
it('should render the summary metrics', () => { |
|||
const textContent = fixture.nativeElement.textContent; |
|||
|
|||
expect(textContent).toContain('Buzz'); |
|||
expect(textContent).toContain('Bullish'); |
|||
expect(textContent).toContain('Coverage'); |
|||
expect(textContent).toContain('Alignment'); |
|||
expect(textContent).toContain('48.7'); |
|||
expect(textContent).toContain('62%'); |
|||
}); |
|||
|
|||
it('should render source rows when requested', () => { |
|||
fixture.componentRef.setInput('showSources', true); |
|||
fixture.detectChanges(); |
|||
|
|||
const textContent = fixture.nativeElement.textContent; |
|||
|
|||
expect(textContent).toContain('Reddit'); |
|||
expect(textContent).toContain('X'); |
|||
expect(textContent).toContain('Mentions: 120'); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,63 @@ |
|||
import { |
|||
MarketSentiment, |
|||
MarketSentimentSource |
|||
} from '@ghostfolio/common/interfaces'; |
|||
|
|||
import { CommonModule } from '@angular/common'; |
|||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; |
|||
|
|||
@Component({ |
|||
changeDetection: ChangeDetectionStrategy.OnPush, |
|||
imports: [CommonModule], |
|||
selector: 'gf-market-sentiment-summary', |
|||
styleUrls: ['./market-sentiment-summary.component.scss'], |
|||
templateUrl: './market-sentiment-summary.component.html' |
|||
}) |
|||
export class GfMarketSentimentSummaryComponent { |
|||
@Input({ required: true }) marketSentiment: MarketSentiment; |
|||
@Input() showSources = false; |
|||
|
|||
public get alignmentLabel() { |
|||
return this.marketSentiment?.sourceAlignment |
|||
?.replaceAll('_', ' ') |
|||
.toLowerCase(); |
|||
} |
|||
|
|||
public get sortedSourceMetrics() { |
|||
return [...(this.marketSentiment?.sourceMetrics ?? [])].sort((a, b) => { |
|||
return b.buzzScore - a.buzzScore; |
|||
}); |
|||
} |
|||
|
|||
public get trendLabel() { |
|||
return this.marketSentiment?.trend?.toLowerCase(); |
|||
} |
|||
|
|||
public getActivityLabel(sourceMetric: MarketSentimentSource) { |
|||
return sourceMetric.source === 'POLYMARKET' ? 'Trades' : 'Mentions'; |
|||
} |
|||
|
|||
public getSourceLabel(sourceMetric: MarketSentimentSource) { |
|||
switch (sourceMetric.source) { |
|||
case 'NEWS': |
|||
return 'Finance News'; |
|||
case 'POLYMARKET': |
|||
return 'Polymarket'; |
|||
case 'REDDIT': |
|||
return 'Reddit'; |
|||
case 'X': |
|||
return 'X.com'; |
|||
} |
|||
} |
|||
|
|||
public getTrendClass(sourceMetric?: MarketSentimentSource) { |
|||
switch (sourceMetric?.trend ?? this.marketSentiment?.trend) { |
|||
case 'FALLING': |
|||
return 'is-falling'; |
|||
case 'RISING': |
|||
return 'is-rising'; |
|||
default: |
|||
return 'is-neutral'; |
|||
} |
|||
} |
|||
} |
|||
@ -1,3 +1,11 @@ |
|||
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'; |
|||
|
|||
setupZoneTestEnv(); |
|||
|
|||
Object.assign(globalThis, { |
|||
$localize: (messageParts: TemplateStringsArray, ...expressions: string[]) => { |
|||
return messageParts.reduce((result, part, index) => { |
|||
return result + part + (expressions[index] ?? ''); |
|||
}, ''); |
|||
} |
|||
}); |
|||
|
|||
Loading…
Reference in new issue