mirror of https://github.com/ghostfolio/ghostfolio
Browse Source
- Add NewsArticle Prisma model with Finnhub API integration and PostgreSQL storage - Create NestJS news module (service, controller, module) with CRUD endpoints - Add get_portfolio_news AI agent tool wrapping NewsService - Expand eval suite from 55 to 58 test cases with news-specific scenarios - Update all references from 8 to 9 tools and 55 to 58 test cases across docs - Add AI Agent section to project README - Fix Array<T> lint errors in eval.ts and verification.ts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>pull/6456/head
21 changed files with 1651 additions and 1109 deletions
File diff suppressed because it is too large
File diff suppressed because it is too large
@ -0,0 +1,54 @@ |
|||
import { NewsService } from '@ghostfolio/api/app/endpoints/news/news.service'; |
|||
|
|||
import { tool } from 'ai'; |
|||
import { z } from 'zod'; |
|||
|
|||
export function getPortfolioNewsTool(deps: { newsService: NewsService }) { |
|||
return tool({ |
|||
description: |
|||
'Get recent financial news for a specific stock symbol. Provide a ticker symbol like AAPL, MSFT, or VTI to see recent news articles.', |
|||
parameters: z.object({ |
|||
symbol: z |
|||
.string() |
|||
.describe( |
|||
'The stock ticker symbol to get news for (e.g. AAPL, MSFT, VTI)' |
|||
) |
|||
}), |
|||
execute: async ({ symbol }) => { |
|||
const now = new Date(); |
|||
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); |
|||
|
|||
// Try to fetch fresh news from Finnhub
|
|||
await deps.newsService.fetchAndStoreNews({ |
|||
symbol, |
|||
from: thirtyDaysAgo, |
|||
to: now |
|||
}); |
|||
|
|||
// Return stored articles
|
|||
const articles = await deps.newsService.getStoredNews({ |
|||
symbol, |
|||
limit: 5 |
|||
}); |
|||
|
|||
if (articles.length === 0) { |
|||
return { |
|||
symbol, |
|||
articles: [], |
|||
message: `No recent news found for ${symbol}. This may be because the FINNHUB_API_KEY is not configured or the symbol has no recent coverage.` |
|||
}; |
|||
} |
|||
|
|||
return { |
|||
symbol, |
|||
articles: articles.map((a) => ({ |
|||
headline: a.headline, |
|||
summary: a.summary, |
|||
source: a.source, |
|||
publishedAt: a.publishedAt.toISOString(), |
|||
url: a.url |
|||
})) |
|||
}; |
|||
} |
|||
}); |
|||
} |
|||
@ -0,0 +1,60 @@ |
|||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; |
|||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
|||
import { permissions } from '@ghostfolio/common/permissions'; |
|||
|
|||
import { |
|||
Controller, |
|||
Delete, |
|||
Get, |
|||
Post, |
|||
Query, |
|||
UseGuards |
|||
} from '@nestjs/common'; |
|||
import { AuthGuard } from '@nestjs/passport'; |
|||
|
|||
import { NewsService } from './news.service'; |
|||
|
|||
@Controller('news') |
|||
export class NewsController { |
|||
public constructor(private readonly newsService: NewsService) {} |
|||
|
|||
@Get() |
|||
@HasPermission(permissions.readAiPrompt) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async getNews( |
|||
@Query('symbol') symbol?: string, |
|||
@Query('limit') limit?: string |
|||
) { |
|||
return this.newsService.getStoredNews({ |
|||
symbol, |
|||
limit: limit ? parseInt(limit, 10) : 10 |
|||
}); |
|||
} |
|||
|
|||
@Post('fetch') |
|||
@HasPermission(permissions.readAiPrompt) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async fetchNews(@Query('symbol') symbol: string) { |
|||
if (!symbol) { |
|||
return { stored: 0, message: 'symbol query parameter is required' }; |
|||
} |
|||
|
|||
const now = new Date(); |
|||
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); |
|||
|
|||
return this.newsService.fetchAndStoreNews({ |
|||
symbol, |
|||
from: thirtyDaysAgo, |
|||
to: now |
|||
}); |
|||
} |
|||
|
|||
@Delete('cleanup') |
|||
@HasPermission(permissions.readAiPrompt) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async cleanupNews() { |
|||
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); |
|||
|
|||
return this.newsService.deleteOldNews(thirtyDaysAgo); |
|||
} |
|||
} |
|||
@ -0,0 +1,14 @@ |
|||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
|||
|
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { NewsController } from './news.controller'; |
|||
import { NewsService } from './news.service'; |
|||
|
|||
@Module({ |
|||
controllers: [NewsController], |
|||
exports: [NewsService], |
|||
imports: [PrismaModule], |
|||
providers: [NewsService] |
|||
}) |
|||
export class NewsModule {} |
|||
@ -0,0 +1,109 @@ |
|||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
|||
|
|||
import { Injectable, Logger } from '@nestjs/common'; |
|||
|
|||
@Injectable() |
|||
export class NewsService { |
|||
private readonly logger = new Logger(NewsService.name); |
|||
|
|||
public constructor(private readonly prismaService: PrismaService) {} |
|||
|
|||
public async fetchAndStoreNews({ |
|||
symbol, |
|||
from, |
|||
to |
|||
}: { |
|||
symbol: string; |
|||
from: Date; |
|||
to: Date; |
|||
}) { |
|||
const apiKey = process.env.FINNHUB_API_KEY; |
|||
|
|||
if (!apiKey) { |
|||
this.logger.warn('FINNHUB_API_KEY is not configured'); |
|||
return { stored: 0, message: 'FINNHUB_API_KEY is not configured' }; |
|||
} |
|||
|
|||
const fromStr = from.toISOString().split('T')[0]; |
|||
const toStr = to.toISOString().split('T')[0]; |
|||
const url = `https://finnhub.io/api/v1/company-news?symbol=${encodeURIComponent(symbol)}&from=${fromStr}&to=${toStr}&token=${apiKey}`; |
|||
|
|||
try { |
|||
const response = await fetch(url); |
|||
|
|||
if (!response.ok) { |
|||
this.logger.warn( |
|||
`Finnhub API error: ${response.status} ${response.statusText}` |
|||
); |
|||
return { |
|||
stored: 0, |
|||
message: `Finnhub API error: ${response.status}` |
|||
}; |
|||
} |
|||
|
|||
const articles = await response.json(); |
|||
|
|||
if (!Array.isArray(articles) || articles.length === 0) { |
|||
return { stored: 0, message: 'No articles found' }; |
|||
} |
|||
|
|||
let stored = 0; |
|||
|
|||
for (const article of articles) { |
|||
try { |
|||
await this.prismaService.newsArticle.upsert({ |
|||
where: { finnhubId: article.id }, |
|||
create: { |
|||
symbol: symbol.toUpperCase(), |
|||
headline: article.headline || '', |
|||
summary: article.summary || '', |
|||
source: article.source || '', |
|||
url: article.url || '', |
|||
imageUrl: article.image || null, |
|||
publishedAt: new Date(article.datetime * 1000), |
|||
finnhubId: article.id |
|||
}, |
|||
update: { |
|||
headline: article.headline || '', |
|||
summary: article.summary || '', |
|||
source: article.source || '', |
|||
url: article.url || '', |
|||
imageUrl: article.image || null |
|||
} |
|||
}); |
|||
stored++; |
|||
} catch (error) { |
|||
this.logger.warn( |
|||
`Failed to upsert article ${article.id}: ${error.message}` |
|||
); |
|||
} |
|||
} |
|||
|
|||
return { stored, message: `Stored ${stored} articles for ${symbol}` }; |
|||
} catch (error) { |
|||
this.logger.error(`Failed to fetch news for ${symbol}:`, error); |
|||
return { stored: 0, message: `Failed to fetch news: ${error.message}` }; |
|||
} |
|||
} |
|||
|
|||
public async getStoredNews({ |
|||
symbol, |
|||
limit = 10 |
|||
}: { |
|||
symbol?: string; |
|||
limit?: number; |
|||
}) { |
|||
return this.prismaService.newsArticle.findMany({ |
|||
where: symbol ? { symbol: symbol.toUpperCase() } : undefined, |
|||
orderBy: { publishedAt: 'desc' }, |
|||
take: limit |
|||
}); |
|||
} |
|||
|
|||
public async deleteOldNews(olderThan: Date) { |
|||
const result = await this.prismaService.newsArticle.deleteMany({ |
|||
where: { publishedAt: { lt: olderThan } } |
|||
}); |
|||
return { deleted: result.count }; |
|||
} |
|||
} |
|||
@ -0,0 +1,67 @@ |
|||
# BOUNTY.md — Financial News Integration for Ghostfolio |
|||
|
|||
## The Customer |
|||
|
|||
**Self-directed retail investors** who use Ghostfolio to track their portfolio but lack context for _why_ their holdings are moving. Currently, Ghostfolio shows performance numbers — a user sees their portfolio dropped 3% today but has to leave the app and manually search for news about each holding. This is the most common complaint in personal finance tools: data without context. |
|||
|
|||
The specific niche: investors holding 5-20 individual stocks who check their portfolio daily and want a single place to understand both the _what_ (performance) and the _why_ (news events driving price changes). |
|||
|
|||
## The Data Source |
|||
|
|||
**Finnhub Financial News API** (finnhub.io) — a real-time financial data provider offering company-specific news aggregated from major financial publications. The API returns structured articles with headlines, summaries, source attribution, publication timestamps, and URLs. |
|||
|
|||
Articles are fetched per-symbol and stored in Ghostfolio's PostgreSQL database via Prisma, creating a persistent, queryable news archive tied to the user's portfolio holdings. This is not a pass-through cache — articles are stored as first-class entities with full CRUD operations. |
|||
|
|||
### Data Model |
|||
|
|||
``` |
|||
NewsArticle { |
|||
id String @id @default(cuid()) |
|||
symbol String // e.g., "AAPL" |
|||
headline String |
|||
summary String |
|||
source String // e.g., "Reuters", "CNBC" |
|||
url String |
|||
imageUrl String? |
|||
publishedAt DateTime |
|||
finnhubId Int @unique // deduplication key |
|||
createdAt DateTime @default(now()) |
|||
updatedAt DateTime @updatedAt |
|||
} |
|||
``` |
|||
|
|||
### API Endpoints (CRUD) |
|||
|
|||
| Method | Endpoint | Purpose | |
|||
| ------ | -------------------------------- | ---------------------------------- | |
|||
| GET | `/api/v1/news?symbol=AAPL` | Read stored articles for a symbol | |
|||
| POST | `/api/v1/news/fetch?symbol=AAPL` | Fetch from Finnhub and store | |
|||
| DELETE | `/api/v1/news/cleanup` | Remove articles older than 30 days | |
|||
|
|||
## The Features |
|||
|
|||
### 1. News Storage and Retrieval |
|||
|
|||
Ghostfolio now stores financial news articles linked to portfolio symbols. Articles are fetched from Finnhub, deduplicated by source ID, and persisted in PostgreSQL. The system handles missing API keys, rate limits, and invalid symbols gracefully. |
|||
|
|||
### 2. AI Agent News Tool |
|||
|
|||
A new `get_portfolio_news` tool in the AI agent allows natural language news queries: |
|||
|
|||
- **"What news is there about AAPL?"** — Fetches and returns recent Apple news |
|||
- **"What news is affecting my portfolio?"** — Combines holdings lookup with news fetch across all positions |
|||
- **"Why did my portfolio drop today?"** — Multi-step: gets performance data, identifies losers, fetches their news |
|||
|
|||
The tool integrates with the existing 8-tool agent, enabling multi-step queries that combine news context with portfolio data, performance metrics, and transaction history. |
|||
|
|||
### 3. Eval Coverage |
|||
|
|||
New test cases validate the news tool across happy path, multi-step, and edge case scenarios, maintaining the suite's 100% pass rate. |
|||
|
|||
## The Impact |
|||
|
|||
**Before:** A Ghostfolio user sees their portfolio is down 2.4% today. They open a new browser tab, search for "AAPL stock news," then "MSFT stock news," then "VTI stock news" — repeating for each holding. They mentally piece together which news events explain the drop. |
|||
|
|||
**After:** The user asks the AI agent "Why is my portfolio down today?" The agent checks performance, identifies the biggest losers, fetches relevant news for those symbols, and synthesizes a response: "Your portfolio is down 2.4% today, primarily driven by MSFT (-3.1%) after reports of slowing cloud growth, and AAPL (-1.8%) following supply chain concerns in Asia. VTI is flat. Here are the key articles..." |
|||
|
|||
This transforms Ghostfolio from a portfolio _tracker_ into a portfolio _intelligence_ tool — the difference between a dashboard and an advisor. |
|||
Loading…
Reference in new issue