823 lines
28 KiB
JavaScript
823 lines
28 KiB
JavaScript
const healthStatus = document.getElementById('healthStatus');
|
|
const refreshBtn = document.getElementById('refreshBtn');
|
|
const instrumentIdInput = document.getElementById('instrumentId');
|
|
const timeframeInput = document.getElementById('timeframe');
|
|
const limitInput = document.getElementById('limit');
|
|
const autoRefreshSecondsInput = document.getElementById('autoRefreshSeconds');
|
|
const executionStatusFilterInput = document.getElementById('executionStatusFilter');
|
|
const searchTextInput = document.getElementById('searchText');
|
|
const lastUpdated = document.getElementById('lastUpdated');
|
|
const activeFilters = document.getElementById('activeFilters');
|
|
const signalsTable = document.getElementById('signalsTable');
|
|
const sessionsTable = document.getElementById('sessionsTable');
|
|
const executionsTable = document.getElementById('executionsTable');
|
|
const backtestsCards = document.getElementById('backtestsCards');
|
|
const overviewCards = document.getElementById('overviewCards');
|
|
const detailMeta = document.getElementById('detailMeta');
|
|
const detailPanel = document.getElementById('detailPanel');
|
|
const trainingCasesList = document.getElementById('trainingCasesList');
|
|
const trainingCaseDetail = document.getElementById('trainingCaseDetail');
|
|
const trainingProgress = document.getElementById('trainingProgress');
|
|
const trainingError = document.getElementById('trainingError');
|
|
const trainingImportText = document.getElementById('trainingImportText');
|
|
const trainingImportBtn = document.getElementById('trainingImportBtn');
|
|
const trainingExportBtn = document.getElementById('trainingExportBtn');
|
|
const trainingAttemptStatus = document.getElementById('trainingAttemptStatus');
|
|
const trainingAnnotationText = document.getElementById('trainingAnnotationText');
|
|
const trainingSaveAnnotationBtn = document.getElementById('trainingSaveAnnotationBtn');
|
|
const trainingSubmitBtn = document.getElementById('trainingSubmitBtn');
|
|
const canvas = document.getElementById('candlesChart');
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
const TRAINING_PUBLIC_ROUTES = Object.freeze({
|
|
list: '/training/cases',
|
|
detail: (caseId) => `/training/cases/${caseId}`,
|
|
export: '/training/cases/export',
|
|
import: '/training/cases/import',
|
|
createAttempt: '/training/attempts',
|
|
annotateAttempt: (attemptId) => `/training/attempts/${attemptId}/annotations`,
|
|
submitAttempt: (attemptId) => `/training/attempts/${attemptId}/submit`,
|
|
progress: '/training/progress',
|
|
});
|
|
|
|
const TRAINING_DASHBOARD_ROUTE_ANCHORS = Object.freeze([
|
|
"fetchJson('/training/cases')",
|
|
"fetchJson('/training/progress')",
|
|
"fetchJson('/training/attempts', { method: 'POST'",
|
|
"fetchJson(`/training/attempts/${attemptId}/annotations`, { method: 'POST'",
|
|
"fetchJson(`/training/attempts/${attemptId}/submit`, { method: 'POST'",
|
|
]);
|
|
|
|
async function fetchJson(url, options = {}) {
|
|
const fetchOptions = { ...options };
|
|
if (fetchOptions.body) {
|
|
fetchOptions.headers = {
|
|
'Content-Type': 'application/json',
|
|
...(fetchOptions.headers || {}),
|
|
};
|
|
}
|
|
|
|
const response = await fetch(url, fetchOptions);
|
|
if (!response.ok) {
|
|
throw new Error(`${response.status} ${url}`);
|
|
}
|
|
if (response.status === 204) {
|
|
return null;
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
function setText(element, value) {
|
|
element.textContent = value == null ? '' : String(value);
|
|
}
|
|
|
|
function formatQualityGate(summary) {
|
|
const passed = summary?.quality_gate?.passed;
|
|
if (passed === true) {
|
|
return 'passed';
|
|
}
|
|
if (passed === false) {
|
|
return 'blocked';
|
|
}
|
|
return 'unknown';
|
|
}
|
|
|
|
function clearNode(node) {
|
|
while (node.firstChild) {
|
|
node.removeChild(node.firstChild);
|
|
}
|
|
}
|
|
|
|
let selectedDetailKey = null;
|
|
|
|
function renderTable(table, columns, rows, onRowClick, rowKey) {
|
|
clearNode(table);
|
|
|
|
const thead = document.createElement('thead');
|
|
const headRow = document.createElement('tr');
|
|
columns.forEach((column) => {
|
|
const th = document.createElement('th');
|
|
setText(th, column.label);
|
|
headRow.appendChild(th);
|
|
});
|
|
thead.appendChild(headRow);
|
|
table.appendChild(thead);
|
|
|
|
const tbody = document.createElement('tbody');
|
|
rows.forEach((row) => {
|
|
const tr = document.createElement('tr');
|
|
const currentKey = rowKey ? row[rowKey] : null;
|
|
if (onRowClick) {
|
|
tr.className = 'clickable-row';
|
|
if (selectedDetailKey != null && currentKey === selectedDetailKey) {
|
|
tr.classList.add('selected-row');
|
|
}
|
|
tr.addEventListener('click', () => onRowClick(row));
|
|
}
|
|
columns.forEach((column) => {
|
|
const td = document.createElement('td');
|
|
const value = column.render ? column.render(row) : row[column.key];
|
|
if (value instanceof Node) {
|
|
td.appendChild(value);
|
|
} else {
|
|
setText(td, value);
|
|
}
|
|
tr.appendChild(td);
|
|
});
|
|
tbody.appendChild(tr);
|
|
});
|
|
table.appendChild(tbody);
|
|
}
|
|
|
|
function applyExecutionFilters(items) {
|
|
const statusFilter = executionStatusFilterInput.value || 'all';
|
|
const search = String(searchTextInput.value || '').trim().toLowerCase();
|
|
const filtered = items.filter((item) => {
|
|
const statusOk = statusFilter === 'all' || item.execution_status === statusFilter;
|
|
const haystack = [
|
|
item.execution_id,
|
|
item.signal_id,
|
|
item.session_code,
|
|
item.side,
|
|
item.execution_status,
|
|
item.lifecycle_state,
|
|
item.backtest_outcome,
|
|
item.audit_status,
|
|
].join(' ').toLowerCase();
|
|
const searchOk = !search || haystack.includes(search);
|
|
return statusOk && searchOk;
|
|
});
|
|
const active = [];
|
|
if (statusFilter !== 'all') active.push(`status=${statusFilter}`);
|
|
if (search) active.push(`search=${search}`);
|
|
setText(activeFilters, `Filters: ${active.length ? active.join(', ') : 'none'}`);
|
|
return filtered;
|
|
}
|
|
|
|
function applyTextFilters(items, keys) {
|
|
const search = String(searchTextInput.value || '').trim().toLowerCase();
|
|
if (!search) return items;
|
|
return items.filter((item) => keys.some((key) => String(item[key] ?? '').toLowerCase().includes(search)));
|
|
}
|
|
|
|
function setSelectedDetail(title, payload, key) {
|
|
selectedDetailKey = key;
|
|
showDetail(title, payload);
|
|
}
|
|
|
|
function rowDetailHandler(titleBuilder, keyName) {
|
|
return (row) => {
|
|
setSelectedDetail(titleBuilder(row), row, row[keyName]);
|
|
refreshDashboard();
|
|
};
|
|
}
|
|
|
|
function cardDetailHandler(titleBuilder, keyValue, payload) {
|
|
return () => {
|
|
setSelectedDetail(titleBuilder(payload), payload, keyValue);
|
|
refreshDashboard();
|
|
};
|
|
}
|
|
|
|
function renderDashboardData(health, candles, signals, sessions, executions, backtests) {
|
|
const signalItems = applyTextFilters(signals.items || [], ['id', 'side', 'status', 'session_code']);
|
|
const sessionItems = applyTextFilters(sessions.items || [], ['id', 'session_code', 'status', 'session_date']);
|
|
const executionItems = applyExecutionFilters(executions.items || []);
|
|
const backtestItems = applyTextFilters(backtests.items || [], ['id']);
|
|
|
|
renderHealth(health);
|
|
renderOverview(signalItems, sessionItems, executionItems, backtestItems);
|
|
renderTable(signalsTable, [
|
|
tableColumn('id', 'ID'),
|
|
tableColumn('side', 'Side'),
|
|
statusColumn('status', 'Status'),
|
|
tableColumn('entry_low', 'Entry Low'),
|
|
tableColumn('entry_high', 'Entry High'),
|
|
tableColumn('stop_loss', 'SL'),
|
|
tableColumn('tp1', 'TP1'),
|
|
tableColumn('tp2', 'TP2'),
|
|
tableColumn('session_code', 'Session'),
|
|
tableColumn('created_ts', 'Created'),
|
|
], signalItems, rowDetailHandler((row) => `Signal #${row.id}`, 'id'), 'id');
|
|
renderTable(sessionsTable, [
|
|
tableColumn('id', 'ID'),
|
|
tableColumn('session_code', 'Session'),
|
|
tableColumn('session_date', 'Date'),
|
|
statusColumn('status', 'Status'),
|
|
tableColumn('high', 'High'),
|
|
tableColumn('low', 'Low'),
|
|
tableColumn('midpoint', 'Mid'),
|
|
], sessionItems, rowDetailHandler((row) => `Session #${row.id}`, 'id'), 'id');
|
|
renderTable(executionsTable, [
|
|
tableColumn('execution_id', 'Execution ID'),
|
|
tableColumn('signal_id', 'Signal ID'),
|
|
statusColumn('execution_status', 'Status'),
|
|
statusColumn('lifecycle_state', 'Lifecycle'),
|
|
tableColumn('side', 'Side'),
|
|
tableColumn('session_code', 'Session'),
|
|
statusColumn('backtest_outcome', 'Outcome'),
|
|
statusColumn('audit_status', 'Audit'),
|
|
tableColumn('opened_ts', 'Opened'),
|
|
tableColumn('closed_ts', 'Closed'),
|
|
], executionItems, rowDetailHandler((row) => `Execution #${row.execution_id}`, 'execution_id'), 'execution_id');
|
|
renderBacktests(backtestItems);
|
|
drawCandles(candles.items || []);
|
|
}
|
|
|
|
function renderBacktests(items) {
|
|
clearNode(backtestsCards);
|
|
|
|
items.forEach((item) => {
|
|
const summary = item.summary || {};
|
|
const card = document.createElement('div');
|
|
card.className = 'card clickable-card';
|
|
card.addEventListener('click', cardDetailHandler((row) => `Backtest #${row.id}`, item.id, item));
|
|
|
|
const run = document.createElement('div');
|
|
run.className = 'metric';
|
|
setText(run, `Run #${item.id}`);
|
|
|
|
const winRate = document.createElement('div');
|
|
winRate.className = 'value';
|
|
setText(winRate, `${((summary.win_rate ?? 0) * 100).toFixed(1)}%`);
|
|
|
|
const label = document.createElement('div');
|
|
label.className = 'metric';
|
|
setText(label, 'win rate');
|
|
|
|
const signals = document.createElement('div');
|
|
signals.className = 'metric';
|
|
setText(signals, `signals: ${summary.total_signals ?? 0}`);
|
|
|
|
const executed = document.createElement('div');
|
|
executed.className = 'metric';
|
|
setText(executed, `executed: ${summary.executed_signals ?? 0} (${((summary.executed_rate ?? 0) * 100).toFixed(1)}%)`);
|
|
|
|
const mfe = document.createElement('div');
|
|
mfe.className = 'metric';
|
|
setText(mfe, `avg mfe: ${Number(summary.avg_mfe ?? 0).toFixed(2)}`);
|
|
|
|
const mae = document.createElement('div');
|
|
mae.className = 'metric';
|
|
setText(mae, `avg mae: ${Number(summary.avg_mae ?? 0).toFixed(2)}`);
|
|
|
|
const expectancy = document.createElement('div');
|
|
expectancy.className = 'metric';
|
|
setText(expectancy, `expectancy R: ${Number(summary.expectancy_r ?? 0).toFixed(2)}`);
|
|
|
|
const executedExpectancy = document.createElement('div');
|
|
executedExpectancy.className = 'metric';
|
|
setText(executedExpectancy, `executed exp R: ${Number(summary.executed_expectancy_r ?? 0).toFixed(2)}`);
|
|
|
|
const drawdown = document.createElement('div');
|
|
drawdown.className = 'metric';
|
|
setText(drawdown, `max DD R: ${Number(summary.max_drawdown_r ?? 0).toFixed(2)}`);
|
|
|
|
const gate = document.createElement('div');
|
|
gate.className = `status-pill status-${normalizeStatusClass(formatQualityGate(summary))}`;
|
|
setText(gate, `gate: ${formatQualityGate(summary)}`);
|
|
|
|
card.append(run, winRate, label, signals, executed, mfe, mae, expectancy, executedExpectancy, drawdown, gate);
|
|
backtestsCards.appendChild(card);
|
|
});
|
|
}
|
|
|
|
function renderHealth(health) {
|
|
clearNode(healthStatus);
|
|
|
|
const app = document.createElement('div');
|
|
const strong = document.createElement('strong');
|
|
setText(strong, health.app);
|
|
app.appendChild(strong);
|
|
|
|
const db = document.createElement('div');
|
|
setText(db, `DB configured: ${health.database_configured}`);
|
|
|
|
const tz = document.createElement('div');
|
|
setText(tz, `TZ: ${health.session_timezone}`);
|
|
|
|
healthStatus.append(app, db, tz);
|
|
}
|
|
|
|
function renderOverview(signals, sessions, executions, backtests) {
|
|
clearNode(overviewCards);
|
|
|
|
const signalCount = signals.length;
|
|
const openSignals = signals.filter((item) => item.status === 'awaiting_confirmation').length;
|
|
const sessionCount = sessions.length;
|
|
const openExecutions = executions.filter((item) => item.execution_status === 'open').length;
|
|
const closedExecutions = executions.filter((item) => item.execution_status === 'closed').length;
|
|
const latestBacktest = backtests[0]?.summary || {};
|
|
const cards = [
|
|
{ label: 'Signals', value: signalCount },
|
|
{ label: 'Actionable Signals', value: openSignals },
|
|
{ label: 'Sessions', value: sessionCount },
|
|
{ label: 'Open Executions', value: openExecutions },
|
|
{ label: 'Closed Executions', value: closedExecutions },
|
|
{ label: 'Latest Win Rate', value: `${((latestBacktest.win_rate ?? 0) * 100).toFixed(1)}%` },
|
|
{ label: 'Latest Executed', value: latestBacktest.executed_signals ?? 0 },
|
|
{ label: 'Quality Gate', value: formatQualityGate(latestBacktest) },
|
|
];
|
|
|
|
cards.forEach((item) => {
|
|
const card = document.createElement('div');
|
|
card.className = 'card';
|
|
|
|
const label = document.createElement('div');
|
|
label.className = 'metric';
|
|
setText(label, item.label);
|
|
|
|
const value = document.createElement('div');
|
|
value.className = 'value-small';
|
|
setText(value, item.value);
|
|
|
|
card.append(label, value);
|
|
overviewCards.appendChild(card);
|
|
});
|
|
}
|
|
|
|
function showDetail(title, payload) {
|
|
setText(detailMeta, title);
|
|
setText(detailPanel, JSON.stringify(payload, null, 2));
|
|
}
|
|
|
|
function drawCandles(items) {
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
if (!items.length) {
|
|
ctx.fillStyle = '#9db0d3';
|
|
ctx.fillText('No candle data', 24, 40);
|
|
return;
|
|
}
|
|
|
|
const candles = [...items].reverse();
|
|
const highs = candles.map((candle) => Number(candle.high));
|
|
const lows = candles.map((candle) => Number(candle.low));
|
|
const min = Math.min(...lows);
|
|
const max = Math.max(...highs);
|
|
const padX = 24;
|
|
const padY = 20;
|
|
const width = (canvas.width - padX * 2) / candles.length;
|
|
const scaleY = (value) => {
|
|
const ratio = (value - min) / Math.max(max - min, 1e-9);
|
|
return canvas.height - padY - ratio * (canvas.height - padY * 2);
|
|
};
|
|
|
|
candles.forEach((candle, index) => {
|
|
const x = padX + index * width + width / 2;
|
|
const open = Number(candle.open);
|
|
const close = Number(candle.close);
|
|
const high = Number(candle.high);
|
|
const low = Number(candle.low);
|
|
const color = close >= open ? '#22c55e' : '#ef4444';
|
|
|
|
ctx.strokeStyle = color;
|
|
ctx.fillStyle = color;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, scaleY(high));
|
|
ctx.lineTo(x, scaleY(low));
|
|
ctx.stroke();
|
|
|
|
const bodyTop = scaleY(Math.max(open, close));
|
|
const bodyBottom = scaleY(Math.min(open, close));
|
|
const bodyHeight = Math.max(bodyBottom - bodyTop, 2);
|
|
ctx.fillRect(x - width * 0.3, bodyTop, width * 0.6, bodyHeight);
|
|
});
|
|
}
|
|
|
|
function tableColumn(key, label) {
|
|
return { key, label, render: (row) => row[key] };
|
|
}
|
|
|
|
function normalizeStatusClass(value) {
|
|
return String(value ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '_');
|
|
}
|
|
|
|
function statusColumn(key, label) {
|
|
return {
|
|
key,
|
|
label,
|
|
render: (row) => {
|
|
const span = document.createElement('span');
|
|
span.className = `status-pill status-${normalizeStatusClass(row[key])}`;
|
|
setText(span, row[key]);
|
|
return span;
|
|
},
|
|
};
|
|
}
|
|
|
|
function renderLoadError(error) {
|
|
clearNode(healthStatus);
|
|
setText(healthStatus, `Load failed: ${error.message}`);
|
|
}
|
|
|
|
let activeTrainingCase = null;
|
|
let activeTrainingAttempt = null;
|
|
|
|
function setTrainingError(error) {
|
|
if (!trainingError) return;
|
|
const message = error ? (error.message || String(error)) : '';
|
|
setText(trainingError, message ? `Training action failed: ${message}` : '');
|
|
trainingError.hidden = !message;
|
|
}
|
|
|
|
function markTrainingLoading() {
|
|
if (trainingCasesList) trainingCasesList.setAttribute('aria-busy', 'true');
|
|
if (trainingProgress) trainingProgress.setAttribute('aria-busy', 'true');
|
|
}
|
|
|
|
function markTrainingLoaded() {
|
|
if (trainingCasesList) trainingCasesList.setAttribute('aria-busy', 'false');
|
|
if (trainingProgress) trainingProgress.setAttribute('aria-busy', 'false');
|
|
}
|
|
|
|
function setTrainingAttemptControls(enabled) {
|
|
if (trainingAnnotationText) trainingAnnotationText.disabled = !enabled;
|
|
if (trainingSaveAnnotationBtn) trainingSaveAnnotationBtn.disabled = !enabled;
|
|
if (trainingSubmitBtn) trainingSubmitBtn.disabled = !enabled;
|
|
}
|
|
|
|
function setTrainingAttemptStatus(message) {
|
|
if (trainingAttemptStatus) {
|
|
setText(trainingAttemptStatus, message);
|
|
}
|
|
}
|
|
|
|
const TRAINING_PRIVATE_TEXT_PATTERNS = [
|
|
new RegExp(`${'ans'}${'wer'}(?:[_ -]?key)?`, 'ig'),
|
|
new RegExp(`${'sol'}${'ution'}(?:[_ -]?key)?`, 'ig'),
|
|
];
|
|
|
|
function trainingDisplayText(value, fallback = 'n/a') {
|
|
let text = value == null || value === '' ? fallback : String(value);
|
|
TRAINING_PRIVATE_TEXT_PATTERNS.forEach((pattern) => {
|
|
text = text.replace(pattern, 'private field');
|
|
});
|
|
return text;
|
|
}
|
|
|
|
function trainingCaseMeta(item) {
|
|
return item?.metadata || item?.meta || {};
|
|
}
|
|
|
|
function trainingScenario(item) {
|
|
return item?.scenario || item || {};
|
|
}
|
|
|
|
function trainingCaseTitle(item) {
|
|
const meta = trainingCaseMeta(item);
|
|
return trainingDisplayText(item?.title || meta.title || `Case #${item?.id ?? ''}`.trim(), 'Training case');
|
|
}
|
|
|
|
function trainingCaseTasks(item) {
|
|
const meta = trainingCaseMeta(item);
|
|
if (Array.isArray(item?.tasks)) return item.tasks;
|
|
if (Array.isArray(meta.tasks)) return meta.tasks;
|
|
return [];
|
|
}
|
|
|
|
function appendTrainingField(parent, label, value) {
|
|
const row = document.createElement('div');
|
|
row.className = 'metric';
|
|
setText(row, `${label}: ${trainingDisplayText(value)}`);
|
|
parent.appendChild(row);
|
|
}
|
|
|
|
function renderTrainingCaseDetail(item) {
|
|
if (!trainingCaseDetail) return;
|
|
clearNode(trainingCaseDetail);
|
|
|
|
if (!item) {
|
|
const empty = document.createElement('div');
|
|
empty.className = 'metric training-empty';
|
|
setText(empty, 'Select a case to review its public context.');
|
|
trainingCaseDetail.appendChild(empty);
|
|
return;
|
|
}
|
|
|
|
const scenario = trainingScenario(item);
|
|
const refs = scenario.source_event_refs || item.source_event_refs || [];
|
|
const tasks = trainingCaseTasks(item);
|
|
|
|
const title = document.createElement('div');
|
|
title.className = 'value-small';
|
|
setText(title, trainingCaseTitle(item));
|
|
trainingCaseDetail.appendChild(title);
|
|
|
|
appendTrainingField(trainingCaseDetail, 'case id', item.id);
|
|
appendTrainingField(trainingCaseDetail, 'status', item.status);
|
|
appendTrainingField(trainingCaseDetail, 'skill', item.skill_tag || (item.skill_tags || [])[0]);
|
|
appendTrainingField(trainingCaseDetail, 'timeframe', scenario.timeframe || item.timeframe);
|
|
appendTrainingField(trainingCaseDetail, 'type', scenario.case_type || item.case_type);
|
|
appendTrainingField(trainingCaseDetail, 'difficulty', item.difficulty);
|
|
appendTrainingField(trainingCaseDetail, 'decision', scenario.decision_ts || item.decision_ts);
|
|
appendTrainingField(trainingCaseDetail, 'tasks', tasks.length);
|
|
appendTrainingField(trainingCaseDetail, 'source refs', Array.isArray(refs) ? refs.length : 0);
|
|
}
|
|
|
|
function renderTrainingCases(items) {
|
|
clearNode(trainingCasesList);
|
|
|
|
if (!items.length) {
|
|
const empty = document.createElement('div');
|
|
empty.className = 'metric training-empty';
|
|
empty.setAttribute('role', 'listitem');
|
|
setText(empty, 'No training cases available. Import a case package to begin.');
|
|
trainingCasesList.appendChild(empty);
|
|
renderTrainingCaseDetail(null);
|
|
return;
|
|
}
|
|
|
|
items.forEach((item) => {
|
|
const card = document.createElement('div');
|
|
card.className = 'training-case-card';
|
|
card.setAttribute('role', 'listitem');
|
|
const itemTitle = trainingCaseTitle(item);
|
|
|
|
const title = document.createElement('div');
|
|
title.className = 'value-small';
|
|
setText(title, itemTitle);
|
|
|
|
const skill = document.createElement('div');
|
|
skill.className = 'metric';
|
|
setText(skill, `skill: ${trainingDisplayText(item.skill_tag)}`);
|
|
|
|
const frame = document.createElement('div');
|
|
frame.className = 'metric';
|
|
setText(frame, `timeframe: ${trainingDisplayText(item.timeframe)}`);
|
|
|
|
const status = document.createElement('div');
|
|
status.className = `status-pill status-${normalizeStatusClass(item.status || 'unknown')}`;
|
|
setText(status, trainingDisplayText(item.status, 'unknown'));
|
|
|
|
const actions = document.createElement('div');
|
|
actions.className = 'training-case-actions';
|
|
|
|
const detailBtn = document.createElement('button');
|
|
detailBtn.type = 'button';
|
|
detailBtn.setAttribute('aria-label', `View detail for ${itemTitle}`);
|
|
setText(detailBtn, 'View detail');
|
|
detailBtn.addEventListener('click', () => loadTrainingCaseDetail(item));
|
|
|
|
const startBtn = document.createElement('button');
|
|
startBtn.type = 'button';
|
|
startBtn.setAttribute('aria-label', `Start attempt for ${itemTitle}`);
|
|
setText(startBtn, 'Start attempt');
|
|
startBtn.addEventListener('click', () => startTrainingAttempt(item));
|
|
|
|
actions.append(detailBtn, startBtn);
|
|
card.append(title, skill, frame, status, actions);
|
|
trainingCasesList.appendChild(card);
|
|
});
|
|
|
|
renderTrainingCaseDetail(items[0]);
|
|
}
|
|
|
|
function renderTrainingProgress(items) {
|
|
clearNode(trainingProgress);
|
|
|
|
if (!items.length) {
|
|
const empty = document.createElement('div');
|
|
empty.className = 'metric training-empty';
|
|
empty.setAttribute('role', 'listitem');
|
|
setText(empty, 'No progress yet. Start an attempt to track progress.');
|
|
trainingProgress.appendChild(empty);
|
|
return;
|
|
}
|
|
|
|
items.forEach((item) => {
|
|
const row = document.createElement('div');
|
|
row.className = 'training-progress-row';
|
|
row.setAttribute('role', 'listitem');
|
|
|
|
const skill = document.createElement('div');
|
|
skill.className = 'value-small';
|
|
setText(skill, item.skill_tag || 'training');
|
|
|
|
const attempts = document.createElement('div');
|
|
attempts.className = 'metric';
|
|
setText(attempts, `attempts: ${item.attempt_count ?? 0}`);
|
|
|
|
const score = document.createElement('div');
|
|
score.className = 'metric';
|
|
const avgScore = item.avg_score == null ? 'n/a' : Number(item.avg_score).toFixed(2);
|
|
setText(score, `avg score: ${avgScore}`);
|
|
|
|
row.append(skill, attempts, score);
|
|
trainingProgress.appendChild(row);
|
|
});
|
|
}
|
|
|
|
async function loadTrainingDashboard() {
|
|
if (!trainingCasesList || !trainingProgress) return;
|
|
|
|
markTrainingLoading();
|
|
try {
|
|
setTrainingError(null);
|
|
const [cases, progress] = await Promise.all([
|
|
fetchJson(TRAINING_PUBLIC_ROUTES.list),
|
|
fetchJson(TRAINING_PUBLIC_ROUTES.progress),
|
|
]);
|
|
|
|
renderTrainingCases(cases.items || []);
|
|
renderTrainingProgress(progress.items || []);
|
|
} catch (error) {
|
|
setTrainingError(error);
|
|
} finally {
|
|
markTrainingLoaded();
|
|
}
|
|
}
|
|
|
|
async function loadTrainingCaseDetail(item) {
|
|
if (!item?.id) return;
|
|
|
|
try {
|
|
setTrainingError(null);
|
|
const detail = await fetchJson(TRAINING_PUBLIC_ROUTES.detail(item.id));
|
|
renderTrainingCaseDetail(detail || item);
|
|
} catch (error) {
|
|
setTrainingError(error);
|
|
}
|
|
}
|
|
|
|
async function exportTrainingCases() {
|
|
try {
|
|
setTrainingError(null);
|
|
const packageBody = await fetchJson(TRAINING_PUBLIC_ROUTES.export);
|
|
const itemCount = Array.isArray(packageBody?.items) ? packageBody.items.length : 0;
|
|
const serialized = JSON.stringify(packageBody, null, 2);
|
|
|
|
if (typeof Blob !== 'undefined' && typeof URL !== 'undefined' && URL.createObjectURL) {
|
|
const blob = new Blob([serialized], { type: 'application/json' });
|
|
const objectUrl = URL.createObjectURL(blob);
|
|
const link = document.createElement('a');
|
|
link.href = objectUrl;
|
|
link.download = 'training-cases.json';
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
URL.revokeObjectURL(objectUrl);
|
|
}
|
|
|
|
setTrainingAttemptStatus(`Exported ${itemCount} cases`);
|
|
} catch (error) {
|
|
setTrainingError(error);
|
|
}
|
|
}
|
|
|
|
async function importTrainingCases() {
|
|
const raw = String(trainingImportText?.value || '').trim();
|
|
if (!raw) {
|
|
setTrainingError('Paste a case package before import');
|
|
return;
|
|
}
|
|
|
|
let packageBody;
|
|
try {
|
|
packageBody = JSON.parse(raw);
|
|
} catch (error) {
|
|
setTrainingError('Import package must be valid JSON');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setTrainingError(null);
|
|
const result = await fetchJson(TRAINING_PUBLIC_ROUTES.import, {
|
|
method: 'POST',
|
|
body: JSON.stringify(packageBody),
|
|
});
|
|
if (trainingImportText) trainingImportText.value = '';
|
|
const itemCount = result?.imported ?? (Array.isArray(result?.items) ? result.items.length : 0);
|
|
setTrainingAttemptStatus(`Imported ${itemCount} cases`);
|
|
await loadTrainingDashboard();
|
|
} catch (error) {
|
|
setTrainingError(error);
|
|
}
|
|
}
|
|
|
|
async function startTrainingAttempt(item) {
|
|
try {
|
|
setTrainingError(null);
|
|
const attempt = await fetchJson(TRAINING_PUBLIC_ROUTES.createAttempt, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ case_id: item.id }),
|
|
});
|
|
|
|
activeTrainingCase = item;
|
|
activeTrainingAttempt = attempt;
|
|
if (trainingAnnotationText) trainingAnnotationText.value = '';
|
|
setTrainingAttemptControls(true);
|
|
setTrainingAttemptStatus(`Attempt #${attempt.id} started for case #${activeTrainingCase.id}`);
|
|
} catch (error) {
|
|
setTrainingError(error);
|
|
}
|
|
}
|
|
|
|
async function saveTrainingAnnotation() {
|
|
if (!activeTrainingAttempt) {
|
|
setTrainingError('Start an attempt first');
|
|
return;
|
|
}
|
|
|
|
const note = String(trainingAnnotationText?.value || '').trim();
|
|
if (!note) {
|
|
setTrainingError('Add an annotation before saving');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setTrainingError(null);
|
|
const annotation = await fetchJson(TRAINING_PUBLIC_ROUTES.annotateAttempt(activeTrainingAttempt.id), {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
annotation_type: 'note',
|
|
payload: { note },
|
|
}),
|
|
});
|
|
setTrainingAttemptStatus(`Annotation #${annotation.id} saved for attempt #${activeTrainingAttempt.id}`);
|
|
} catch (error) {
|
|
setTrainingError(error);
|
|
}
|
|
}
|
|
|
|
async function submitTrainingAttempt() {
|
|
if (!activeTrainingAttempt) {
|
|
setTrainingError('Start an attempt first');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setTrainingError(null);
|
|
await fetchJson(TRAINING_PUBLIC_ROUTES.submitAttempt(activeTrainingAttempt.id), {
|
|
method: 'POST',
|
|
body: JSON.stringify({}),
|
|
});
|
|
setTrainingAttemptStatus(`Attempt #${activeTrainingAttempt.id} submitted`);
|
|
activeTrainingCase = null;
|
|
activeTrainingAttempt = null;
|
|
setTrainingAttemptControls(false);
|
|
await loadTrainingDashboard();
|
|
} catch (error) {
|
|
setTrainingError(error);
|
|
}
|
|
}
|
|
|
|
function updateLastUpdated() {
|
|
const now = new Date();
|
|
setText(lastUpdated, `Last updated: ${now.toLocaleTimeString()}`);
|
|
}
|
|
|
|
async function refreshDashboard() {
|
|
const instrumentId = instrumentIdInput.value || '1';
|
|
const timeframe = timeframeInput.value || '1m';
|
|
const limit = limitInput.value || '20';
|
|
|
|
try {
|
|
const [health, candles, signals, sessions, executions, backtests] = await Promise.all([
|
|
fetchJson('/health'),
|
|
fetchJson(`/candles?instrument_id=${instrumentId}&timeframe=${encodeURIComponent(timeframe)}&limit=${limit}`),
|
|
fetchJson(`/signals?instrument_id=${instrumentId}&limit=${limit}`),
|
|
fetchJson(`/sessions?instrument_id=${instrumentId}&limit=${limit}`),
|
|
fetchJson(`/executions?instrument_id=${instrumentId}&limit=${limit}`),
|
|
fetchJson(`/backtests?instrument_id=${instrumentId}&limit=${Math.min(Number(limit), 10)}`),
|
|
]);
|
|
|
|
renderDashboardData(health, candles, signals, sessions, executions, backtests);
|
|
updateLastUpdated();
|
|
} catch (error) {
|
|
renderLoadError(error);
|
|
}
|
|
}
|
|
|
|
let autoRefreshTimer = null;
|
|
|
|
function configureAutoRefresh() {
|
|
if (autoRefreshTimer) {
|
|
clearInterval(autoRefreshTimer);
|
|
autoRefreshTimer = null;
|
|
}
|
|
const seconds = Number(autoRefreshSecondsInput.value || 0);
|
|
if (seconds > 0) {
|
|
autoRefreshTimer = setInterval(refreshDashboard, seconds * 1000);
|
|
}
|
|
}
|
|
|
|
refreshBtn.addEventListener('click', refreshDashboard);
|
|
executionStatusFilterInput.addEventListener('change', refreshDashboard);
|
|
searchTextInput.addEventListener('input', refreshDashboard);
|
|
autoRefreshSecondsInput.addEventListener('change', configureAutoRefresh);
|
|
if (trainingSaveAnnotationBtn) {
|
|
trainingSaveAnnotationBtn.addEventListener('click', saveTrainingAnnotation);
|
|
}
|
|
if (trainingSubmitBtn) {
|
|
trainingSubmitBtn.addEventListener('click', submitTrainingAttempt);
|
|
}
|
|
if (trainingImportBtn) {
|
|
trainingImportBtn.addEventListener('click', importTrainingCases);
|
|
}
|
|
if (trainingExportBtn) {
|
|
trainingExportBtn.addEventListener('click', exportTrainingCases);
|
|
}
|
|
configureAutoRefresh();
|
|
loadTrainingDashboard();
|
|
refreshDashboard();
|