Build and Push Docker Image / build-and-push (push) Successful in 4m7s
Signed-off-by: kaedwen <kaedwen@heinrich.blue>
319 lines
7.3 KiB
Go
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++
|
|
}
|