diff --git a/.gitignore b/.gitignore index ee318f7..8560ff2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .claude data/ -config.yaml \ No newline at end of file +config.yaml +aitrade \ No newline at end of file diff --git a/pkg/app/app.go b/pkg/app/app.go index 1258ccd..d28e79d 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -36,8 +36,17 @@ func New(cfg *config.Config, logger *slog.Logger) (*Application, error) { return nil, fmt.Errorf("failed to initialize database: %w", err) } - // Create IB client - ibClient := client.New(&cfg.IBGateway, logger) + // Create IB client (optional) + var ibClient *client.IBClient + if cfg.IBGateway.Enabled { + ibClient = client.New(&cfg.IBGateway, logger) + logger.Info("IB Gateway enabled", + slog.String("host", cfg.IBGateway.Host), + slog.Int("port", cfg.IBGateway.Port), + slog.Int("client_id", cfg.IBGateway.ClientID)) + } else { + logger.Info("IB Gateway disabled in config, skipping") + } // Create repositories balanceRepo := db.NewBalanceRepository(database) @@ -79,18 +88,24 @@ func New(cfg *config.Config, logger *slog.Logger) (*Application, error) { cfg.Trading.StopLossPercent, ) - // Create trader - traderInstance := trader.NewTrader( - ibClient, - tradeRepo, - newsRepo, - balanceRepo, - whitelistRepo, - positionRepo, - tradingStrategy, - &cfg.Trading, - logger, - ) + // Create trader (only if IB Gateway is enabled - trader requires IB connection) + var traderInstance *trader.Trader + if cfg.IBGateway.Enabled { + traderInstance = trader.NewTrader( + ibClient, + tradeRepo, + newsRepo, + balanceRepo, + whitelistRepo, + positionRepo, + tradingStrategy, + &cfg.Trading, + logger, + ) + logger.Info("trader instance created") + } else { + logger.Info("trader instance skipped (IB Gateway disabled)") + } // Create web server webServer, err := web.NewServer( @@ -135,20 +150,29 @@ func (a *Application) Run(ctx context.Context) error { g, ctx := errgroup.WithContext(ctx) - // Start IB Gateway client - g.Go(func() error { - return a.ibClient.Run(ctx) - }) + // Start IB Gateway client (if enabled) + if a.ibClient != nil && a.cfg.IBGateway.Enabled { + g.Go(func() error { + return a.ibClient.Run(ctx) + }) + } // Start news aggregator g.Go(func() error { return a.newsAgg.Run(ctx) }) - // Start trader - g.Go(func() error { - return a.trader.Run(ctx) - }) + // Start trader (if trader instance exists and trading enabled) + if a.trader != nil && a.cfg.Trading.Enabled { + a.logger.Info("trader starting") + g.Go(func() error { + return a.trader.Run(ctx) + }) + } else if a.trader == nil { + a.logger.Info("trader not available (IB Gateway disabled)") + } else { + a.logger.Info("trader disabled in config") + } // Start web server g.Go(func() error { diff --git a/pkg/app/web/server.go b/pkg/app/web/server.go index e294302..5789965 100644 --- a/pkg/app/web/server.go +++ b/pkg/app/web/server.go @@ -107,6 +107,7 @@ func (s *Server) Run(ctx context.Context) error { mux.Handle("/api/config", s.auth.Middleware(http.HandlerFunc(s.handleGetConfig))) mux.Handle("/api/news", s.auth.Middleware(http.HandlerFunc(s.handleGetNews))) mux.Handle("/api/news/recent", s.auth.Middleware(http.HandlerFunc(s.handleGetNewsRecent))) + mux.Handle("/api/news/count", s.auth.Middleware(http.HandlerFunc(s.handleGetNewsCount))) mux.Handle("/api/trades", s.auth.Middleware(http.HandlerFunc(s.handleGetTrades))) mux.Handle("/api/trades/", s.auth.Middleware(http.HandlerFunc(s.handleTradeAction))) mux.Handle("/api/whitelist", s.auth.Middleware(http.HandlerFunc(s.handleWhitelist))) @@ -249,13 +250,21 @@ func (s *Server) handleGetTrades(w http.ResponseWriter, r *http.Request) { func (s *Server) handleGetBalance(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - // Get live balance from IB Gateway via trader - balance, err := s.trader.GetLiveBalance(ctx) - if err != nil { - s.logger.Warn("failed to get live balance from IB, falling back to DB", - slog.Any("error", err)) + var balance *model.Balance + var err error - // Fallback: read from DB if IB Gateway is unavailable + // Get live balance from IB Gateway via trader (if available) + if s.trader != nil { + balance, err = s.trader.GetLiveBalance(ctx) + if err != nil { + s.logger.Warn("failed to get live balance from IB, falling back to DB", + slog.Any("error", err)) + balance = nil // Force fallback + } + } + + // Fallback: read from DB if trader is unavailable or IB Gateway failed + if balance == nil { balance, err = s.balanceRepo.GetLatest(ctx) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) @@ -296,13 +305,18 @@ func (s *Server) handleGetNews(w http.ResponseWriter, r *http.Request) { func (s *Server) handleGetNewsRecent(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - // Get limit from query parameter, default to 50 + // Get limit and offset from query parameters limit := 50 if limitStr := r.URL.Query().Get("limit"); limitStr != "" { fmt.Sscanf(limitStr, "%d", &limit) } - news, err := s.newsRepo.GetRecent(ctx, limit) + offset := 0 + if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" { + fmt.Sscanf(offsetStr, "%d", &offset) + } + + news, err := s.newsRepo.GetRecentWithOffset(ctx, limit, offset) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -312,7 +326,37 @@ func (s *Server) handleGetNewsRecent(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(news) } +func (s *Server) handleGetNewsCount(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Get hours parameter, default to 1 hour + hours := 1 + if hoursStr := r.URL.Query().Get("hours"); hoursStr != "" { + fmt.Sscanf(hoursStr, "%d", &hours) + } + + since := time.Now().Add(-time.Duration(hours) * time.Hour) + count, err := s.newsRepo.CountRecentByTime(ctx, since) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "count": count, + "hours": hours, + "since": since, + }) +} + func (s *Server) handleTradeAction(w http.ResponseWriter, r *http.Request) { + // Check if trader is available + if s.trader == nil { + http.Error(w, "Trader not available (IB Gateway disabled)", http.StatusServiceUnavailable) + return + } + // Parse trade ID from URL: /api/trades/{id}/{action} path := r.URL.Path[len("/api/trades/"):] diff --git a/pkg/app/web/static/app.js b/pkg/app/web/static/app.js index ae5017f..0979a04 100644 --- a/pkg/app/web/static/app.js +++ b/pkg/app/web/static/app.js @@ -87,6 +87,9 @@ function initPage(page) { pageIntervals.newsRefresh = newsInterval; break; } + + // Initialize sticky headers for the new page + setTimeout(() => initStickyHeaders(), 100); } function cleanupPage(page) { @@ -117,10 +120,21 @@ window.addEventListener('popstate', (e) => { let newsCurrentFilter = 'all'; let allNewsArticles = []; +let newsCurrentOffset = 0; +let newsIsLoading = false; +let newsHasMore = true; +const NEWS_BATCH_SIZE = 50; function initNewsPage() { - // Initialize news page - loadNews(); + // Reset state + newsCurrentOffset = 0; + newsIsLoading = false; + newsHasMore = true; + allNewsArticles = []; + + // Initial load + loadNewsCount(); + loadMoreNews(); // Set up filter buttons document.querySelectorAll('.filter-btn').forEach(btn => { @@ -133,7 +147,118 @@ function initNewsPage() { }); // Auto-refresh every 30 seconds - return setInterval(loadNews, 30000); + return setInterval(() => { + loadNewsCount(); + loadMoreNews(); + }, 30000); +} + +function loadNewsCount() { + fetch('/api/news/count?hours=1') + .then(response => response.json()) + .then(data => { + const countEl = document.getElementById('news-count-hour'); + if (countEl) { + countEl.textContent = data.count + ' in last hour'; + } + }) + .catch(error => console.error('Failed to load news count:', error)); +} + +function loadMoreNews() { + if (newsIsLoading || !newsHasMore) return; + + newsIsLoading = true; + const container = document.getElementById('news-list'); + + fetch(`/api/news/recent?limit=${NEWS_BATCH_SIZE}&offset=${newsCurrentOffset}`) + .then(response => response.json()) + .then(data => { + const newArticles = data || []; + + if (newArticles.length < NEWS_BATCH_SIZE) { + newsHasMore = false; + } + + allNewsArticles = allNewsArticles.concat(newArticles); + appendNews(newArticles); + newsCurrentOffset += newArticles.length; + newsIsLoading = false; + }) + .catch(error => { + console.error('Failed to load news:', error); + if (container) { + container.innerHTML = '
Failed to load news. Please try again later.
'; + } + newsIsLoading = false; + }); +} + +function appendNews(articles) { + const container = document.getElementById('news-list'); + if (!container) return; + + // Clear loading message on first load + if (newsCurrentOffset === 0) { + container.innerHTML = ''; + } + + const filtered = newsCurrentFilter === 'all' + ? articles + : articles.filter(a => getSentimentLabel(a) === newsCurrentFilter); + + filtered.forEach(article => { + const sentiment = getSentimentLabel(article); + const score = article.SentimentScore; + const symbols = article.Symbols ? article.Symbols.split(',').filter(s => s.trim()) : []; + const method = article.SentimentMethod || 'keyword'; + + const articleEl = document.createElement('div'); + articleEl.className = `news-article ${sentiment}`; + articleEl.innerHTML = ` +Loading news...
-| ID | -Symbol | -Action | -Qty | -Status | -Price | -P&L | -Confidence | -Created | -Actions | -
|---|---|---|---|---|---|---|---|---|---|
| Loading... | |||||||||
| ID | -Symbol | -Action | -Quantity | -Status | -Price | -P&L | -Confidence | -Reasoning | -Created | -Actions | -
|---|---|---|---|---|---|---|---|---|---|---|
| Loading... | ||||||||||
| Symbol | -Name | -WKN | -ISIN | -Status | -Notes | -Actions | -
|---|---|---|---|---|---|---|
| Loading... | ||||||