<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>실시간 업비트 체결가 차트</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
:root {
--bg-primary: #0a0e27;
--bg-secondary: #151932;
--bg-tertiary: #1e2742;
--bg-card: #1a1f3a;
--bg-hover: #252b4a;
--bg-border: #2a3458;
--text-primary: #e2e8f0;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--accent-primary: #3b82f6;
--warning: #ffb300;
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.5);
}
body {
margin: 0;
padding: 0;
background: linear-gradient(135deg, var(--bg-primary) 0%, #0f172a 100%);
color: var(--text-primary);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Malgun Gothic', 'Roboto', sans-serif;
}
header {
padding: 16px 20px;
background: var(--bg-card);
border-bottom: 1px solid var(--bg-border);
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: var(--shadow-lg);
}
h2 {
margin: 0;
font-size: 22px;
font-weight: 700;
color: var(--text-primary);
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
select {
background: rgba(15, 21, 37, 0.4);
color: rgba(226, 232, 240, 0.5);
padding: 8px 12px;
border: 1px solid rgba(42, 52, 88, 0.4);
border-radius: 8px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
}
select:hover {
background: rgba(18, 34, 104, 0.6);
border-color: rgba(59, 130, 246, 0.6);
}
select:focus {
outline: none;
border-color: rgba(59, 130, 246, 0.4);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.05);
}
#priceBox {
margin-left: 16px;
font-size: 15px;
font-weight: bold;
color: #4aeee3ff;
padding: 5px 16px;
background: var(--bg-tertiary);
border-radius: 8px;
border: 1px solid var(--bg-border);
transition: all 0.3s ease;
}
.loading-text {
font-size: 10px;
color: rgba(226, 232, 240, 0.5);
}
.info-panel {
margin-left: 8px;
font-size: 11px;
color: var(--text-secondary);
padding: 4px 8px;
background: var(--bg-tertiary);
border-radius: 4px;
}
.control-btn {
background: rgba(30, 39, 66, 0.4);
color: rgba(226, 232, 240, 0.6);
border: 1px solid rgba(42, 52, 88, 0.4);
border-radius: 6px;
padding: 6px 12px;
font-size: 12px;
cursor: pointer;
margin-left: 8px;
transition: all 0.2s ease;
}
.control-btn:hover {
background: rgba(37, 43, 74, 0.6);
border-color: rgba(59, 130, 246, 0.5);
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.blink-warning {
animation: blink 0.5s infinite;
}
#chart-wrap {
height: 700px;
padding: 20px;
background: var(--bg-secondary);
}
#chartCanvas {
width: 100%;
height: 100%;
background: linear-gradient(to bottom, #000000 0%, var(--bg-primary) 100%);
border-radius: 12px;
border: 1px solid var(--bg-border);
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.5);
}
a {
background: rgba(15, 21, 37, 0.5);
color: rgba(148, 163, 184, 0.6);
border: 1px solid rgba(42, 52, 88, 0.5);
border-radius: 6px;
padding: 6px 12px;
font-size: 13px;
text-decoration: none;
transition: all 0.2s ease;
}
a:hover {
background: rgba(18, 34, 104, 0.6);
color: rgba(226, 232, 240, 0.8);
border-color: rgba(255, 179, 0, 0.4);
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: var(--bg-tertiary);
border-radius: 4px;
border: 2px solid var(--bg-secondary);
}
::-webkit-scrollbar-thumb:hover {
background: var(--bg-hover);
}
.manual-section {
margin: 20px;
background: var(--bg-card);
border-radius: 12px;
border: 1px solid var(--bg-border);
box-shadow: var(--shadow-lg);
}
.manual-header {
padding: 16px 20px;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--bg-border);
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
user-select: none;
transition: background 0.2s ease;
}
.manual-header:hover {
background: var(--bg-hover);
}
.manual-header h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.manual-toggle {
font-size: 20px;
color: var(--text-secondary);
transition: transform 0.3s ease;
}
.manual-toggle.open {
transform: rotate(180deg);
}
.manual-content {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
.manual-content.open {
max-height: 5000px;
}
.manual-body {
padding: 20px;
}
.manual-item {
margin-bottom: 24px;
padding-bottom: 24px;
border-bottom: 1px solid var(--bg-border);
}
.manual-item:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.manual-item-header {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
padding: 8px 0;
user-select: none;
transition: background 0.2s ease;
border-radius: 4px;
margin: -8px 0 8px 0;
}
.manual-item-header:hover {
background: rgba(59, 130, 246, 0.1);
}
.manual-item-header h4 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--accent-primary);
flex: 1;
}
.manual-item-toggle {
font-size: 14px;
color: var(--text-secondary);
margin-left: 12px;
transition: transform 0.3s ease;
}
.manual-item-toggle.open {
transform: rotate(90deg);
}
.manual-item-content {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
.manual-item-content.open {
max-height: 2000px;
}
.manual-item p {
margin: 8px 0;
font-size: 13px;
line-height: 1.6;
color: var(--text-secondary);
}
.manual-item ul {
margin: 8px 0;
padding-left: 24px;
font-size: 13px;
line-height: 1.8;
color: var(--text-secondary);
}
.manual-item li {
margin: 4px 0;
}
.manual-item code {
background: var(--bg-tertiary);
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
color: var(--accent-primary);
font-family: 'Courier New', monospace;
}
.manual-example {
background: var(--bg-tertiary);
padding: 12px;
border-radius: 8px;
margin: 12px 0;
border-left: 3px solid var(--accent-primary);
}
.manual-example strong {
color: var(--text-primary);
display: block;
margin-bottom: 8px;
}
</style>
</head>
<body>
<?php
// ===== 업비트 마켓 목록 =====
$api = 'https://api.upbit.com/v1/market/all';
$apiResponse = @file_get_contents($api);
$data = [];
if ($apiResponse !== false && $apiResponse !== '') {
$decoded = json_decode($apiResponse, true);
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
$data = $decoded;
}
}
// ===== 마켓 파라미터 =====
$MARKET = $_GET['market'] ?? 'KRW-BTC';
$validMarket = 'KRW-BTC';
if (is_array($data) && count($data) > 0) {
foreach ($data as $m) {
if (isset($m['market']) && $m['market'] === $MARKET && strpos($m['market'], 'KRW-') === 0) {
$validMarket = $MARKET;
break;
}
}
}
// ===== 차트 시간 단위 파라미터 =====
// 0 = 실시간, 1 = 1분, 2 = 2분 ...
$chart_time = isset($_GET['time']) ? (int)$_GET['time'] : 0;
?>
<header>
<h2>Real-Time Price Line Chart</h2>
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap;">
<div style="display:flex; align-items:center; gap:6px; margin-right:10px;">
<a href="?market=<?= $validMarket ?>&time=1">1분</a>
<a href="?market=<?= $validMarket ?>&time=5">5분</a>
<a href="?market=<?= $validMarket ?>&time=15">15분</a>
<a href="?market=<?= $validMarket ?>&time=60">1시간</a>
<a href="?market=<?= $validMarket ?>&time=240">4시간</a>
<a href="?market=<?= $validMarket ?>&time=480">8시간</a>
<a href="?market=<?= $validMarket ?>&time=720">12시간</a>
<a href="?market=<?= $validMarket ?>&time=1440">24시간</a>
</div>
<select id="coinSelect">
<?php
if (is_array($data) && count($data) > 0) {
foreach ($data as $m) {
if (!isset($m['market']) || strpos($m['market'], 'KRW-') !== 0) continue;
$symbol = str_replace('KRW-', '', $m['market']);
$korean = isset($m['korean_name']) ? $m['korean_name'] : $symbol;
$selected = ($m['market'] === $validMarket) ? ' selected' : '';
echo "<option value=\"{$m['market']}\"{$selected}>{$symbol} : {$korean}</option>\n";
}
}
?>
</select>
<div id="priceBox"><span class="loading-text">로딩 중...</span></div>
<div id="infoPanel" class="info-panel"></div>
<div id="wsStatus" class="info-panel">연결 중...</div>
<button id="stateBtn" class="control-btn">상태 출력</button>
</div>
</header>
<div id="chart-wrap">
<canvas id="chartCanvas"></canvas>
</div>
<div class="manual-section">
<div class="manual-header" id="manualHeader">
<h3>📊 차트 분석 방법 가이드</h3>
<span class="manual-toggle" id="manualToggle">▼</span>
</div>
<div class="manual-content" id="manualContent">
<div class="manual-body">
<div class="manual-item">
<div class="manual-item-header" data-item="1">
<h4>1. 이동평균선(MA) 분석법</h4>
<span class="manual-item-toggle">▶</span>
</div>
<div class="manual-item-content" id="itemContent1">
<p><strong>이동평균선 교차 분석</strong></p>
<ul>
<li>골든크로스: 가격이 MA5를 상향 돌파 → 단기 상승 신호</li>
<li>데드크로스: 가격이 MA5를 하향 돌파 → 단기 하락 신호</li>
<li>MA5 > MA20 > MA60: 상승 추세</li>
<li>MA5 < MA20 < MA60: 하락 추세</li>
</ul>
<p><strong>이동평균 기울기 분석</strong></p>
<ul>
<li>정보 패널의 <code>MA5:↑</code> 또는 <code>MA5:↓</code> 확인</li>
<li><code>MA5:↑</code>: 단기 상승 추세</li>
<li><code>MA5:↓</code>: 단기 하락 추세</li>
<li>기울기 변화: 추세 전환 가능성</li>
</ul>
<div class="manual-example">
<strong>실전 활용:</strong>
<p>1. 차트에서 가격 라인과 MA5 선의 위치 관계 확인<br>
2. 정보 패널에서 MA5 기울기 방향 확인<br>
3. MA5가 상승 중이고 가격이 MA5 위에 있으면 → 강세 신호<br>
4. MA5가 하락 중이고 가격이 MA5 아래에 있으면 → 약세 신호</p>
</div>
</div>
</div>
<div class="manual-item">
<div class="manual-item-header" data-item="2">
<h4>2. 최고가/최저가 분석법</h4>
<span class="manual-item-toggle">▶</span>
</div>
<div class="manual-item-content" id="itemContent2">
<p><strong>지지선/저항선 분석</strong></p>
<ul>
<li>최저가 포인트(빨간 점): 잠재적 지지선</li>
<li>최고가 포인트(녹색 점): 잠재적 저항선</li>
<li>가격이 최저가 근처: 지지 테스트 중</li>
<li>가격이 최고가 근처: 저항 테스트 중</li>
</ul>
<p><strong>가격 범위 분석</strong></p>
<ul>
<li>정보 패널의 <code>최고:XXX 최저:XXX</code> 확인</li>
<li>범위가 좁음: 횡보 구간</li>
<li>범위가 넓음: 변동성 큰 구간</li>
<li>현재가 위치: 범위 내 상단/중간/하단</li>
</ul>
<div class="manual-example">
<strong>실전 활용:</strong>
<p>1. 차트에서 최고가/최저가 포인트 위치 확인<br>
2. 정보 패널에서 최고가와 최저가 수치 확인<br>
3. 현재가가 최저가 근처 → 반등 가능성 관찰<br>
4. 현재가가 최고가 근처 → 조정 가능성 관찰</p>
</div>
</div>
</div>
<div class="manual-item">
<div class="manual-item-header" data-item="3">
<h4>3. 최근 변동폭 분석법</h4>
<span class="manual-item-toggle">▶</span>
</div>
<div class="manual-item-content" id="itemContent3">
<p><strong>단기 모멘텀 분석</strong></p>
<ul>
<li><code>10s:0.25%</code>: 초단위 모멘텀</li>
<li><code>1m:1.50%</code>: 분단위 모멘텀</li>
<li>양수: 상승 모멘텀</li>
<li>음수: 하락 모멘텀</li>
</ul>
<p><strong>변동폭 비교 분석</strong></p>
<ul>
<li><code>10s</code>와 <code>1m</code> 비교</li>
<li><code>10s</code> > <code>1m</code>: 가속 상승/하락</li>
<li><code>10s</code> < <code>1m</code>: 모멘텀 둔화</li>
<li>절대값이 큼: 변동성 큼</li>
<li>절대값이 작음: 횡보</li>
</ul>
<div class="manual-example">
<strong>실전 활용:</strong>
<p>1. 정보 패널에서 10s와 1m 변동폭 동시 확인<br>
2. <code>10s: +0.5%, 1m: +0.3%</code> → 가속 상승 중<br>
3. <code>10s: -0.2%, 1m: -0.8%</code> → 하락 모멘텀 둔화<br>
4. 변동폭이 지속적으로 증가 → 급등/급락 가능성</p>
</div>
</div>
</div>
<div class="manual-item">
<div class="manual-item-header" data-item="4">
<h4>4. 가격 이동 속도 분석법</h4>
<span class="manual-item-toggle">▶</span>
</div>
<div class="manual-item-content" id="itemContent4">
<p><strong>가격 변화 속도 측정</strong></p>
<ul>
<li>정보 패널의 <code>상승:123.45</code> 또는 <code>하락:123.45</code> 확인</li>
<li>단위: 원/초</li>
<li>값이 큼: 빠른 변화</li>
<li>값이 작음: 느린 변화</li>
</ul>
<p><strong>속도 변화 추이 분석</strong></p>
<ul>
<li>속도 증가: 가속 중</li>
<li>속도 감소: 둔화 중</li>
<li>방향 전환: 추세 전환 가능</li>
</ul>
<div class="manual-example">
<strong>실전 활용:</strong>
<p>1. 정보 패널에서 가격 이동 속도 확인<br>
2. <code>상승:500</code> → 매우 빠른 상승 중<br>
3. <code>하락:50</code> → 느린 하락 중<br>
4. 속도가 지속적으로 증가 → 급등/급락 가능성</p>
</div>
</div>
</div>
<div class="manual-item">
<div class="manual-item-header" data-item="5">
<h4>5. 틱 수(거래량) 분석법</h4>
<span class="manual-item-toggle">▶</span>
</div>
<div class="manual-item-content" id="itemContent5">
<p><strong>거래 활동도 측정</strong></p>
<ul>
<li>정보 패널의 <code>틱:5/s</code> 확인 (실시간 모드만)</li>
<li>초당 체결 횟수</li>
<li>값이 큼: 활발한 거래</li>
<li>값이 작음: 거래 부진</li>
</ul>
<p><strong>거래량과 가격 관계</strong></p>
<ul>
<li>틱 수 증가 + 가격 상승: 매수 압력</li>
<li>틱 수 증가 + 가격 하락: 매도 압력</li>
<li>틱 수 감소: 관망</li>
</ul>
<div class="manual-example">
<strong>실전 활용:</strong>
<p>1. 실시간 모드에서 틱 수 확인<br>
2. <code>틱:10/s + 가격 상승</code> → 강한 매수세<br>
3. <code>틱:2/s + 가격 하락</code> → 약한 매도세<br>
4. 틱 수가 급격히 증가 → 큰 움직임 전조</p>
</div>
</div>
</div>
<div class="manual-item">
<div class="manual-item-header" data-item="6">
<h4>6. 연속 상승/하락 분석법</h4>
<span class="manual-item-toggle">▶</span>
</div>
<div class="manual-item-content" id="itemContent6">
<p><strong>추세 강도 측정</strong></p>
<ul>
<li>정보 패널의 <code>연속↑:5</code> 또는 <code>연속↓:3</code> 확인</li>
<li>연속 틱 수</li>
<li>값이 큼: 강한 추세</li>
<li>값이 작음: 약한 추세</li>
</ul>
<p><strong>추세 지속성 분석</strong></p>
<ul>
<li>연속 상승/하락이 계속 증가: 추세 강화</li>
<li>연속 상승/하락이 감소: 추세 약화</li>
<li>0으로 리셋: 추세 전환</li>
</ul>
<div class="manual-example">
<strong>실전 활용:</strong>
<p>1. 정보 패널에서 연속 상승/하락 카운트 확인<br>
2. <code>연속↑:10</code> → 강한 상승 추세<br>
3. <code>연속↓:2</code> → 약한 하락 추세<br>
4. 연속 카운트가 급격히 증가 → 추세 가속</p>
</div>
</div>
</div>
<div class="manual-item">
<div class="manual-item-header" data-item="7">
<h4>7. 변동성 분석법</h4>
<span class="manual-item-toggle">▶</span>
</div>
<div class="manual-item-content" id="itemContent7">
<p><strong>시장 안정성 측정</strong></p>
<ul>
<li>정보 패널의 <code>변동성:1234</code> 확인</li>
<li>표준편차 기반 수치</li>
<li>값이 큼: 높은 변동성</li>
<li>값이 작음: 낮은 변동성</li>
</ul>
<p><strong>변동성 추이 분석</strong></p>
<ul>
<li>변동성 증가: 불안정</li>
<li>변동성 감소: 안정화</li>
<li>변동성 급증: 큰 움직임 전조</li>
</ul>
<div class="manual-example">
<strong>실전 활용:</strong>
<p>1. 정보 패널에서 변동성 수치 확인<br>
2. <code>변동성:5000</code> → 매우 불안정한 시장<br>
3. <code>변동성:500</code> → 안정적인 시장<br>
4. 변동성이 지속적으로 증가 → 큰 변동 예상</p>
</div>
</div>
</div>
<div class="manual-item">
<div class="manual-item-header" data-item="8">
<h4>8. 급변 감지 분석법</h4>
<span class="manual-item-toggle">▶</span>
</div>
<div class="manual-item-content" id="itemContent8">
<p><strong>급등/급락 감지</strong></p>
<ul>
<li>가격 박스 점멸: 10초 내 ±5% 이상 변동</li>
<li>급변 발생 시각 확인</li>
<li>급변 후 반등/추락 여부 관찰</li>
</ul>
<p><strong>급변 패턴 분석</strong></p>
<ul>
<li>급등 후 조정: 일시적 상승</li>
<li>급등 후 지속: 강한 상승 추세</li>
<li>급락 후 반등: 바닥 매수</li>
<li>급락 후 지속: 강한 하락 추세</li>
</ul>
<div class="manual-example">
<strong>실전 활용:</strong>
<p>1. 가격 박스 점멸 시 즉시 확인<br>
2. 급변 발생 시 정보 패널의 변동폭 확인<br>
3. 급변 후 가격 이동 속도 확인<br>
4. 급변 후 연속 상승/하락 카운트 확인</p>
</div>
</div>
</div>
<div class="manual-item">
<div class="manual-item-header" data-item="9">
<h4>9. 비정상 틱 감지 분석법</h4>
<span class="manual-item-toggle">▶</span>
</div>
<div class="manual-item-content" id="itemContent9">
<p><strong>이상 거래 감지</strong></p>
<ul>
<li>브라우저 콘솔(F12)에서 "비정상 틱 감지" 메시지 확인</li>
<li>직전 가격 대비 10% 이상 변동</li>
<li>오류 데이터 가능성 또는 큰 거래 발생</li>
</ul>
<p><strong>비정상 틱 대응</strong></p>
<ul>
<li>콘솔에서 가격과 직전 가격 확인</li>
<li>차트에서 급격한 스파이크 확인</li>
<li>실제 변동인지 오류인지 판단</li>
</ul>
<div class="manual-example">
<strong>실전 활용:</strong>
<p>1. F12 → Console 탭 열기<br>
2. "비정상 틱 감지" 메시지 확인<br>
3. 차트에서 해당 시점의 급격한 변화 확인<br>
4. 정보 패널의 변동폭과 비교</p>
</div>
</div>
</div>
<div class="manual-item">
<div class="manual-item-header" data-item="10">
<h4>10. 종합 분석 전략</h4>
<span class="manual-item-toggle">▶</span>
</div>
<div class="manual-item-content" id="itemContent10">
<p><strong>다중 지표 종합 분석</strong></p>
<ul>
<li>이동평균선 + 기울기: <code>MA5:↑ + 가격이 MA5 위</code> → 강한 상승 추세</li>
<li>변동폭 + 속도: <code>10s: +1% + 상승:500</code> → 급등 중</li>
<li>연속 카운트 + 틱 수: <code>연속↑:10 + 틱:15/s</code> → 강한 매수세</li>
<li>변동성 + 최고가/최저가: <code>변동성:2000 + 최고가 근처</code> → 저항 테스트 중</li>
</ul>
<div class="manual-example">
<strong>분석 시나리오 예시:</strong>
<p><strong>상승 추세 확인:</strong><br>
✓ MA5:↑ (상승 기울기)<br>
✓ 가격이 MA5 위에 위치<br>
✓ 연속↑:8 (강한 상승 추세)<br>
✓ 10s: +0.8% (가속 상승)<br>
✓ 틱:12/s (활발한 거래)<br>
→ 강한 상승 추세, 지속 가능성 높음</p>
<p><strong>하락 추세 확인:</strong><br>
✓ MA5:↓ (하락 기울기)<br>
✓ 가격이 MA5 아래 위치<br>
✓ 연속↓:5 (하락 추세)<br>
✓ 1m: -1.2% (하락 모멘텀)<br>
✓ 변동성:1500 (불안정)<br>
→ 하락 추세, 추가 하락 가능성</p>
</div>
</div>
</div>
<div class="manual-item">
<div class="manual-item-header" data-item="11">
<h4>11. 실시간 모니터링 체크리스트</h4>
<span class="manual-item-toggle">▶</span>
</div>
<div class="manual-item-content" id="itemContent11">
<p><strong>매 틱마다 확인할 항목</strong></p>
<ul>
<li>가격 박스 색상: 상승(녹색)/하락(빨간색)</li>
<li>MA5 기울기: ↑ 또는 ↓</li>
<li>연속 카운트: 추세 강도</li>
<li>변동폭: 10s와 1m 비교</li>
<li>틱 수: 거래 활동도</li>
</ul>
<p><strong>주기적으로 확인할 항목</strong></p>
<ul>
<li>최고가/최저가: 지지/저항 확인</li>
<li>변동성: 시장 안정성</li>
<li>이동평균선 교차: 추세 전환 신호</li>
<li>가격 이동 속도: 변화 속도</li>
</ul>
<p><strong>경고 발생 시 확인할 항목</strong></p>
<ul>
<li>급변 경고: 변동폭, 속도, 연속 카운트</li>
<li>비정상 틱: 콘솔 메시지, 차트 스파이크</li>
<li>데이터 정지: 연결 상태, 재연결 대기</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<script>
function toggleManual() {
const content = document.getElementById('manualContent');
const toggle = document.getElementById('manualToggle');
if (!content || !toggle) {
console.error("Manual elements not found");
return;
}
if (content.classList.contains('open')) {
content.classList.remove('open');
toggle.classList.remove('open');
toggle.textContent = '▼';
} else {
content.classList.add('open');
toggle.classList.add('open');
toggle.textContent = '▲';
}
}
function toggleItem(itemNum) {
const content = document.getElementById('itemContent' + itemNum);
const header = document.querySelector(`[data-item="${itemNum}"]`);
const toggle = header ? header.querySelector('.manual-item-toggle') : null;
if (!content || !toggle) {
console.error("Item elements not found for item:", itemNum);
return;
}
if (content.classList.contains('open')) {
content.classList.remove('open');
toggle.classList.remove('open');
toggle.textContent = '▶';
} else {
content.classList.add('open');
toggle.classList.add('open');
toggle.textContent = '▼';
}
}
// DOM 로드 후 이벤트 리스너 등록
document.addEventListener('DOMContentLoaded', function() {
const manualHeader = document.getElementById('manualHeader');
if (manualHeader) {
manualHeader.addEventListener('click', toggleManual);
}
// 각 항목 헤더에 클릭 이벤트 추가
const itemHeaders = document.querySelectorAll('.manual-item-header');
itemHeaders.forEach(header => {
header.addEventListener('click', function() {
const itemNum = this.getAttribute('data-item');
if (itemNum) {
toggleItem(itemNum);
}
});
});
});
let MARKET = "<?= $validMarket ?>";
let UNIT_MIN = <?= $chart_time ?>; // 0=실시간, n=분단위
let ws = null;
let MAX_POINTS = 300;
let lastPrice = null;
let lastTime = null;
let priceHistory = [];
let tickTimestamps = [];
let consecutiveUp = 0;
let consecutiveDown = 0;
let lastDataTime = Date.now();
let wsConnected = false;
let noDataInterval = null;
let reconnectAttempts = 0;
const MAX_RECONNECT_ATTEMPTS = 10;
// 최고가/최저가
let maxPrice = null;
let minPrice = null;
let maxPriceIndex = null;
let minPriceIndex = null;
// 이동평균 데이터
let ma5Data = [];
let ma20Data = [];
let ma60Data = [];
// 화면 크기 기반 MAX_POINTS 조절
function adjustMaxPoints() {
const width = window.innerWidth;
if (width < 768) {
MAX_POINTS = 150;
} else if (width < 1024) {
MAX_POINTS = 200;
} else if (width < 1400) {
MAX_POINTS = 250;
} else {
MAX_POINTS = 300;
}
}
adjustMaxPoints();
window.addEventListener('resize', adjustMaxPoints);
function getLabel(unitMin) {
const d = new Date();
if (unitMin === 0) {
return d.toTimeString().split(" ")[0]; // HH:MM:SS
}
const h = String(d.getHours()).padStart(2, '0');
const mm = String(Math.floor(d.getMinutes() / unitMin) * unitMin).padStart(2, '0');
return h + ':' + mm;
}
const canvasEl = document.getElementById("chartCanvas");
if (!canvasEl) {
console.error("chartCanvas element not found");
} else {
const ctx = canvasEl.getContext("2d");
const chartData = {
labels: [],
datasets: [{
label: MARKET + " 체결가",
data: [],
borderColor: "#00c8ff",
borderWidth: 2,
pointRadius: 0,
tension: 0.1
}, {
label: "MA5",
data: [],
borderColor: "rgba(255,255,255,0.5)",
borderWidth: 1,
pointRadius: 0,
tension: 0.1
}, {
label: "MA20",
data: [],
borderColor: "rgba(255,255,255,0.4)",
borderWidth: 1,
pointRadius: 0,
tension: 0.1
}, {
label: "MA60",
data: [],
borderColor: "rgba(255,255,255,0.3)",
borderWidth: 1,
pointRadius: 0,
tension: 0.1
}, {
label: "최고가",
data: [],
borderColor: "#10b981",
borderWidth: 0,
pointRadius: 4,
pointBackgroundColor: "#10b981",
pointBorderColor: "#ffffff",
pointBorderWidth: 2,
showLine: false
}, {
label: "최저가",
data: [],
borderColor: "#ef4444",
borderWidth: 0,
pointRadius: 4,
pointBackgroundColor: "#ef4444",
pointBorderColor: "#ffffff",
pointBorderWidth: 2,
showLine: false
}]
};
const chart = new Chart(ctx, {
type: "line",
data: chartData,
options: {
animation: false,
responsive: true,
maintainAspectRatio: false,
scales: {
x: { ticks: { color: "#888" }, grid: { color: "rgba(255,255,255,0.07)" } },
y: { ticks: { color: "#ccc" }, grid: { color: "rgba(255,255,255,0.07)" } }
},
plugins: {
legend: { labels: { color: "#ddd" }, display: false },
tooltip: {
callbacks: {
label: function(context) {
if (context.datasetIndex === 4) {
return "최고가: " + context.raw.toLocaleString() + " 원";
} else if (context.datasetIndex === 5) {
return "최저가: " + context.raw.toLocaleString() + " 원";
} else {
return context.raw.toLocaleString() + " 원";
}
}
}
}
}
}
});
// 이동평균 계산
function calculateMA(period) {
const data = chartData.datasets[0].data;
if (data.length < period) return null;
const recent = data.slice(-period).filter(v => v !== null && typeof v === 'number' && !isNaN(v));
if (recent.length < period) return null;
const sum = recent.reduce((a, b) => a + b, 0);
return sum / recent.length;
}
// 이동평균 기울기 계산
function calculateMAGradient(maData) {
if (maData.length < 2) return 0;
const recent = maData.slice(-2);
if (recent[0] === null || recent[1] === null) return 0;
return recent[1] - recent[0];
}
// 표준편차 계산
function calculateStdDev() {
const data = chartData.datasets[0].data.filter(v => v !== null && typeof v === 'number' && !isNaN(v));
if (data.length < 2) return 0;
const mean = data.reduce((a, b) => a + b, 0) / data.length;
const variance = data.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / data.length;
return Math.sqrt(variance);
}
// 최근 변동폭 계산
function calculateRecentChange(seconds) {
const now = Date.now();
const filtered = priceHistory.filter(p => now - p.ts <= seconds * 1000);
if (filtered.length < 2) return null;
const oldest = filtered[0].price;
const newest = filtered[filtered.length - 1].price;
if (oldest === 0) return null;
return ((newest - oldest) / oldest) * 100;
}
// 가격 이동 속도 계산
function calculatePriceSpeed(currentPrice, currentTime) {
if (lastPrice === null || lastTime === null) return { speed: 0, direction: "" };
const timeDiff = (currentTime - lastTime) / 1000;
if (timeDiff <= 0) return { speed: 0, direction: "" };
const priceDiff = Math.abs(currentPrice - lastPrice);
const speed = priceDiff / timeDiff;
const direction = currentPrice > lastPrice ? "상승" : "하락";
return { speed, direction };
}
// 틱 수 카운트 (초당)
function countTicksPerSecond() {
const now = Date.now();
tickTimestamps = tickTimestamps.filter(ts => now - ts <= 1000);
return tickTimestamps.length;
}
// 스프레드 계산
function calculateSpread(bidPrice, askPrice) {
if (!bidPrice || !askPrice || bidPrice <= 0 || askPrice <= 0) return null;
const spread = askPrice - bidPrice;
const spreadPercent = (spread / bidPrice) * 100;
return { spread, spreadPercent };
}
// 급변 감지
function detectSuddenChange(currentPrice, changeRate) {
const change10s = calculateRecentChange(10);
if (change10s === null) return false;
return Math.abs(change10s) > 5;
}
// 비정상 틱 감지
function detectAbnormalTick(currentPrice) {
if (lastPrice === null) return false;
if (lastPrice === 0) return false;
const changePercent = Math.abs((currentPrice - lastPrice) / lastPrice) * 100;
return changePercent > 10;
}
// 상태 객체 업데이트
function updateState(price, rate, speed, volatility, tickCount) {
window.STATE = {
price: price,
rate: rate,
speed: speed,
volatility: volatility,
tickCount: tickCount,
timestamp: Date.now()
};
}
// 최고가/최저가 차트 업데이트 (최적화)
function updateHighLowPoints() {
const dataLength = chartData.labels.length;
if (dataLength === 0) return;
const priceData = chartData.datasets[0].data;
if (priceData.length === 0) return;
// 기존 최고가/최저가 데이터 초기화
chartData.datasets[4].data = new Array(dataLength).fill(null);
chartData.datasets[5].data = new Array(dataLength).fill(null);
let currentMax = null;
let currentMaxIdx = null;
let currentMin = null;
let currentMinIdx = null;
priceData.forEach((price, idx) => {
if (price !== null && typeof price === 'number' && !isNaN(price)) {
if (currentMax === null || price > currentMax) {
currentMax = price;
currentMaxIdx = idx;
}
if (currentMin === null || price < currentMin) {
currentMin = price;
currentMinIdx = idx;
}
}
});
if (currentMaxIdx !== null && currentMaxIdx < dataLength) {
chartData.datasets[4].data[currentMaxIdx] = currentMax;
maxPrice = currentMax;
maxPriceIndex = currentMaxIdx;
}
if (currentMinIdx !== null && currentMinIdx < dataLength) {
chartData.datasets[5].data[currentMinIdx] = currentMin;
minPrice = currentMin;
minPriceIndex = currentMinIdx;
}
}
// 정보 패널 업데이트
function updateInfoPanel(data) {
if (!data || typeof data !== 'object') return;
const price = data.trade_price;
if (typeof price !== 'number' || isNaN(price)) return;
const changeRate = (typeof data.signed_change_rate === 'number') ? data.signed_change_rate : 0;
const bidPrice = (typeof data.bid_price === 'number') ? data.bid_price : null;
const askPrice = (typeof data.ask_price === 'number') ? data.ask_price : null;
// 변동폭 계산
const change10s = calculateRecentChange(10);
const change1m = calculateRecentChange(60);
// 틱 수
const ticksPerSec = UNIT_MIN === 0 ? countTicksPerSecond() : 0;
// 가격 이동 속도
const speedInfo = calculatePriceSpeed(price, Date.now());
// 스프레드
const spreadInfo = calculateSpread(bidPrice, askPrice);
// 이동평균 기울기
const ma5Gradient = calculateMAGradient(ma5Data);
const ma20Gradient = calculateMAGradient(ma20Data);
const ma60Gradient = calculateMAGradient(ma60Data);
// 표준편차
const stdDev = calculateStdDev();
// 마지막 체결 시각
const lastTradeTime = new Date().toTimeString().split(" ")[0];
// 정보 텍스트 구성
let infoText = [];
if (maxPrice !== null && minPrice !== null) {
infoText.push(`최고:${maxPrice.toLocaleString()} 최저:${minPrice.toLocaleString()}`);
}
if (change10s !== null) {
infoText.push(`10s:${change10s.toFixed(2)}%`);
}
if (change1m !== null) {
infoText.push(`1m:${change1m.toFixed(2)}%`);
}
if (ticksPerSec > 0) {
infoText.push(`틱:${ticksPerSec}/s`);
}
if (speedInfo.speed > 0) {
infoText.push(`${speedInfo.direction}:${speedInfo.speed.toFixed(2)}`);
}
if (spreadInfo) {
infoText.push(`스프레드:${spreadInfo.spreadPercent.toFixed(3)}%`);
}
infoText.push(`시각:${lastTradeTime}`);
if (consecutiveUp > 0) {
infoText.push(`연속↑:${consecutiveUp}`);
}
if (consecutiveDown > 0) {
infoText.push(`연속↓:${consecutiveDown}`);
}
if (ma5Gradient !== 0) {
infoText.push(`MA5:${ma5Gradient > 0 ? "↑" : "↓"}`);
}
if (stdDev > 0) {
infoText.push(`변동성:${stdDev.toFixed(0)}`);
}
const infoPanel = document.getElementById("infoPanel");
if (infoPanel) {
infoPanel.innerText = infoText.join(" | ");
}
// 상태 객체 업데이트
updateState(price, changeRate, speedInfo.speed, stdDev, ticksPerSec);
// 급변 경고
if (detectSuddenChange(price, changeRate)) {
const priceBox = document.getElementById("priceBox");
if (priceBox) {
priceBox.classList.add("blink-warning");
setTimeout(() => {
if (priceBox) {
priceBox.classList.remove("blink-warning");
}
}, 2000);
}
}
// 비정상 틱 감지
if (detectAbnormalTick(price)) {
console.warn("비정상 틱 감지:", price, "직전:", lastPrice);
}
// 스프레드 급확대 경고
const priceBox = document.getElementById("priceBox");
if (priceBox) {
if (spreadInfo && spreadInfo.spreadPercent > 1) {
priceBox.style.borderColor = "#ef4444";
} else {
priceBox.style.borderColor = "";
}
}
}
function connectWS() {
if (ws) {
try {
ws.close();
} catch(e) {
console.error("WebSocket close error:", e);
}
}
try {
ws = new WebSocket("wss://api.upbit.com/websocket/v1");
ws.binaryType = "arraybuffer";
ws.onopen = () => {
wsConnected = true;
reconnectAttempts = 0;
const wsStatus = document.getElementById("wsStatus");
if (wsStatus) {
wsStatus.innerText = "연결됨";
wsStatus.style.color = "#10b981";
}
try {
ws.send(JSON.stringify([
{ ticket: "trade-price" },
{ type: "ticker", codes: [MARKET] }
]));
} catch(e) {
console.error("WebSocket send error:", e);
}
};
ws.onmessage = e => {
try {
lastDataTime = Date.now();
const decoder = new TextDecoder("utf-8");
const text = decoder.decode(e.data);
let d;
try {
d = JSON.parse(text);
} catch(parseError) {
console.error("JSON parse error:", parseError);
return;
}
if (!d || typeof d !== 'object' || typeof d.trade_price !== 'number' || isNaN(d.trade_price)) {
return;
}
const price = d.trade_price;
const time = getLabel(UNIT_MIN);
const currentTime = Date.now();
// 틱 타임스탬프 기록 (메모리 관리)
if (UNIT_MIN === 0) {
tickTimestamps.push(currentTime);
// 2초 이상 된 데이터 제거
tickTimestamps = tickTimestamps.filter(ts => currentTime - ts <= 2000);
}
// 가격 히스토리
priceHistory.push({ price, ts: currentTime });
if (priceHistory.length > 100) {
priceHistory.shift();
}
// 연속 상승/하락 카운트
if (lastPrice !== null) {
if (price > lastPrice) {
consecutiveUp++;
consecutiveDown = 0;
} else if (price < lastPrice) {
consecutiveDown++;
consecutiveUp = 0;
} else {
consecutiveUp = 0;
consecutiveDown = 0;
}
}
// 색상 결정
let color = "#00c8ff";
if (lastPrice !== null) {
if (price > lastPrice) {
color = "#10b981";
} else if (price < lastPrice) {
color = "#ef4444";
}
}
const priceBoxEl = document.getElementById("priceBox");
if (priceBoxEl) {
priceBoxEl.style.color = color;
priceBoxEl.innerText = price.toLocaleString() + " 원";
}
chartData.labels.push(time);
chartData.datasets[0].data.push(price);
chartData.datasets[0].borderColor = color;
// 이동평균 업데이트
const ma5 = calculateMA(5);
const ma20 = calculateMA(20);
const ma60 = calculateMA(60);
chartData.datasets[1].data.push(ma5 !== null ? ma5 : null);
chartData.datasets[2].data.push(ma20 !== null ? ma20 : null);
chartData.datasets[3].data.push(ma60 !== null ? ma60 : null);
// 이동평균 데이터 배열에도 push (기울기 계산용) - null도 push하여 길이 동기화
ma5Data.push(ma5);
ma20Data.push(ma20);
ma60Data.push(ma60);
if (chartData.labels.length > MAX_POINTS) {
chartData.labels.shift();
chartData.datasets[0].data.shift();
chartData.datasets[1].data.shift();
chartData.datasets[2].data.shift();
chartData.datasets[3].data.shift();
chartData.datasets[4].data.shift();
chartData.datasets[5].data.shift();
// 이동평균 데이터 배열도 shift (길이 동기화)
if (ma5Data.length > 0) ma5Data.shift();
if (ma20Data.length > 0) ma20Data.shift();
if (ma60Data.length > 0) ma60Data.shift();
// 최고가/최저가 인덱스 감소
if (maxPriceIndex !== null) {
maxPriceIndex--;
if (maxPriceIndex < 0) {
maxPriceIndex = null;
maxPrice = null;
}
}
if (minPriceIndex !== null) {
minPriceIndex--;
if (minPriceIndex < 0) {
minPriceIndex = null;
minPrice = null;
}
}
}
// 최고가/최저가 차트 업데이트
updateHighLowPoints();
chart.update("none");
lastPrice = price;
lastTime = currentTime;
// 정보 패널 업데이트
updateInfoPanel(d);
} catch(error) {
console.error("WebSocket message processing error:", error);
}
};
ws.onclose = () => {
wsConnected = false;
const wsStatus = document.getElementById("wsStatus");
if (wsStatus) {
wsStatus.innerText = "끊김";
wsStatus.style.color = "#ef4444";
}
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
reconnectAttempts++;
setTimeout(() => {
connectWS();
}, 1000);
} else {
if (wsStatus) {
wsStatus.innerText = "재연결 실패";
wsStatus.style.color = "#ef4444";
}
}
};
ws.onerror = (error) => {
wsConnected = false;
const wsStatus = document.getElementById("wsStatus");
if (wsStatus) {
wsStatus.innerText = "오류";
wsStatus.style.color = "#ef4444";
}
console.error("WebSocket error:", error);
};
} catch(error) {
console.error("WebSocket connection error:", error);
wsConnected = false;
const wsStatus = document.getElementById("wsStatus");
if (wsStatus) {
wsStatus.innerText = "연결 실패";
wsStatus.style.color = "#ef4444";
}
}
}
// 데이터 무응답 감지
function checkNoData() {
const elapsed = (Date.now() - lastDataTime) / 1000;
if (elapsed > 5 && wsConnected) {
const priceBox = document.getElementById("priceBox");
if (priceBox) {
priceBox.innerText = "데이터 정지";
priceBox.style.color = "#ef4444";
}
}
}
noDataInterval = setInterval(checkNoData, 1000);
// 페이지 이탈 시 정리
window.addEventListener('beforeunload', () => {
if (noDataInterval) {
clearInterval(noDataInterval);
}
if (ws) {
try {
ws.close();
} catch(e) {
// 무시
}
}
});
const coinSelect = document.getElementById("coinSelect");
if (coinSelect) {
coinSelect.addEventListener("change", function(){
location.href = "?market=" + this.value + "&time=<?= $chart_time ?>";
});
}
const stateBtn = document.getElementById("stateBtn");
if (stateBtn) {
stateBtn.addEventListener("click", function(){
if (window.STATE) {
console.log(JSON.stringify(window.STATE, null, 2));
alert(JSON.stringify(window.STATE, null, 2));
} else {
alert("상태 데이터가 없습니다.");
}
});
}
connectWS();
}
</script>
</body>
</html>