<?php
// ======================================================
// observer.php (USER VIEW · DYNAMIC · MAGIC COMPACT v6)
// - 거래금 기반 옵저버 (WS ticker)
// - 단일 파일 완결 (CSS/JS 포함)
// - 로그 엔드포인트 내장 (?action=log)
// ======================================================
date_default_timezone_set('Asia/Seoul');
// action: log
if (isset($_GET['action']) && $_GET['action'] === 'log') {
header('Content-Type: text/plain; charset=utf-8');
$msg = $_POST['msg'] ?? '';
$msg = trim($msg);
// 기본 방어: 빈값/과도한 길이/개행 폭탄 차단
if ($msg === '' || mb_strlen($msg) > 300) { echo "OK"; exit; }
$msg = str_replace(["\r", "\n"], " ", $msg);
$dir = __DIR__ . '/logs';
if (!is_dir($dir)) @mkdir($dir, 0777, true);
@file_put_contents(
$dir . '/' . date('Y-m-d') . '.log',
'[' . date('H:i:s') . '] ' . $msg . PHP_EOL,
FILE_APPEND
);
echo "OK";
exit;
}
?>
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Observer Stable v6</title>
<style>
:root{
--bg: linear-gradient(135deg, #0a0e27 0%, #1a1f3a 50%, #0f1419 100%);
--panel: linear-gradient(145deg, #1a1f3a 0%, #0f1419 100%);
--panel-border: rgba(99, 102, 241, 0.2);
--line: rgba(148, 163, 184, 0.15);
--text: #e2e8f0;
--muted: #94a3b8;
--up: #10b981;
--up-glow: rgba(16, 185, 129, 0.4);
--down: #ef4444;
--down-glow: rgba(239, 68, 68, 0.4);
--warn: #f59e0b;
--warn-glow: rgba(245, 158, 11, 0.4);
--card: linear-gradient(145deg, #1e293b 0%, #0f172a 100%);
--shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
--shadow-lg: 0 20px 60px rgba(0, 0, 0, 0.7);
}
* {
box-sizing: border-box;
}
body{
background: var(--bg);
background-attachment: fixed;
color: var(--text);
font-family: 'Segoe UI', 'Malgun Gothic', Arial, Helvetica, sans-serif;
margin: 0;
padding: 20px;
min-height: 100vh;
}
h1{
margin: 0 0 20px;
font-size: 24px;
font-weight: 700;
background: linear-gradient(135deg, #60a5fa 0%, #a78bfa 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-shadow: 0 0 30px rgba(96, 165, 250, 0.3);
letter-spacing: -0.5px;
}
#top-wrap{
display: flex;
gap: 20px;
align-items: stretch;
margin-bottom: 20px;
}
#status-box{
flex: 1;
background: var(--card);
border: 1px solid var(--panel-border);
padding: 20px;
border-radius: 16px;
box-shadow: var(--shadow);
backdrop-filter: blur(10px);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
#status-box:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
#status{
font-size: 15px;
margin-bottom: 12px;
font-weight: 600;
color: #cbd5e1;
display: flex;
align-items: center;
gap: 8px;
}
#status::before {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--warn);
box-shadow: 0 0 10px var(--warn-glow);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.2); }
}
.tiny{
font-size: 13px;
color: var(--muted);
line-height: 1.6;
margin: 4px 0;
}
.tiny b {
color: #cbd5e1;
font-weight: 600;
}
#right-panel{
width: 420px;
max-width: 45vw;
background: var(--card);
border: 1px solid var(--panel-border);
border-radius: 16px;
display: flex;
flex-direction: column;
box-shadow: var(--shadow);
backdrop-filter: blur(10px);
overflow: hidden;
}
#top-right-header{
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--line);
background: linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%);
}
#msg-title{
font-size: 14px;
color: #e2e8f0;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
#sound-toggle{
border: 1px solid var(--line);
background: rgba(30, 41, 59, 0.8);
color: #e2e8f0;
padding: 8px 16px;
border-radius: 10px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
transition: all 0.2s ease;
backdrop-filter: blur(10px);
}
#sound-toggle:hover {
background: rgba(99, 102, 241, 0.3);
border-color: rgba(99, 102, 241, 0.5);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2);
}
#msg-panel{
padding: 16px;
overflow: auto;
max-height: 220px;
font-size: 12px;
background: rgba(15, 23, 42, 0.5);
}
#msg-panel div{
padding: 8px 0;
border-bottom: 1px dashed rgba(148, 163, 184, 0.1);
color: #cbd5e1;
transition: all 0.2s ease;
padding-left: 8px;
border-left: 2px solid transparent;
}
#msg-panel div:hover {
background: rgba(99, 102, 241, 0.05);
border-left-color: rgba(99, 102, 241, 0.5);
padding-left: 12px;
}
#msg-panel div:first-child{
color: #fff;
font-weight: 600;
border-left-color: var(--up);
}
table{
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 13px;
background: var(--card);
border-radius: 16px;
overflow: hidden;
box-shadow: var(--shadow);
backdrop-filter: blur(10px);
}
th,td{
border: 1px solid var(--line);
padding: 12px 10px;
text-align: center;
white-space: nowrap;
transition: background 0.2s ease;
}
th{
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
color: #e2e8f0;
font-weight: 700;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 2px solid var(--panel-border);
position: sticky;
top: 0;
z-index: 10;
}
tbody tr {
transition: all 0.2s ease;
}
tbody tr:hover {
background: rgba(99, 102, 241, 0.05);
}
td.name{
font-weight: 700;
text-align: left;
padding-left: 16px;
color: #e2e8f0;
font-size: 14px;
}
td.price{
text-align: right;
padding-right: 16px;
font-weight: 600;
color: #cbd5e1;
font-size: 13px;
}
.up{
color: var(--up);
font-weight: 700;
text-shadow: 0 0 8px var(--up-glow);
}
.down{
color: var(--down);
font-weight: 700;
text-shadow: 0 0 8px var(--down-glow);
}
.bg-up{
background: linear-gradient(135deg, rgba(16, 185, 129, 0.15) 0%, rgba(16, 185, 129, 0.05) 100%) !important;
color: var(--up) !important;
font-weight: 700;
border-color: rgba(16, 185, 129, 0.3) !important;
}
.bg-down{
background: linear-gradient(135deg, rgba(239, 68, 68, 0.15) 0%, rgba(239, 68, 68, 0.05) 100%) !important;
color: var(--down) !important;
font-weight: 700;
border-color: rgba(239, 68, 68, 0.3) !important;
}
.fast{
background: rgba(99, 102, 241, 0.08);
animation: fastPulse 1.5s ease-in-out infinite;
}
@keyframes fastPulse {
0%, 100% { background: rgba(99, 102, 241, 0.08); }
50% { background: rgba(99, 102, 241, 0.15); }
}
.muted{
color: var(--muted);
}
/* 전일대비 거래금 전용 */
.vol-daily-up{
background: linear-gradient(135deg, rgba(16, 185, 129, 0.2) 0%, rgba(16, 185, 129, 0.1) 100%) !important;
color: var(--up) !important;
font-weight: 700;
text-shadow: 0 0 10px var(--up-glow);
border-color: rgba(16, 185, 129, 0.4) !important;
}
.vol-daily-down{
background: linear-gradient(135deg, rgba(239, 68, 68, 0.2) 0%, rgba(239, 68, 68, 0.1) 100%) !important;
color: var(--down) !important;
font-weight: 700;
text-shadow: 0 0 10px var(--down-glow);
border-color: rgba(239, 68, 68, 0.4) !important;
}
.vol-daily-zero{
background: transparent !important;
color: #cbd5e1 !important;
font-weight: 600;
}
/* 심전도 */
.ekg-cell{
width: 120px;
padding: 8px !important;
}
.ekg-bar{
height: 3px;
background: linear-gradient(90deg, #10b981 0%, #22d3ee 50%, #f59e0b 100%);
transform-origin: left center;
transition: transform 0.12s linear;
border-radius: 2px;
box-shadow: 0 0 10px rgba(16, 185, 129, 0.5);
}
/* 스파이크 하이라이트 */
.row-spike{
outline: 3px solid rgba(245, 158, 11, 0.6);
outline-offset: -3px;
background: linear-gradient(135deg, rgba(245, 158, 11, 0.15) 0%, rgba(245, 158, 11, 0.05) 100%);
animation: spikeGlow 0.5s ease;
}
@keyframes spikeGlow {
0% { box-shadow: 0 0 0 rgba(245, 158, 11, 0); }
50% { box-shadow: 0 0 30px rgba(245, 158, 11, 0.6); }
100% { box-shadow: 0 0 0 rgba(245, 158, 11, 0); }
}
.row-dead{
background: linear-gradient(135deg, rgba(239, 68, 68, 0.12) 0%, rgba(239, 68, 68, 0.05) 100%);
opacity: 0.7;
}
/* 토스트 */
#toast{
position: fixed;
left: 20px;
bottom: 20px;
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
border: 1px solid var(--panel-border);
color: #fff;
padding: 16px 20px;
border-radius: 12px;
opacity: 0;
transform: translateY(20px);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
max-width: min(520px, calc(100vw - 40px));
font-size: 13px;
z-index: 9999;
box-shadow: var(--shadow-lg);
backdrop-filter: blur(20px);
}
#toast.show{
opacity: 1;
transform: translateY(0);
}
/* 스크롤바 스타일링 */
#msg-panel::-webkit-scrollbar {
width: 6px;
}
#msg-panel::-webkit-scrollbar-track {
background: rgba(15, 23, 42, 0.5);
border-radius: 3px;
}
#msg-panel::-webkit-scrollbar-thumb {
background: rgba(99, 102, 241, 0.5);
border-radius: 3px;
}
#msg-panel::-webkit-scrollbar-thumb:hover {
background: rgba(99, 102, 241, 0.7);
}
</style>
</head>
<body>
<h1>📊 Observer Stable v6 (거래금 기반)</h1>
<div id="top-wrap">
<div id="status-box">
<div id="status">⏳ WebSocket 연결 준비 중...</div>
<div class="tiny">
마지막 수신: <span id="last-rx" class="muted">-</span> /
재연결: <span id="reconn" class="muted">0</span> 회
</div>
<div class="tiny">
스파이크 기준: <b id="cfg-spike">x3.00</b> /
반응 기준: <b id="cfg-react">0.50%</b> /
반응 윈도우: <b id="cfg-win">10s</b>
</div>
</div>
<div id="right-panel">
<div id="top-right-header">
<div id="msg-title">📡 실시간 이벤트 로그</div>
<button id="sound-toggle">🔇 SOUND OFF</button>
</div>
<div id="msg-panel"></div>
</div>
</div>
<table>
<thead>
<tr>
<th rowspan="2">코인</th>
<th rowspan="2">현재가</th>
<th rowspan="2">전일대비 거래금</th>
<th colspan="3">거래금 스파이크</th>
<th colspan="7">거래금 변동률 (%)</th>
<th rowspan="2">심전도</th>
</tr>
<tr>
<th>3초V</th><th>10초V</th><th>AvgV</th>
<th>30초</th><th>1분</th><th>5분</th><th>1시간</th><th>4시간</th><th>8시간</th><th>24시간</th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
<div id="toast"></div>
<audio id="snd-spike" src="/observer/snd_tick.wav" preload="auto"></audio>
<audio id="snd-kill" src="/observer/snd_kill.wav" preload="auto"></audio>
<script>
/* ======================================================
CONFIG
====================================================== */
const MARKETS = {"KRW-BTC":"BTC","KRW-ETH":"ETH","KRW-XRP":"XRP","KRW-QTUM":"QTUM","KRW-TRUMP":"TRUMP"};
const PRICE_WINDOWS = [
{key:"sec30",seconds:30},{key:"min1",seconds:60},{key:"min5",seconds:300},
{key:"hour1",seconds:3600},{key:"hour4",seconds:14400},{key:"hour8",seconds:28800},{key:"hour24",seconds:86400}
];
const VOL_SPIKE_RATIO = 3.0; // 거래금 스파이크 기준(배수)
const PRICE_REACTION_PCT = 0.5; // 거래금 스파이크 이후 가격 반응 기준(%)
const PRICE_REACTION_WINDOW = 10000; // 거래금 스파이크 이후 반응 체크 윈도우(ms)
const SPIKE_COOLDOWN_MS = 10000; // 스파이크 중복 알림 쿨다운
const DEAD_RX_MS = 10000; // 수신 없으면 행 데드 표시 기준
const MAX_AMOUNT_TICKS_MS = 86400 * 1000;
/* UI cfg 표기 */
document.getElementById("cfg-spike").innerText = "x" + VOL_SPIKE_RATIO.toFixed(2);
document.getElementById("cfg-react").innerText = PRICE_REACTION_PCT.toFixed(2) + "%";
document.getElementById("cfg-win").innerText = (PRICE_REACTION_WINDOW/1000).toFixed(0) + "s";
/* ======================================================
STATE
====================================================== */
const marketState = {};
const dumpState = {};
const tbody = document.getElementById("tbody");
const statusEl = document.getElementById("status");
const msgPanel = document.getElementById("msg-panel");
const toastEl = document.getElementById("toast");
const lastRxEl = document.getElementById("last-rx");
const reconnEl = document.getElementById("reconn");
const sndSpike = document.getElementById("snd-spike");
const sndKill = document.getElementById("snd-kill");
const soundBtn = document.getElementById("sound-toggle");
let ws = null;
let reconnectCount = 0;
let soundOn = (localStorage.getItem("observer_sound") === "1");
soundBtn.textContent = soundOn ? "🔊 SOUND ON" : "🔇 SOUND OFF";
soundBtn.addEventListener("click", ()=>{
soundOn = !soundOn;
localStorage.setItem("observer_sound", soundOn ? "1" : "0");
soundBtn.textContent = soundOn ? "🔊 SOUND ON" : "🔇 SOUND OFF";
});
/* ======================================================
HELPERS
====================================================== */
function nowTs(){ return Date.now(); }
function playSound(type){
if(!soundOn) return;
let el = null;
if(type==="spike") el = sndSpike;
if(type==="kill") el = sndKill;
if(!el) return;
try{
el.currentTime = 0;
el.play().catch(()=>{});
}catch(e){}
}
function showToast(msg){
toastEl.textContent = msg;
toastEl.classList.add("show");
setTimeout(()=>toastEl.classList.remove("show"), 1600);
}
function logMessage(msg){
const line = document.createElement("div");
const tStr = new Date().toTimeString().split(" ")[0];
line.textContent = `[${tStr}] ${msg}`;
if(msgPanel.firstChild) msgPanel.insertBefore(line, msgPanel.firstChild);
else msgPanel.appendChild(line);
fetch("?action=log",{
method:"POST",
headers:{"Content-Type":"application/x-www-form-urlencoded"},
body:"msg="+encodeURIComponent(msg)
}).catch(()=>{});
}
function setCellText(id, text){
const el = document.getElementById(id);
if(el) el.textContent = text;
}
function setSpikeCell(id, v){
const e = document.getElementById(id);
if(!e) return;
e.classList.remove("bg-up","bg-down");
e.textContent = v.toFixed(2) + "x";
if(v>1) e.classList.add("bg-up");
else if(v<1) e.classList.add("bg-down");
}
function normalizeUpbitTs(d){
// Upbit WS에서 trade_timestamp는 ms(정수)로 오는 편이지만, 방어적으로 처리
const t = (d.ttms ?? d.trade_timestamp ?? d.timestamp ?? null);
if(!t) return nowTs();
const n = Number(t);
if(!isFinite(n)) return nowTs();
return (n < 1000000000000) ? (n * 1000) : n; // 초단위면 ms로
}
/* ======================================================
TABLE INIT
====================================================== */
function initTable(){
for(const m in MARKETS){
const tr = document.createElement("tr");
tr.id = "row-"+m;
tr.innerHTML = `
<td class="name">${MARKETS[m]}</td>
<td id="price-${m}" class="price">-</td>
<td id="volchange-${m}" class="vol-daily-zero">-</td>
<td id="v3-${m}">-</td>
<td id="v10-${m}">-</td>
<td id="vavg-${m}">-</td>
`;
PRICE_WINDOWS.forEach(w=>{
tr.innerHTML += `<td id="cell-${m}-${w.key}">-</td>`;
});
tr.innerHTML += `
<td class="ekg-cell">
<div id="ekg-${m}" class="ekg-bar"></div>
</td>
`;
tbody.appendChild(tr);
marketState[m] = {
amountTicks: [], // {ts(ms), amount} - 거래대금
avgAmount60: null,
dayBaseAccAmount: null, // 일자 기준 base (거래대금)
dayKey: null, // YYYY-MM-DD
lastPrice: null,
priceTicks: [], // {ts(ms), price}
lastAmountSpike: null, // {ts, price, ...}
lastRx: 0
};
dumpState[m] = { recent: [], lastAlertTs: 0 };
}
}
initTable();
/* ======================================================
DAILY AMOUNT CHANGE (일자별 베이스 리셋) - 거래대금 기준
====================================================== */
function ymd(ms){
const d = new Date(ms);
const y = d.getFullYear();
const m = String(d.getMonth()+1).padStart(2,'0');
const dd= String(d.getDate()).padStart(2,'0');
return `${y}-${m}-${dd}`;
}
function updateDailyAmountChange(m, acc, tsMs){
const st = marketState[m];
const day = ymd(tsMs);
if(st.dayKey !== day){
st.dayKey = day;
st.dayBaseAccAmount = acc; // 자정 이후 첫 acc_trade_price_24h를 베이스로
}
const base = st.dayBaseAccAmount;
if(base == null || base <= 0) return;
const pct = ((acc - base) / base) * 100;
const c = document.getElementById("volchange-"+m);
if(!c) return;
c.classList.remove("vol-daily-up","vol-daily-down","vol-daily-zero");
if(pct > 0) c.classList.add("vol-daily-up");
else if(pct < 0) c.classList.add("vol-daily-down");
else c.classList.add("vol-daily-zero");
c.textContent = (pct>=0?"+":"") + pct.toFixed(2) + "%";
}
/* ======================================================
PRICE TICKS (20초 유지)
====================================================== */
function trimPriceTicks(m){
const st = marketState[m];
const limit = nowTs() - 20000;
while(st.priceTicks.length && st.priceTicks[0].ts < limit){
st.priceTicks.shift();
}
}
function getChangePercentFor(m, sec){
const st = marketState[m];
if(!st.lastPrice) return null;
const target = nowTs() - sec*1000;
const arr = st.priceTicks;
for(let i = arr.length - 1; i >= 0; i--){
if(arr[i].ts <= target){
return ((st.lastPrice - arr[i].price) / arr[i].price) * 100;
}
}
return null;
}
function updateEkg(m){
const bar = document.getElementById("ekg-"+m);
if(!bar) return;
const pct = getChangePercentFor(m,10);
let amp = 0.25;
if(pct !== null){
amp = Math.min(Math.max(Math.abs(pct)*0.8, 0.3), 3.0);
}
const pulse = 0.08 + Math.random()*0.10;
bar.style.transform = "scaleX(" + (amp + pulse).toFixed(2) + ")";
}
/* ======================================================
AMOUNT WINDOWS (거래대금 기준)
====================================================== */
function updateAmountWindows(m){
const st = marketState[m], t = st.amountTicks;
if(!t.length) return;
const now = nowTs();
// 1분 거래대금(최근 60초)
let amount60 = 0;
for(let i=t.length-1;i>=0;i--){
if(now - t[i].ts <= 60000) amount60 += t[i].amount;
else break;
}
st.avgAmount60 = (st.avgAmount60 == null) ? amount60 : (st.avgAmount60*0.9 + amount60*0.1);
// 3초/10초
let a3=0, a10=0;
for(let i=t.length-1;i>=0;i--){
const dt = now - t[i].ts;
if(dt<=3000) a3 += t[i].amount;
if(dt<=10000) a10 += t[i].amount;
if(dt>10000) break;
}
const avg = st.avgAmount60 || 0;
const r3 = avg ? a3 / (avg/20) : 0; // 60초 평균 → 3초 기대치
const r10 = avg ? a10 / (avg/6) : 0; // 10초 기대치
const rAvg = avg ? amount60/avg : 0; // 60초 누적 대비
setSpikeCell(`v3-${m}`, r3);
setSpikeCell(`v10-${m}`, r10);
setSpikeCell(`vavg-${m}`, rAvg);
const maxRatio = Math.max(r3, r10, rAvg);
maybeCheckAmountSpike(m, maxRatio, {a3, a10, amount60});
}
function updateAmountChangeWindows(m){
const st = marketState[m], t = st.amountTicks;
if(!t.length || !st.avgAmount60 || st.avgAmount60<=0) return;
const now = nowTs();
// 너무 오래된 tick 정리
const cutAll = now - MAX_AMOUNT_TICKS_MS;
while(t[0] && t[0].ts < cutAll) t.shift();
PRICE_WINDOWS.forEach(w=>{
let sum = 0;
const cut = now - w.seconds*1000;
for(let i=t.length-1;i>=0;i--){
if(t[i].ts >= cut) sum += t[i].amount;
else break;
}
const exp = st.avgAmount60 * (w.seconds/60);
const c = document.getElementById(`cell-${m}-${w.key}`);
if(!c || exp<=0){
if(c) c.textContent="-";
return;
}
const pct = ((sum-exp)/exp)*100;
c.classList.remove("bg-up","bg-down");
c.textContent = (pct>=0?"+":"") + pct.toFixed(2) + "%";
if(pct>0) c.classList.add("bg-up");
else if(pct<0) c.classList.add("bg-down");
if(["sec30","min1"].includes(w.key)) c.classList.add("fast");
});
}
/* ======================================================
SPIKE / REACTION / DUMP
====================================================== */
function maybeCheckAmountSpike(m, ratio, amounts){
if(!ratio || ratio < VOL_SPIKE_RATIO) return;
const st = marketState[m];
const now = nowTs();
if(st.lastAmountSpike && (now - st.lastAmountSpike.ts) < SPIKE_COOLDOWN_MS) return;
st.lastAmountSpike = {
ts: now,
price: st.lastPrice || null,
reacted: false,
strength: ratio,
a3: amounts.a3,
a10: amounts.a10,
amount60: amounts.amount60
};
// 행 하이라이트
const row = document.getElementById("row-"+m);
if(row){
row.classList.add("row-spike");
setTimeout(()=>row.classList.remove("row-spike"), 1800);
}
const name = MARKETS[m];
const msg = `${name} 거래금 스파이크 (x${ratio.toFixed(2)}) / 3초:${amounts.a3.toFixed(2)}, 10초:${amounts.a10.toFixed(2)}, 1분:${amounts.amount60.toFixed(2)}`;
showToast(msg);
logMessage(msg);
playSound("spike");
}
function checkPriceReaction(m){
const st = marketState[m];
if(!st.lastAmountSpike || st.lastAmountSpike.reacted) return;
if(!st.lastAmountSpike.price || !st.lastPrice) return;
const now = nowTs();
if(now - st.lastAmountSpike.ts > PRICE_REACTION_WINDOW) return;
const base = st.lastAmountSpike.price;
const pct = ((st.lastPrice - base)/base)*100;
if(Math.abs(pct) >= PRICE_REACTION_PCT){
st.lastAmountSpike.reacted = true;
const name = MARKETS[m];
const sign = pct>0?"+":"";
const msg = `${name} 가격반응 ${sign}${pct.toFixed(2)}% (스파이크 연계)`;
showToast(msg);
logMessage(msg);
playSound("spike");
}
}
function updateDumpDetector(m, ts, price){
const d = dumpState[m];
d.recent.push({ts, price});
const cut = ts - 5000;
while(d.recent[0] && d.recent[0].ts < cut) d.recent.shift();
if(d.recent.length < 2) return;
let max = d.recent[0].price, min = max;
for(const p of d.recent){
if(p.price > max) max = p.price;
if(p.price < min) min = p.price;
}
const drop = ((min - max)/max)*100;
if(Math.abs(drop) >= 5 && ts - d.lastAlertTs > 10000){
d.lastAlertTs = ts;
const name = MARKETS[m];
const msg = `${name} Kill-Switch ${drop.toFixed(2)}%`;
showToast(msg);
logMessage(msg);
playSound("kill");
const row = document.getElementById("row-"+m);
if(row){
row.classList.add("row-dead");
setTimeout(()=>row.classList.remove("row-dead"), 1600);
}
}
}
/* ======================================================
RX WATCHDOG (수신 멈추면 행 음영)
====================================================== */
setInterval(()=>{
const now = nowTs();
for(const m in MARKETS){
const st = marketState[m];
const row = document.getElementById("row-"+m);
if(!row) continue;
if(st.lastRx && (now - st.lastRx) > DEAD_RX_MS) row.classList.add("row-dead");
else row.classList.remove("row-dead");
}
}, 600);
/* ======================================================
WEBSOCKET
====================================================== */
function connectWebSocket(){
if(ws) try{ ws.close(); }catch(e){}
statusEl.textContent = "⏳ WebSocket 연결 중...";
ws = new WebSocket("wss://api.upbit.com/websocket/v1");
ws.onopen = ()=>{
statusEl.textContent = "✅ WebSocket 연결됨";
statusEl.style.color = "var(--up)";
const payload = [{ticket:"observer_v6"}, {type:"ticker", codes:Object.keys(MARKETS)}];
ws.send(JSON.stringify(payload));
};
ws.onmessage = (e)=>{
e.data.arrayBuffer().then(buf=>{
let d = null;
try{
d = JSON.parse(new TextDecoder().decode(buf));
}catch(_){
return;
}
const m = d.cd || d.code;
if(!marketState[m]) return;
const p = Number(d.tp ?? d.trade_price ?? 0);
const vol = Number(d.tv ?? d.trade_volume ?? 0); // 체결 거래량(해당 tick)
const acc = Number(d.atp ?? d.acc_trade_price_24h ?? 0); // 24h 누적 거래대금
const ts = normalizeUpbitTs(d);
const st = marketState[m];
st.lastRx = nowTs();
lastRxEl.textContent = new Date(st.lastRx).toTimeString().split(" ")[0];
if(p>0){
st.lastPrice = p;
st.priceTicks.push({ts: nowTs(), price: p}); // 가격 tick은 '수신 시각' 기준으로 통일
trimPriceTicks(m);
setCellText("price-"+m, p.toLocaleString()+" 원");
}
// 전일대비 거래대금 (일자별 base)
if(acc>0) updateDailyAmountChange(m, acc, ts);
// 거래대금 tick 적재 (price * volume = 거래대금)
if(vol>0 && p>0){
const amount = p * vol; // 거래대금 = 가격 × 거래량
st.amountTicks.push({ts: nowTs(), amount: amount});
}
updateAmountWindows(m);
updateAmountChangeWindows(m);
updateDumpDetector(m, nowTs(), p);
updateEkg(m);
checkPriceReaction(m);
}).catch(()=>{});
};
ws.onerror = ()=>{
statusEl.textContent = "❌ WebSocket 에러";
statusEl.style.color = "var(--down)";
};
ws.onclose = ()=>{
reconnectCount++;
reconnEl.textContent = String(reconnectCount);
statusEl.textContent = "❌ WS 끊김 → 재연결 중";
statusEl.style.color = "var(--warn)";
setTimeout(connectWebSocket, 1500);
};
}
connectWebSocket();
</script>
</body>
</html>