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 = ` +
+

+ ${article.Title} +

+
+ ${score !== null && score !== undefined ? + `${formatScore(score)}` : ''} + ${sentiment} +
+
+
+ ${article.Source} + ${formatTimeAgo(article.PublishedAt)} + 📥 ${formatTimeAgo(article.FetchedAt)} +
+ ${article.Content ? `
${article.Content}
` : ''} + ${symbols.length > 0 ? ` +
+ ${symbols.map(s => `${s.trim()}`).join('')} +
+ ` : ''} +
Analysis: ${method}
+ `; + container.appendChild(articleEl); + }); + + updateNewsStats(); +} + +function updateNewsStats() { + const countEl = document.getElementById('news-count'); + const updateEl = document.getElementById('last-update'); + + const filtered = newsCurrentFilter === 'all' + ? allNewsArticles + : allNewsArticles.filter(a => getSentimentLabel(a) === newsCurrentFilter); + + if (countEl) { + countEl.textContent = filtered.length + ' article' + (filtered.length !== 1 ? 's' : ''); + } + if (updateEl) { + updateEl.textContent = 'Last update: ' + new Date().toLocaleTimeString(); + } } function loadNews() { @@ -153,6 +278,7 @@ function loadNews() { } function renderNews(articles) { + // Deprecated - now using appendNews for infinite scroll const container = document.getElementById('news-list'); if (!container) return; @@ -170,44 +296,43 @@ function renderNews(articles) { return; } - container.innerHTML = filtered.map(article => { + container.innerHTML = ''; + 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'; - return ` -
-
-

- ${article.Title} -

-
- ${score !== null && score !== undefined ? - `${formatScore(score)}` : ''} - ${sentiment} -
+ const articleEl = document.createElement('div'); + articleEl.className = `news-article ${sentiment}`; + articleEl.innerHTML = ` +
+

+ ${article.Title} +

+
+ ${score !== null && score !== undefined ? + `${formatScore(score)}` : ''} + ${sentiment}
-
- ${article.Source} - ${formatTimeAgo(article.PublishedAt)} - 📥 ${formatTimeAgo(article.FetchedAt)} -
- ${article.Content ? `
${article.Content}
` : ''} - ${symbols.length > 0 ? ` -
- ${symbols.map(s => `${s.trim()}`).join('')} -
- ` : ''} -
Analysis: ${method}
+
+ ${article.Source} + ${formatTimeAgo(article.PublishedAt)} + 📥 ${formatTimeAgo(article.FetchedAt)} +
+ ${article.Content ? `
${article.Content}
` : ''} + ${symbols.length > 0 ? ` +
+ ${symbols.map(s => `${s.trim()}`).join('')} +
+ ` : ''} +
Analysis: ${method}
`; - }).join(''); + container.appendChild(articleEl); + }); - const countEl = document.getElementById('news-count'); - const updateEl = document.getElementById('last-update'); - if (countEl) countEl.textContent = filtered.length + ' article' + (filtered.length !== 1 ? 's' : ''); - if (updateEl) updateEl.textContent = 'Last update: ' + new Date().toLocaleTimeString(); + updateNewsStats(); } function formatTimeAgo(dateString) { @@ -501,3 +626,42 @@ async function deleteWhitelist(id) { function closeModal() { document.getElementById('whitelist-modal').classList.remove('active'); } + +// ===== STICKY HEADER HANDLER ===== +let stickyScrollHandler = null; + +function initStickyHeaders() { + // Remove old scroll handler if exists + if (stickyScrollHandler) { + window.removeEventListener('scroll', stickyScrollHandler); + } + + const stickyHeaders = document.querySelectorAll('.sticky-header'); + + if (stickyHeaders.length === 0) return; + + stickyScrollHandler = () => { + stickyHeaders.forEach(header => { + if (window.scrollY > 50) { + header.classList.add('scrolled'); + } else { + header.classList.remove('scrolled'); + } + }); + + // Infinite scroll for news page + if (currentRoute === 'news') { + if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight - 500) { + loadMoreNews(); + } + } + }; + + window.addEventListener('scroll', stickyScrollHandler); +} + +// Initialize sticky headers after page load +document.addEventListener('DOMContentLoaded', () => { + initStickyHeaders(); +}); + diff --git a/pkg/app/web/static/style.css b/pkg/app/web/static/style.css index c6a06ac..e3f3d1b 100644 --- a/pkg/app/web/static/style.css +++ b/pkg/app/web/static/style.css @@ -242,16 +242,25 @@ th { align-items: center; margin-bottom: 20px; padding-bottom: 10px; - border-bottom: 2px solid #333; + border-bottom: 2px solid #ddd; } .news-stats { display: flex; - gap: 20px; + align-items: center; + gap: 15px; font-size: 0.9em; color: #666; } +.stat-highlight { + font-weight: bold; + color: #007bff; + background: #e7f3ff; + padding: 4px 12px; + border-radius: 12px; +} + .news-filters { display: flex; gap: 10px; @@ -289,11 +298,12 @@ th { border-left: 4px solid #ccc; background: white; box-shadow: 0 2px 4px rgba(0,0,0,0.1); - transition: transform 0.2s; + transition: all 0.2s ease; } .news-article:hover { - transform: translateX(5px); + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + border-left-width: 6px; } .news-article.positive { @@ -440,3 +450,51 @@ th { font-style: italic; } + +/* Sticky Header Styles */ +.sticky-header { + position: sticky; + top: 0; + background: white; + z-index: 100; + transition: all 0.3s ease; + padding: 20px 20px 10px 20px; + margin: 0 -20px 20px -20px; /* Extend to container edges */ +} + +.sticky-header.scrolled { + padding: 10px 20px 5px 20px; +} + +.sticky-header.scrolled h2 { + font-size: 1.3rem; + margin-top: 10px; +} + +.sticky-header.scrolled .stats { + gap: 10px; +} + +.sticky-header.scrolled .stat-card { + padding: 10px; +} + +.sticky-header.scrolled .stat-card h3 { + font-size: 0.8rem; +} + +.sticky-header.scrolled .stat-card .value { + font-size: 1.2rem; +} + +.sticky-header h2 { + transition: all 0.3s ease; +} + +.sticky-header .stats { + transition: all 0.3s ease; +} + +.sticky-header .stat-card { + transition: all 0.3s ease; +} diff --git a/pkg/app/web/templates/news-content.html b/pkg/app/web/templates/news-content.html index f964273..0ce4a90 100644 --- a/pkg/app/web/templates/news-content.html +++ b/pkg/app/web/templates/news-content.html @@ -1,18 +1,21 @@ {{define "news-content"}}
-
-

📰 Live News Feed

-
- 0 articles - Last update: never + -
- - - - +
+ + + + +
diff --git a/pkg/app/web/templates/news.html b/pkg/app/web/templates/news.html deleted file mode 100644 index c1445af..0000000 --- a/pkg/app/web/templates/news.html +++ /dev/null @@ -1,344 +0,0 @@ - - - - - {{.Title}} - - - -
-
-
-

📰 Live News Feed

-
- 0 articles - Last update: never -
-
- -
- - - - -
- -
-

Loading news...

-
-
-
- - - - - - - diff --git a/pkg/app/web/templates/overview-content.html b/pkg/app/web/templates/overview-content.html index 58d7cf2..a469861 100644 --- a/pkg/app/web/templates/overview-content.html +++ b/pkg/app/web/templates/overview-content.html @@ -1,17 +1,19 @@ {{define "overview-content"}}
-
-
-

Total Balance

-
$0.00
-
-
-

Active Trades

-
0
-
-
-

Pending Trades

-
0
+ diff --git a/pkg/app/web/templates/overview.html b/pkg/app/web/templates/overview.html deleted file mode 100644 index 548bd68..0000000 --- a/pkg/app/web/templates/overview.html +++ /dev/null @@ -1,39 +0,0 @@ -{{define "overview"}} -
-
-
-

Total Balance

-
$0.00
-
-
-

Active Trades

-
0
-
-
-

Pending Trades

-
0
-
-
- -

Recent Trades

- - - - - - - - - - - - - - - - - - -
IDSymbolActionQtyStatusPriceP&LConfidenceCreatedActions
Loading...
-
-{{end}} diff --git a/pkg/app/web/templates/trades-content.html b/pkg/app/web/templates/trades-content.html index 84459f3..c27246c 100644 --- a/pkg/app/web/templates/trades-content.html +++ b/pkg/app/web/templates/trades-content.html @@ -1,6 +1,9 @@ {{define "trades-content"}}
-

All Trades

+ + diff --git a/pkg/app/web/templates/trades.html b/pkg/app/web/templates/trades.html deleted file mode 100644 index 443cbee..0000000 --- a/pkg/app/web/templates/trades.html +++ /dev/null @@ -1,25 +0,0 @@ -{{define "trades"}} -
-

All Trades

-
- - - - - - - - - - - - - - - - - - -
IDSymbolActionQuantityStatusPriceP&LConfidenceReasoningCreatedActions
Loading...
-
-{{end}} diff --git a/pkg/app/web/templates/whitelist-content.html b/pkg/app/web/templates/whitelist-content.html index 3912aeb..c92f40d 100644 --- a/pkg/app/web/templates/whitelist-content.html +++ b/pkg/app/web/templates/whitelist-content.html @@ -1,9 +1,12 @@ {{define "whitelist-content"}}
-
-

Trading Whitelist

- + + diff --git a/pkg/app/web/templates/whitelist.html b/pkg/app/web/templates/whitelist.html deleted file mode 100644 index 3ec8700..0000000 --- a/pkg/app/web/templates/whitelist.html +++ /dev/null @@ -1,24 +0,0 @@ -{{define "whitelist"}} -
-
-

Trading Whitelist

- -
-
- - - - - - - - - - - - - - -
SymbolNameWKNISINStatusNotesActions
Loading...
-
-{{end}} diff --git a/pkg/config/config.go b/pkg/config/config.go index 57a1fb0..5eaf14b 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -20,13 +20,15 @@ type Config struct { } type IBGatewayConfig struct { - Host string `yaml:"host"` - Port int `yaml:"port"` - ClientID int `yaml:"client_id"` - MarketDataType int `yaml:"market_data_type"` // 1=Live, 2=Frozen, 3=Delayed(15min), 4=Delayed-Frozen + Enabled bool `yaml:"enabled"` // Master switch to enable/disable IB Gateway + Host string `yaml:"host"` + Port int `yaml:"port"` + ClientID int `yaml:"client_id"` + MarketDataType int `yaml:"market_data_type"` // 1=Live, 2=Frozen, 3=Delayed(15min), 4=Delayed-Frozen } type TradingConfig struct { + Enabled bool `yaml:"enabled"` // Master switch to enable/disable trader Strategy string `yaml:"strategy"` StopLossEnabled bool `yaml:"stop_loss_enabled"` StopLossPercent float64 `yaml:"stop_loss_percent"` @@ -35,7 +37,7 @@ type TradingConfig struct { PendingTime Duration `yaml:"pending_time"` DryRun bool `yaml:"dry_run"` DryRunBalance float64 `yaml:"dry_run_balance"` - TradingEnabled bool `yaml:"trading_enabled"` + TradingEnabled bool `yaml:"trading_enabled"` // Auto-trading (deprecated, use Enabled) TradingInterval Duration `yaml:"trading_interval"` WatchSymbols []string `yaml:"watch_symbols"` TakeProfitPercent float64 `yaml:"take_profit_percent"` diff --git a/pkg/db/news.go b/pkg/db/news.go index 86594a7..ba8d3c9 100644 --- a/pkg/db/news.go +++ b/pkg/db/news.go @@ -61,16 +61,20 @@ func (r *NewsRepository) Create(ctx context.Context, article *model.NewsArticle) } func (r *NewsRepository) GetRecent(ctx context.Context, limit int) ([]*model.NewsArticle, error) { + return r.GetRecentWithOffset(ctx, limit, 0) +} + +func (r *NewsRepository) GetRecentWithOffset(ctx context.Context, limit, offset int) ([]*model.NewsArticle, error) { query := ` SELECT id, source, title, url, content, published_at, fetched_at, symbols, sentiment_score, sentiment_label, llm_sentiment_score, llm_model, llm_confidence, sentiment_method FROM news_articles ORDER BY published_at DESC - LIMIT ? + LIMIT ? OFFSET ? ` - rows, err := r.db.QueryContext(ctx, query, limit) + rows, err := r.db.QueryContext(ctx, query, limit, offset) if err != nil { return nil, fmt.Errorf("failed to query recent news: %w", err) } @@ -154,3 +158,16 @@ func (r *NewsRepository) GetBySymbol(ctx context.Context, symbol string, since t return articles, nil } + +// CountRecentByTime returns the count of news articles fetched since the given time +func (r *NewsRepository) CountRecentByTime(ctx context.Context, since time.Time) (int, error) { + query := `SELECT COUNT(*) FROM news_articles WHERE fetched_at >= ?` + + var count int + err := r.db.QueryRowContext(ctx, query, since).Scan(&count) + if err != nil { + return 0, fmt.Errorf("failed to count recent news: %w", err) + } + + return count, nil +}