feat(calibration): complete step 1 data inspection with data quality v1
This commit is contained in:
9
src/web/ui/v1/static/css/tabler.min.css
vendored
Normal file
9
src/web/ui/v1/static/css/tabler.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
7
src/web/ui/v1/static/js/api.js
Normal file
7
src/web/ui/v1/static/js/api.js
Normal file
@@ -0,0 +1,7 @@
|
||||
const API_BASE = "/api/v1";
|
||||
|
||||
async function apiGet(path) {
|
||||
const res = await fetch(`${API_BASE}${path}`);
|
||||
if (!res.ok) throw new Error(res.statusText);
|
||||
return res.json();
|
||||
}
|
||||
306
src/web/ui/v1/static/js/dashboard.js
Normal file
306
src/web/ui/v1/static/js/dashboard.js
Normal file
@@ -0,0 +1,306 @@
|
||||
let equityChart = null;
|
||||
let showDrawdown = true;
|
||||
let currentRange = "all";
|
||||
|
||||
// --------------------------------------------------
|
||||
// STATUS
|
||||
// --------------------------------------------------
|
||||
async function updateStatus() {
|
||||
const data = await apiGet("/bot/status");
|
||||
const el = document.getElementById("bot-status");
|
||||
|
||||
el.textContent = data.state;
|
||||
el.className =
|
||||
"ms-auto badge " +
|
||||
(data.state === "RUNNING"
|
||||
? "bg-green"
|
||||
: data.state === "ERROR"
|
||||
? "bg-red"
|
||||
: "bg-secondary");
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
// KPI
|
||||
// --------------------------------------------------
|
||||
async function updateEquity() {
|
||||
const data = await apiGet("/equity/state");
|
||||
document.getElementById("kpi-equity").textContent =
|
||||
data.equity?.toFixed(2) ?? "—";
|
||||
document.getElementById("kpi-pnl").textContent =
|
||||
data.realized_pnl?.toFixed(2) ?? "—";
|
||||
}
|
||||
|
||||
async function fetchTrades() {
|
||||
const data = await apiGet("/trades?limit=500");
|
||||
return data.items || [];
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
// MAIN CHART
|
||||
// --------------------------------------------------
|
||||
async function updateCurve() {
|
||||
const data = await apiGet(`/equity/curve?range=${currentRange}`);
|
||||
|
||||
const labels = data.timestamps;
|
||||
const equity = data.equity;
|
||||
const cash = data.cash;
|
||||
|
||||
// -----------------------------
|
||||
// Max Equity (for drawdown)
|
||||
// -----------------------------
|
||||
const maxEquityCurve = [];
|
||||
let runningMax = -Infinity;
|
||||
|
||||
for (let i = 0; i < equity.length; i++) {
|
||||
runningMax = Math.max(runningMax, equity[i]);
|
||||
maxEquityCurve.push(runningMax);
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Max Drawdown KPI
|
||||
// -----------------------------
|
||||
let maxDD = 0;
|
||||
for (let i = 0; i < equity.length; i++) {
|
||||
const dd = (equity[i] / maxEquityCurve[i] - 1) * 100;
|
||||
maxDD = Math.min(maxDD, dd);
|
||||
}
|
||||
|
||||
const elDD = document.getElementById("kpi-max-dd");
|
||||
if (elDD) elDD.textContent = `${maxDD.toFixed(2)} %`;
|
||||
|
||||
// -----------------------------
|
||||
// Trades → markers
|
||||
// -----------------------------
|
||||
const trades = await fetchTrades();
|
||||
const buyPoints = [];
|
||||
const sellPoints = [];
|
||||
|
||||
const minEquity = Math.min(...equity);
|
||||
const maxEquity = Math.max(...equity);
|
||||
const offset = Math.max((maxEquity - minEquity) * 0.05, 350);
|
||||
|
||||
trades.forEach(t => {
|
||||
if (!t.timestamp || !t.side) return;
|
||||
|
||||
const tradeTs = new Date(t.timestamp).getTime();
|
||||
let idx = labels.length - 1;
|
||||
|
||||
for (let i = labels.length - 1; i >= 0; i--) {
|
||||
if (new Date(labels[i]).getTime() <= tradeTs) {
|
||||
idx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const y = equity[idx];
|
||||
if (y == null) return;
|
||||
|
||||
if (t.side === "BUY") {
|
||||
buyPoints.push({ x: labels[idx], y: y - offset, trade: t });
|
||||
}
|
||||
|
||||
if (t.side === "SELL" || t.side === "CLOSE") {
|
||||
sellPoints.push({ x: labels[idx], y: y + offset, trade: t });
|
||||
}
|
||||
});
|
||||
|
||||
// --------------------------------------------------
|
||||
// INIT CHART
|
||||
// --------------------------------------------------
|
||||
if (!equityChart) {
|
||||
const ctx = document.getElementById("equityChart").getContext("2d");
|
||||
|
||||
equityChart = new Chart(ctx, {
|
||||
type: "line",
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: "Max Equity",
|
||||
data: maxEquityCurve,
|
||||
borderColor: "rgba(0,0,0,0)",
|
||||
pointRadius: 0
|
||||
},
|
||||
{
|
||||
label: "Equity",
|
||||
data: equity,
|
||||
borderColor: "#206bc4",
|
||||
backgroundColor: "rgba(214,57,57,0.15)",
|
||||
pointRadius: 0,
|
||||
fill: {
|
||||
target: 0,
|
||||
above: "rgba(0,0,0,0)",
|
||||
below: "rgba(214,57,57,0.15)"
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "Cash",
|
||||
data: cash,
|
||||
borderColor: "#2fb344",
|
||||
borderDash: [5, 5],
|
||||
pointRadius: 0
|
||||
},
|
||||
{
|
||||
type: "scatter",
|
||||
label: "BUY",
|
||||
data: buyPoints,
|
||||
pointStyle: "triangle",
|
||||
pointRotation: 0,
|
||||
pointRadius: 6,
|
||||
backgroundColor: "#2fb344"
|
||||
},
|
||||
{
|
||||
type: "scatter",
|
||||
label: "SELL",
|
||||
data: sellPoints,
|
||||
pointStyle: "triangle",
|
||||
pointRotation: 180,
|
||||
pointRadius: 6,
|
||||
backgroundColor: "#d63939"
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
animation: false,
|
||||
|
||||
// -----------------------------
|
||||
// SOLUCIÓN 1: TIME SCALE
|
||||
// -----------------------------
|
||||
scales: {
|
||||
x: {
|
||||
type: "time",
|
||||
time: {
|
||||
tooltipFormat: "yyyy-MM-dd HH:mm",
|
||||
displayFormats: {
|
||||
minute: "HH:mm",
|
||||
hour: "HH:mm",
|
||||
day: "MMM dd"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// -----------------------------
|
||||
// SOLUCIÓN 2: ZOOM + PAN
|
||||
// -----------------------------
|
||||
plugins: {
|
||||
zoom: {
|
||||
limits: {
|
||||
x: { min: "original", max: "original" }
|
||||
},
|
||||
pan: {
|
||||
enabled: true,
|
||||
mode: "x",
|
||||
modifierKey: "ctrl"
|
||||
},
|
||||
zoom: {
|
||||
wheel: { enabled: true },
|
||||
pinch: { enabled: true },
|
||||
mode: "x"
|
||||
}
|
||||
},
|
||||
|
||||
// -----------------------------
|
||||
// SOLUCIÓN 3: DECIMATION
|
||||
// -----------------------------
|
||||
decimation: {
|
||||
enabled: true,
|
||||
algorithm: "lttb",
|
||||
samples: 500
|
||||
},
|
||||
|
||||
// -----------------------------
|
||||
// LEYENDA
|
||||
// -----------------------------
|
||||
legend: {
|
||||
display: true,
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
generateLabels(chart) {
|
||||
return chart.data.datasets
|
||||
// ❌ fuera Max Equity
|
||||
.map((ds, i) => ({ ds, i }))
|
||||
.filter(({ ds }) => ds.label !== "Max Equity")
|
||||
.map(({ ds, i }) => {
|
||||
const isScatter = ds.type === "scatter";
|
||||
|
||||
return {
|
||||
text: ds.label,
|
||||
datasetIndex: i,
|
||||
hidden: !chart.isDatasetVisible(i),
|
||||
|
||||
// 🎨 colores reales del dataset
|
||||
fillStyle: isScatter ? ds.backgroundColor : ds.borderColor,
|
||||
strokeStyle: ds.borderColor,
|
||||
|
||||
// 📏 línea vs punto
|
||||
lineWidth: isScatter ? 0 : 3,
|
||||
borderDash: ds.borderDash || [],
|
||||
|
||||
// 🔺 BUY / SELL = triángulos reales
|
||||
pointStyle: isScatter ? ds.pointStyle : "line",
|
||||
rotation: isScatter ? ds.pointRotation || 0 : 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
equityChart.data.labels = labels;
|
||||
equityChart.data.datasets[0].data = maxEquityCurve;
|
||||
equityChart.data.datasets[1].data = equity;
|
||||
equityChart.data.datasets[2].data = cash;
|
||||
equityChart.data.datasets[3].data = buyPoints;
|
||||
equityChart.data.datasets[4].data = sellPoints;
|
||||
equityChart.update("none");
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
// SOLUCIÓN 4: TIME WINDOWS
|
||||
// --------------------------------------------------
|
||||
function setTimeWindow(hours) {
|
||||
if (!equityChart) return;
|
||||
|
||||
if (hours === "ALL") {
|
||||
equityChart.options.scales.x.min = undefined;
|
||||
equityChart.options.scales.x.max = undefined;
|
||||
} else {
|
||||
const now = Date.now();
|
||||
equityChart.options.scales.x.min = now - hours * 3600_000;
|
||||
equityChart.options.scales.x.max = now;
|
||||
}
|
||||
|
||||
equityChart.update();
|
||||
}
|
||||
|
||||
async function updateEvents() {
|
||||
const data = await apiGet("/events?limit=20");
|
||||
document.getElementById("events-log").textContent =
|
||||
data.items.join("\n");
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
// UI
|
||||
// --------------------------------------------------
|
||||
document.getElementById("reset-zoom")?.addEventListener("click", () => {
|
||||
equityChart?.resetZoom();
|
||||
});
|
||||
|
||||
document.getElementById("toggle-dd")?.addEventListener("change", e => {
|
||||
showDrawdown = e.target.checked;
|
||||
equityChart.data.datasets[1].fill = showDrawdown
|
||||
? { target: 0, above: "rgba(0,0,0,0)", below: "rgba(214,57,57,0.15)" }
|
||||
: false;
|
||||
equityChart.update("none");
|
||||
});
|
||||
|
||||
// --------------------------------------------------
|
||||
poll(updateStatus, 2000);
|
||||
poll(updateEquity, 5000);
|
||||
poll(updateCurve, 10000);
|
||||
poll(updateEvents, 10000);
|
||||
4
src/web/ui/v1/static/js/polling.js
Normal file
4
src/web/ui/v1/static/js/polling.js
Normal file
@@ -0,0 +1,4 @@
|
||||
function poll(fn, interval) {
|
||||
fn();
|
||||
return setInterval(fn, interval);
|
||||
}
|
||||
Reference in New Issue
Block a user