Files
BitGet_API/Bitget_Server/wwwroot/chart.html

605 lines
20 KiB
HTML

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bitget Server — Candle + Indicator</title>
<!-- Chart.js 4 + Financial plugin (candlestick/ohlc) + date-fns adapter (필수) -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/luxon@3.4.4/build/global/luxon.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-luxon@1.3.1/dist/chartjs-adapter-luxon.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-chart-financial@0.2.1/dist/chartjs-chart-financial.min.js"></script>
<style>
:root {
--bg: #0e1116;
--panel: #161b22;
--border: #30363d;
--text: #c9d1d9;
--muted: #8b949e;
--accent: #2f81f7;
--accent-2: #58a6ff;
--green: #26a269;
--red: #e5484d;
--mono: ui-monospace, SFMono-Regular, Menlo, monospace;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, "Segoe UI", Roboto, "Noto Sans KR", sans-serif;
background: var(--bg);
color: var(--text);
}
header {
padding: 14px 24px;
border-bottom: 1px solid var(--border);
display: flex;
gap: 14px;
align-items: center;
flex-wrap: wrap;
}
header h1 { margin: 0; font-size: 16px; font-weight: 600; }
header a { color: var(--accent-2); text-decoration: none; font-size: 13px; margin-left: auto; }
header select, header input, header button {
background: #0d1117; color: var(--text);
border: 1px solid var(--border); border-radius: 6px;
padding: 6px 10px; font-size: 13px;
}
header button {
background: var(--accent); color: white; cursor: pointer; border: none;
}
header button:hover { background: var(--accent-2); }
main { padding: 16px 24px 32px; }
.row { display: grid; grid-template-columns: 1fr; gap: 14px; }
.card {
background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
}
.card-title {
padding: 10px 14px;
font-size: 12px; color: var(--muted);
border-bottom: 1px solid var(--border);
display: flex; justify-content: space-between; gap: 10px;
}
.card-body { padding: 12px 14px; }
.chart-wrap { height: 480px; }
.sub-chart { height: 180px; }
.levels-grid {
display: grid; grid-template-columns: repeat(4, 1fr);
gap: 8px; font-family: var(--mono); font-size: 12px;
}
.level {
border: 1px solid var(--border); border-radius: 6px;
padding: 8px 10px; background: #0d1117;
}
.level .k { color: var(--muted); font-size: 10px; letter-spacing: 0.5px; }
.level .v { font-weight: 600; font-size: 13px; margin-top: 2px; }
.level .v.zero { color: #6e7681; }
.toggle-group { display: inline-flex; gap: 4px; margin-left: 6px; }
.toggle-group label {
background: #0d1117; border: 1px solid var(--border); border-radius: 4px;
padding: 4px 8px; font-size: 11px; color: var(--muted); cursor: pointer;
display: inline-flex; gap: 4px; align-items: center;
}
.toggle-group input { accent-color: var(--accent); }
.status {
font-family: var(--mono); font-size: 11px;
padding: 2px 8px; border-radius: 999px;
}
.status.ok { background: #0f2a17; color: #3fb950; }
.status.err { background: #2a0f10; color: #f85149; }
.signals-list {
display: grid; grid-template-columns: repeat(2, 1fr);
gap: 6px; font-family: var(--mono); font-size: 12px;
}
.signal-row {
padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px;
display: flex; justify-content: space-between; gap: 10px;
}
.signal-row .dir.LongUp { color: #3fb950; }
.signal-row .dir.ShortDown { color: #f85149; }
.signal-row .dir.LongDown { color: #d29922; }
.signal-row .dir.ShortUp { color: #58a6ff; }
</style>
</head>
<body>
<header>
<h1>Candle + Indicator</h1>
<label>Symbol
<select id="symbolSel"></select>
</label>
<label>Interval
<select id="intervalSel"></select>
</label>
<label>Limit
<input id="limitInput" type="number" min="50" max="1000" value="200" style="width:80px"/>
</label>
<label>Auto-refresh
<select id="refreshSel">
<option value="0">off</option>
<option value="2000">2s</option>
<option value="5000" selected>5s</option>
<option value="15000">15s</option>
</select>
</label>
<button id="reloadBtn">Reload</button>
<span id="status" class="status">idle</span>
<a href="/">← API Console</a>
</header>
<main>
<div class="row">
<!-- 메인 캔들 + 인디케이터 오버레이 -->
<section class="card">
<div class="card-title">
<div>
<span>Candlestick + Indicators</span>
<span class="toggle-group">
<label><input type="checkbox" id="tgl_ichimoku" checked> Ichimoku</label>
<label><input type="checkbox" id="tgl_ma" checked> MA</label>
<label><input type="checkbox" id="tgl_bol" checked> Bollinger</label>
<label><input type="checkbox" id="tgl_levels" checked> Price Levels</label>
<label><input type="checkbox" id="tgl_signals" checked> Signals</label>
</span>
</div>
<span id="metaInfo"></span>
</div>
<div class="card-body chart-wrap">
<canvas id="mainChart"></canvas>
</div>
</section>
<!-- 볼륨 -->
<section class="card">
<div class="card-title"><span>Volume (USDT)</span></div>
<div class="card-body sub-chart">
<canvas id="volChart"></canvas>
</div>
</section>
<!-- Impulse MACD -->
<section class="card">
<div class="card-title"><span>Impulse MACD (MD/SH/SB)</span></div>
<div class="card-body sub-chart">
<canvas id="macdChart"></canvas>
</div>
</section>
<!-- PriceLevelTracker state -->
<section class="card">
<div class="card-title"><span>PriceLevelTracker state</span><span id="levelsKey"></span></div>
<div class="card-body">
<div class="levels-grid" id="levelsGrid"></div>
</div>
</section>
<!-- 현재 신호 리스트 -->
<section class="card">
<div class="card-title"><span>Active Signals</span><span id="signalsCount"></span></div>
<div class="card-body">
<div class="signals-list" id="signalsList"></div>
</div>
</section>
</div>
</main>
<script>
// =====================================================================
// Chart.js — financial plugin 등록 확인
// =====================================================================
const Cc = Chart.controllers;
if (!Cc.candlestick && !Cc["candlestick"]) {
console.warn("candlestick controller 미등록 — chartjs-chart-financial 스크립트 로드 실패 가능");
}
// 글로벌 옵션
Chart.defaults.color = "#8b949e";
Chart.defaults.borderColor = "#30363d";
Chart.defaults.font.family = "ui-monospace, SFMono-Regular, Menlo, monospace";
// =====================================================================
// DOM refs
// =====================================================================
const $ = id => document.getElementById(id);
const symbolSel = $("symbolSel");
const intervalSel = $("intervalSel");
const limitInput = $("limitInput");
const refreshSel = $("refreshSel");
const statusEl = $("status");
const metaInfo = $("metaInfo");
const levelsKey = $("levelsKey");
const levelsGrid = $("levelsGrid");
const signalsCount = $("signalsCount");
const signalsList = $("signalsList");
const toggleIchimoku = $("tgl_ichimoku");
const toggleMA = $("tgl_ma");
const toggleBol = $("tgl_bol");
const toggleLevels = $("tgl_levels");
const toggleSignals = $("tgl_signals");
// =====================================================================
// Chart instances
// =====================================================================
let mainChart, volChart, macdChart;
let refreshTimer = null;
function initCharts() {
const mainCtx = $("mainChart").getContext("2d");
mainChart = new Chart(mainCtx, {
type: "candlestick",
data: { datasets: [] },
options: {
responsive: true,
maintainAspectRatio: false,
parsing: false,
interaction: { mode: "index", intersect: false },
plugins: {
legend: { position: "top", labels: { boxWidth: 12 } },
tooltip: {
callbacks: {
label: ctx => {
const d = ctx.raw;
if (d && d.o !== undefined) {
return `O ${d.o} H ${d.h} L ${d.l} C ${d.c}`;
}
return `${ctx.dataset.label}: ${ctx.formattedValue}`;
}
}
}
},
scales: {
x: { type: "timeseries", ticks: { source: "auto", maxRotation: 0 }, grid: { color: "#1f242b" } },
y: { position: "right", grid: { color: "#1f242b" } }
}
}
});
const volCtx = $("volChart").getContext("2d");
volChart = new Chart(volCtx, {
type: "bar",
data: { datasets: [] },
options: {
responsive: true, maintainAspectRatio: false, parsing: false,
plugins: { legend: { display: false } },
scales: {
x: { type: "timeseries", ticks: { source: "auto", maxRotation: 0 }, grid: { color: "#1f242b" } },
y: { position: "right", grid: { color: "#1f242b" } }
}
}
});
const macdCtx = $("macdChart").getContext("2d");
macdChart = new Chart(macdCtx, {
type: "line",
data: { datasets: [] },
options: {
responsive: true, maintainAspectRatio: false, parsing: false,
interaction: { mode: "index", intersect: false },
elements: { point: { radius: 0 } },
plugins: { legend: { position: "top", labels: { boxWidth: 12 } } },
scales: {
x: { type: "timeseries", ticks: { source: "auto", maxRotation: 0 }, grid: { color: "#1f242b" } },
y: { position: "right", grid: { color: "#1f242b" } }
}
}
});
}
// =====================================================================
// Data loading
// =====================================================================
async function loadSymbolsAndIntervals() {
const r = await fetch("/api/diag/symbols");
if (!r.ok) throw new Error("symbols 조회 실패: " + r.status);
const js = await r.json();
return js;
}
async function loadCandles(symbol, interval, limit) {
const u = `/api/diag/candles?symbol=${encodeURIComponent(symbol)}&interval=${encodeURIComponent(interval)}&limit=${limit}`;
const r = await fetch(u);
if (r.status === 404) return { candles: [], notFound: true };
if (!r.ok) throw new Error("candles 조회 실패: " + r.status);
const js = await r.json();
// Newtonsoft 는 PascalCase(Candles), System.Text.Json 은 camelCase(candles) 둘 다 대응
const candles = js.Candles || js.candles || [];
const count = js.Count ?? js.count ?? candles.length;
return { candles, count, notFound: false };
}
async function loadPriceLevels(symbol, interval) {
const u = `/api/diag/price-levels?symbol=${encodeURIComponent(symbol)}&interval=${encodeURIComponent(interval)}`;
const r = await fetch(u);
if (r.status === 404) return null;
if (!r.ok) throw new Error("price-levels 조회 실패: " + r.status);
return await r.json();
}
async function loadSignalMarkers(symbol, interval) {
const u = `/api/diag/signal-markers?symbol=${encodeURIComponent(symbol)}&interval=${encodeURIComponent(interval)}`;
const r = await fetch(u);
if (!r.ok) return [];
return await r.json();
}
// =====================================================================
// Data → Chart datasets 변환
// =====================================================================
function buildMainDatasets(candles, levels, markers) {
const datasets = [];
// (1) 캔들스틱
datasets.push({
type: "candlestick",
label: "OHLC",
data: candles.map(c => ({ x: c.t, o: c.o, h: c.h, l: c.l, c: c.c })),
borderColor: ctx => (ctx.raw && ctx.raw.c >= ctx.raw.o) ? "#26a269" : "#e5484d",
color: { up: "#26a269", down: "#e5484d", unchanged: "#8b949e" }
});
// (2) Ichimoku
if (toggleIchimoku.checked) {
const has = candles.some(c => c.ichimoku);
if (has) {
const series = (field, color) => ({
type: "line",
label: "Ichimoku/" + field,
data: candles.map(c => ({ x: c.t, y: c.ichimoku ? c.ichimoku[field] : null })).filter(p => p.y),
borderColor: color, borderWidth: 1.2, pointRadius: 0
});
datasets.push(series("기준선", "#f85149"));
datasets.push(series("전환선", "#3fb950"));
datasets.push(series("선행스팬1", "#58a6ff"));
datasets.push(series("선행스팬2", "#d29922"));
datasets.push(series("후행스팬", "#a371f7"));
}
}
// (3) Moving Averages
if (toggleMA.checked || toggleBol.checked) {
const ma = {}; // period -> array of {x,y}
const bolUp = {}, bolDn = {};
for (const c of candles) {
if (!c.ma) continue;
for (const [p, v] of Object.entries(c.ma)) {
if (toggleMA.checked) {
(ma[p] ||= []).push({ x: c.t, y: v.Value ?? v.value });
}
if (toggleBol.checked) {
(bolUp[p] ||= []).push({ x: c.t, y: v.BolUp ?? v.bolUp });
(bolDn[p] ||= []).push({ x: c.t, y: v.BolDown ?? v.bolDown });
}
}
}
const MA_COLORS = ["#58a6ff","#3fb950","#d29922","#f85149","#a371f7","#ec6cb9"];
let i = 0;
for (const [p, arr] of Object.entries(ma)) {
datasets.push({ type: "line", label: `MA${p}`, data: arr, borderColor: MA_COLORS[i%MA_COLORS.length], borderWidth: 1.2, pointRadius: 0 });
i++;
}
i = 0;
for (const [p, arr] of Object.entries(bolUp)) {
datasets.push({ type: "line", label: `BOL${p}↑`, data: arr, borderColor: "#8b949e", borderDash: [4,4], borderWidth: 1, pointRadius: 0 });
datasets.push({ type: "line", label: `BOL${p}↓`, data: bolDn[p], borderColor: "#8b949e", borderDash: [4,4], borderWidth: 1, pointRadius: 0 });
i++;
}
}
// (4) PriceLevelTracker horizontal lines
if (toggleLevels.checked && levels && candles.length > 0) {
const xMin = candles[0].t;
const xMax = candles[candles.length - 1].t;
const levelMap = levels.Levels || levels.levels || {};
const LEVEL_KEYS = [
{ k: "전고가", color: "#e5484d" },
{ k: "전저가", color: "#26a269" },
{ k: "전전고가", color: "#f5a524" },
{ k: "전전저가", color: "#17c964" }
];
for (const { k, color } of LEVEL_KEYS) {
const v = levelMap[k];
if (v && v !== 0) {
datasets.push({
type: "line",
label: k,
data: [{ x: xMin, y: v }, { x: xMax, y: v }],
borderColor: color, borderWidth: 1.2, borderDash: [6, 6],
pointRadius: 0, fill: false
});
}
}
}
// (5) Signal markers (point overlay)
if (toggleSignals.checked && markers && markers.length > 0) {
const byDir = {};
for (const m of markers) {
(byDir[m.direction] ||= []).push({ x: m.t, y: m.price });
}
const MARKER_STYLE = {
LongUp: { bg: "#3fb950", shape: "triangle", rot: 0 },
ShortDown: { bg: "#f85149", shape: "triangle", rot: 180 },
LongDown: { bg: "#d29922", shape: "rect", rot: 0 },
ShortUp: { bg: "#58a6ff", shape: "rect", rot: 0 }
};
for (const [dir, arr] of Object.entries(byDir)) {
const s = MARKER_STYLE[dir] || { bg: "#ffffff", shape: "circle", rot: 0 };
datasets.push({
type: "scatter",
label: `SIG/${dir}`,
data: arr,
pointBackgroundColor: s.bg,
pointBorderColor: s.bg,
pointStyle: s.shape,
pointRotation: s.rot,
pointRadius: 7,
showLine: false
});
}
}
return datasets;
}
function buildVolumeDatasets(candles) {
return [{
type: "bar",
label: "Vol USDT",
data: candles.map(c => ({ x: c.t, y: c.volUsdt })),
backgroundColor: candles.map(c => (c.c >= c.o) ? "rgba(38,162,105,0.55)" : "rgba(229,72,77,0.55)"),
borderWidth: 0
}];
}
function buildMacdDatasets(candles) {
const hasMacd = candles.some(c => c.impulseMacd);
if (!hasMacd) return [];
const keys = new Set();
candles.forEach(c => { if (c.impulseMacd) Object.keys(c.impulseMacd).forEach(k => keys.add(k)); });
const firstKey = Array.from(keys)[0];
const series = (field, color) => ({
type: "line",
label: `MACD/${field}`,
data: candles.map(c => ({ x: c.t, y: c.impulseMacd?.[firstKey]?.[field] ?? null })).filter(p => p.y !== null),
borderColor: color, borderWidth: 1.2, pointRadius: 0
});
return [series("MD", "#58a6ff"), series("SH", "#f85149"), series("SB", "#3fb950")];
}
function renderLevelsGrid(levelsResp) {
levelsGrid.innerHTML = "";
if (!levelsResp) {
levelsKey.textContent = "(캐시 없음)";
return;
}
levelsKey.textContent = `key=${levelsResp.Key || levelsResp.key || ""}`;
const lv = levelsResp.Levels || levelsResp.levels || {};
const keys = Object.keys(lv);
for (const k of keys) {
const v = lv[k];
const box = document.createElement("div");
box.className = "level";
box.innerHTML = `<div class="k">${k}</div><div class="v ${v === 0 ? "zero":""}">${Number(v).toLocaleString()}</div>`;
levelsGrid.appendChild(box);
}
}
function renderSignalList(markers) {
signalsList.innerHTML = "";
signalsCount.textContent = `count ${markers.length}`;
for (const m of markers.slice(0, 50)) {
const row = document.createElement("div");
row.className = "signal-row";
const t = new Date(m.t).toLocaleString();
row.innerHTML = `
<span class="dir ${m.direction}">${m.direction}</span>
<span>${m.symbol} / ${m.interval}</span>
<span>@ ${m.price}</span>
<span>${t}</span>`;
signalsList.appendChild(row);
}
}
// =====================================================================
// Orchestration
// =====================================================================
async function refreshAll() {
const symbol = symbolSel.value;
const interval = intervalSel.value;
const limit = parseInt(limitInput.value) || 200;
if (!symbol) return;
statusEl.textContent = "loading…";
statusEl.className = "status";
try {
const [{ candles, notFound }, levels, markers] = await Promise.all([
loadCandles(symbol, interval, limit),
loadPriceLevels(symbol, interval),
loadSignalMarkers(symbol, interval)
]);
metaInfo.textContent = `${symbol} / ${interval} — ${candles.length} candles`;
mainChart.data.datasets = buildMainDatasets(candles, levels, markers);
mainChart.update("none");
volChart.data.datasets = buildVolumeDatasets(candles);
volChart.update("none");
macdChart.data.datasets = buildMacdDatasets(candles);
macdChart.update("none");
renderLevelsGrid(levels);
renderSignalList(markers || []);
if (notFound) {
statusEl.textContent = "no cache";
statusEl.className = "status err";
} else {
statusEl.textContent = `OK (${new Date().toLocaleTimeString()})`;
statusEl.className = "status ok";
}
} catch (err) {
console.error(err);
statusEl.textContent = "ERR";
statusEl.className = "status err";
metaInfo.textContent = String(err.message || err);
}
}
function scheduleAutoRefresh() {
if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; }
const ms = parseInt(refreshSel.value) || 0;
if (ms > 0) refreshTimer = setInterval(refreshAll, ms);
}
// =====================================================================
// Init
// =====================================================================
(async () => {
initCharts();
try {
const meta = await loadSymbolsAndIntervals();
const symbols = meta.Symbols || meta.symbols || [];
const intervals = meta.Intervals || meta.intervals || [];
symbolSel.innerHTML = "";
for (const s of symbols) symbolSel.add(new Option(s, s));
if (symbols.length === 0) symbolSel.add(new Option("(no cache)", ""));
intervalSel.innerHTML = "";
for (const i of intervals) intervalSel.add(new Option(i, i));
// 기본 선택
if ([...intervalSel.options].some(o => o.value === "M1")) intervalSel.value = "M1";
} catch (e) {
console.error(e);
statusEl.textContent = "meta ERR";
statusEl.className = "status err";
}
symbolSel.addEventListener("change", refreshAll);
intervalSel.addEventListener("change", refreshAll);
limitInput.addEventListener("change", refreshAll);
refreshSel.addEventListener("change", scheduleAutoRefresh);
[toggleIchimoku, toggleMA, toggleBol, toggleLevels, toggleSignals].forEach(
t => t.addEventListener("change", refreshAll));
$("reloadBtn").addEventListener("click", refreshAll);
await refreshAll();
scheduleAutoRefresh();
})();
</script>
</body>
</html>