From 7a346081ed813fe0905c4bc147714792629dc88f Mon Sep 17 00:00:00 2001 From: Alex Schneider Date: Wed, 1 Apr 2026 17:47:21 +0200 Subject: [PATCH] feat: add retail market sentiment overlays --- .env.example | 1 + .../endpoints/watchlist/watchlist.module.ts | 2 + .../endpoints/watchlist/watchlist.service.ts | 22 + .../api/src/app/portfolio/portfolio.module.ts | 2 + .../src/app/portfolio/portfolio.service.ts | 7 + .../configuration/configuration.service.ts | 2 + .../interfaces/environment.interface.ts | 2 + .../market-sentiment.module.ts | 13 + .../market-sentiment.service.spec.ts | 181 ++++++++ .../market-sentiment.service.ts | 391 ++++++++++++++++++ .../holding-detail-dialog.component.scss | 6 + .../holding-detail-dialog.component.spec.ts | 192 +++++++++ .../holding-detail-dialog.component.ts | 6 + .../holding-detail-dialog.html | 17 + .../home-watchlist.component.spec.ts | 134 ++++++ .../home-watchlist.component.ts | 16 + .../home-watchlist/home-watchlist.html | 32 ++ .../home-watchlist/home-watchlist.scss | 10 + apps/client/src/test-setup.ts | 8 + .../src/lib/interfaces/benchmark.interface.ts | 2 + libs/common/src/lib/interfaces/index.ts | 12 + .../interfaces/market-sentiment.interface.ts | 26 ++ .../portfolio-holding-response.interface.ts | 4 +- .../responses/watchlist-response.interface.ts | 1 + .../src/lib/market-sentiment-summary/index.ts | 1 + .../market-sentiment-summary.component.html | 65 +++ .../market-sentiment-summary.component.scss | 74 ++++ ...market-sentiment-summary.component.spec.ts | 71 ++++ .../market-sentiment-summary.component.ts | 63 +++ libs/ui/src/test-setup.ts | 8 + 30 files changed, 1370 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/services/market-sentiment/market-sentiment.module.ts create mode 100644 apps/api/src/services/market-sentiment/market-sentiment.service.spec.ts create mode 100644 apps/api/src/services/market-sentiment/market-sentiment.service.ts create mode 100644 apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.spec.ts create mode 100644 apps/client/src/app/components/home-watchlist/home-watchlist.component.spec.ts create mode 100644 libs/common/src/lib/interfaces/market-sentiment.interface.ts create mode 100644 libs/ui/src/lib/market-sentiment-summary/index.ts create mode 100644 libs/ui/src/lib/market-sentiment-summary/market-sentiment-summary.component.html create mode 100644 libs/ui/src/lib/market-sentiment-summary/market-sentiment-summary.component.scss create mode 100644 libs/ui/src/lib/market-sentiment-summary/market-sentiment-summary.component.spec.ts create mode 100644 libs/ui/src/lib/market-sentiment-summary/market-sentiment-summary.component.ts diff --git a/.env.example b/.env.example index e4a935626..1ee42bb35 100644 --- a/.env.example +++ b/.env.example @@ -12,5 +12,6 @@ POSTGRES_PASSWORD= # VARIOUS ACCESS_TOKEN_SALT= +API_KEY_ADANOS= DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer JWT_SECRET_KEY= diff --git a/apps/api/src/app/endpoints/watchlist/watchlist.module.ts b/apps/api/src/app/endpoints/watchlist/watchlist.module.ts index ce9ae12bb..7743a61ca 100644 --- a/apps/api/src/app/endpoints/watchlist/watchlist.module.ts +++ b/apps/api/src/app/endpoints/watchlist/watchlist.module.ts @@ -4,6 +4,7 @@ import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.mo import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; +import { MarketSentimentModule } from '@ghostfolio/api/services/market-sentiment/market-sentiment.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; @@ -20,6 +21,7 @@ import { WatchlistService } from './watchlist.service'; DataGatheringModule, DataProviderModule, ImpersonationModule, + MarketSentimentModule, MarketDataModule, PrismaModule, SymbolProfileModule, diff --git a/apps/api/src/app/endpoints/watchlist/watchlist.service.ts b/apps/api/src/app/endpoints/watchlist/watchlist.service.ts index 666023dbf..83707e06d 100644 --- a/apps/api/src/app/endpoints/watchlist/watchlist.service.ts +++ b/apps/api/src/app/endpoints/watchlist/watchlist.service.ts @@ -1,9 +1,11 @@ import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; +import { MarketSentimentService } from '@ghostfolio/api/services/market-sentiment/market-sentiment.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { WatchlistResponse } from '@ghostfolio/common/interfaces'; import { BadRequestException, Injectable } from '@nestjs/common'; @@ -15,6 +17,7 @@ export class WatchlistService { private readonly benchmarkService: BenchmarkService, private readonly dataGatheringService: DataGatheringService, private readonly dataProviderService: DataProviderService, + private readonly marketSentimentService: MarketSentimentService, private readonly marketDataService: MarketDataService, private readonly prismaService: PrismaService, private readonly symbolProfileService: SymbolProfileService @@ -110,6 +113,22 @@ export class WatchlistService { }) ]); + const watchlistMarketSentiment = + await this.marketSentimentService.getWatchlistMarketSentiment( + assetProfiles + ); + const watchlistMarketSentimentBySymbol = new Map( + watchlistMarketSentiment.map((item) => { + return [ + getAssetProfileIdentifier({ + dataSource: item.dataSource, + symbol: item.symbol + }), + item.marketSentiment + ] as const; + }) + ); + const watchlist = await Promise.all( user.watchlist.map(async ({ dataSource, symbol }) => { const assetProfile = assetProfiles.find((profile) => { @@ -135,6 +154,9 @@ export class WatchlistService { symbol, marketCondition: this.benchmarkService.getMarketCondition(performancePercent), + marketSentiment: watchlistMarketSentimentBySymbol.get( + getAssetProfileIdentifier({ dataSource, symbol }) + ), name: assetProfile?.name, performances: { allTimeHigh: { diff --git a/apps/api/src/app/portfolio/portfolio.module.ts b/apps/api/src/app/portfolio/portfolio.module.ts index 65a9b71aa..4775bc26a 100644 --- a/apps/api/src/app/portfolio/portfolio.module.ts +++ b/apps/api/src/app/portfolio/portfolio.module.ts @@ -16,6 +16,7 @@ import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-d import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; +import { MarketSentimentModule } from '@ghostfolio/api/services/market-sentiment/market-sentiment.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; @@ -44,6 +45,7 @@ import { RulesService } from './rules.service'; I18nModule, ImpersonationModule, MarketDataModule, + MarketSentimentModule, PerformanceLoggingModule, PortfolioSnapshotQueueModule, PrismaModule, diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 60b413cf9..ce914f011 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -25,6 +25,7 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; +import { MarketSentimentService } from '@ghostfolio/api/services/market-sentiment/market-sentiment.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { getAnnualizedPerformancePercent, @@ -112,6 +113,7 @@ export class PortfolioService { private readonly exchangeRateDataService: ExchangeRateDataService, private readonly i18nService: I18nService, private readonly impersonationService: ImpersonationService, + private readonly marketSentimentService: MarketSentimentService, @Inject(REQUEST) private readonly request: RequestWithUser, private readonly rulesService: RulesService, private readonly symbolProfileService: SymbolProfileService, @@ -792,6 +794,10 @@ export class PortfolioService { const [SymbolProfile] = await this.symbolProfileService.getSymbolProfiles([ { dataSource, symbol } ]); + const marketSentiment = + await this.marketSentimentService.getHoldingMarketSentiment( + SymbolProfile + ); const portfolioCalculator = this.calculatorFactory.createCalculator({ activities, @@ -949,6 +955,7 @@ export class PortfolioService { marketPrice, marketPriceMax, marketPriceMin, + marketSentiment, SymbolProfile, tags, averagePrice: averagePrice.toNumber(), diff --git a/apps/api/src/services/configuration/configuration.service.ts b/apps/api/src/services/configuration/configuration.service.ts index ad8e84a99..42406f327 100644 --- a/apps/api/src/services/configuration/configuration.service.ts +++ b/apps/api/src/services/configuration/configuration.service.ts @@ -22,6 +22,7 @@ export class ConfigurationService { public constructor() { this.environmentConfiguration = cleanEnv(process.env, { ACCESS_TOKEN_SALT: str(), + API_KEY_ADANOS: str({ default: '' }), API_KEY_ALPHA_VANTAGE: str({ default: '' }), API_KEY_BETTER_UPTIME: str({ default: '' }), API_KEY_COINGECKO_DEMO: str({ default: '' }), @@ -31,6 +32,7 @@ export class ConfigurationService { API_KEY_OPEN_FIGI: str({ default: '' }), API_KEY_RAPID_API: str({ default: '' }), BULL_BOARD_IS_READ_ONLY: bool({ default: true }), + CACHE_MARKET_SENTIMENT_TTL: num({ default: ms('6 hours') }), CACHE_QUOTES_TTL: num({ default: ms('1 minute') }), CACHE_TTL: num({ default: CACHE_TTL_NO_CACHE }), DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }), diff --git a/apps/api/src/services/interfaces/environment.interface.ts b/apps/api/src/services/interfaces/environment.interface.ts index 9664ae144..37e7dee56 100644 --- a/apps/api/src/services/interfaces/environment.interface.ts +++ b/apps/api/src/services/interfaces/environment.interface.ts @@ -2,6 +2,7 @@ import { CleanedEnvAccessors } from 'envalid'; export interface Environment extends CleanedEnvAccessors { ACCESS_TOKEN_SALT: string; + API_KEY_ADANOS: string; API_KEY_ALPHA_VANTAGE: string; API_KEY_BETTER_UPTIME: string; API_KEY_COINGECKO_DEMO: string; @@ -11,6 +12,7 @@ export interface Environment extends CleanedEnvAccessors { API_KEY_OPEN_FIGI: string; API_KEY_RAPID_API: string; BULL_BOARD_IS_READ_ONLY: boolean; + CACHE_MARKET_SENTIMENT_TTL: number; CACHE_QUOTES_TTL: number; CACHE_TTL: number; DATA_SOURCE_EXCHANGE_RATES: string; diff --git a/apps/api/src/services/market-sentiment/market-sentiment.module.ts b/apps/api/src/services/market-sentiment/market-sentiment.module.ts new file mode 100644 index 000000000..6eeb6a0cc --- /dev/null +++ b/apps/api/src/services/market-sentiment/market-sentiment.module.ts @@ -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 {} diff --git a/apps/api/src/services/market-sentiment/market-sentiment.service.spec.ts b/apps/api/src/services/market-sentiment/market-sentiment.service.spec.ts new file mode 100644 index 000000000..630cb7c1c --- /dev/null +++ b/apps/api/src/services/market-sentiment/market-sentiment.service.spec.ts @@ -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); + }); +}); diff --git a/apps/api/src/services/market-sentiment/market-sentiment.service.ts b/apps/api/src/services/market-sentiment/market-sentiment.service.ts new file mode 100644 index 000000000..33deaf229 --- /dev/null +++ b/apps/api/src/services/market-sentiment/market-sentiment.service.ts @@ -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 { + 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(); + + 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 => { + 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 { + 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 { + 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 { + switch ((trend ?? '').toLowerCase()) { + case 'falling': + return 'FALLING'; + case 'rising': + return 'RISING'; + default: + return 'STABLE'; + } + } +} diff --git a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.scss b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.scss index 50b8db3fe..1a84aaedc 100644 --- a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.scss +++ b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.scss @@ -12,5 +12,11 @@ .button-container { gap: 0.5rem; } + + .market-sentiment-card { + background-color: var(--oc-gray-0); + border: 1px solid var(--oc-gray-2); + border-radius: 0.75rem; + } } } diff --git a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.spec.ts b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.spec.ts new file mode 100644 index 000000000..ef5b4fbaf --- /dev/null +++ b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.spec.ts @@ -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; + + const stateChanged = new Subject(); + + 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); + }); +}); diff --git a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts index 13ded73eb..7c4ec54dc 100644 --- a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts +++ b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts @@ -12,6 +12,7 @@ import { EnhancedSymbolProfile, Filter, LineChartItem, + MarketSentiment, User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; @@ -24,6 +25,7 @@ import { GfDialogHeaderComponent } from '@ghostfolio/ui/dialog-header'; import { GfHistoricalMarketDataEditorComponent } from '@ghostfolio/ui/historical-market-data-editor'; import { translate } from '@ghostfolio/ui/i18n'; 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'; @@ -84,6 +86,7 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces'; GfDialogHeaderComponent, GfHistoricalMarketDataEditorComponent, GfLineChartComponent, + GfMarketSentimentSummaryComponent, GfPortfolioProportionChartComponent, GfTagsSelectorComponent, GfValueComponent, @@ -135,6 +138,7 @@ export class GfHoldingDetailDialogComponent implements OnInit { public marketPriceMin: number; public marketPriceMinPrecision = 2; public marketPricePrecision = 2; + public marketSentiment: MarketSentiment; public netPerformance: number; public netPerformancePrecision = 2; public netPerformancePercent: number; @@ -275,6 +279,7 @@ export class GfHoldingDetailDialogComponent implements OnInit { marketPrice, marketPriceMax, marketPriceMin, + marketSentiment, netPerformance, netPerformancePercent, netPerformancePercentWithCurrencyEffect, @@ -357,6 +362,7 @@ export class GfHoldingDetailDialogComponent implements OnInit { } this.marketPriceMin = marketPriceMin; + this.marketSentiment = marketSentiment; if ( this.data.deviceType === 'mobile' && diff --git a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html index b8cb8dda2..215cc3d95 100644 --- a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html +++ b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html @@ -219,6 +219,23 @@ >First Activity + @if (marketSentiment) { +
+
+
+ Market Sentiment + + Retail discussion and market attention across supported + sources + +
+ +
+
+ }
{ + class MockIonIconComponent {} + + return { IonIcon: MockIonIconComponent }; +}); + +describe('GfHomeWatchlistComponent', () => { + let component: GfHomeWatchlistComponent; + let fixture: ComponentFixture; + + 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']); + }); +}); diff --git a/apps/client/src/app/components/home-watchlist/home-watchlist.component.ts b/apps/client/src/app/components/home-watchlist/home-watchlist.component.ts index 4adb4e54f..2fdfa2d2c 100644 --- a/apps/client/src/app/components/home-watchlist/home-watchlist.component.ts +++ b/apps/client/src/app/components/home-watchlist/home-watchlist.component.ts @@ -7,6 +7,7 @@ import { } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; 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'; @@ -35,6 +36,7 @@ import { CreateWatchlistItemDialogParams } from './create-watchlist-item-dialog/ changeDetection: ChangeDetectionStrategy.OnPush, imports: [ GfBenchmarkComponent, + GfMarketSentimentSummaryComponent, GfPremiumIndicatorComponent, IonIcon, MatButtonModule, @@ -53,6 +55,20 @@ export class GfHomeWatchlistComponent implements OnDestroy, OnInit { public user: User; public watchlist: Benchmark[]; + public get watchlistWithMarketSentiment() { + return (this.watchlist ?? []) + .filter(({ marketSentiment }) => { + return !!marketSentiment; + }) + .sort((a, b) => { + return ( + (b.marketSentiment?.averageBuzzScore ?? 0) - + (a.marketSentiment?.averageBuzzScore ?? 0) + ); + }) + .slice(0, 3); + } + private unsubscribeSubject = new Subject(); public constructor( diff --git a/apps/client/src/app/components/home-watchlist/home-watchlist.html b/apps/client/src/app/components/home-watchlist/home-watchlist.html index 743d86c37..82757f682 100644 --- a/apps/client/src/app/components/home-watchlist/home-watchlist.html +++ b/apps/client/src/app/components/home-watchlist/home-watchlist.html @@ -9,6 +9,38 @@
+ @if (watchlistWithMarketSentiment.length > 0) { +
+
+

Market Sentiment

+ Most discussed watchlist assets +
+
+ @for ( + watchlistItem of watchlistWithMarketSentiment; + track watchlistItem.dataSource + ':' + watchlistItem.symbol + ) { +
+
+
+
+ + {{ watchlistItem.name || watchlistItem.symbol }} + + {{ + watchlistItem.symbol + }} +
+ +
+
+
+ } +
+
+ } { + return messageParts.reduce((result, part, index) => { + return result + part + (expressions[index] ?? ''); + }, ''); + } +}); diff --git a/libs/common/src/lib/interfaces/benchmark.interface.ts b/libs/common/src/lib/interfaces/benchmark.interface.ts index bf85cd752..192a3a3df 100644 --- a/libs/common/src/lib/interfaces/benchmark.interface.ts +++ b/libs/common/src/lib/interfaces/benchmark.interface.ts @@ -1,10 +1,12 @@ import { BenchmarkTrend } from '@ghostfolio/common/types/'; import { EnhancedSymbolProfile } from './enhanced-symbol-profile.interface'; +import { MarketSentiment } from './market-sentiment.interface'; export interface Benchmark { dataSource: EnhancedSymbolProfile['dataSource']; marketCondition: 'ALL_TIME_HIGH' | 'BEAR_MARKET' | 'NEUTRAL_MARKET'; + marketSentiment?: MarketSentiment; name: EnhancedSymbolProfile['name']; performances: { allTimeHigh: { diff --git a/libs/common/src/lib/interfaces/index.ts b/libs/common/src/lib/interfaces/index.ts index ad747d94e..80121eec3 100644 --- a/libs/common/src/lib/interfaces/index.ts +++ b/libs/common/src/lib/interfaces/index.ts @@ -28,6 +28,13 @@ import type { InvestmentItem } from './investment-item.interface'; import type { LineChartItem } from './line-chart-item.interface'; import type { LookupItem } from './lookup-item.interface'; import type { MarketData } from './market-data.interface'; +import type { + MarketSentiment, + MarketSentimentAlignment, + MarketSentimentSource, + MarketSentimentSourceName, + MarketSentimentTrend +} from './market-sentiment.interface'; import type { PortfolioChart } from './portfolio-chart.interface'; import type { PortfolioDetails } from './portfolio-details.interface'; import type { PortfolioPerformance } from './portfolio-performance.interface'; @@ -153,6 +160,11 @@ export { InvestmentItem, LineChartItem, LookupItem, + MarketSentiment, + MarketSentimentAlignment, + MarketSentimentSource, + MarketSentimentSourceName, + MarketSentimentTrend, LookupResponse, MarketData, MarketDataDetailsResponse, diff --git a/libs/common/src/lib/interfaces/market-sentiment.interface.ts b/libs/common/src/lib/interfaces/market-sentiment.interface.ts new file mode 100644 index 000000000..629f9ada1 --- /dev/null +++ b/libs/common/src/lib/interfaces/market-sentiment.interface.ts @@ -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; +} + +export interface MarketSentiment { + averageBullishPct?: number; + averageBuzzScore: number; + coverage: number; + sourceAlignment: MarketSentimentAlignment; + sourceMetrics: MarketSentimentSource[]; + trend: MarketSentimentTrend; +} diff --git a/libs/common/src/lib/interfaces/responses/portfolio-holding-response.interface.ts b/libs/common/src/lib/interfaces/responses/portfolio-holding-response.interface.ts index 76bc7dc02..25fb3455d 100644 --- a/libs/common/src/lib/interfaces/responses/portfolio-holding-response.interface.ts +++ b/libs/common/src/lib/interfaces/responses/portfolio-holding-response.interface.ts @@ -2,7 +2,8 @@ import { Benchmark, DataProviderInfo, EnhancedSymbolProfile, - HistoricalDataItem + HistoricalDataItem, + MarketSentiment } from '@ghostfolio/common/interfaces'; import { Tag } from '@prisma/client'; @@ -25,6 +26,7 @@ export interface PortfolioHoldingResponse { marketPrice: number; marketPriceMax: number; marketPriceMin: number; + marketSentiment?: MarketSentiment; netPerformance: number; netPerformancePercent: number; netPerformancePercentWithCurrencyEffect: number; diff --git a/libs/common/src/lib/interfaces/responses/watchlist-response.interface.ts b/libs/common/src/lib/interfaces/responses/watchlist-response.interface.ts index 21570a459..476aeebce 100644 --- a/libs/common/src/lib/interfaces/responses/watchlist-response.interface.ts +++ b/libs/common/src/lib/interfaces/responses/watchlist-response.interface.ts @@ -6,6 +6,7 @@ import { export interface WatchlistResponse { watchlist: (AssetProfileIdentifier & { marketCondition: Benchmark['marketCondition']; + marketSentiment?: Benchmark['marketSentiment']; name: string; performances: Benchmark['performances']; trend50d: Benchmark['trend50d']; diff --git a/libs/ui/src/lib/market-sentiment-summary/index.ts b/libs/ui/src/lib/market-sentiment-summary/index.ts new file mode 100644 index 000000000..77eac6c51 --- /dev/null +++ b/libs/ui/src/lib/market-sentiment-summary/index.ts @@ -0,0 +1 @@ +export * from './market-sentiment-summary.component'; diff --git a/libs/ui/src/lib/market-sentiment-summary/market-sentiment-summary.component.html b/libs/ui/src/lib/market-sentiment-summary/market-sentiment-summary.component.html new file mode 100644 index 000000000..c69eab539 --- /dev/null +++ b/libs/ui/src/lib/market-sentiment-summary/market-sentiment-summary.component.html @@ -0,0 +1,65 @@ +@if (marketSentiment) { +
+
+ Buzz + {{ marketSentiment.averageBuzzScore | number: '1.1-1' }} +
+
+ Bullish + + @if (marketSentiment.averageBullishPct !== undefined) { + {{ marketSentiment.averageBullishPct | number: '1.0-0' }}% + } @else { + N/A + } + +
+
+ Coverage + {{ marketSentiment.coverage }} +
+
+ Alignment + {{ alignmentLabel }} +
+
+ +
+ Trend + {{ + trendLabel + }} +
+ + @if (showSources && sortedSourceMetrics.length > 0) { +
+ @for (sourceMetric of sortedSourceMetrics; track sourceMetric.source) { +
+
{{ getSourceLabel(sourceMetric) }}
+
+ {{ sourceMetric.buzzScore | number: '1.1-1' }} + + @if (sourceMetric.bullishPct !== undefined) { + {{ sourceMetric.bullishPct | number: '1.0-0' }}% + } @else { + N/A + } + + + {{ getActivityLabel(sourceMetric) }}: + {{ sourceMetric.activityCount | number: '1.0-0' }} + + @if (sourceMetric.trend) { + + {{ sourceMetric.trend.toLowerCase() }} + + } +
+
+ } +
+ } +} diff --git a/libs/ui/src/lib/market-sentiment-summary/market-sentiment-summary.component.scss b/libs/ui/src/lib/market-sentiment-summary/market-sentiment-summary.component.scss new file mode 100644 index 000000000..70274a3dc --- /dev/null +++ b/libs/ui/src/lib/market-sentiment-summary/market-sentiment-summary.component.scss @@ -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); + } +} diff --git a/libs/ui/src/lib/market-sentiment-summary/market-sentiment-summary.component.spec.ts b/libs/ui/src/lib/market-sentiment-summary/market-sentiment-summary.component.spec.ts new file mode 100644 index 000000000..60cba2072 --- /dev/null +++ b/libs/ui/src/lib/market-sentiment-summary/market-sentiment-summary.component.spec.ts @@ -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; + + 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'); + }); +}); diff --git a/libs/ui/src/lib/market-sentiment-summary/market-sentiment-summary.component.ts b/libs/ui/src/lib/market-sentiment-summary/market-sentiment-summary.component.ts new file mode 100644 index 000000000..9494ea3c6 --- /dev/null +++ b/libs/ui/src/lib/market-sentiment-summary/market-sentiment-summary.component.ts @@ -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'; + } + } +} diff --git a/libs/ui/src/test-setup.ts b/libs/ui/src/test-setup.ts index 58c511e08..e7d2c3938 100644 --- a/libs/ui/src/test-setup.ts +++ b/libs/ui/src/test-setup.ts @@ -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] ?? ''); + }, ''); + } +});