stock/static/app.js
2025-08-15 12:32:27 +02:00

222 lines
7.4 KiB
JavaScript

// static/app.js
const fmt2 = (x) => (x == null || isNaN(x) ? "—" : Number(x).toFixed(2));
const fmt6 = (x) => (x == null || isNaN(x) ? "—" : Number(x).toFixed(6));
const fmtPct= (x) => (x == null || isNaN(x) ? "—" : (Number(x) * 100).toFixed(2) + "%");
async function getJSON(url) {
const r = await fetch(url, { cache: "no-store" });
if (!r.ok) throw new Error(`${url}: ${r.status}`);
return r.json();
}
// prosty wykres linii na canvas (bez bibliotek)
function drawLineChart(canvas, points) {
const ctx = canvas.getContext("2d");
const pad = 32;
const w = canvas.width, h = canvas.height;
ctx.clearRect(0, 0, w, h);
if (!points || points.length === 0) {
ctx.fillStyle = "#9aa3b2";
ctx.fillText("Brak danych", 10, 20);
return;
}
const n = points.length;
const ys = points.map(p => p.y);
const minY = Math.min(...ys), maxY = Math.max(...ys);
const yLo = minY === maxY ? minY - 1 : minY;
const yHi = minY === maxY ? maxY + 1 : maxY;
const x0 = pad, y0 = h - pad, x1 = w - pad, y1 = pad;
const xScale = (i) => x0 + (i / (n - 1)) * (x1 - x0);
const yScale = (y) => y0 - ((y - yLo) / (yHi - yLo)) * (y0 - y1);
// osie
ctx.strokeStyle = "#242a36";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x0, y0); ctx.lineTo(x1, y0);
ctx.moveTo(x0, y0); ctx.lineTo(x0, y1);
ctx.stroke();
// siatka
ctx.strokeStyle = "#1b2130";
[0.25, 0.5, 0.75].forEach(f => {
const yy = y0 - (y0 - y1) * f;
ctx.beginPath();
ctx.moveTo(x0, yy); ctx.lineTo(x1, yy);
ctx.stroke();
});
// podpisy min/max
ctx.fillStyle = "#9aa3b2";
ctx.font = "12px system-ui, -apple-system, Segoe UI, Roboto, Inter, Arial";
ctx.fillText(fmt2(yHi), 6, y1 + 10);
ctx.fillText(fmt2(yLo), 6, y0 - 2);
// linia
ctx.strokeStyle = "#4da3ff";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(xScale(0), yScale(ys[0]));
for (let i = 1; i < n; i++) ctx.lineTo(xScale(i), yScale(ys[i]));
ctx.stroke();
// kropka na końcu
const lastX = xScale(n - 1), lastY = yScale(ys[n - 1]);
ctx.fillStyle = "#e7e9ee";
ctx.beginPath(); ctx.arc(lastX, lastY, 3, 0, Math.PI * 2); ctx.fill();
ctx.fillText(fmt2(ys[n - 1]), lastX + 6, lastY - 6);
}
async function loadAll() {
try {
const [snap, hist, pos, trades] = await Promise.all([
getJSON("/api/snapshot"),
getJSON("/api/history"),
getJSON("/api/positions"),
getJSON("/api/trades"),
]);
// czas
document.getElementById("last-update").textContent =
"Ostatnia aktualizacja: " + (snap?.last_history?.time || "—");
// ===== ostatni wiersz historii =====
const last = Array.isArray(hist) && hist.length ? hist[hist.length - 1] : null;
const cashVal = Number(last?.cash ?? 0);
const totalVal = Number(
last?.total_value ??
(Number(last?.cash ?? 0) + Number(last?.positions_net ?? 0)) // fallback dla starszych logów
);
// KARTY
document.getElementById("cash").textContent = fmt2(cashVal);
document.getElementById("total-value").textContent = fmt2(totalVal);
// mapa ostatnich cen do liczenia zysku (unrealized)
const lastPrice = new Map();
(snap?.signals || []).forEach(s => {
const px = Number(s.price);
if (!isNaN(px)) lastPrice.set(s.ticker, px);
});
// Zysk = suma niezrealizowanych PnL na otwartych pozycjach
let unrealPnL = 0;
(pos || []).forEach(p => {
const price = Number(lastPrice.get(p.ticker));
const entry = Number(p.entry);
const qty = Number(p.qty);
const side = Number(p.side);
if (isNaN(price) || isNaN(entry) || isNaN(qty) || isNaN(side)) return;
unrealPnL += side === 1 ? (price - entry) * qty : (entry - price) * qty;
});
const unrlEl = document.getElementById("unrealized");
unrlEl.textContent = fmt2(unrealPnL);
unrlEl.classList.remove("pnl-positive", "pnl-negative");
if (unrealPnL > 0) unrlEl.classList.add("pnl-positive");
else if (unrealPnL < 0) unrlEl.classList.add("pnl-negative");
// liczba otwartych pozycji
document.getElementById("open-pos").textContent =
Number(last?.open_positions ?? (pos?.length ?? 0));
// ===== WYKRES WARTOŚCI KONTA (TOTAL) =====
const totalCanvas = document.getElementById("totalChart");
const totalPoints = (hist || [])
.map((row, i) => {
const v = (row.total_value != null)
? Number(row.total_value)
: Number(row.cash ?? 0) + Number(row.positions_net ?? 0);
return { x: i, y: v };
})
.filter(p => !isNaN(p.y))
.slice(-500);
drawLineChart(totalCanvas, totalPoints);
// ===== WYKRES GOTÓWKI (CASH) =====
const cashCanvas = document.getElementById("cashChart");
const cashPoints = (hist || [])
.map((row, i) => ({ x: i, y: Number(row.cash ?? NaN) }))
.filter(p => !isNaN(p.y))
.slice(-500);
drawLineChart(cashCanvas, cashPoints);
// ===== POZYCJE =====
const posBody = document.querySelector("#positions-table tbody");
posBody.innerHTML = "";
(pos || []).forEach((p) => {
const price = Number(lastPrice.get(p.ticker));
const entry = Number(p.entry);
const qty = Number(p.qty);
const side = Number(p.side);
let upnl = NaN, upct = NaN;
if (!isNaN(price) && !isNaN(entry) && !isNaN(qty) && !isNaN(side)) {
upnl = side === 1 ? (price - entry) * qty : (entry - price) * qty;
const denom = Math.max(qty * entry, 1e-12);
upct = upnl / denom;
}
const pnlClass = upnl > 0 ? "pnl-positive" : (upnl < 0 ? "pnl-negative" : "");
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${p.ticker}</td>
<td>${fmt6(qty)}</td>
<td>${fmt6(entry)}</td>
<td>${side === 1 ? "LONG" : "SHORT"}</td>
<td>${fmt6(price)}</td>
<td class="${pnlClass}">${fmt2(upnl)}</td>
<td class="${pnlClass}">${fmtPct(upct)}</td>
`;
posBody.appendChild(tr);
});
// ===== SYGNAŁY =====
const sigBody = document.querySelector("#signals-table tbody");
sigBody.innerHTML = "";
const signals = (snap?.signals || []).slice().sort((a,b)=>a.ticker.localeCompare(b.ticker));
signals.forEach((s) => {
const sigTxt = s.signal === 1 ? "BUY" : (s.signal === -1 ? "SELL" : "HOLD");
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${s.ticker}</td>
<td>${s.time}</td>
<td>${fmt6(s.price)}</td>
<td class="${sigTxt}">${sigTxt}</td>
<td>${s.interval}</td>
`;
sigBody.appendChild(tr);
});
// ===== TRANSAKCJE =====
const tradesBody = document.querySelector("#trades-table tbody");
tradesBody.innerHTML = "";
(trades || []).slice(-50).reverse().forEach((t) => {
const pnlClass = t.pnl_abs > 0 ? "pnl-positive" : (t.pnl_abs < 0 ? "pnl-negative" : "");
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${t.time}</td>
<td class="${t.action}">${t.action}</td>
<td>${t.ticker}</td>
<td>${fmt6(t.price)}</td>
<td>${fmt6(t.qty)}</td>
<td class="${pnlClass}">${fmt2(t.pnl_abs)}</td>
<td class="${pnlClass}">${fmtPct(t.pnl_pct)}</td>
<td>${fmt2(t.cash_after)}</td>
`;
tradesBody.appendChild(tr);
});
} catch (e) {
console.error("loadAll error:", e);
document.getElementById("last-update").textContent = "Błąd ładowania danych";
}
}
document.getElementById("refresh-btn").addEventListener("click", loadAll);
loadAll();
setInterval(loadAll, 1000);