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