Files
aitrade/pkg/app/news/json_sources.go
T
kaedwen 62809a01ea
Build and Push Docker Image / build-and-push (push) Successful in 4m7s
parallel source fetch with timeout
Signed-off-by: kaedwen <kaedwen@heinrich.blue>
2026-07-09 21:11:58 +02:00

319 lines
7.3 KiB
Go

package news
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/pheinrich/aitrade/pkg/model"
)
// AlphaVantageSource fetches news from Alpha Vantage News API
type AlphaVantageSource struct {
name string
url string
logger *slog.Logger
httpClient *http.Client
// Rate limiting
maxPerHour int
maxPerDay int
hourlyCounter int
dailyCounter int
lastHourReset time.Time
lastDayReset time.Time
}
type alphaVantageResponse struct {
Feed []struct {
Title string `json:"title"`
URL string `json:"url"`
TimePublished string `json:"time_published"`
Summary string `json:"summary"`
Source string `json:"source"`
} `json:"feed"`
}
func NewAlphaVantageSource(name, url string, logger *slog.Logger) *AlphaVantageSource {
now := time.Now()
return &AlphaVantageSource{
name: name,
url: url,
logger: logger,
httpClient: &http.Client{
Timeout: 30 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("stopped after 10 redirects")
}
logger.Debug("following redirect",
slog.String("source", name),
slog.String("from", via[len(via)-1].URL.String()),
slog.String("to", req.URL.String()))
return nil
},
},
lastHourReset: now,
lastDayReset: now,
}
}
func (s *AlphaVantageSource) SetRateLimit(maxPerHour, maxPerDay int) {
s.maxPerHour = maxPerHour
s.maxPerDay = maxPerDay
}
func (s *AlphaVantageSource) Name() string {
return s.name
}
func (s *AlphaVantageSource) Fetch(ctx context.Context) ([]*model.NewsArticle, error) {
// Check rate limits
if !s.checkRateLimit() {
s.logger.Warn("rate limit reached, skipping fetch",
slog.String("source", s.name),
slog.Int("hourly", s.hourlyCounter),
slog.Int("max_per_hour", s.maxPerHour),
slog.Int("daily", s.dailyCounter),
slog.Int("max_per_day", s.maxPerDay))
return nil, nil
}
s.logger.Debug("fetching Alpha Vantage news", slog.String("source", s.name))
req, err := http.NewRequestWithContext(ctx, "GET", s.url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("http error: %s", resp.Status)
}
var data alphaVantageResponse
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return nil, fmt.Errorf("failed to parse JSON: %w", err)
}
var articles []*model.NewsArticle
for _, item := range data.Feed {
// Parse time: "20260702T133830"
publishedAt := time.Now()
if t, err := time.Parse("20060102T150405", item.TimePublished); err == nil {
publishedAt = t
}
article := &model.NewsArticle{
Source: s.name,
Title: item.Title,
URL: item.URL,
Content: item.Summary,
PublishedAt: publishedAt,
FetchedAt: time.Now(),
Symbols: extractSymbols(item.Title + " " + item.Summary),
}
articles = append(articles, article)
}
s.logger.Debug("fetched articles",
slog.String("source", s.name),
slog.Int("count", len(articles)))
s.incrementCounters()
return articles, nil
}
func (s *AlphaVantageSource) checkRateLimit() bool {
now := time.Now()
if now.Sub(s.lastHourReset) >= time.Hour {
s.hourlyCounter = 0
s.lastHourReset = now
}
if now.Sub(s.lastDayReset) >= 24*time.Hour {
s.dailyCounter = 0
s.lastDayReset = now
}
if s.maxPerHour > 0 && s.hourlyCounter >= s.maxPerHour {
return false
}
if s.maxPerDay > 0 && s.dailyCounter >= s.maxPerDay {
return false
}
return true
}
func (s *AlphaVantageSource) incrementCounters() {
s.hourlyCounter++
s.dailyCounter++
}
// FinnhubSource fetches news from Finnhub API
type FinnhubSource struct {
name string
url string
logger *slog.Logger
httpClient *http.Client
// Rate limiting
maxPerHour int
maxPerDay int
hourlyCounter int
dailyCounter int
lastHourReset time.Time
lastDayReset time.Time
}
type finnhubArticle struct {
Category string `json:"category"`
Datetime int64 `json:"datetime"`
Headline string `json:"headline"`
ID int64 `json:"id"`
Image string `json:"image"`
Related string `json:"related"`
Source string `json:"source"`
Summary string `json:"summary"`
URL string `json:"url"`
}
func NewFinnhubSource(name, url string, logger *slog.Logger) *FinnhubSource {
now := time.Now()
return &FinnhubSource{
name: name,
url: url,
logger: logger,
httpClient: &http.Client{
Timeout: 30 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("stopped after 10 redirects")
}
logger.Debug("following redirect",
slog.String("source", name),
slog.String("from", via[len(via)-1].URL.String()),
slog.String("to", req.URL.String()))
return nil
},
},
lastHourReset: now,
lastDayReset: now,
}
}
func (s *FinnhubSource) SetRateLimit(maxPerHour, maxPerDay int) {
s.maxPerHour = maxPerHour
s.maxPerDay = maxPerDay
}
func (s *FinnhubSource) Name() string {
return s.name
}
func (s *FinnhubSource) Fetch(ctx context.Context) ([]*model.NewsArticle, error) {
// Check rate limits
if !s.checkRateLimit() {
s.logger.Warn("rate limit reached, skipping fetch",
slog.String("source", s.name),
slog.Int("hourly", s.hourlyCounter),
slog.Int("max_per_hour", s.maxPerHour),
slog.Int("daily", s.dailyCounter),
slog.Int("max_per_day", s.maxPerDay))
return nil, nil
}
s.logger.Debug("fetching Finnhub news", slog.String("source", s.name))
req, err := http.NewRequestWithContext(ctx, "GET", s.url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("http error: %s", resp.Status)
}
var data []finnhubArticle
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return nil, fmt.Errorf("failed to parse JSON: %w", err)
}
var articles []*model.NewsArticle
for _, item := range data {
publishedAt := time.Unix(item.Datetime, 0)
// Extract symbols from Related field (comma-separated tickers)
symbols := item.Related
if symbols == "" {
symbols = extractSymbols(item.Headline + " " + item.Summary)
}
article := &model.NewsArticle{
Source: s.name,
Title: item.Headline,
URL: item.URL,
Content: item.Summary,
PublishedAt: publishedAt,
FetchedAt: time.Now(),
Symbols: symbols,
}
articles = append(articles, article)
}
s.logger.Debug("fetched articles",
slog.String("source", s.name),
slog.Int("count", len(articles)))
s.incrementCounters()
return articles, nil
}
func (s *FinnhubSource) checkRateLimit() bool {
now := time.Now()
if now.Sub(s.lastHourReset) >= time.Hour {
s.hourlyCounter = 0
s.lastHourReset = now
}
if now.Sub(s.lastDayReset) >= 24*time.Hour {
s.dailyCounter = 0
s.lastDayReset = now
}
if s.maxPerHour > 0 && s.hourlyCounter >= s.maxPerHour {
return false
}
if s.maxPerDay > 0 && s.dailyCounter >= s.maxPerDay {
return false
}
return true
}
func (s *FinnhubSource) incrementCounters() {
s.hourlyCounter++
s.dailyCounter++
}