차트 화면 추가
This commit is contained in:
604
Bitget_Server/wwwroot/chart.html
Normal file
604
Bitget_Server/wwwroot/chart.html
Normal file
@@ -0,0 +1,604 @@
|
||||
<!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>
|
||||
409
Bitget_Server/wwwroot/index.html
Normal file
409
Bitget_Server/wwwroot/index.html
Normal file
@@ -0,0 +1,409 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Bitget Server — API Console</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0e1116;
|
||||
--panel: #161b22;
|
||||
--border: #30363d;
|
||||
--text: #c9d1d9;
|
||||
--muted: #8b949e;
|
||||
--accent: #2f81f7;
|
||||
--accent-2: #58a6ff;
|
||||
--green: #238636;
|
||||
--red: #da3633;
|
||||
--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: 18px 28px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
header h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
header .badge {
|
||||
padding: 3px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
header a {
|
||||
margin-left: auto;
|
||||
color: var(--accent-2);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
}
|
||||
main {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 28px 48px;
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card h2 {
|
||||
margin: 0;
|
||||
padding: 12px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #0d1117;
|
||||
}
|
||||
.endpoints {
|
||||
max-height: calc(100vh - 140px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.endpoint {
|
||||
display: grid;
|
||||
grid-template-columns: 62px 1fr;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid #1f242b;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
}
|
||||
.endpoint:hover { background: #1b2029; }
|
||||
.endpoint.active { background: #1e2a3a; }
|
||||
.method {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
.method.GET { color: #3fb950; border: 1px solid #1f6d30; }
|
||||
.method.POST { color: #58a6ff; border: 1px solid #1f4da1; }
|
||||
.method.PUT { color: #d29922; border: 1px solid #6b4b18; }
|
||||
.method.DELETE { color: #f85149; border: 1px solid #82261f; }
|
||||
.path {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
word-break: break-all;
|
||||
}
|
||||
.desc {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.group-title {
|
||||
padding: 10px 16px 4px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.runner { display: flex; flex-direction: column; }
|
||||
.runner-head {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.runner-head .method-box {
|
||||
font-family: var(--mono);
|
||||
font-weight: 700;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
}
|
||||
.runner-head input[type=text] {
|
||||
flex: 1;
|
||||
background: #0d1117;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
color: var(--text);
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
}
|
||||
.runner-head button {
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 8px 18px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
.runner-head button:hover { background: var(--accent-2); }
|
||||
|
||||
.runner-body { padding: 14px 16px; }
|
||||
.runner-body label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.runner-body textarea {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
background: #0d1117;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
padding: 10px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
resize: vertical;
|
||||
}
|
||||
.runner-body pre {
|
||||
background: #0d1117;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin: 10px 0 6px;
|
||||
}
|
||||
.status-pill {
|
||||
font-family: var(--mono);
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
}
|
||||
.status-pill.ok { background: #0f2a17; color: #3fb950; }
|
||||
.status-pill.err { background: #2a0f10; color: #f85149; }
|
||||
hr.sep { border: none; border-top: 1px solid var(--border); margin: 14px 0; }
|
||||
|
||||
.quick-links {
|
||||
padding: 10px 16px 16px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.quick-links a {
|
||||
font-size: 12px;
|
||||
color: var(--accent-2);
|
||||
text-decoration: none;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.quick-links a:hover { background: #1b2029; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Bitget Server — API Console</h1>
|
||||
<span class="badge" id="healthBadge">health: —</span>
|
||||
<a href="/chart.html">[Candle Chart →]</a>
|
||||
<a href="/swagger">[Swagger →]</a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="card">
|
||||
<h2>Endpoints</h2>
|
||||
<div class="quick-links">
|
||||
<a href="/chart.html">Candle Chart</a>
|
||||
<a href="/swagger">Swagger UI</a>
|
||||
</div>
|
||||
<div class="endpoints" id="endpointList"></div>
|
||||
</section>
|
||||
|
||||
<section class="card runner">
|
||||
<div class="runner-head">
|
||||
<span class="method-box" id="runMethod">GET</span>
|
||||
<input type="text" id="runUrl" value="/api/health" />
|
||||
<button id="runBtn">Send</button>
|
||||
</div>
|
||||
<div class="runner-body">
|
||||
<label>Request Body (POST/PUT 전용 — JSON)</label>
|
||||
<textarea id="runBody" placeholder='{ "example": "value" }'></textarea>
|
||||
|
||||
<div class="meta">
|
||||
<span id="runTime">—</span>
|
||||
<span class="status-pill" id="runStatus">idle</span>
|
||||
</div>
|
||||
|
||||
<label>Response</label>
|
||||
<pre id="runResp">// 엔드포인트를 선택하거나 URL 을 편집 후 Send 를 누르세요.</pre>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// 전체 엔드포인트 카탈로그 (백엔드 라우트와 수동 동기화)
|
||||
const ENDPOINTS = [
|
||||
{ group: "Health" },
|
||||
{ method: "GET", path: "/api/health", desc: "서버 버전/타임스탬프" },
|
||||
|
||||
{ group: "Settings" },
|
||||
{ method: "GET", path: "/api/settings/candle-interval", desc: "현재 캔들 인터벌" },
|
||||
{ method: "PUT", path: "/api/settings/candle-interval", desc: "캔들 인터벌 변경", body: '{ "interval": "M1" }' },
|
||||
{ method: "GET", path: "/api/settings/candle-interval/options", desc: "지원 인터벌 목록" },
|
||||
{ method: "GET", path: "/api/settings/selected-coin", desc: "선택 코인" },
|
||||
{ method: "PUT", path: "/api/settings/selected-coin", desc: "선택 코인 변경", body: '{ "symbol": "BTCUSDT" }' },
|
||||
{ method: "GET", path: "/api/settings/leverage", desc: "레버리지" },
|
||||
{ method: "PUT", path: "/api/settings/leverage", desc: "레버리지 변경", body: '{ "leverage": 5 }' },
|
||||
{ method: "GET", path: "/api/settings/order-amount", desc: "주문 수량" },
|
||||
{ method: "PUT", path: "/api/settings/order-amount", desc: "주문 수량 변경", body: '{ "amount": 100 }' },
|
||||
{ method: "GET", path: "/api/settings/api-key", desc: "선택 API 키" },
|
||||
{ method: "PUT", path: "/api/settings/api-key", desc: "API 키 변경", body: '{ "id": 0 }' },
|
||||
|
||||
{ group: "Market" },
|
||||
{ method: "GET", path: "/api/coins", desc: "전체 선물 마켓 정보" },
|
||||
|
||||
{ group: "Signals" },
|
||||
{ method: "GET", path: "/api/signals", desc: "현재 신호 목록 + 카운트" },
|
||||
{ method: "GET", path: "/api/signals/conditions", desc: "조건식 플래그" },
|
||||
{ method: "PUT", path: "/api/signals/conditions", desc: "조건식 플래그 변경", body: '{ "구름떼상향돌파": true, "구름떼하향돌파": false, "영호조건식_상향돌파": false, "영호조건식_하향돌파": false }' },
|
||||
{ method: "GET", path: "/api/signals/checked-markets", desc: "체크된 마켓/인터벌" },
|
||||
{ method: "PUT", path: "/api/signals/checked-markets", desc: "체크 마켓/인터벌 일괄 변경", body: '{ "markets": ["BTCUSDT","ETHUSDT"], "intervals": ["M1","M5"] }' },
|
||||
|
||||
{ group: "Trading" },
|
||||
{ method: "GET", path: "/api/trading/timestamp", desc: "Bitget 서버 timestamp" },
|
||||
{ method: "GET", path: "/api/trading/account", desc: "선물 계정" },
|
||||
{ method: "GET", path: "/api/trading/orders/pending", desc: "대기 주문" },
|
||||
{ method: "GET", path: "/api/trading/orders/BTCUSDT/1234567", desc: "주문 상세" },
|
||||
{ method: "POST", path: "/api/trading/orders", desc: "주문 실행", body: '{ "symbol": "BTCUSDT", "marginMode": "isolated", "size": 0.01, "price": 60000, "side": "buy", "orderType": "limit", "leverage": 5, "clientOrderId": "" }' },
|
||||
{ method: "DELETE", path: "/api/trading/orders", desc: "주문 취소", body: '{ "symbol": "BTCUSDT", "orderId": "1234567" }' },
|
||||
{ method: "PUT", path: "/api/trading/position-mode", desc: "포지션 모드 변경", body: '{ "mode": "one_way_mode" }' },
|
||||
|
||||
{ group: "WebSocket" },
|
||||
{ method: "GET", path: "/api/websocket/status", desc: "WS 연결 상태" },
|
||||
{ method: "POST", path: "/api/websocket/start", desc: "WS 연결 시작" },
|
||||
{ method: "POST", path: "/api/websocket/stop", desc: "WS 연결 종료" },
|
||||
|
||||
{ group: "Diagnostics (캔들/지표 점검)" },
|
||||
{ method: "GET", path: "/api/diag/symbols", desc: "캐시된 심볼 + 지원 인터벌" },
|
||||
{ method: "GET", path: "/api/diag/candles?symbol=BTCUSDT&interval=M1&limit=200", desc: "캔들 스냅샷 + 인디케이터" },
|
||||
{ method: "GET", path: "/api/diag/price-levels?symbol=BTCUSDT&interval=M1", desc: "전고점/전저점 상태" },
|
||||
{ method: "GET", path: "/api/diag/signal-markers?symbol=BTCUSDT&interval=M1", desc: "차트 마커용 신호" }
|
||||
];
|
||||
|
||||
const list = document.getElementById("endpointList");
|
||||
for (const e of ENDPOINTS) {
|
||||
if (e.group) {
|
||||
const h = document.createElement("div");
|
||||
h.className = "group-title";
|
||||
h.textContent = e.group;
|
||||
list.appendChild(h);
|
||||
continue;
|
||||
}
|
||||
const item = document.createElement("div");
|
||||
item.className = "endpoint";
|
||||
item.innerHTML = `
|
||||
<span class="method ${e.method}">${e.method}</span>
|
||||
<div>
|
||||
<div class="path">${e.path}</div>
|
||||
<div class="desc">${e.desc || ""}</div>
|
||||
</div>`;
|
||||
item.addEventListener("click", () => selectEndpoint(e, item));
|
||||
list.appendChild(item);
|
||||
}
|
||||
|
||||
const runMethod = document.getElementById("runMethod");
|
||||
const runUrl = document.getElementById("runUrl");
|
||||
const runBody = document.getElementById("runBody");
|
||||
const runBtn = document.getElementById("runBtn");
|
||||
const runResp = document.getElementById("runResp");
|
||||
const runStatus = document.getElementById("runStatus");
|
||||
const runTime = document.getElementById("runTime");
|
||||
|
||||
function selectEndpoint(e, item) {
|
||||
document.querySelectorAll(".endpoint").forEach(el => el.classList.remove("active"));
|
||||
item.classList.add("active");
|
||||
runMethod.textContent = e.method;
|
||||
runUrl.value = e.path;
|
||||
runBody.value = e.body || "";
|
||||
}
|
||||
|
||||
async function send() {
|
||||
const method = runMethod.textContent;
|
||||
const url = runUrl.value.trim();
|
||||
const bodyRaw = runBody.value.trim();
|
||||
|
||||
const init = { method, headers: { "Accept": "application/json" } };
|
||||
if (bodyRaw && (method === "POST" || method === "PUT" || method === "DELETE")) {
|
||||
init.headers["Content-Type"] = "application/json";
|
||||
init.body = bodyRaw;
|
||||
}
|
||||
|
||||
runStatus.textContent = "sending…";
|
||||
runStatus.className = "status-pill";
|
||||
runResp.textContent = "";
|
||||
const start = performance.now();
|
||||
|
||||
try {
|
||||
const res = await fetch(url, init);
|
||||
const ms = (performance.now() - start).toFixed(0);
|
||||
runTime.textContent = `${method} ${url} — ${ms}ms`;
|
||||
runStatus.textContent = `${res.status} ${res.statusText || ""}`;
|
||||
runStatus.className = "status-pill " + (res.ok ? "ok" : "err");
|
||||
|
||||
const text = await res.text();
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
runResp.textContent = JSON.stringify(json, null, 2);
|
||||
} catch {
|
||||
runResp.textContent = text || "(empty)";
|
||||
}
|
||||
} catch (err) {
|
||||
runStatus.textContent = "NETWORK ERR";
|
||||
runStatus.className = "status-pill err";
|
||||
runResp.textContent = String(err);
|
||||
}
|
||||
}
|
||||
|
||||
runBtn.addEventListener("click", send);
|
||||
runUrl.addEventListener("keydown", e => { if (e.key === "Enter") send(); });
|
||||
|
||||
// health 자동 ping
|
||||
async function pingHealth() {
|
||||
const el = document.getElementById("healthBadge");
|
||||
try {
|
||||
const r = await fetch("/api/health");
|
||||
el.textContent = "health: " + (r.ok ? "OK" : `err ${r.status}`);
|
||||
el.style.color = r.ok ? "#3fb950" : "#f85149";
|
||||
} catch {
|
||||
el.textContent = "health: unreachable";
|
||||
el.style.color = "#f85149";
|
||||
}
|
||||
}
|
||||
pingHealth();
|
||||
setInterval(pingHealth, 15000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user