Browse Source

Merge 7a346081ed into 766dc5f2ea

pull/6646/merge
Alexander Schneider 3 days ago
committed by GitHub
parent
commit
1c3bc71810
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      .env.example
  2. 2
      apps/api/src/app/endpoints/watchlist/watchlist.module.ts
  3. 22
      apps/api/src/app/endpoints/watchlist/watchlist.service.ts
  4. 2
      apps/api/src/app/portfolio/portfolio.module.ts
  5. 7
      apps/api/src/app/portfolio/portfolio.service.ts
  6. 2
      apps/api/src/services/configuration/configuration.service.ts
  7. 2
      apps/api/src/services/interfaces/environment.interface.ts
  8. 13
      apps/api/src/services/market-sentiment/market-sentiment.module.ts
  9. 181
      apps/api/src/services/market-sentiment/market-sentiment.service.spec.ts
  10. 391
      apps/api/src/services/market-sentiment/market-sentiment.service.ts
  11. 6
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.scss
  12. 192
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.spec.ts
  13. 6
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  14. 17
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  15. 134
      apps/client/src/app/components/home-watchlist/home-watchlist.component.spec.ts
  16. 16
      apps/client/src/app/components/home-watchlist/home-watchlist.component.ts
  17. 32
      apps/client/src/app/components/home-watchlist/home-watchlist.html
  18. 10
      apps/client/src/app/components/home-watchlist/home-watchlist.scss
  19. 8
      apps/client/src/test-setup.ts
  20. 2
      libs/common/src/lib/interfaces/benchmark.interface.ts
  21. 12
      libs/common/src/lib/interfaces/index.ts
  22. 26
      libs/common/src/lib/interfaces/market-sentiment.interface.ts
  23. 4
      libs/common/src/lib/interfaces/responses/portfolio-holding-response.interface.ts
  24. 1
      libs/common/src/lib/interfaces/responses/watchlist-response.interface.ts
  25. 1
      libs/ui/src/lib/market-sentiment-summary/index.ts
  26. 65
      libs/ui/src/lib/market-sentiment-summary/market-sentiment-summary.component.html
  27. 74
      libs/ui/src/lib/market-sentiment-summary/market-sentiment-summary.component.scss
  28. 71
      libs/ui/src/lib/market-sentiment-summary/market-sentiment-summary.component.spec.ts
  29. 63
      libs/ui/src/lib/market-sentiment-summary/market-sentiment-summary.component.ts
  30. 8
      libs/ui/src/test-setup.ts

1
.env.example

@ -12,5 +12,6 @@ POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
# VARIOUS
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
API_KEY_ADANOS=
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>

2
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,

22
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: {

2
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,

7
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(),

2
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 }),

2
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;

13
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 {}

181
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);
});
});

391
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<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';
}
}
}

6
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;
}
}
}

192
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<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);
});
});

6
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' &&

17
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html

@ -219,6 +219,23 @@
>First Activity</gf-value
>
</div>
@if (marketSentiment) {
<div class="col-12 mb-3">
<div class="market-sentiment-card p-3">
<div class="d-flex flex-column mb-3">
<strong>Market Sentiment</strong>
<small class="text-muted">
Retail discussion and market attention across supported
sources
</small>
</div>
<gf-market-sentiment-summary
[marketSentiment]="marketSentiment"
[showSources]="true"
/>
</div>
</div>
}
<div class="col-6 mb-3">
<gf-value
size="medium"

134
apps/client/src/app/components/home-watchlist/home-watchlist.component.spec.ts

@ -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']);
});
});

16
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<void>();
public constructor(

32
apps/client/src/app/components/home-watchlist/home-watchlist.html

@ -9,6 +9,38 @@
</h1>
<div class="row">
<div class="col-xs-12 col-md-10 offset-md-1">
@if (watchlistWithMarketSentiment.length > 0) {
<section class="market-sentiment-section mb-4">
<div class="align-items-center d-flex justify-content-between mb-3">
<h2 class="h5 mb-0">Market Sentiment</h2>
<small class="text-muted">Most discussed watchlist assets</small>
</div>
<div class="row">
@for (
watchlistItem of watchlistWithMarketSentiment;
track watchlistItem.dataSource + ':' + watchlistItem.symbol
) {
<div class="col-12 col-xl-4 mb-3">
<div class="card h-100 market-sentiment-card">
<div class="card-body">
<div class="d-flex flex-column mb-3">
<strong class="line-height-1">
{{ watchlistItem.name || watchlistItem.symbol }}
</strong>
<small class="text-muted">{{
watchlistItem.symbol
}}</small>
</div>
<gf-market-sentiment-summary
[marketSentiment]="watchlistItem.marketSentiment"
/>
</div>
</div>
</div>
}
</div>
</section>
}
<gf-benchmark
[benchmarks]="watchlist"
[deviceType]="deviceType"

10
apps/client/src/app/components/home-watchlist/home-watchlist.scss

@ -1,3 +1,13 @@
:host {
display: block;
.market-sentiment-card {
border-color: var(--oc-gray-2);
}
.market-sentiment-section {
.card-body {
padding: 1rem;
}
}
}

8
apps/client/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] ?? '');
}, '');
}
});

2
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: {

12
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,

26
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<MarketSentimentTrend, 'MIXED'>;
}
export interface MarketSentiment {
averageBullishPct?: number;
averageBuzzScore: number;
coverage: number;
sourceAlignment: MarketSentimentAlignment;
sourceMetrics: MarketSentimentSource[];
trend: MarketSentimentTrend;
}

4
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;

1
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'];

1
libs/ui/src/lib/market-sentiment-summary/index.ts

@ -0,0 +1 @@
export * from './market-sentiment-summary.component';

65
libs/ui/src/lib/market-sentiment-summary/market-sentiment-summary.component.html

@ -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>
}
}

74
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);
}
}

71
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<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');
});
});

63
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';
}
}
}

8
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] ?? '');
}, '');
}
});

Loading…
Cancel
Save