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
+1
View File
@@ -1,3 +1,4 @@
.claude
data/
config.yaml
aitrade
+30 -6
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)
}
// 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,8 +88,10 @@ func New(cfg *config.Config, logger *slog.Logger) (*Application, error) {
cfg.Trading.StopLossPercent,
)
// Create trader
traderInstance := trader.NewTrader(
// 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,
@@ -91,6 +102,10 @@ func New(cfg *config.Config, logger *slog.Logger) (*Application, error) {
&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
// 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
// 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 {
+49 -5
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/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)
var balance *model.Balance
var err error
// 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 IB Gateway is unavailable
// 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/"):]
+188 -24
View File
@@ -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,51 +147,75 @@ function initNewsPage() {
});
// Auto-refresh every 30 seconds
return setInterval(loadNews, 30000);
return setInterval(() => {
loadNewsCount();
loadMoreNews();
}, 30000);
}
function loadNews() {
fetch('/api/news/recent?limit=50')
function loadNewsCount() {
fetch('/api/news/count?hours=1')
.then(response => response.json())
.then(data => {
allNewsArticles = data || [];
renderNews(allNewsArticles);
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);
const container = document.getElementById('news-list');
if (container) {
container.innerHTML = '<p class="no-news">Failed to load news. Please try again later.</p>';
}
newsIsLoading = false;
});
}
function renderNews(articles) {
function appendNews(articles) {
const container = document.getElementById('news-list');
if (!container) return;
if (!articles || articles.length === 0) {
container.innerHTML = '<p class="no-news">No news articles available</p>';
return;
// Clear loading message on first load
if (newsCurrentOffset === 0) {
container.innerHTML = '';
}
const filtered = newsCurrentFilter === 'all'
? articles
: articles.filter(a => getSentimentLabel(a) === newsCurrentFilter);
if (filtered.length === 0) {
container.innerHTML = '<p class="no-news">No ' + newsCurrentFilter + ' articles</p>';
return;
}
container.innerHTML = filtered.map(article => {
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 `
<div class="news-article ${sentiment}">
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>
@@ -200,14 +238,101 @@ function renderNews(articles) {
</div>
` : ''}
<div class="news-method">Analysis: ${method}</div>
</div>
`;
}).join('');
container.appendChild(articleEl);
});
updateNewsStats();
}
function updateNewsStats() {
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();
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() {
fetch('/api/news/recent?limit=50')
.then(response => response.json())
.then(data => {
allNewsArticles = data || [];
renderNews(allNewsArticles);
})
.catch(error => {
console.error('Failed to load news:', error);
const container = document.getElementById('news-list');
if (container) {
container.innerHTML = '<p class="no-news">Failed to load news. Please try again later.</p>';
}
});
}
function renderNews(articles) {
// Deprecated - now using appendNews for infinite scroll
const container = document.getElementById('news-list');
if (!container) return;
if (!articles || articles.length === 0) {
container.innerHTML = '<p class="no-news">No news articles available</p>';
return;
}
const filtered = newsCurrentFilter === 'all'
? articles
: articles.filter(a => getSentimentLabel(a) === newsCurrentFilter);
if (filtered.length === 0) {
container.innerHTML = '<p class="no-news">No ' + newsCurrentFilter + ' articles</p>';
return;
}
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';
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 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();
});
+62 -4
View File
@@ -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;
}
+3
View File
@@ -1,8 +1,10 @@
{{define "news-content"}}
<div class="news-container">
<div class="sticky-header" id="news-sticky-header">
<div class="news-header">
<h1>📰 Live News Feed</h1>
<div class="news-stats">
<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>
@@ -14,6 +16,7 @@
<button class="filter-btn" data-filter="neutral">Neutral</button>
<button class="filter-btn" data-filter="negative">Negative</button>
</div>
</div>
<div id="news-list" class="news-list">
<p class="loading">Loading news...</p>
-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>
@@ -1,5 +1,6 @@
{{define "overview-content"}}
<div id="tab-overview" class="tab-content">
<div class="sticky-header">
<div class="stats">
<div class="stat-card">
<h3>Total Balance</h3>
@@ -14,6 +15,7 @@
<div class="value" id="pending-trades">0</div>
</div>
</div>
</div>
<h2>Recent Trades</h2>
<table>
-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}}
@@ -1,6 +1,9 @@
{{define "trades-content"}}
<div id="tab-trades" class="tab-content">
<div class="sticky-header">
<h2>All Trades</h2>
</div>
<table>
<thead>
<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}}
@@ -1,9 +1,12 @@
{{define "whitelist-content"}}
<div id="tab-whitelist" class="tab-content">
<div class="sticky-header">
<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>
</div>
<table>
<thead>
<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}}
+3 -1
View File
@@ -20,6 +20,7 @@ type Config struct {
}
type IBGatewayConfig struct {
Enabled bool `yaml:"enabled"` // Master switch to enable/disable IB Gateway
Host string `yaml:"host"`
Port int `yaml:"port"`
ClientID int `yaml:"client_id"`
@@ -27,6 +28,7 @@ type IBGatewayConfig struct {
}
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"`
+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) {
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
}