605 lines
20 KiB
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>
|