GNU/_PAGE/observer/upbit/observer_price.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;
}

// 기본 환경 설정 파일 포함
include_once('./_common.php'); 

// 헤더 부분 포함
include_once(G5_PATH.'/_head.php'); 
?>

<link rel="stylesheet" type="text/css" href="./observer_volume.css">

<title>Observer Stable v6</title>

<div class="Main-Box">

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

</div>

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

<?php require_once G5_PATH.'/_PAGE/tail.php'; ?>