Signed-off-by: kaedwen <kaedwen@heinrich.blue>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
.claude
|
||||
data/
|
||||
config.yaml
|
||||
aitrade
|
||||
+30
-6
@@ -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
@@ -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
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user