WebUI Redesign and Component 1: category mapper fixes
This commit is contained in:
@@ -168,21 +168,125 @@ const fmt = {
|
||||
let platformChart, tierChart;
|
||||
|
||||
function getChartColors() {
|
||||
const isDark = html.getAttribute('data-theme') === 'dark';
|
||||
return {
|
||||
text: isDark ? '#AAAAAA' : '#666666',
|
||||
grid: isDark ? '#2A2C33' : '#E8E9EC',
|
||||
bg: isDark ? '#16181D' : '#FFFFFF'
|
||||
text: '#8B93A7',
|
||||
grid: 'rgba(255,255,255,0.07)',
|
||||
bg: 'rgba(255,255,255,0.045)'
|
||||
};
|
||||
}
|
||||
|
||||
function updateChartColors() {
|
||||
const c = getChartColors();
|
||||
[platformChart, tierChart].forEach(chart => {
|
||||
if (!chart) return;
|
||||
if (chart.options.plugins?.legend) chart.options.plugins.legend.labels.color = c.text;
|
||||
chart.update();
|
||||
});
|
||||
function getAvatarBg(id) {
|
||||
const gradients = [
|
||||
'linear-gradient(135deg,#1652F0,#4c8dff)',
|
||||
'linear-gradient(135deg,#12D48A,#0a8f5f)',
|
||||
'linear-gradient(135deg,#7c5cff,#4c8dff)',
|
||||
'linear-gradient(135deg,#ff9d4c,#ff6b8a)',
|
||||
'linear-gradient(135deg,#F6465D,#ff6b8a)'
|
||||
];
|
||||
return gradients[id % gradients.length];
|
||||
}
|
||||
|
||||
function getTraitClass(trait) {
|
||||
if (!trait) return 'tier-unknown';
|
||||
const lower = trait.toLowerCase();
|
||||
if (lower.includes('winrate') || lower.includes('wins') || lower.includes('farming') || lower.includes('insider')) return 'trait-winrate';
|
||||
if (lower.includes('volume') || lower.includes('amounts') || lower.includes('sizes') || lower.includes('stake')) return 'trait-volume';
|
||||
if (lower.includes('whale') || lower.includes('scalper') || lower.includes('martingale')) return 'trait-whales';
|
||||
if (lower.includes('sentiment') || lower.includes('cadence') || lower.includes('24_7') || lower.includes('copyable') || lower.includes('wallet')) return 'trait-sentiment';
|
||||
return 'tier-unknown';
|
||||
}
|
||||
|
||||
const TraitMetadata = {
|
||||
"sub_second_cadence": {
|
||||
name: "Sub-Second Cadence",
|
||||
desc: "Executes trades in sub-second intervals, highly indicative of algorithmic execution."
|
||||
},
|
||||
"always_on_24_7": {
|
||||
name: "24/7 Activity",
|
||||
desc: "Trades at all hours of the day and night with very short gaps, indicating bot operation."
|
||||
},
|
||||
"uniform_sizes": {
|
||||
name: "Uniform Sizes",
|
||||
desc: "Executes trades with identical position sizes, suggesting a systematic layout."
|
||||
},
|
||||
"round_amounts": {
|
||||
name: "Round Amounts",
|
||||
desc: "Executes trades with round amounts (e.g. 100, 500, 1000 USD), common for manual traders."
|
||||
},
|
||||
"uses_split_merge": {
|
||||
name: "Split & Merge",
|
||||
desc: "Splits large positions into smaller orders or merges multiple positions to optimize slippage."
|
||||
},
|
||||
"both_sides_same_market": {
|
||||
name: "Two-Sided Market Maker",
|
||||
desc: "Buys and sells in the same market, extracting spreads or resolving inventory."
|
||||
},
|
||||
"resolution_farming": {
|
||||
name: "Resolution Farmer",
|
||||
desc: "Buys options at high probabilities (e.g., >93%) to collect predictable small returns."
|
||||
},
|
||||
"longshot_buyer": {
|
||||
name: "Longshot Buyer",
|
||||
desc: "Buys options at low probabilities (e.g., <10%), seeking rare high-payoff events."
|
||||
},
|
||||
"scalper": {
|
||||
name: "Scalper",
|
||||
desc: "Holds positions for very short periods (typically <1 hour) to lock in quick profits."
|
||||
},
|
||||
"holds_to_resolution": {
|
||||
name: "Holds to Resolution",
|
||||
desc: "Keeps positions open until the market officially resolves, avoiding early exits."
|
||||
},
|
||||
"fresh_wallet": {
|
||||
name: "Fresh Wallet",
|
||||
desc: "Wallet was created recently (less than 30 days of active history)."
|
||||
},
|
||||
"stable_stake_fraction": {
|
||||
name: "Stable Stake",
|
||||
desc: "Risk per trade is a highly stable fraction of estimated total bankroll."
|
||||
},
|
||||
"possible_insider": {
|
||||
name: "Possible Insider",
|
||||
desc: "Consistently wins low-probability bets with very low volume, indicating asymmetric information."
|
||||
},
|
||||
"thin_margin_wins": {
|
||||
name: "Thin Margin Winner",
|
||||
desc: "Consistently resolves trades with small margins of win (low ROI)."
|
||||
},
|
||||
"high_payoff_wins": {
|
||||
name: "High Payoff Winner",
|
||||
desc: "Consistently resolves trades with high margins of win (high ROI)."
|
||||
},
|
||||
"sells_at_loss": {
|
||||
name: "Sells at Loss",
|
||||
desc: "Uses strict stop-loss rules, selling options at a loss when the market goes against them."
|
||||
},
|
||||
"days_active": {
|
||||
name: "Days Active",
|
||||
desc: "The number of days this wallet has been active on-chain."
|
||||
},
|
||||
"trades_last_30_days": {
|
||||
name: "Trades Last 30d",
|
||||
desc: "The total number of trades executed in the last 30 days."
|
||||
},
|
||||
"martingale_pattern": {
|
||||
name: "Martingale Trader",
|
||||
desc: "Increases bet size after losses to attempt to recoup losses, typical of high-risk strategies."
|
||||
},
|
||||
"not_copyable_hf": {
|
||||
name: "Uncopyable (High Freq)",
|
||||
desc: "Trades at frequencies too high to replicate manually or with standard copytrading setups."
|
||||
}
|
||||
};
|
||||
|
||||
function getTraitDisplayName(traitKey) {
|
||||
const meta = TraitMetadata[traitKey];
|
||||
return meta ? meta.name : traitKey.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||
}
|
||||
|
||||
function getTraitDescription(traitKey) {
|
||||
const meta = TraitMetadata[traitKey];
|
||||
return meta ? meta.desc : 'No description available.';
|
||||
}
|
||||
|
||||
// ─── Dashboard Load ───
|
||||
@@ -224,19 +328,20 @@ async function loadDashboard() {
|
||||
// Recent Trades table
|
||||
const rBody = document.getElementById('recentTradesBody');
|
||||
rBody.innerHTML = data.recentTrades.map(t => `
|
||||
<tr>
|
||||
<tr onclick="viewTrader(${t.traderId})">
|
||||
<td>${fmt.time(t.executedAt)}</td>
|
||||
<td onclick="viewTrader(${t.traderId})" style="cursor:pointer; color:var(--primary)">${t.traderName}</td>
|
||||
<td onclick="${t.dbMarketId ? `viewMarket(${t.dbMarketId})` : `''`}" style="cursor:${t.dbMarketId ? 'pointer' : 'default'}" title="Market ID: ${t.marketId}">
|
||||
${t.marketName || (t.marketId.length > 20 ? t.marketId.substring(0,20)+'...' : t.marketId)}
|
||||
</td>
|
||||
<td><strong>${t.traderName}</strong></td>
|
||||
<td>${fmt.side(t.side)}</td>
|
||||
<td>${Number(t.price).toFixed(2)}</td>
|
||||
<td>${fmt.num(t.size)}</td>
|
||||
<td>${fmt.usd(t.amount)}</td>
|
||||
<td class="num-col">${Number(t.price).toFixed(2)}</td>
|
||||
<td class="num-col">${fmt.num(t.size)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
const headerVol = document.getElementById('headerVolumeBadge');
|
||||
if (headerVol) {
|
||||
headerVol.textContent = `24H VOL: ${fmt.usd(data.volume24h)}`;
|
||||
}
|
||||
|
||||
// Platform Chart
|
||||
const cc = getChartColors();
|
||||
const pLabels = Object.keys(data.platformBreakdown.traderCounts);
|
||||
@@ -245,25 +350,25 @@ async function loadDashboard() {
|
||||
if (platformChart) platformChart.destroy();
|
||||
platformChart = new Chart(document.getElementById('platformChart'), {
|
||||
type: 'doughnut',
|
||||
data: { labels: pLabels.length ? pLabels : ['No Data'], datasets: [{ data: pData.length ? pData : [1],
|
||||
backgroundColor: ['#FF2D55', '#5AC8FA', '#FF9500', '#34C759', '#AF52DE', '#FF6B8A', '#30D158'],
|
||||
data: { labels: pLabels.length ? pLabels : ['Keine Daten'], datasets: [{ data: pData.length ? pData : [1],
|
||||
backgroundColor: ['#1652F0', '#12D48A', '#7c5cff', '#ff9d4c', '#F6465D', '#5b9dff'],
|
||||
borderWidth: 0 }] },
|
||||
options: { responsive: true, maintainAspectRatio: false, cutout: '70%',
|
||||
plugins: { legend: { position: 'bottom', labels: { color: cc.text, padding: 16, font: { family: "'Inter'", size: 12 } } } } }
|
||||
plugins: { legend: { position: 'bottom', labels: { color: cc.text, padding: 16, font: { family: "'Manrope'", size: 12 } } } } }
|
||||
});
|
||||
|
||||
// Tier chart
|
||||
const tierData = data.topTraders.reduce((acc, t) => { acc[t.tier] = (acc[t.tier] || 0) + 1; return acc; }, {});
|
||||
if (tierChart) tierChart.destroy();
|
||||
const tLabels = Object.keys(tierData).length ? Object.keys(tierData) : ['No Data'];
|
||||
const tLabels = Object.keys(tierData).length ? Object.keys(tierData) : ['Keine Daten'];
|
||||
const tData = Object.values(tierData).length ? Object.values(tierData) : [1];
|
||||
tierChart = new Chart(document.getElementById('tierChart'), {
|
||||
type: 'bar',
|
||||
data: { labels: tLabels, datasets: [{ label: 'Traders', data: tData,
|
||||
backgroundColor: '#FF2D55', borderRadius: 6, barThickness: 32 }] },
|
||||
data: { labels: tLabels, datasets: [{ label: 'Trader', data: tData,
|
||||
backgroundColor: '#1652F0', borderRadius: 6, barThickness: 32 }] },
|
||||
options: { responsive: true, maintainAspectRatio: false,
|
||||
scales: { x: { grid: { display: false }, ticks: { color: cc.text, font: { family: "'Inter'" } } },
|
||||
y: { grid: { color: cc.grid }, ticks: { color: cc.text, font: { family: "'Inter'" } } } },
|
||||
scales: { x: { grid: { display: false }, ticks: { color: cc.text, font: { family: "'Manrope'" } } },
|
||||
y: { grid: { color: cc.grid }, ticks: { color: cc.text, font: { family: "'Manrope'" } } } },
|
||||
plugins: { legend: { display: false } } }
|
||||
});
|
||||
}
|
||||
@@ -328,26 +433,24 @@ async function loadTraders() {
|
||||
});
|
||||
|
||||
tbody.innerHTML = data.map((t, i) => `
|
||||
<tr>
|
||||
<tr onclick="viewTrader(${t.id})">
|
||||
<td>${i + 1}</td>
|
||||
<td><strong><a href="#" onclick="viewTrader(${t.id}); return false;" style="color:var(--primary);text-decoration:none;">${t.displayName}</a></strong>${t.isSuspectedBot ? ' 🤖' : ''}</td>
|
||||
<td><strong>${t.displayName}</strong>${t.isSuspectedBot ? ' 🤖' : ''}</td>
|
||||
<td>${t.platform}</td>
|
||||
<td><strong>${Number(t.combinedScore).toFixed(1)}</strong></td>
|
||||
<td>${Number(t.copytradingQualityScore || 0).toFixed(1)}</td>
|
||||
<td>${Number(t.copytradingCopyabilityScore || 0).toFixed(1)}</td>
|
||||
<td>${fmt.pct(t.winRate)}</td>
|
||||
<td>${fmt.pnl(t.totalPnl)}</td>
|
||||
<td class="num-col"><strong>${Number(t.combinedScore).toFixed(1)}</strong></td>
|
||||
<td class="num-col">${Number(t.copytradingQualityScore || 0).toFixed(1)}</td>
|
||||
<td class="num-col">${Number(t.copytradingCopyabilityScore || 0).toFixed(1)}</td>
|
||||
<td class="num-col">${fmt.pct(t.winRate)}</td>
|
||||
<td class="num-col">${fmt.pnl(t.totalPnl)}</td>
|
||||
<td>${t.trades30d} | ${t.totalTrades}</td>
|
||||
<td>
|
||||
${t.strategy}
|
||||
${t.traits ? '<div style="display:flex; flex-wrap:wrap; gap:4px; margin-top:4px;">' + t.traits.map(tr => `<span style="font-size:10px; padding:2px 6px; background:var(--bg-input); border-radius:10px;">${tr}</span>`).join('') + '</div>' : ''}
|
||||
</td>
|
||||
<td>
|
||||
<div style="display:flex; gap:4px;">
|
||||
<button class="btn-sm" onclick="viewTrader(${t.id})">Details</button>
|
||||
<button class="btn-sm" onclick="queueHistorySync(${t.id})" style="background:var(--bg-input); border:1px solid var(--border); color:var(--text)">Sync</button>
|
||||
<button class="btn-sm" onclick="queueTraderAnalysis(${t.id})" style="background:var(--bg-input); border:1px solid var(--border); color:var(--text)">Analyze</button>
|
||||
</div>
|
||||
${t.strategy || '—'}
|
||||
${t.traits ? '<div style="display:flex; flex-wrap:wrap; gap:4px; margin-top:4px;">' + t.traits.map(tr => {
|
||||
const rawKey = tr.trait || tr;
|
||||
const name = getTraitDisplayName(rawKey);
|
||||
const desc = getTraitDescription(rawKey);
|
||||
return `<span class="tier-badge ${getTraitClass(rawKey)}" title="${desc}">${name}</span>`;
|
||||
}).join('') + '</div>' : ''}
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
@@ -454,17 +557,30 @@ async function viewTrader(id) {
|
||||
// Reset tabs to default (Analytics)
|
||||
switchTraderTab('td-tab-analytics');
|
||||
|
||||
document.getElementById('td-name').textContent = t.displayName;
|
||||
document.getElementById('td-name').textContent = `Trader-Profil: ${t.displayName}`;
|
||||
document.getElementById('td-displayName').textContent = t.displayName;
|
||||
document.getElementById('td-platform').textContent = t.platform;
|
||||
document.getElementById('td-platformId').textContent = t.platformUserId;
|
||||
document.getElementById('td-tier').innerHTML = fmt.tier(t.tier);
|
||||
document.getElementById('td-strategy').textContent = t.strategy;
|
||||
|
||||
const avatarEl = document.getElementById('td-avatar');
|
||||
if (avatarEl) {
|
||||
avatarEl.textContent = t.displayName ? t.displayName[0].toUpperCase() : 'P';
|
||||
avatarEl.style.background = getAvatarBg(id);
|
||||
}
|
||||
|
||||
const traitsContainer = document.getElementById('td-traits-container');
|
||||
const traitsEl = document.getElementById('td-traits');
|
||||
if (t.traits && t.traits.length > 0) {
|
||||
traitsContainer.style.display = 'block';
|
||||
traitsEl.innerHTML = t.traits.map(tr => `<span title="Value: ${Number(tr.value).toFixed(4)}" style="font-size:11px; padding:2px 8px; background:var(--bg-input); border-radius:12px; border:1px solid var(--border);">${tr.trait}</span>`).join('');
|
||||
traitsEl.innerHTML = t.traits.map(tr => {
|
||||
const rawKey = tr.trait || tr;
|
||||
const name = getTraitDisplayName(rawKey);
|
||||
const desc = getTraitDescription(rawKey);
|
||||
const scoreVal = tr.value !== undefined ? ` (Value: ${Number(tr.value).toFixed(4)})` : '';
|
||||
return `<span class="tier-badge ${getTraitClass(rawKey)}" title="${desc}${scoreVal}">${name}</span>`;
|
||||
}).join('');
|
||||
} else {
|
||||
traitsContainer.style.display = 'none';
|
||||
traitsEl.innerHTML = '';
|
||||
@@ -487,7 +603,7 @@ async function viewTrader(id) {
|
||||
document.getElementById('td-median-win-loss').innerHTML = `<span class="side-buy">+${Number(medianWin).toFixed(1)}%</span> / <span class="side-sell">${Number(medianLoss).toFixed(1)}%</span>`;
|
||||
document.getElementById('td-profit-factor').textContent = t.profitFactor ? Number(t.profitFactor).toFixed(2) : '—';
|
||||
document.getElementById('td-expectancy').innerHTML = expectancy > 0 ? `<span class="side-buy">+${expectancy.toFixed(1)}%</span>` : `<span class="side-sell">${expectancy.toFixed(1)}%</span>`;
|
||||
document.getElementById('td-ai-summary').textContent = t.aiStrategySummary || 'Not analyzed yet.';
|
||||
document.getElementById('td-ai-summary').textContent = t.aiStrategySummary || 'Bisher keine KI-Strategieanalyse durchgeführt.';
|
||||
|
||||
const syncBtn = document.getElementById('btn-sync-trader');
|
||||
if (syncBtn) {
|
||||
@@ -497,7 +613,7 @@ async function viewTrader(id) {
|
||||
const deepSyncBtn = document.getElementById('btn-deep-resync-trader');
|
||||
if (deepSyncBtn) {
|
||||
deepSyncBtn.onclick = () => {
|
||||
if (confirm("Are you sure? This will delete all compacted trades and reset positions, then fetch all historical trades via pagination.")) {
|
||||
if (confirm("Bist du sicher? Dies löscht alle komprimierten Trades, setzt die Positionen zurück und lädt den gesamten Verlauf neu.")) {
|
||||
queueDeepResync(id);
|
||||
}
|
||||
};
|
||||
@@ -509,7 +625,12 @@ async function viewTrader(id) {
|
||||
}
|
||||
|
||||
const aiBtn = document.getElementById('btn-ai-analysis');
|
||||
aiBtn.onclick = () => triggerAiAnalysis(id, true);
|
||||
if (window.capabilities && !window.capabilities.canControl) {
|
||||
aiBtn.style.display = 'none';
|
||||
} else {
|
||||
aiBtn.style.display = 'inline-block';
|
||||
aiBtn.onclick = () => triggerAiAnalysis(id, true);
|
||||
}
|
||||
|
||||
const wlBtn = document.getElementById('btn-toggle-watchlist');
|
||||
wlBtn.textContent = t.isOnWatchlist ? 'Watchlist (Remove)' : 'Watchlist (Add)';
|
||||
@@ -750,6 +871,27 @@ async function queueBacklogAnalysis() {
|
||||
|
||||
// Initialize Dashboard
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// Load capabilities first
|
||||
try {
|
||||
const caps = await api('/api/capabilities');
|
||||
if (caps) {
|
||||
window.capabilities = caps;
|
||||
if (!caps.canControl) {
|
||||
// Hide control-mode guarded elements
|
||||
const jobsNav = document.getElementById('nav-jobs-container');
|
||||
if (jobsNav) jobsNav.style.display = 'none';
|
||||
|
||||
const addTraderPanel = document.getElementById('control-add-trader-panel');
|
||||
if (addTraderPanel) addTraderPanel.style.display = 'none';
|
||||
|
||||
const traderActions = document.getElementById('control-trader-actions');
|
||||
if (traderActions) traderActions.style.display = 'none';
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load capabilities', e);
|
||||
}
|
||||
|
||||
// Load traits for filter
|
||||
try {
|
||||
const traits = await api('/api/traders/traits');
|
||||
@@ -759,7 +901,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
traits.forEach(t => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = t;
|
||||
opt.textContent = t;
|
||||
opt.textContent = getTraitDisplayName(t);
|
||||
filterTrait.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user