fix news page
Build and Push Docker Image / build-and-push (push) Successful in 3m43s

Signed-off-by: kaedwen <kaedwen@heinrich.blue>
This commit is contained in:
kaedwen
2026-07-09 22:54:40 +02:00
parent 62809a01ea
commit fe16cb55cb
15 changed files with 422 additions and 533 deletions
+2 -1
View File
@@ -1,3 +1,4 @@
.claude .claude
data/ data/
config.yaml config.yaml
aitrade
+46 -22
View File
@@ -36,8 +36,17 @@ func New(cfg *config.Config, logger *slog.Logger) (*Application, error) {
return nil, fmt.Errorf("failed to initialize database: %w", err) return nil, fmt.Errorf("failed to initialize database: %w", err)
} }
// Create IB client // Create IB client (optional)
ibClient := client.New(&cfg.IBGateway, logger) 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 // Create repositories
balanceRepo := db.NewBalanceRepository(database) balanceRepo := db.NewBalanceRepository(database)
@@ -79,18 +88,24 @@ func New(cfg *config.Config, logger *slog.Logger) (*Application, error) {
cfg.Trading.StopLossPercent, cfg.Trading.StopLossPercent,
) )
// Create trader // Create trader (only if IB Gateway is enabled - trader requires IB connection)
traderInstance := trader.NewTrader( var traderInstance *trader.Trader
ibClient, if cfg.IBGateway.Enabled {
tradeRepo, traderInstance = trader.NewTrader(
newsRepo, ibClient,
balanceRepo, tradeRepo,
whitelistRepo, newsRepo,
positionRepo, balanceRepo,
tradingStrategy, whitelistRepo,
&cfg.Trading, positionRepo,
logger, tradingStrategy,
) &cfg.Trading,
logger,
)
logger.Info("trader instance created")
} else {
logger.Info("trader instance skipped (IB Gateway disabled)")
}
// Create web server // Create web server
webServer, err := web.NewServer( webServer, err := web.NewServer(
@@ -135,20 +150,29 @@ func (a *Application) Run(ctx context.Context) error {
g, ctx := errgroup.WithContext(ctx) g, ctx := errgroup.WithContext(ctx)
// Start IB Gateway client // Start IB Gateway client (if enabled)
g.Go(func() error { if a.ibClient != nil && a.cfg.IBGateway.Enabled {
return a.ibClient.Run(ctx) g.Go(func() error {
}) return a.ibClient.Run(ctx)
})
}
// Start news aggregator // Start news aggregator
g.Go(func() error { g.Go(func() error {
return a.newsAgg.Run(ctx) return a.newsAgg.Run(ctx)
}) })
// Start trader // Start trader (if trader instance exists and trading enabled)
g.Go(func() error { if a.trader != nil && a.cfg.Trading.Enabled {
return a.trader.Run(ctx) 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 // Start web server
g.Go(func() error { g.Go(func() error {
+52 -8
View File
@@ -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/config", s.auth.Middleware(http.HandlerFunc(s.handleGetConfig)))
mux.Handle("/api/news", s.auth.Middleware(http.HandlerFunc(s.handleGetNews))) 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/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.handleGetTrades)))
mux.Handle("/api/trades/", s.auth.Middleware(http.HandlerFunc(s.handleTradeAction))) mux.Handle("/api/trades/", s.auth.Middleware(http.HandlerFunc(s.handleTradeAction)))
mux.Handle("/api/whitelist", s.auth.Middleware(http.HandlerFunc(s.handleWhitelist))) 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) { func (s *Server) handleGetBalance(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
// Get live balance from IB Gateway via trader var balance *model.Balance
balance, err := s.trader.GetLiveBalance(ctx) var err error
if err != nil {
s.logger.Warn("failed to get live balance from IB, falling back to DB",
slog.Any("error", err))
// 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) balance, err = s.balanceRepo.GetLatest(ctx)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) 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) { func (s *Server) handleGetNewsRecent(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
// Get limit from query parameter, default to 50 // Get limit and offset from query parameters
limit := 50 limit := 50
if limitStr := r.URL.Query().Get("limit"); limitStr != "" { if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
fmt.Sscanf(limitStr, "%d", &limit) 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 { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
@@ -312,7 +326,37 @@ func (s *Server) handleGetNewsRecent(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(news) 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) { 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} // Parse trade ID from URL: /api/trades/{id}/{action}
path := r.URL.Path[len("/api/trades/"):] path := r.URL.Path[len("/api/trades/"):]
+196 -32
View File
@@ -87,6 +87,9 @@ function initPage(page) {
pageIntervals.newsRefresh = newsInterval; pageIntervals.newsRefresh = newsInterval;
break; break;
} }
// Initialize sticky headers for the new page
setTimeout(() => initStickyHeaders(), 100);
} }
function cleanupPage(page) { function cleanupPage(page) {
@@ -117,10 +120,21 @@ window.addEventListener('popstate', (e) => {
let newsCurrentFilter = 'all'; let newsCurrentFilter = 'all';
let allNewsArticles = []; let allNewsArticles = [];
let newsCurrentOffset = 0;
let newsIsLoading = false;
let newsHasMore = true;
const NEWS_BATCH_SIZE = 50;
function initNewsPage() { function initNewsPage() {
// Initialize news page // Reset state
loadNews(); newsCurrentOffset = 0;
newsIsLoading = false;
newsHasMore = true;
allNewsArticles = [];
// Initial load
loadNewsCount();
loadMoreNews();
// Set up filter buttons // Set up filter buttons
document.querySelectorAll('.filter-btn').forEach(btn => { document.querySelectorAll('.filter-btn').forEach(btn => {
@@ -133,7 +147,118 @@ function initNewsPage() {
}); });
// Auto-refresh every 30 seconds // 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 = '<p class="no-news">Failed to load news. Please try again later.</p>';
}
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 = `
<div class="news-article-header">
<h3 class="news-title">
<a href="${article.URL}" target="_blank" rel="noopener">${article.Title}</a>
</h3>
<div class="news-sentiment">
${score !== null && score !== undefined ?
`<span class="sentiment-score">${formatScore(score)}</span>` : ''}
<span class="sentiment-badge ${sentiment}">${sentiment}</span>
</div>
</div>
<div class="news-meta">
<span class="news-source">${article.Source}</span>
<span class="news-time" title="${new Date(article.PublishedAt).toLocaleString()}">${formatTimeAgo(article.PublishedAt)}</span>
<span class="news-fetched" title="Fetched: ${new Date(article.FetchedAt).toLocaleString()}">📥 ${formatTimeAgo(article.FetchedAt)}</span>
</div>
${article.Content ? `<div class="news-content">${article.Content}</div>` : ''}
${symbols.length > 0 ? `
<div class="news-symbols">
${symbols.map(s => `<span class="symbol-tag">${s.trim()}</span>`).join('')}
</div>
` : ''}
<div class="news-method">Analysis: ${method}</div>
`;
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() { function loadNews() {
@@ -153,6 +278,7 @@ function loadNews() {
} }
function renderNews(articles) { function renderNews(articles) {
// Deprecated - now using appendNews for infinite scroll
const container = document.getElementById('news-list'); const container = document.getElementById('news-list');
if (!container) return; if (!container) return;
@@ -170,44 +296,43 @@ function renderNews(articles) {
return; return;
} }
container.innerHTML = filtered.map(article => { container.innerHTML = '';
filtered.forEach(article => {
const sentiment = getSentimentLabel(article); const sentiment = getSentimentLabel(article);
const score = article.SentimentScore; const score = article.SentimentScore;
const symbols = article.Symbols ? article.Symbols.split(',').filter(s => s.trim()) : []; const symbols = article.Symbols ? article.Symbols.split(',').filter(s => s.trim()) : [];
const method = article.SentimentMethod || 'keyword'; const method = article.SentimentMethod || 'keyword';
return ` const articleEl = document.createElement('div');
<div class="news-article ${sentiment}"> articleEl.className = `news-article ${sentiment}`;
<div class="news-article-header"> articleEl.innerHTML = `
<h3 class="news-title"> <div class="news-article-header">
<a href="${article.URL}" target="_blank" rel="noopener">${article.Title}</a> <h3 class="news-title">
</h3> <a href="${article.URL}" target="_blank" rel="noopener">${article.Title}</a>
<div class="news-sentiment"> </h3>
${score !== null && score !== undefined ? <div class="news-sentiment">
`<span class="sentiment-score">${formatScore(score)}</span>` : ''} ${score !== null && score !== undefined ?
<span class="sentiment-badge ${sentiment}">${sentiment}</span> `<span class="sentiment-score">${formatScore(score)}</span>` : ''}
</div> <span class="sentiment-badge ${sentiment}">${sentiment}</span>
</div> </div>
<div class="news-meta">
<span class="news-source">${article.Source}</span>
<span class="news-time" title="${new Date(article.PublishedAt).toLocaleString()}">${formatTimeAgo(article.PublishedAt)}</span>
<span class="news-fetched" title="Fetched: ${new Date(article.FetchedAt).toLocaleString()}">📥 ${formatTimeAgo(article.FetchedAt)}</span>
</div>
${article.Content ? `<div class="news-content">${article.Content}</div>` : ''}
${symbols.length > 0 ? `
<div class="news-symbols">
${symbols.map(s => `<span class="symbol-tag">${s.trim()}</span>`).join('')}
</div>
` : ''}
<div class="news-method">Analysis: ${method}</div>
</div> </div>
<div class="news-meta">
<span class="news-source">${article.Source}</span>
<span class="news-time" title="${new Date(article.PublishedAt).toLocaleString()}">${formatTimeAgo(article.PublishedAt)}</span>
<span class="news-fetched" title="Fetched: ${new Date(article.FetchedAt).toLocaleString()}">📥 ${formatTimeAgo(article.FetchedAt)}</span>
</div>
${article.Content ? `<div class="news-content">${article.Content}</div>` : ''}
${symbols.length > 0 ? `
<div class="news-symbols">
${symbols.map(s => `<span class="symbol-tag">${s.trim()}</span>`).join('')}
</div>
` : ''}
<div class="news-method">Analysis: ${method}</div>
`; `;
}).join(''); container.appendChild(articleEl);
});
const countEl = document.getElementById('news-count'); updateNewsStats();
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();
} }
function formatTimeAgo(dateString) { function formatTimeAgo(dateString) {
@@ -501,3 +626,42 @@ async function deleteWhitelist(id) {
function closeModal() { function closeModal() {
document.getElementById('whitelist-modal').classList.remove('active'); 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();
});
+62 -4
View File
@@ -242,16 +242,25 @@ th {
align-items: center; align-items: center;
margin-bottom: 20px; margin-bottom: 20px;
padding-bottom: 10px; padding-bottom: 10px;
border-bottom: 2px solid #333; border-bottom: 2px solid #ddd;
} }
.news-stats { .news-stats {
display: flex; display: flex;
gap: 20px; align-items: center;
gap: 15px;
font-size: 0.9em; font-size: 0.9em;
color: #666; color: #666;
} }
.stat-highlight {
font-weight: bold;
color: #007bff;
background: #e7f3ff;
padding: 4px 12px;
border-radius: 12px;
}
.news-filters { .news-filters {
display: flex; display: flex;
gap: 10px; gap: 10px;
@@ -289,11 +298,12 @@ th {
border-left: 4px solid #ccc; border-left: 4px solid #ccc;
background: white; background: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1); box-shadow: 0 2px 4px rgba(0,0,0,0.1);
transition: transform 0.2s; transition: all 0.2s ease;
} }
.news-article:hover { .news-article:hover {
transform: translateX(5px); box-shadow: 0 4px 12px rgba(0,0,0,0.15);
border-left-width: 6px;
} }
.news-article.positive { .news-article.positive {
@@ -440,3 +450,51 @@ th {
font-style: italic; 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;
}
+14 -11
View File
@@ -1,18 +1,21 @@
{{define "news-content"}} {{define "news-content"}}
<div class="news-container"> <div class="news-container">
<div class="news-header"> <div class="sticky-header" id="news-sticky-header">
<h1>📰 Live News Feed</h1> <div class="news-header">
<div class="news-stats"> <h1>📰 Live News Feed</h1>
<span id="news-count">0 articles</span> <div class="news-stats">
<span id="last-update">Last update: never</span> <span id="news-count-hour" class="stat-highlight">0 in last hour</span>
<span id="news-count">0 articles</span>
<span id="last-update">Last update: never</span>
</div>
</div> </div>
</div>
<div class="news-filters"> <div class="news-filters">
<button class="filter-btn active" data-filter="all">All</button> <button class="filter-btn active" data-filter="all">All</button>
<button class="filter-btn" data-filter="positive">Positive</button> <button class="filter-btn" data-filter="positive">Positive</button>
<button class="filter-btn" data-filter="neutral">Neutral</button> <button class="filter-btn" data-filter="neutral">Neutral</button>
<button class="filter-btn" data-filter="negative">Negative</button> <button class="filter-btn" data-filter="negative">Negative</button>
</div>
</div> </div>
<div id="news-list" class="news-list"> <div id="news-list" class="news-list">
-344
View File
@@ -1,344 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{{.Title}}</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<div class="news-container">
<div class="news-header">
<h1>📰 Live News Feed</h1>
<div class="news-stats">
<span id="news-count">0 articles</span>
<span id="last-update">Last update: never</span>
</div>
</div>
<div class="news-filters">
<button class="filter-btn active" data-filter="all">All</button>
<button class="filter-btn" data-filter="positive">Positive</button>
<button class="filter-btn" data-filter="neutral">Neutral</button>
<button class="filter-btn" data-filter="negative">Negative</button>
</div>
<div id="news-list" class="news-list">
<p class="loading">Loading news...</p>
</div>
</div>
</div>
<script src="/static/app.js"></script>
<style>
.news-container {
max-width: 1200px;
margin: 0 auto;
}
.news-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid #333;
}
.news-stats {
display: flex;
gap: 20px;
font-size: 0.9em;
color: #666;
}
.news-filters {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.filter-btn {
padding: 8px 16px;
border: 2px solid #ddd;
background: white;
cursor: pointer;
border-radius: 4px;
transition: all 0.2s;
}
.filter-btn:hover {
background: #f0f0f0;
}
.filter-btn.active {
background: #007bff;
color: white;
border-color: #007bff;
}
.news-list {
display: flex;
flex-direction: column;
gap: 15px;
}
.news-article {
padding: 15px;
border-radius: 8px;
border-left: 4px solid #ccc;
background: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
transition: transform 0.2s;
}
.news-article:hover {
transform: translateX(5px);
}
.news-article.positive {
border-left-color: #28a745;
background: #f0fff4;
}
.news-article.negative {
border-left-color: #dc3545;
background: #fff5f5;
}
.news-article.neutral {
border-left-color: #6c757d;
background: #f8f9fa;
}
.news-article-header {
display: flex;
justify-content: space-between;
align-items: start;
margin-bottom: 10px;
}
.news-title {
font-size: 1.1em;
font-weight: bold;
margin: 0;
flex: 1;
}
.news-title a {
color: #333;
text-decoration: none;
}
.news-title a:hover {
color: #007bff;
text-decoration: underline;
}
.news-sentiment {
display: flex;
align-items: center;
gap: 8px;
margin-left: 15px;
}
.sentiment-badge {
padding: 4px 12px;
border-radius: 12px;
font-size: 0.85em;
font-weight: bold;
white-space: nowrap;
}
.sentiment-badge.positive {
background: #28a745;
color: white;
}
.sentiment-badge.negative {
background: #dc3545;
color: white;
}
.sentiment-badge.neutral {
background: #6c757d;
color: white;
}
.sentiment-score {
font-size: 0.9em;
color: #666;
font-weight: bold;
}
.news-meta {
display: flex;
gap: 15px;
font-size: 0.85em;
color: #666;
margin-bottom: 8px;
}
.news-source {
font-weight: bold;
color: #007bff;
}
.news-time {
color: #999;
}
.news-content {
color: #555;
line-height: 1.5;
margin-top: 8px;
}
.news-symbols {
margin-top: 10px;
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.symbol-tag {
padding: 3px 8px;
background: #e9ecef;
border-radius: 4px;
font-size: 0.85em;
font-weight: bold;
color: #495057;
}
.news-method {
font-size: 0.8em;
color: #999;
font-style: italic;
margin-top: 5px;
}
.loading {
text-align: center;
padding: 40px;
color: #999;
}
.no-news {
text-align: center;
padding: 40px;
color: #999;
font-style: italic;
}
</style>
<script>
let currentFilter = 'all';
let allNews = [];
function formatTimeAgo(dateString) {
const date = new Date(dateString);
const now = new Date();
const seconds = Math.floor((now - date) / 1000);
if (seconds < 60) return 'just now';
if (seconds < 3600) return Math.floor(seconds / 60) + 'm ago';
if (seconds < 86400) return Math.floor(seconds / 3600) + 'h ago';
return Math.floor(seconds / 86400) + 'd ago';
}
function formatScore(score) {
if (score === null || score === undefined) return '';
return (score > 0 ? '+' : '') + score.toFixed(2);
}
function getSentimentLabel(article) {
return article.SentimentLabel || 'neutral';
}
function renderNews(articles) {
const container = document.getElementById('news-list');
if (!articles || articles.length === 0) {
container.innerHTML = '<p class="no-news">No news articles available</p>';
return;
}
const filtered = currentFilter === 'all'
? articles
: articles.filter(a => getSentimentLabel(a) === currentFilter);
if (filtered.length === 0) {
container.innerHTML = '<p class="no-news">No ' + currentFilter + ' articles</p>';
return;
}
container.innerHTML = filtered.map(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 `
<div class="news-article ${sentiment}">
<div class="news-article-header">
<h3 class="news-title">
<a href="${article.URL}" target="_blank" rel="noopener">${article.Title}</a>
</h3>
<div class="news-sentiment">
${score !== null && score !== undefined ?
`<span class="sentiment-score">${formatScore(score)}</span>` : ''}
<span class="sentiment-badge ${sentiment}">${sentiment}</span>
</div>
</div>
<div class="news-meta">
<span class="news-source">${article.Source}</span>
<span class="news-time">${formatTimeAgo(article.PublishedAt)}</span>
</div>
${article.Content ? `<div class="news-content">${article.Content}</div>` : ''}
${symbols.length > 0 ? `
<div class="news-symbols">
${symbols.map(s => `<span class="symbol-tag">${s.trim()}</span>`).join('')}
</div>
` : ''}
<div class="news-method">Analysis: ${method}</div>
</div>
`;
}).join('');
// Update stats
document.getElementById('news-count').textContent = filtered.length + ' article' + (filtered.length !== 1 ? 's' : '');
document.getElementById('last-update').textContent = 'Last update: ' + new Date().toLocaleTimeString();
}
function loadNews() {
fetch('/api/news/recent?limit=50')
.then(response => response.json())
.then(data => {
allNews = data || [];
renderNews(allNews);
})
.catch(error => {
console.error('Failed to load news:', error);
document.getElementById('news-list').innerHTML =
'<p class="no-news">Failed to load news. Please try again later.</p>';
});
}
// Filter buttons
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.addEventListener('click', function() {
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
this.classList.add('active');
currentFilter = this.dataset.filter;
renderNews(allNews);
});
});
// Initial load
loadNews();
// Auto-refresh every 30 seconds
setInterval(loadNews, 30000);
</script>
</body>
</html>
+14 -12
View File
@@ -1,17 +1,19 @@
{{define "overview-content"}} {{define "overview-content"}}
<div id="tab-overview" class="tab-content"> <div id="tab-overview" class="tab-content">
<div class="stats"> <div class="sticky-header">
<div class="stat-card"> <div class="stats">
<h3>Total Balance</h3> <div class="stat-card">
<div class="value" id="balance">$0.00</div> <h3>Total Balance</h3>
</div> <div class="value" id="balance">$0.00</div>
<div class="stat-card"> </div>
<h3>Active Trades</h3> <div class="stat-card">
<div class="value" id="active-trades">0</div> <h3>Active Trades</h3>
</div> <div class="value" id="active-trades">0</div>
<div class="stat-card"> </div>
<h3>Pending Trades</h3> <div class="stat-card">
<div class="value" id="pending-trades">0</div> <h3>Pending Trades</h3>
<div class="value" id="pending-trades">0</div>
</div>
</div> </div>
</div> </div>
-39
View File
@@ -1,39 +0,0 @@
{{define "overview"}}
<div id="tab-overview" class="tab-content">
<div class="stats">
<div class="stat-card">
<h3>Total Balance</h3>
<div class="value" id="balance">$0.00</div>
</div>
<div class="stat-card">
<h3>Active Trades</h3>
<div class="value" id="active-trades">0</div>
</div>
<div class="stat-card">
<h3>Pending Trades</h3>
<div class="value" id="pending-trades">0</div>
</div>
</div>
<h2>Recent Trades</h2>
<table>
<thead>
<tr>
<th>ID</th>
<th>Symbol</th>
<th>Action</th>
<th>Qty</th>
<th>Status</th>
<th>Price</th>
<th>P&L</th>
<th>Confidence</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="trades-body-overview">
<tr><td colspan="10" style="text-align: center;">Loading...</td></tr>
</tbody>
</table>
</div>
{{end}}
+4 -1
View File
@@ -1,6 +1,9 @@
{{define "trades-content"}} {{define "trades-content"}}
<div id="tab-trades" class="tab-content"> <div id="tab-trades" class="tab-content">
<h2>All Trades</h2> <div class="sticky-header">
<h2>All Trades</h2>
</div>
<table> <table>
<thead> <thead>
<tr> <tr>
-25
View File
@@ -1,25 +0,0 @@
{{define "trades"}}
<div id="tab-trades" class="tab-content">
<h2>All Trades</h2>
<table>
<thead>
<tr>
<th>ID</th>
<th>Symbol</th>
<th>Action</th>
<th>Quantity</th>
<th>Status</th>
<th>Price</th>
<th>P&L</th>
<th>Confidence</th>
<th>Reasoning</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="trades-body">
<tr><td colspan="11" style="text-align: center;">Loading...</td></tr>
</tbody>
</table>
</div>
{{end}}
+6 -3
View File
@@ -1,9 +1,12 @@
{{define "whitelist-content"}} {{define "whitelist-content"}}
<div id="tab-whitelist" class="tab-content"> <div id="tab-whitelist" class="tab-content">
<div style="display: flex; justify-content: space-between; align-items: center;"> <div class="sticky-header">
<h2 style="margin: 0;">Trading Whitelist</h2> <div style="display: flex; justify-content: space-between; align-items: center;">
<button class="add-btn" onclick="showAddModal()"> Add Symbol</button> <h2 style="margin: 0;">Trading Whitelist</h2>
<button class="add-btn" onclick="showAddModal()"> Add Symbol</button>
</div>
</div> </div>
<table> <table>
<thead> <thead>
<tr> <tr>
-24
View File
@@ -1,24 +0,0 @@
{{define "whitelist"}}
<div id="tab-whitelist" class="tab-content">
<div style="display: flex; justify-content: space-between; align-items: center;">
<h2 style="margin: 0;">Trading Whitelist</h2>
<button class="add-btn" onclick="showAddModal()"> Add Symbol</button>
</div>
<table>
<thead>
<tr>
<th>Symbol</th>
<th>Name</th>
<th>WKN</th>
<th>ISIN</th>
<th>Status</th>
<th>Notes</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="whitelist-body">
<tr><td colspan="7" style="text-align: center;">Loading...</td></tr>
</tbody>
</table>
</div>
{{end}}
+7 -5
View File
@@ -20,13 +20,15 @@ type Config struct {
} }
type IBGatewayConfig struct { type IBGatewayConfig struct {
Host string `yaml:"host"` Enabled bool `yaml:"enabled"` // Master switch to enable/disable IB Gateway
Port int `yaml:"port"` Host string `yaml:"host"`
ClientID int `yaml:"client_id"` Port int `yaml:"port"`
MarketDataType int `yaml:"market_data_type"` // 1=Live, 2=Frozen, 3=Delayed(15min), 4=Delayed-Frozen ClientID int `yaml:"client_id"`
MarketDataType int `yaml:"market_data_type"` // 1=Live, 2=Frozen, 3=Delayed(15min), 4=Delayed-Frozen
} }
type TradingConfig struct { type TradingConfig struct {
Enabled bool `yaml:"enabled"` // Master switch to enable/disable trader
Strategy string `yaml:"strategy"` Strategy string `yaml:"strategy"`
StopLossEnabled bool `yaml:"stop_loss_enabled"` StopLossEnabled bool `yaml:"stop_loss_enabled"`
StopLossPercent float64 `yaml:"stop_loss_percent"` StopLossPercent float64 `yaml:"stop_loss_percent"`
@@ -35,7 +37,7 @@ type TradingConfig struct {
PendingTime Duration `yaml:"pending_time"` PendingTime Duration `yaml:"pending_time"`
DryRun bool `yaml:"dry_run"` DryRun bool `yaml:"dry_run"`
DryRunBalance float64 `yaml:"dry_run_balance"` 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"` TradingInterval Duration `yaml:"trading_interval"`
WatchSymbols []string `yaml:"watch_symbols"` WatchSymbols []string `yaml:"watch_symbols"`
TakeProfitPercent float64 `yaml:"take_profit_percent"` TakeProfitPercent float64 `yaml:"take_profit_percent"`
+19 -2
View File
@@ -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) { 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 := ` query := `
SELECT id, source, title, url, content, published_at, fetched_at, symbols, SELECT id, source, title, url, content, published_at, fetched_at, symbols,
sentiment_score, sentiment_label, sentiment_score, sentiment_label,
llm_sentiment_score, llm_model, llm_confidence, sentiment_method llm_sentiment_score, llm_model, llm_confidence, sentiment_method
FROM news_articles FROM news_articles
ORDER BY published_at DESC 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 { if err != nil {
return nil, fmt.Errorf("failed to query recent news: %w", err) 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 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
}