GNU/_PAGE/observer/upbit/observer2.php
<?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>