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