504 lines
16 KiB
JavaScript
504 lines
16 KiB
JavaScript
// ===== CLIENT-SIDE ROUTER =====
|
|
|
|
let currentRoute = null;
|
|
let pageIntervals = {}; // Track intervals per page for cleanup
|
|
|
|
const routes = {
|
|
'/': 'overview',
|
|
'/overview': 'overview',
|
|
'/trades': 'trades',
|
|
'/whitelist': 'whitelist',
|
|
'/news': 'news'
|
|
};
|
|
|
|
async function navigateTo(path, skipHistory = false) {
|
|
const page = routes[path];
|
|
if (!page) {
|
|
console.warn('Unknown route:', path);
|
|
return;
|
|
}
|
|
|
|
// Skip if already on this page
|
|
if (currentRoute === page) return;
|
|
|
|
// Update browser history
|
|
if (!skipHistory) {
|
|
history.pushState({ page }, '', path);
|
|
}
|
|
|
|
// Clean up previous page
|
|
cleanupPage(currentRoute);
|
|
|
|
// Update active tab
|
|
updateActiveTab(page);
|
|
|
|
// Load new content
|
|
await loadContent(page);
|
|
|
|
// Initialize new page
|
|
initPage(page);
|
|
|
|
currentRoute = page;
|
|
}
|
|
|
|
async function loadContent(page) {
|
|
const contentDiv = document.getElementById('content');
|
|
if (!contentDiv) return;
|
|
|
|
contentDiv.innerHTML = '<p style="text-align: center; padding: 40px;">Loading...</p>';
|
|
|
|
try {
|
|
const resp = await fetch(`/content/${page}`);
|
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
|
|
const html = await resp.text();
|
|
contentDiv.innerHTML = html;
|
|
} catch (err) {
|
|
console.error('Failed to load content:', err);
|
|
contentDiv.innerHTML = '<p style="text-align: center; padding: 40px; color: red;">Failed to load content</p>';
|
|
}
|
|
}
|
|
|
|
function updateActiveTab(page) {
|
|
document.querySelectorAll('.tab').forEach(tab => {
|
|
tab.classList.remove('active');
|
|
});
|
|
|
|
const activeTab = document.querySelector(`.tab[data-page="${page}"]`);
|
|
if (activeTab) {
|
|
activeTab.classList.add('active');
|
|
}
|
|
}
|
|
|
|
function initPage(page) {
|
|
switch(page) {
|
|
case 'overview':
|
|
loadBalance();
|
|
loadTrades();
|
|
break;
|
|
case 'trades':
|
|
loadTrades();
|
|
break;
|
|
case 'whitelist':
|
|
loadWhitelist();
|
|
break;
|
|
case 'news':
|
|
const newsInterval = initNewsPage();
|
|
pageIntervals.newsRefresh = newsInterval;
|
|
break;
|
|
}
|
|
}
|
|
|
|
function cleanupPage(page) {
|
|
// Clear any intervals for previous page
|
|
if (pageIntervals[page]) {
|
|
clearInterval(pageIntervals[page]);
|
|
delete pageIntervals[page];
|
|
}
|
|
}
|
|
|
|
// Intercept tab link clicks
|
|
document.addEventListener('click', (e) => {
|
|
const link = e.target.closest('a.tab');
|
|
if (link && link.origin === location.origin) {
|
|
e.preventDefault();
|
|
const path = link.getAttribute('href');
|
|
navigateTo(path);
|
|
}
|
|
});
|
|
|
|
// Handle browser back/forward
|
|
window.addEventListener('popstate', (e) => {
|
|
const path = location.pathname;
|
|
navigateTo(path, true); // true = skip pushState
|
|
});
|
|
|
|
// ===== NEWS PAGE FUNCTIONS =====
|
|
|
|
let newsCurrentFilter = 'all';
|
|
let allNewsArticles = [];
|
|
|
|
function initNewsPage() {
|
|
// Initialize news page
|
|
loadNews();
|
|
|
|
// Set up 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');
|
|
newsCurrentFilter = this.dataset.filter;
|
|
renderNews(allNewsArticles);
|
|
});
|
|
});
|
|
|
|
// Auto-refresh every 30 seconds
|
|
return setInterval(loadNews, 30000);
|
|
}
|
|
|
|
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) {
|
|
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.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" 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>
|
|
`;
|
|
}).join('');
|
|
|
|
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();
|
|
}
|
|
|
|
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';
|
|
}
|
|
|
|
// ===== SSE CONNECTION =====
|
|
|
|
// SSE connection
|
|
const evtSource = new EventSource('/events');
|
|
evtSource.onmessage = function(event) {
|
|
console.log('SSE:', event.data);
|
|
|
|
// Update relevant data based on current page and event type
|
|
if (event.data.includes('whitelist')) {
|
|
if (currentRoute === 'whitelist') loadWhitelist();
|
|
}
|
|
|
|
if (event.data.includes('trade')) {
|
|
if (currentRoute === 'overview' || currentRoute === 'trades') {
|
|
loadTrades();
|
|
}
|
|
}
|
|
|
|
if (event.data.includes('news')) {
|
|
if (currentRoute === 'news') {
|
|
console.log('News updated, reloading...');
|
|
loadNews();
|
|
}
|
|
}
|
|
|
|
// Always update balance (shown in multiple places)
|
|
loadBalance();
|
|
};
|
|
|
|
// ===== INITIALIZATION =====
|
|
|
|
// Initial data and config (always load these)
|
|
loadBalance();
|
|
loadConfigOnce();
|
|
|
|
// Initial route dispatch
|
|
if (window.initialPage) {
|
|
// Server-rendered initial page, content already in DOM
|
|
currentRoute = window.initialPage;
|
|
updateActiveTab(currentRoute);
|
|
initPage(currentRoute);
|
|
} else {
|
|
// Client-side navigation (e.g., page refresh)
|
|
navigateTo(location.pathname, true);
|
|
}
|
|
|
|
setInterval(() => { loadBalance(); loadTrades(); }, 10000);
|
|
|
|
async function loadConfigOnce() {
|
|
// Load fresh config from API (no caching to ensure we always get latest state)
|
|
try {
|
|
const resp = await fetch('/api/config');
|
|
const config = await resp.json();
|
|
updateBanner(config);
|
|
} catch (err) {
|
|
console.error('Failed to load config:', err);
|
|
}
|
|
}
|
|
|
|
function updateBanner(config) {
|
|
const banner = document.getElementById('mode-banner');
|
|
if (banner) {
|
|
if (config.dry_run) {
|
|
banner.textContent = '⚠️ DRY RUN MODE';
|
|
banner.className = 'mode-banner mode-dryrun loaded';
|
|
} else {
|
|
banner.textContent = '🔴 LIVE MODE';
|
|
banner.className = 'mode-banner mode-live loaded';
|
|
}
|
|
}
|
|
}
|
|
|
|
async function loadConfig() {
|
|
try {
|
|
const resp = await fetch('/api/config');
|
|
const config = await resp.json();
|
|
|
|
const banner = document.getElementById('mode-banner');
|
|
if (banner) {
|
|
if (config.dry_run) {
|
|
banner.textContent = '⚠️ DRY RUN MODE';
|
|
banner.className = 'mode-banner mode-dryrun loaded';
|
|
} else {
|
|
banner.textContent = '🔴 LIVE MODE';
|
|
banner.className = 'mode-banner mode-live loaded';
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load config:', err);
|
|
}
|
|
}
|
|
|
|
async function loadBalance() {
|
|
const resp = await fetch('/api/balance');
|
|
const data = await resp.json();
|
|
const balanceEl = document.getElementById('balance');
|
|
if (balanceEl) {
|
|
balanceEl.textContent = '$' + (data.TotalValue || 0).toLocaleString('en-US', {
|
|
minimumFractionDigits: 2,
|
|
maximumFractionDigits: 2
|
|
});
|
|
}
|
|
}
|
|
|
|
async function loadTrades() {
|
|
const resp = await fetch('/api/trades');
|
|
const trades = await resp.json();
|
|
|
|
let pending = 0, active = 0;
|
|
|
|
// Overview table (last 10)
|
|
const overviewBody = document.getElementById('trades-body-overview');
|
|
if (overviewBody) {
|
|
overviewBody.innerHTML = '';
|
|
trades.slice(0, 10).forEach(trade => {
|
|
if (trade.status === 'PENDING') pending++;
|
|
if (trade.status === 'SUBMITTED' || trade.status === 'FILLED') active++;
|
|
overviewBody.innerHTML += buildTradeRow(trade, false);
|
|
});
|
|
}
|
|
|
|
// Full trades table
|
|
const tradesBody = document.getElementById('trades-body');
|
|
if (tradesBody) {
|
|
tradesBody.innerHTML = '';
|
|
trades.forEach(trade => {
|
|
tradesBody.innerHTML += buildTradeRow(trade, true);
|
|
});
|
|
}
|
|
|
|
const pendingEl = document.getElementById('pending-trades');
|
|
const activeEl = document.getElementById('active-trades');
|
|
if (pendingEl) pendingEl.textContent = pending;
|
|
if (activeEl) activeEl.textContent = active;
|
|
}
|
|
|
|
function buildTradeRow(trade, detailed) {
|
|
const dryBadge = trade.is_dry_run ? '<span class="dry-run-badge">DRY</span>' : '';
|
|
const price = trade.executed_price ? '$' + trade.executed_price.toFixed(2) : '-';
|
|
const pnl = formatPnL(trade.dry_run_pnl);
|
|
const actions = trade.status === 'PENDING' ?
|
|
'<button class="action-btn approve-btn" onclick="approveTrade(' + trade.id + ', false)">✓</button>' +
|
|
'<button class="action-btn force-btn" onclick="approveTrade(' + trade.id + ', true)">⚡</button>' +
|
|
'<button class="action-btn reject-btn" onclick="rejectTrade(' + trade.id + ')">✕</button>' : '-';
|
|
|
|
let row = '<tr>' +
|
|
'<td>' + trade.id + dryBadge + '</td>' +
|
|
'<td>' + trade.symbol + '</td>' +
|
|
'<td>' + trade.action + '</td>' +
|
|
'<td>' + trade.quantity + '</td>' +
|
|
'<td><span class="status status-' + trade.status.toLowerCase() + '">' + trade.status + '</span></td>' +
|
|
'<td>' + price + '</td>' +
|
|
'<td>' + pnl + '</td>' +
|
|
'<td>' + (trade.confidence * 100).toFixed(0) + '%</td>';
|
|
|
|
if (detailed) {
|
|
row += '<td style="max-width: 200px; font-size: 11px;">' + (trade.reasoning || '-') + '</td>';
|
|
}
|
|
|
|
row += '<td>' + new Date(trade.created_at).toLocaleString() + '</td>' +
|
|
'<td>' + actions + '</td></tr>';
|
|
|
|
return row;
|
|
}
|
|
|
|
function formatPnL(pnl) {
|
|
if (!pnl) return '-';
|
|
const formatted = '$' + Math.abs(pnl).toFixed(2);
|
|
const cssClass = pnl >= 0 ? 'pnl-positive' : 'pnl-negative';
|
|
const sign = pnl >= 0 ? '+' : '-';
|
|
return '<span class="' + cssClass + '">' + sign + formatted + '</span>';
|
|
}
|
|
|
|
async function approveTrade(id, forceNow) {
|
|
await fetch('/api/trades/' + id + '/approve?force=' + forceNow, { method: 'POST' });
|
|
loadTrades();
|
|
}
|
|
|
|
async function rejectTrade(id) {
|
|
const reason = prompt('Rejection reason:') || 'User rejected';
|
|
await fetch('/api/trades/' + id + '/reject', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ reason })
|
|
});
|
|
loadTrades();
|
|
}
|
|
|
|
// Whitelist functions
|
|
async function loadWhitelist() {
|
|
const resp = await fetch('/api/whitelist');
|
|
const entries = await resp.json();
|
|
|
|
const tbody = document.getElementById('whitelist-body');
|
|
tbody.innerHTML = '';
|
|
|
|
entries.forEach(entry => {
|
|
const statusClass = entry.Enabled ? 'enabled' : 'disabled';
|
|
const statusText = entry.Enabled ? '✓ Enabled' : '✕ Disabled';
|
|
tbody.innerHTML += '<tr>' +
|
|
'<td><strong>' + entry.Symbol + '</strong></td>' +
|
|
'<td>' + (entry.Name || '-') + '</td>' +
|
|
'<td>' + (entry.WKN || '-') + '</td>' +
|
|
'<td>' + (entry.ISIN || '-') + '</td>' +
|
|
'<td class="' + statusClass + '">' + statusText + '</td>' +
|
|
'<td style="max-width: 200px; font-size: 11px;">' + (entry.Notes || '-') + '</td>' +
|
|
'<td>' +
|
|
'<button class="action-btn edit-btn" onclick="editWhitelist(' + entry.ID + ')">✎</button>' +
|
|
'<button class="action-btn delete-btn" onclick="deleteWhitelist(' + entry.ID + ')">🗑</button>' +
|
|
'</td></tr>';
|
|
});
|
|
}
|
|
|
|
function showAddModal() {
|
|
document.getElementById('modal-title').textContent = 'Add Symbol';
|
|
document.getElementById('whitelist-form').reset();
|
|
document.getElementById('entry-id').value = '';
|
|
document.getElementById('entry-enabled').checked = true;
|
|
document.getElementById('whitelist-modal').classList.add('active');
|
|
}
|
|
|
|
async function editWhitelist(id) {
|
|
const resp = await fetch('/api/whitelist/' + id);
|
|
const entry = await resp.json();
|
|
|
|
document.getElementById('modal-title').textContent = 'Edit Symbol';
|
|
document.getElementById('entry-id').value = entry.ID;
|
|
document.getElementById('entry-symbol').value = entry.Symbol;
|
|
document.getElementById('entry-name').value = entry.Name || '';
|
|
document.getElementById('entry-wkn').value = entry.WKN || '';
|
|
document.getElementById('entry-isin').value = entry.ISIN || '';
|
|
document.getElementById('entry-enabled').checked = entry.Enabled;
|
|
document.getElementById('entry-notes').value = entry.Notes || '';
|
|
document.getElementById('whitelist-modal').classList.add('active');
|
|
}
|
|
|
|
async function saveWhitelist(event) {
|
|
event.preventDefault();
|
|
|
|
const id = document.getElementById('entry-id').value;
|
|
const data = {
|
|
symbol: document.getElementById('entry-symbol').value,
|
|
name: document.getElementById('entry-name').value,
|
|
wkn: document.getElementById('entry-wkn').value,
|
|
isin: document.getElementById('entry-isin').value,
|
|
enabled: document.getElementById('entry-enabled').checked,
|
|
notes: document.getElementById('entry-notes').value
|
|
};
|
|
|
|
const url = id ? '/api/whitelist/' + id : '/api/whitelist';
|
|
const method = id ? 'PUT' : 'POST';
|
|
|
|
await fetch(url, {
|
|
method: method,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data)
|
|
});
|
|
|
|
closeModal();
|
|
loadWhitelist();
|
|
}
|
|
|
|
async function deleteWhitelist(id) {
|
|
if (!confirm('Delete this symbol from whitelist?')) return;
|
|
await fetch('/api/whitelist/' + id, { method: 'DELETE' });
|
|
loadWhitelist();
|
|
}
|
|
|
|
function closeModal() {
|
|
document.getElementById('whitelist-modal').classList.remove('active');
|
|
}
|