차트 화면 추가

This commit is contained in:
2026-04-21 01:19:08 +09:00
parent 83fb215eec
commit 01365218af
9 changed files with 1321 additions and 3 deletions

1
.gitignore vendored
View File

@@ -11,3 +11,4 @@
/packages
/.cr
/.idea
/Bitget_Server/logs

View File

@@ -130,6 +130,7 @@
<Compile Include="WebService\WebConnection.cs" />
<EmbeddedResource Include="MainForm.resx">
<DependentUpon>MainForm.cs</DependentUpon>
<SubType>Designer</SubType>
</EmbeddedResource>
<EmbeddedResource Include="Properties\Resources.resx">
<Generator>ResXFileCodeGenerator</Generator>

View File

@@ -0,0 +1,212 @@
using Bitget_Server.Api.Dtos;
using Bitget_Server.Application.Services;
using Bitget_Server.Application.State;
using Bitget_Server.Domain.Enums;
using Bitget_Server.Domain.Models;
using Microsoft.AspNetCore.Mvc;
namespace Bitget_Server.Api.Controllers;
/// <summary>
/// 런타임 캐시/지표 계산 결과를 외부에서 점검하기 위한 진단용 API.
/// wwwroot/chart.html 화면이 주로 소비한다.
/// </summary>
[ApiController]
[Route("api/diag")]
[Produces("application/json")]
public class DiagnosticsController : ControllerBase
{
private readonly MarketState _state;
private readonly PriceLevelTracker _priceLevels;
private readonly SignalProcessService _signals;
public DiagnosticsController(MarketState state, PriceLevelTracker priceLevels, SignalProcessService signals)
{
_state = state;
_priceLevels = priceLevels;
_signals = signals;
}
/// <summary>
/// 현재 캐시에 존재하는 심볼 + 서버가 지원하는 인터벌 목록.
/// </summary>
[HttpGet("symbols")]
[ProducesResponseType(typeof(DiagSymbolsResponse), StatusCodes.Status200OK)]
public ActionResult<DiagSymbolsResponse> GetSymbols()
{
lock (_state.SyncRoot)
{
var symbols = _state.candleDataLists
.Select(x => x.ConGoodsData?.MarketName)
.Where(n => !string.IsNullOrEmpty(n))
.Distinct()
.OrderBy(n => n)
.ToList();
var intervals = Enum.GetNames(typeof(MCandleTimeType));
return Ok(new DiagSymbolsResponse(symbols, intervals));
}
}
/// <summary>
/// 특정 심볼/인터벌의 캔들 데이터 스냅샷 + 인디케이터 동봉.
/// Chart.js financial 플러그인 호환 포맷 (t,o,h,l,c).
/// </summary>
[HttpGet("candles")]
[ProducesResponseType(typeof(DiagCandleResponse), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public ActionResult<DiagCandleResponse> GetCandles(
[FromQuery] string symbol,
[FromQuery] string interval = "M1",
[FromQuery] int limit = 200)
{
if (string.IsNullOrWhiteSpace(symbol))
return Problem("symbol 파라미터 필수", statusCode: StatusCodes.Status400BadRequest);
if (!Enum.TryParse<MCandleTimeType>(interval, ignoreCase: true, out var t))
return Problem($"지원하지 않는 interval: {interval}", statusCode: StatusCodes.Status400BadRequest);
List<CandleData> snapshot;
lock (_state.SyncRoot)
{
var entry = _state.candleDataLists
.FirstOrDefault(x => string.Equals(x.ConGoodsData?.MarketName, symbol, StringComparison.OrdinalIgnoreCase));
if (entry == null || entry.candleDataList == null || !entry.candleDataList.TryGetValue(t, out var list) || list == null)
return Problem(
detail: $"캐시에 {symbol}/{interval} 데이터가 없습니다. WebSocketCalcBackgroundService 가 동작 중이며 대상 마켓이 checked 되었는지 확인.",
statusCode: StatusCodes.Status404NotFound,
title: "Candle data not cached");
// 얕은 복사 (원본 컬렉션 수정과 응답 직렬화 동시 진행 방지)
snapshot = list.ToList();
}
if (limit > 0 && snapshot.Count > limit)
snapshot = snapshot.Skip(snapshot.Count - limit).ToList();
var dtos = snapshot.Select(ToDto).OrderBy(d => d.t).ToList();
return Ok(new DiagCandleResponse(symbol.ToUpperInvariant(), t.ToString(), dtos.Count, dtos));
}
/// <summary>
/// 특정 심볼/인터벌에 대한 PriceLevelTracker 의 전체 내부 상태.
/// (전고가/전저가/전전고가/전전저가/tempHighVal/tempLowVal 등)
/// </summary>
[HttpGet("price-levels")]
[ProducesResponseType(typeof(DiagPriceLevelsDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public ActionResult<DiagPriceLevelsDto> GetPriceLevels(
[FromQuery] string symbol,
[FromQuery] string interval = "M1")
{
if (string.IsNullOrWhiteSpace(symbol))
return Problem("symbol 파라미터 필수", statusCode: StatusCodes.Status400BadRequest);
var key = symbol + interval;
if (!_priceLevels.ContainsKey(key))
return Problem(
detail: $"PriceLevelTracker 에 키가 없습니다 (key={key}). SignalProcessBackgroundService 가 해당 마켓을 처리한 적이 있는지 확인.",
statusCode: StatusCodes.Status404NotFound,
title: "Price levels not tracked");
var levels = _priceLevels.GetOrCreate(key);
// 값 복사
var copy = new Dictionary<string, double>(levels);
return Ok(new DiagPriceLevelsDto(symbol.ToUpperInvariant(), interval, key, copy));
}
/// <summary>
/// 현재 활성 신호를 심볼/인터벌로 필터링해 차트 마커용 포맷으로 반환.
/// </summary>
[HttpGet("signal-markers")]
[ProducesResponseType(typeof(IEnumerable<DiagSignalMarkerDto>), StatusCodes.Status200OK)]
public ActionResult<IEnumerable<DiagSignalMarkerDto>> GetSignalMarkers(
[FromQuery] string symbol,
[FromQuery] string interval = null)
{
IEnumerable<DiagSignalMarkerDto> Map(IEnumerable<SignalInfoData> src, string dir) =>
src.Select(s => new DiagSignalMarkerDto(
direction: dir,
symbol: s.MarketName,
interval: s.MCandleTimeType.ToString(),
t: new DateTimeOffset(s.SignalStart == default ? DateTime.UtcNow : s.SignalStart.ToUniversalTime())
.ToUnixTimeMilliseconds(),
price: s.Price,
rate: s.Rate,
indicatorPrice: s.IndicatorPrice,
indicatorRate: s.IndicatorRate,
description: s.Description ?? string.Empty));
bool SymbolMatch(SignalInfoData s) =>
string.IsNullOrWhiteSpace(symbol) ||
string.Equals(s.MarketName, symbol, StringComparison.OrdinalIgnoreCase);
bool IntervalMatch(SignalInfoData s) =>
string.IsNullOrWhiteSpace(interval) ||
string.Equals(s.MCandleTimeType.ToString(), interval, StringComparison.OrdinalIgnoreCase);
var all = new List<DiagSignalMarkerDto>();
all.AddRange(Map(_signals.SignalsLongUp.Where(s => SymbolMatch(s) && IntervalMatch(s)), "LongUp"));
all.AddRange(Map(_signals.SignalsShortDown.Where(s => SymbolMatch(s) && IntervalMatch(s)), "ShortDown"));
all.AddRange(Map(_signals.SignalsLongDown.Where(s => SymbolMatch(s) && IntervalMatch(s)), "LongDown"));
all.AddRange(Map(_signals.SignalsShortUp.Where(s => SymbolMatch(s) && IntervalMatch(s)), "ShortUp"));
return Ok(all);
}
// =====================================================================
// private helpers
// =====================================================================
private static DiagCandleDto ToDto(CandleData c)
{
long t = c.Argument == default
? 0L
: new DateTimeOffset(DateTime.SpecifyKind(c.Argument, DateTimeKind.Utc)).ToUnixTimeMilliseconds();
DiagIchimokuDto ichi = null;
if (c.IchimokData != null)
{
ichi = new DiagIchimokuDto(
c.IchimokData.기준선,
c.IchimokData.전환선,
c.IchimokData.후행스팬,
c.IchimokData.선행스팬1,
c.IchimokData.선행스팬2,
c.IchimokData.상한선,
c.IchimokData.하한선,
c.IchimokData.IsLongUp,
c.IchimokData.IsLongDown,
c.IchimokData.IsShortUp,
c.IchimokData.IsShortDown);
}
Dictionary<int, DiagMovingAverageDto> ma = null;
if (c.movingAverageDataList != null)
{
ma = c.movingAverageDataList.ToDictionary(
kv => kv.Key,
kv => new DiagMovingAverageDto(
kv.Value.Value,
kv.Value.Average,
kv.Value.BolangerValue_Up,
kv.Value.BolangerValue_Down));
}
Dictionary<int, DiagImpulseMacdDto> macd = null;
if (c.impulseMacdDataList != null)
{
macd = c.impulseMacdDataList.ToDictionary(
kv => kv.Key,
kv => new DiagImpulseMacdDto(kv.Value.MD, kv.Value.SH, kv.Value.SB));
}
return new DiagCandleDto(
t,
c.Open, c.High, c.Low, c.Close,
c.Vol_C, c.Vol_USDT,
c.WebSocketUpdate, c.WebServiceUpdate,
ichi, ma, macd);
}
}

View File

@@ -0,0 +1,70 @@
namespace Bitget_Server.Api.Dtos;
/// <summary>
/// /api/diag 엔드포인트 전용 응답 모델.
/// Chart.js financial 플러그인 포맷에 맞춰 {t, o, h, l, c} 가 기본이며,
/// 인디케이터는 선택 필드로 포함된다.
/// </summary>
public record DiagSymbolsResponse(
IEnumerable<string> Symbols,
IEnumerable<string> Intervals);
public record DiagCandleDto(
long t, // epoch millis (Chart.js x-axis)
double o,
double h,
double l,
double c,
double volC,
double volUsdt,
bool wsUpdated,
bool restUpdated,
DiagIchimokuDto ichimoku,
Dictionary<int, DiagMovingAverageDto> ma,
Dictionary<int, DiagImpulseMacdDto> impulseMacd);
public record DiagIchimokuDto(
double 기준선,
double 전환선,
double 후행스팬,
double 선행스팬1,
double 선행스팬2,
double 상한선,
double 하한선,
bool IsLongUp,
bool IsLongDown,
bool IsShortUp,
bool IsShortDown);
public record DiagMovingAverageDto(
double Value,
double Average,
double BolUp,
double BolDown);
public record DiagImpulseMacdDto(double MD, double SH, double SB);
public record DiagCandleResponse(
string Symbol,
string Interval,
int Count,
IEnumerable<DiagCandleDto> Candles);
/// <summary>PriceLevelTracker 의 전고점/전저점 상태 전체 키·값.</summary>
public record DiagPriceLevelsDto(
string Symbol,
string Interval,
string Key,
IReadOnlyDictionary<string, double> Levels);
/// <summary>SignalProcessService 의 현재 신호 요약 (차트 마커용).</summary>
public record DiagSignalMarkerDto(
string direction, // "LongUp" / "ShortDown" / "LongDown" / "ShortUp"
string symbol,
string interval,
long t,
double price,
double rate,
double indicatorPrice,
double indicatorRate,
string description);

View File

@@ -1,6 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<ActiveDebugProfile>Container (Dockerfile)</ActiveDebugProfile>
<ActiveDebugProfile>http</ActiveDebugProfile>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<DebuggerFlavor>ProjectDebugger</DebuggerFlavor>
</PropertyGroup>
</Project>

View File

@@ -131,6 +131,10 @@ var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
// 정적 파일 (wwwroot/index.html 등). UseStaticFiles 앞에 UseDefaultFiles 필수.
app.UseDefaultFiles();
app.UseStaticFiles();
// Swagger 는 환경 불문 항상 활성화
app.UseSwagger();
app.UseSwaggerUI(options =>
@@ -143,8 +147,8 @@ app.UseSerilogRequestLogging();
app.MapControllers();
// 루트 접속 시 Swagger 로 리다이렉트
app.MapGet("/", () => Results.Redirect("/swagger"));
// 루트에 wwwroot/index.html 이 없을 경우에만 Swagger 로 폴백 리다이렉트
app.MapFallback(() => Results.Redirect("/swagger"));
Log.Information("Bitget_Server 시작 — URL http://*:5000, DataDir={DataDir}, LogDir={LogDir}", dataDir, logDir);

View File

@@ -0,0 +1,14 @@
{
"LogDebugWrite": true,
"SelectAPI": {
"PassphraseKey": "dudgh0505",
"AccessKey": "bg_f93aa530288b6ec9d62095994c51972a",
"SecretKey": "2fce18bcd12ca23a7920c48295a58da031443ea383af8114ed599c1b14ac678e",
"Name": "김영호",
"Id": 0
},
"Leverage": 10.0,
"OrderAmount": 50.0,
"CandleIntervalMinutes": 5,
"SelectedCoin": ""
}

View 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>

View 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>