810 lines
30 KiB
JavaScript
810 lines
30 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 overviewCards = document.getElementById('overviewCards');
|
|
const marketStatePanel = document.getElementById('marketStatePanel');
|
|
const runtimeStatusPanel = document.getElementById('runtimeStatusPanel');
|
|
const decisionPacketPanel = document.getElementById('decisionPacketPanel');
|
|
const preMarketPlanPanel = document.getElementById('preMarketPlanPanel');
|
|
const tradeCandidatesPanel = document.getElementById('tradeCandidatesPanel');
|
|
const executionTicketsPanel = document.getElementById('executionTicketsPanel');
|
|
const positionManagementPanel = document.getElementById('positionManagementPanel');
|
|
const postTradeReviewPanel = document.getElementById('postTradeReviewPanel');
|
|
const canvas = document.getElementById('candlesChart');
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
let autoRefreshTimer = null;
|
|
|
|
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();
|
|
}
|
|
|
|
async function postJson(url, body = {}) {
|
|
return fetchJson(url, {
|
|
method: 'POST',
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
function setText(element, value) {
|
|
if (!element) return;
|
|
element.textContent = value == null ? '' : String(value);
|
|
}
|
|
|
|
function clearNode(node) {
|
|
if (!node) return;
|
|
while (node.firstChild) {
|
|
node.removeChild(node.firstChild);
|
|
}
|
|
}
|
|
|
|
function createElement(tag, className, text) {
|
|
const element = document.createElement(tag);
|
|
if (className) element.className = className;
|
|
if (text != null) setText(element, text);
|
|
return element;
|
|
}
|
|
|
|
function formatNumber(value, digits = 2) {
|
|
if (value == null || value === '') return 'n/a';
|
|
const number = Number(value);
|
|
if (!Number.isFinite(number)) return 'n/a';
|
|
return number.toFixed(digits);
|
|
}
|
|
|
|
function formatPercent(value) {
|
|
if (value == null) return 'n/a';
|
|
const number = Number(value);
|
|
if (!Number.isFinite(number)) return 'n/a';
|
|
return `${(number * 100).toFixed(1)}%`;
|
|
}
|
|
|
|
function formatTimestamp(value) {
|
|
if (!value) return 'n/a';
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) {
|
|
return String(value);
|
|
}
|
|
return date.toLocaleString();
|
|
}
|
|
|
|
function formatList(values, emptyText = 'n/a') {
|
|
if (!Array.isArray(values) || values.length === 0) return emptyText;
|
|
return values.map((value) => String(value)).join(', ');
|
|
}
|
|
|
|
function formatJsonInline(value, emptyText = 'n/a') {
|
|
if (value == null) return emptyText;
|
|
if (Array.isArray(value) && value.length === 0) return emptyText;
|
|
if (!Array.isArray(value) && typeof value === 'object' && Object.keys(value).length === 0) return emptyText;
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
function statusClass(status) {
|
|
const raw = String(status || 'unknown').toLowerCase().replace(/[^a-z0-9]+/g, '_');
|
|
return `status-pill status-${raw || 'unknown'}`;
|
|
}
|
|
|
|
function buildStatusPill(status) {
|
|
return createElement('span', statusClass(status), status || 'unknown');
|
|
}
|
|
|
|
function buildMetricCard(label, value, detail = '') {
|
|
const card = createElement('div', 'card');
|
|
card.appendChild(createElement('div', 'metric', label));
|
|
card.appendChild(createElement('div', 'value-small', value));
|
|
if (detail) card.appendChild(createElement('div', 'metric', detail));
|
|
return card;
|
|
}
|
|
|
|
function buildInfoGrid(entries) {
|
|
const grid = createElement('div', 'info-grid');
|
|
entries.forEach(([label, value]) => {
|
|
const row = createElement('div', 'info-row');
|
|
row.appendChild(createElement('div', 'metric', label));
|
|
row.appendChild(createElement('div', 'info-value', value == null || value === '' ? 'n/a' : String(value)));
|
|
grid.appendChild(row);
|
|
});
|
|
return grid;
|
|
}
|
|
|
|
function buildActionBar(actions) {
|
|
const bar = createElement('div', 'actions');
|
|
actions
|
|
.filter((action) => action && typeof action.onClick === 'function')
|
|
.forEach((action) => {
|
|
const button = createElement('button', '', action.label);
|
|
button.type = 'button';
|
|
button.addEventListener('click', action.onClick);
|
|
bar.appendChild(button);
|
|
});
|
|
return bar;
|
|
}
|
|
|
|
function buildItemCard(title, status, entries, actions = [], detail = '') {
|
|
const card = createElement('div', 'card');
|
|
const header = createElement('div', 'card-header');
|
|
header.appendChild(createElement('strong', '', title));
|
|
header.appendChild(buildStatusPill(status));
|
|
card.appendChild(header);
|
|
card.appendChild(buildInfoGrid(entries));
|
|
if (detail) card.appendChild(createElement('div', 'metric card-detail', detail));
|
|
if (actions.length) card.appendChild(buildActionBar(actions));
|
|
return card;
|
|
}
|
|
|
|
function getSearchText() {
|
|
return String(searchTextInput.value || '').trim().toLowerCase();
|
|
}
|
|
|
|
function updateActiveFilters() {
|
|
const active = [];
|
|
const executionStatus = executionStatusFilterInput.value || 'all';
|
|
const search = getSearchText();
|
|
if (executionStatus !== 'all') active.push(`execution=${executionStatus}`);
|
|
if (search) active.push(`search=${search}`);
|
|
setText(activeFilters, `Filters: ${active.length ? active.join(', ') : 'none'}`);
|
|
}
|
|
|
|
function filterBySearch(items, projector) {
|
|
const search = getSearchText();
|
|
if (!search) return items;
|
|
return items.filter((item) => String(projector(item)).toLowerCase().includes(search));
|
|
}
|
|
|
|
function filterExecutions(items) {
|
|
const statusFilter = executionStatusFilterInput.value || 'all';
|
|
return filterBySearch(
|
|
items.filter((item) => statusFilter === 'all' || item.execution_status === statusFilter),
|
|
(item) => [
|
|
item.execution_id,
|
|
item.execution_status,
|
|
item.side,
|
|
item.session_code,
|
|
item.close_reason,
|
|
item.ai_explanation,
|
|
...(item.error_tags || []),
|
|
].join(' ')
|
|
);
|
|
}
|
|
|
|
function queryString(params) {
|
|
const search = new URLSearchParams();
|
|
Object.entries(params).forEach(([key, value]) => {
|
|
if (value == null || value === '') return;
|
|
search.set(key, String(value));
|
|
});
|
|
return search.toString();
|
|
}
|
|
|
|
async function runAction(url, body = {}) {
|
|
try {
|
|
await postJson(url, body);
|
|
await refreshDashboard();
|
|
} catch (error) {
|
|
showErrorPanel(String(error));
|
|
}
|
|
}
|
|
|
|
function renderHealth(health) {
|
|
setText(
|
|
healthStatus,
|
|
[
|
|
health?.app || 'ai-ict',
|
|
health?.database_configured ? 'db ready' : 'db missing',
|
|
health?.session_timezone || 'timezone unknown',
|
|
].join(' | ')
|
|
);
|
|
}
|
|
|
|
function renderOverview(weeklyReview, candidates, tickets, executions) {
|
|
clearNode(overviewCards);
|
|
const openExecutions = executions.filter((item) => item.execution_status === 'open');
|
|
const confirmedTickets = tickets.filter((item) => item.status === 'confirmed');
|
|
const executableCandidates = candidates.filter((item) => item.executable);
|
|
const cards = [
|
|
buildMetricCard('Executable Candidates', String(executableCandidates.length), 'ready to ticket'),
|
|
buildMetricCard('Confirmed Tickets', String(confirmedTickets.length), 'human confirmed'),
|
|
buildMetricCard('Open Positions', String(openExecutions.length), 'live management load'),
|
|
buildMetricCard('Framework Rate', formatPercent(weeklyReview?.framework_execution_rate), 'weekly execution discipline'),
|
|
buildMetricCard('Out-of-Model Rate', formatPercent(weeklyReview?.out_of_model_trade_rate), 'model drift'),
|
|
buildMetricCard('Risk Violations', String(weeklyReview?.risk_violation_count ?? 0), 'weekly count'),
|
|
];
|
|
cards.forEach((card) => overviewCards.appendChild(card));
|
|
}
|
|
|
|
function renderMarketState(payload, executions) {
|
|
clearNode(marketStatePanel);
|
|
const marketState = payload?.market_state || {};
|
|
const freshness = marketState.freshness || {};
|
|
const session = marketState.session || {};
|
|
const newsWindow = marketState.news_window || {};
|
|
const riskDecision = payload?.risk_decision || {};
|
|
const brokerState = payload?.broker_state || {};
|
|
const brokerAccount = brokerState.account || {};
|
|
const brokerAlerts = brokerState.alerts || [];
|
|
const panel = buildInfoGrid([
|
|
['symbol', String(instrumentIdInput.value || '1')],
|
|
['session', session.session_code || 'OFF_HOURS'],
|
|
['data freshness', freshness.code || 'unknown'],
|
|
['news state', newsWindow.code || 'unknown'],
|
|
['broker state', brokerState.status || 'unknown'],
|
|
['broker equity', formatNumber(brokerAccount.equity, 2)],
|
|
['broker available balance', formatNumber(brokerAccount.available_balance, 2)],
|
|
['broker open positions', String((brokerState.positions || []).length)],
|
|
['broker pending orders', String((brokerState.orders || []).length)],
|
|
['broker alerts', formatList(brokerAlerts)],
|
|
['account risk state', formatJsonInline(riskDecision.reject_codes || [])],
|
|
['open position', String(executions.filter((item) => item.execution_status === 'open').length)],
|
|
['kill switch', (riskDecision.reject_codes || []).includes('execution_kill_switch_enabled') ? 'active' : 'off'],
|
|
]);
|
|
marketStatePanel.appendChild(panel);
|
|
}
|
|
|
|
function renderRuntimeStatus(runtimeStatus) {
|
|
clearNode(runtimeStatusPanel);
|
|
const tasks = runtimeStatus?.tasks || {};
|
|
const blockers = runtimeStatus?.current_blockers || [];
|
|
const incidents = runtimeStatus?.incidents || [];
|
|
runtimeStatusPanel.appendChild(
|
|
buildInfoGrid([
|
|
['scheduler state', runtimeStatus?.scheduler_state || 'unknown'],
|
|
['supervisor state', runtimeStatus?.supervisor_state || 'unknown'],
|
|
['enabled', String(Boolean(runtimeStatus?.enabled))],
|
|
['degraded', String(Boolean(runtimeStatus?.degraded_mode_state?.is_degraded))],
|
|
['symbols', formatList(runtimeStatus?.symbols || [])],
|
|
['blockers', formatList(blockers)],
|
|
['last incident', incidents.length ? `${incidents[incidents.length - 1].code} @ ${formatTimestamp(incidents[incidents.length - 1].observed_ts)}` : 'none'],
|
|
])
|
|
);
|
|
const grid = createElement('div', 'cards');
|
|
Object.entries(tasks).forEach(([name, task]) => {
|
|
grid.appendChild(
|
|
buildItemCard(
|
|
name,
|
|
task.last_status || 'idle',
|
|
[
|
|
['last success', formatTimestamp(task.last_success_ts)],
|
|
['next run', formatTimestamp(task.next_run_ts)],
|
|
['run count', String(task.run_count ?? 0)],
|
|
['success / failure', `${task.success_count ?? 0} / ${task.failure_count ?? 0}`],
|
|
['last error', task.last_error || 'none'],
|
|
['details', formatJsonInline(task.details || {})],
|
|
]
|
|
)
|
|
);
|
|
});
|
|
runtimeStatusPanel.appendChild(grid);
|
|
}
|
|
|
|
function renderDecisionPacket(packet, alertRows) {
|
|
clearNode(decisionPacketPanel);
|
|
if (!packet || packet.status === 'unavailable') {
|
|
decisionPacketPanel.appendChild(createElement('div', 'empty-state', 'Decision packet unavailable.'));
|
|
return;
|
|
}
|
|
decisionPacketPanel.appendChild(
|
|
buildInfoGrid([
|
|
['status', packet.status || 'unknown'],
|
|
['recommendation', packet.action_recommendation || 'monitor'],
|
|
['bias', packet.bias || 'unknown'],
|
|
['setup', packet.setup_type || 'n/a'],
|
|
['candidate', packet.candidate_id || 'n/a'],
|
|
['ticket', packet.execution_ticket_id || 'n/a'],
|
|
['risk result', formatJsonInline(packet.risk_result?.reasons || [])],
|
|
['why allowed', formatList(packet.why_allowed || [])],
|
|
['why blocked', formatList(packet.why_blocked || [])],
|
|
['ai explanation', packet.ai_explanation || 'n/a'],
|
|
])
|
|
);
|
|
const grid = createElement('div', 'cards');
|
|
grid.appendChild(
|
|
buildItemCard(
|
|
'Plan Context',
|
|
packet.action_recommendation || 'monitor',
|
|
[
|
|
['narrative', packet.narrative || 'n/a'],
|
|
['primary draw', formatJsonInline(packet.primary_draw || {})],
|
|
['confirmation', formatJsonInline(packet.confirmation_chain || {})],
|
|
['entry zone', `${formatNumber(packet.entry_zone?.low, 3)} - ${formatNumber(packet.entry_zone?.high, 3)}`],
|
|
['stop', formatNumber(packet.stop_loss, 3)],
|
|
['targets', formatJsonInline(packet.targets || {})],
|
|
['invalidation', formatJsonInline(packet.invalidation || {})],
|
|
['expiry', formatTimestamp(packet.expiry_ts)],
|
|
]
|
|
)
|
|
);
|
|
const recentAlerts = alertRows?.items || packet.alerts || [];
|
|
recentAlerts.slice(0, 3).forEach((alert) => {
|
|
grid.appendChild(
|
|
buildItemCard(
|
|
alert.alert_type || 'alert',
|
|
alert.status || 'queued',
|
|
[
|
|
['channel', alert.channel || 'workbench'],
|
|
['message', alert.message || 'n/a'],
|
|
['payload', formatJsonInline(alert.payload || {})],
|
|
]
|
|
)
|
|
);
|
|
});
|
|
decisionPacketPanel.appendChild(grid);
|
|
}
|
|
|
|
function renderPreMarketPlan(plan) {
|
|
clearNode(preMarketPlanPanel);
|
|
preMarketPlanPanel.appendChild(
|
|
buildActionBar([
|
|
{
|
|
label: 'Generate Active Plan',
|
|
onClick: () => runAction('/trading/plans/generate', {
|
|
instrument_id: Number(instrumentIdInput.value || 1),
|
|
bias_tf: timeframeInput.value || '1m',
|
|
setup_tf: timeframeInput.value || '1m',
|
|
trigger_tf: timeframeInput.value || '1m',
|
|
activate: true,
|
|
}),
|
|
},
|
|
])
|
|
);
|
|
|
|
if (!plan) {
|
|
preMarketPlanPanel.appendChild(createElement('div', 'empty-state', 'No active plan for this instrument.'));
|
|
return;
|
|
}
|
|
|
|
preMarketPlanPanel.appendChild(
|
|
buildInfoGrid([
|
|
['narrative', plan.narrative || 'n/a'],
|
|
['primary draw', formatJsonInline(plan.primary_draw)],
|
|
['secondary draw', formatJsonInline(plan.secondary_draw)],
|
|
['bias', plan.bias || 'n/a'],
|
|
['allowed sessions', formatList(plan.allowed_sessions || [])],
|
|
['invalidations', formatJsonInline(plan.invalidations || [])],
|
|
['no-trade conditions', formatList(plan.no_trade_conditions || [])],
|
|
['status', plan.status || 'unknown'],
|
|
['activated', formatTimestamp(plan.activated_ts)],
|
|
])
|
|
);
|
|
}
|
|
|
|
function renderCandidates(items) {
|
|
clearNode(tradeCandidatesPanel);
|
|
tradeCandidatesPanel.appendChild(
|
|
buildActionBar([
|
|
{
|
|
label: 'Run Watch',
|
|
onClick: () => runAction('/trading/watch/run', {
|
|
instrument_id: Number(instrumentIdInput.value || 1),
|
|
setup_tf: timeframeInput.value || '1m',
|
|
trigger_tf: timeframeInput.value || '1m',
|
|
}),
|
|
},
|
|
])
|
|
);
|
|
|
|
const filtered = filterBySearch(items, (item) => [
|
|
item.id,
|
|
item.status,
|
|
item.side,
|
|
item.session_code,
|
|
item.ai_explanation,
|
|
...(item.reason_codes || []),
|
|
...(item.missing_conditions || []),
|
|
].join(' '));
|
|
|
|
if (!filtered.length) {
|
|
tradeCandidatesPanel.appendChild(createElement('div', 'empty-state', 'No candidate rows match the current filters.'));
|
|
return;
|
|
}
|
|
|
|
const grid = createElement('div', 'cards');
|
|
filtered.forEach((candidate) => {
|
|
const detail = `quality ${formatNumber(candidate.quality_score)} | ${candidate.ai_explanation || 'no explanation'}`;
|
|
grid.appendChild(
|
|
buildItemCard(
|
|
`Candidate #${candidate.id}`,
|
|
candidate.status,
|
|
[
|
|
['model', candidate.model_code],
|
|
['side', candidate.side || 'n/a'],
|
|
['session', candidate.session_code || 'n/a'],
|
|
['confirmation chain', formatList(candidate.present_conditions || [])],
|
|
['missing requirements', formatList(candidate.missing_conditions || [])],
|
|
['entry zone', `${formatNumber(candidate.entry_low, 3)} - ${formatNumber(candidate.entry_high, 3)}`],
|
|
['stop', formatNumber(candidate.stop_loss, 3)],
|
|
['TP1 / TP2', `${formatNumber(candidate.tp1, 3)} / ${formatNumber(candidate.tp2, 3)}`],
|
|
['RR TP1 / TP2', `${formatNumber(candidate.rr_tp1)} / ${formatNumber(candidate.rr_tp2)}`],
|
|
['expiry', formatTimestamp(candidate.expiry_ts)],
|
|
['management rules', formatJsonInline(candidate.management_rules)],
|
|
['evidence links', formatJsonInline(candidate.evidence)],
|
|
],
|
|
[
|
|
{
|
|
label: 'Build Paper Ticket',
|
|
onClick: () => runAction(`/trading/candidates/${candidate.id}/build-ticket`, {
|
|
execution_mode: 'paper',
|
|
account_equity: 10000,
|
|
risk_pct: 0.01,
|
|
}),
|
|
},
|
|
{
|
|
label: 'Build Semi-Auto Ticket',
|
|
onClick: () => runAction(`/trading/candidates/${candidate.id}/build-ticket`, {
|
|
execution_mode: 'semi_auto',
|
|
account_equity: 10000,
|
|
risk_pct: 0.01,
|
|
}),
|
|
},
|
|
],
|
|
detail
|
|
)
|
|
);
|
|
});
|
|
tradeCandidatesPanel.appendChild(grid);
|
|
}
|
|
|
|
function renderTickets(items) {
|
|
clearNode(executionTicketsPanel);
|
|
const filtered = filterBySearch(items, (item) => {
|
|
const snapshot = item.ticket_snapshot || {};
|
|
return [
|
|
item.id,
|
|
item.status,
|
|
item.execution_mode,
|
|
snapshot.signal?.side,
|
|
snapshot.signal?.session_code,
|
|
snapshot.decision?.meta?.ai_explanation,
|
|
...(snapshot.decision?.reasons || []),
|
|
].join(' ');
|
|
});
|
|
|
|
if (!filtered.length) {
|
|
executionTicketsPanel.appendChild(createElement('div', 'empty-state', 'No execution tickets match the current filters.'));
|
|
return;
|
|
}
|
|
|
|
const grid = createElement('div', 'cards');
|
|
filtered.forEach((ticket) => {
|
|
const snapshot = ticket.ticket_snapshot || {};
|
|
const signal = snapshot.signal || {};
|
|
const decision = snapshot.decision || {};
|
|
const riskGovernor = decision.meta?.risk_governor || {};
|
|
const riskAmount = Number(snapshot.account_equity || 0) * Number(decision.risk_pct || 0);
|
|
const actions = [];
|
|
actions.push({
|
|
label: 'Risk Check',
|
|
onClick: () => runAction(`/trading/tickets/${ticket.id}/risk-check`, {
|
|
account_equity: Number(snapshot.account_equity || 10000),
|
|
risk_pct: Number(decision.risk_pct || 0.01),
|
|
}),
|
|
});
|
|
if (ticket.status === 'awaiting_confirmation') {
|
|
actions.push({
|
|
label: 'Confirm',
|
|
onClick: () => runAction(`/trading/tickets/${ticket.id}/confirm`, { confirmed_by: 'local_operator' }),
|
|
});
|
|
}
|
|
if (ticket.status === 'confirmed' && ticket.execution_mode === 'paper') {
|
|
actions.push({
|
|
label: 'Paper Execute',
|
|
onClick: () => runAction(`/trading/tickets/${ticket.id}/paper-execute`, { confirmed_by: 'local_operator' }),
|
|
});
|
|
}
|
|
if (ticket.status === 'confirmed' && ticket.execution_mode === 'semi_auto') {
|
|
actions.push({
|
|
label: 'Semi-Auto Submit',
|
|
onClick: () => runAction(`/trading/tickets/${ticket.id}/semi-auto-submit`, { confirmed_by: 'local_operator' }),
|
|
});
|
|
}
|
|
grid.appendChild(
|
|
buildItemCard(
|
|
`Ticket #${ticket.id}`,
|
|
ticket.status,
|
|
[
|
|
['mode', ticket.execution_mode],
|
|
['side', signal.side || 'n/a'],
|
|
['entry zone', `${formatNumber(signal.entry_low, 3)} - ${formatNumber(signal.entry_high, 3)}`],
|
|
['stop', formatNumber(signal.stop_loss, 3)],
|
|
['TP1 / TP2', `${formatNumber(signal.tp1, 3)} / ${formatNumber(signal.tp2, 3)}`],
|
|
['RR', `${formatNumber(decision.meta?.tp1 ? Math.abs((Number(decision.meta.tp1) - Number(decision.entry_price || 0)) / Math.max(Math.abs(Number(decision.entry_price || 0) - Number(decision.meta.stop_loss || 0)), 1e-9)) : 0)} / ${formatNumber(decision.meta?.tp2 ? Math.abs((Number(decision.meta.tp2) - Number(decision.entry_price || 0)) / Math.max(Math.abs(Number(decision.entry_price || 0) - Number(decision.meta.stop_loss || 0)), 1e-9)) : 0)}`],
|
|
['risk amount', formatNumber(riskAmount, 2)],
|
|
['expiry', formatTimestamp(ticket.expires_ts)],
|
|
['management rules', formatJsonInline(signal.target_plan?.management_rules || [])],
|
|
['risk governor result', formatJsonInline(riskGovernor.reject_codes || [])],
|
|
['confirmed by', ticket.confirmed_by || 'n/a'],
|
|
['confirmed at', formatTimestamp(ticket.confirmed_at)],
|
|
],
|
|
actions,
|
|
decision.meta?.ai_explanation || ''
|
|
)
|
|
);
|
|
});
|
|
executionTicketsPanel.appendChild(grid);
|
|
}
|
|
|
|
function renderExecutions(items) {
|
|
clearNode(positionManagementPanel);
|
|
const filtered = filterExecutions(items);
|
|
if (!filtered.length) {
|
|
positionManagementPanel.appendChild(createElement('div', 'empty-state', 'No execution rows match the current filters.'));
|
|
return;
|
|
}
|
|
|
|
const grid = createElement('div', 'cards');
|
|
filtered.forEach((execution) => {
|
|
const tpProgress = [
|
|
execution.tp1_hit ? 'TP1 hit' : 'TP1 open',
|
|
execution.tp2_hit ? 'TP2 hit' : 'TP2 open',
|
|
].join(' | ');
|
|
grid.appendChild(
|
|
buildItemCard(
|
|
`Execution #${execution.execution_id}`,
|
|
execution.execution_status,
|
|
[
|
|
['mode', execution.execution_mode],
|
|
['side', execution.side || 'n/a'],
|
|
['fill state', execution.fill_state || 'n/a'],
|
|
['position state', execution.position_state || 'n/a'],
|
|
['TP progress', tpProgress],
|
|
['stop state', execution.close_reason || 'active'],
|
|
['invalidation alerts', formatList(execution.error_tags || [])],
|
|
['ticket', execution.execution_ticket_id || 'n/a'],
|
|
['opened', formatTimestamp(execution.opened_ts)],
|
|
['closed', formatTimestamp(execution.closed_ts)],
|
|
['quality', formatNumber(execution.quality_score)],
|
|
['bracket state', formatJsonInline(execution.bracket_state || {})],
|
|
['reconciliation', formatJsonInline(execution.reconciliation_state || {})],
|
|
],
|
|
[
|
|
{
|
|
label: 'Cancel',
|
|
onClick: () => runAction(`/trading/executions/${execution.execution_id}/cancel`, {}),
|
|
},
|
|
{
|
|
label: 'Close',
|
|
onClick: () => runAction(`/trading/executions/${execution.execution_id}/close`, {}),
|
|
},
|
|
{
|
|
label: 'Sync',
|
|
onClick: () => runAction(`/trading/executions/${execution.execution_id}/sync`, {}),
|
|
},
|
|
],
|
|
execution.ai_explanation || ''
|
|
)
|
|
);
|
|
});
|
|
positionManagementPanel.appendChild(grid);
|
|
}
|
|
|
|
function renderPostTradeReview(summary, journalItems, weeklyReview) {
|
|
clearNode(postTradeReviewPanel);
|
|
postTradeReviewPanel.appendChild(
|
|
buildActionBar([
|
|
{
|
|
label: 'Generate Journal Backfill',
|
|
onClick: () => runAction('/trading/journal/generate', {}),
|
|
},
|
|
])
|
|
);
|
|
|
|
const top = buildInfoGrid([
|
|
['entry count', String(summary?.entry_count ?? weeklyReview?.entry_count ?? 0)],
|
|
['framework execution rate', formatPercent(weeklyReview?.framework_execution_rate)],
|
|
['out-of-model rate', formatPercent(weeklyReview?.out_of_model_trade_rate)],
|
|
['risk violation count', String(weeklyReview?.risk_violation_count ?? 0)],
|
|
['high quality session', formatJsonInline(weeklyReview?.high_quality_session)],
|
|
['low quality session', formatJsonInline(weeklyReview?.low_quality_session)],
|
|
['next actions', formatList(weeklyReview?.next_week_actions || [])],
|
|
]);
|
|
postTradeReviewPanel.appendChild(top);
|
|
|
|
if (!journalItems.length) {
|
|
postTradeReviewPanel.appendChild(createElement('div', 'empty-state', 'No journal entries available for this instrument.'));
|
|
return;
|
|
}
|
|
|
|
const grid = createElement('div', 'cards');
|
|
journalItems.slice(0, 6).forEach((entry) => {
|
|
grid.appendChild(
|
|
buildItemCard(
|
|
`Journal #${entry.id}`,
|
|
entry.compliance_state || 'review',
|
|
[
|
|
['model compliance', String(Boolean(entry.model_compliance))],
|
|
['risk compliance', String(!entry.risk_violation)],
|
|
['execution violations', formatList(entry.execution_violations || [])],
|
|
['model failure', String(Boolean(entry.model_failure))],
|
|
['execution failure', String(Boolean(entry.execution_violation))],
|
|
['next actions', formatList(entry.next_actions || [])],
|
|
],
|
|
[],
|
|
formatJsonInline(entry.summary || {})
|
|
)
|
|
);
|
|
});
|
|
postTradeReviewPanel.appendChild(grid);
|
|
}
|
|
|
|
function drawCandles(items) {
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
if (!Array.isArray(items) || items.length === 0) {
|
|
ctx.fillStyle = '#9db0d3';
|
|
ctx.font = '14px sans-serif';
|
|
ctx.fillText('No candle data available.', 24, 36);
|
|
return;
|
|
}
|
|
|
|
const candles = [...items].reverse();
|
|
const highs = candles.map((item) => Number(item.high || 0));
|
|
const lows = candles.map((item) => Number(item.low || 0));
|
|
const closes = candles.map((item) => Number(item.close || 0));
|
|
const minPrice = Math.min(...lows);
|
|
const maxPrice = Math.max(...highs);
|
|
const range = Math.max(maxPrice - minPrice, 1e-9);
|
|
const chartLeft = 40;
|
|
const chartRight = canvas.width - 16;
|
|
const chartTop = 16;
|
|
const chartBottom = canvas.height - 24;
|
|
const chartHeight = chartBottom - chartTop;
|
|
const chartWidth = chartRight - chartLeft;
|
|
|
|
ctx.strokeStyle = '#24304f';
|
|
ctx.lineWidth = 1;
|
|
for (let i = 0; i <= 4; i += 1) {
|
|
const y = chartTop + (chartHeight / 4) * i;
|
|
ctx.beginPath();
|
|
ctx.moveTo(chartLeft, y);
|
|
ctx.lineTo(chartRight, y);
|
|
ctx.stroke();
|
|
}
|
|
|
|
ctx.strokeStyle = '#4f7cff';
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
closes.forEach((close, index) => {
|
|
const x = chartLeft + (chartWidth * index) / Math.max(closes.length - 1, 1);
|
|
const y = chartBottom - ((close - minPrice) / range) * chartHeight;
|
|
if (index === 0) ctx.moveTo(x, y);
|
|
else ctx.lineTo(x, y);
|
|
});
|
|
ctx.stroke();
|
|
|
|
ctx.fillStyle = '#9db0d3';
|
|
ctx.font = '12px sans-serif';
|
|
ctx.fillText(maxPrice.toFixed(2), 4, chartTop + 10);
|
|
ctx.fillText(minPrice.toFixed(2), 4, chartBottom);
|
|
}
|
|
|
|
function showErrorPanel(message) {
|
|
[
|
|
marketStatePanel,
|
|
runtimeStatusPanel,
|
|
decisionPacketPanel,
|
|
preMarketPlanPanel,
|
|
tradeCandidatesPanel,
|
|
executionTicketsPanel,
|
|
positionManagementPanel,
|
|
postTradeReviewPanel,
|
|
].forEach((panel) => {
|
|
clearNode(panel);
|
|
panel.appendChild(createElement('div', 'empty-state error-state', message));
|
|
});
|
|
}
|
|
|
|
function updateLastUpdated() {
|
|
setText(lastUpdated, `Last updated: ${new Date().toLocaleTimeString()}`);
|
|
}
|
|
|
|
function renderDashboardData({
|
|
health,
|
|
candles,
|
|
tradingPlan,
|
|
marketState,
|
|
runtimeStatus,
|
|
decisionPacket,
|
|
decisionAlerts,
|
|
tradeCandidates,
|
|
executionTickets,
|
|
tradingExecutions,
|
|
journalSummary,
|
|
journalItems,
|
|
weeklyReview,
|
|
}) {
|
|
renderHealth(health);
|
|
renderOverview(weeklyReview, tradeCandidates.items || [], executionTickets.items || [], tradingExecutions.items || []);
|
|
renderMarketState(marketState, tradingExecutions.items || []);
|
|
renderRuntimeStatus(runtimeStatus);
|
|
renderDecisionPacket(decisionPacket, decisionAlerts);
|
|
renderPreMarketPlan(tradingPlan);
|
|
renderCandidates(tradeCandidates.items || []);
|
|
renderTickets(executionTickets.items || []);
|
|
renderExecutions(tradingExecutions.items || []);
|
|
renderPostTradeReview(journalSummary, journalItems.items || [], weeklyReview);
|
|
drawCandles(candles.items || []);
|
|
updateActiveFilters();
|
|
updateLastUpdated();
|
|
}
|
|
|
|
async function refreshDashboard() {
|
|
const instrumentId = instrumentIdInput.value || '1';
|
|
const timeframe = timeframeInput.value || '1m';
|
|
const limit = limitInput.value || '20';
|
|
|
|
try {
|
|
const [health, candles, tradingPlan, marketState, runtimeStatus, decisionPacket, decisionAlerts, tradeCandidates, executionTickets, tradingExecutions, journalSummary, journalItems, weeklyReview] = await Promise.all([
|
|
fetchJson('/health'),
|
|
fetchJson(`/candles?${queryString({ instrument_id: instrumentId, timeframe, limit })}`),
|
|
fetchJson(`/trading/plans/latest?${queryString({ instrument_id: instrumentId })}`).catch(() => null),
|
|
fetchJson(`/trading/market-state?${queryString({ instrument_id: instrumentId, timeframe })}`).catch(() => ({ market_state: {} })),
|
|
fetchJson('/trading/runtime-status').catch(() => ({ scheduler_state: 'unavailable', tasks: {}, current_blockers: ['runtime_status_unavailable'] })),
|
|
fetchJson(`/trading/decision-packet?${queryString({ instrument_id: instrumentId })}`).catch(() => ({ status: 'unavailable', action_recommendation: 'monitor' })),
|
|
fetchJson(`/trading/alerts?${queryString({ instrument_id: instrumentId, limit: 10 })}`).catch(() => ({ items: [] })),
|
|
fetchJson(`/trading/candidates?${queryString({ instrument_id: instrumentId, limit })}`).catch(() => ({ items: [] })),
|
|
fetchJson(`/trading/tickets?${queryString({ instrument_id: instrumentId, limit })}`).catch(() => ({ items: [] })),
|
|
fetchJson(`/trading/executions?${queryString({ instrument_id: instrumentId, limit })}`).catch(() => ({ items: [] })),
|
|
fetchJson(`/trading/journal/summary?${queryString({ instrument_id: instrumentId })}`).catch(() => ({ entry_count: 0, next_week_actions: [] })),
|
|
fetchJson(`/trading/journal?${queryString({ instrument_id: instrumentId, limit })}`).catch(() => ({ items: [] })),
|
|
fetchJson(`/trading/research/weekly-review?${queryString({ instrument_id: instrumentId })}`).catch(() => ({ entry_count: 0, next_week_actions: [] })),
|
|
]);
|
|
|
|
renderDashboardData({
|
|
health,
|
|
candles,
|
|
tradingPlan,
|
|
marketState,
|
|
runtimeStatus,
|
|
decisionPacket,
|
|
decisionAlerts,
|
|
tradeCandidates,
|
|
executionTickets,
|
|
tradingExecutions,
|
|
journalSummary,
|
|
journalItems,
|
|
weeklyReview,
|
|
});
|
|
} catch (error) {
|
|
showErrorPanel(String(error));
|
|
}
|
|
}
|
|
|
|
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);
|
|
|
|
configureAutoRefresh();
|
|
refreshDashboard();
|