<?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_VOL_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] = {
volTicks: [], // {ts(ms), volume}
avgVol60: null,
dayBaseAccVol: null, // 일자 기준 base
dayKey: null, // YYYY-MM-DD
lastPrice: null,
priceTicks: [], // {ts(ms), price}
lastVolSpike: null, // {ts, price, ...}
lastRx: 0
};
dumpState[m] = { recent: [], lastAlertTs: 0 };
}
}
initTable();
/* ======================================================
DAILY VOL 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 updateDailyVolumeChange(m, acc, tsMs){
const st = marketState[m];
const day = ymd(tsMs);
if(st.dayKey !== day){
st.dayKey = day;
st.dayBaseAccVol = acc; // 자정 이후 첫 수신값을 베이스로
}
const base = st.dayBaseAccVol;
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) + ")";
}
/* ======================================================
VOLUME WINDOWS
====================================================== */
function updateVolumeWindows(m){
const st = marketState[m], t = st.volTicks;
if(!t.length) return;
const now = nowTs();
// 1분 볼륨(최근 60초)
let vol60 = 0;
for(let i=t.length-1;i>=0;i--){
if(now - t[i].ts <= 60000) vol60 += t[i].volume;
else break;
}
st.avgVol60 = (st.avgVol60 == null) ? vol60 : (st.avgVol60*0.9 + vol60*0.1);
// 3초/10초
let v3=0, v10=0;
for(let i=t.length-1;i>=0;i--){
const dt = now - t[i].ts;
if(dt<=3000) v3 += t[i].volume;
if(dt<=10000) v10 += t[i].volume;
if(dt>10000) break;
}
const avg = st.avgVol60 || 0;
const r3 = avg ? v3 / (avg/20) : 0; // 60초 평균 → 3초 기대치
const r10 = avg ? v10 / (avg/6) : 0; // 10초 기대치
const rAvg = avg ? vol60/avg : 0; // 60초 누적 대비
setSpikeCell(`v3-${m}`, r3);
setSpikeCell(`v10-${m}`, r10);
setSpikeCell(`vavg-${m}`, rAvg);
const maxRatio = Math.max(r3, r10, rAvg);
maybeCheckVolumeSpike(m, maxRatio, {v3, v10, vol60});
}
function updateVolumeChangeWindows(m){
const st = marketState[m], t = st.volTicks;
if(!t.length || !st.avgVol60 || st.avgVol60<=0) return;
const now = nowTs();
// 너무 오래된 tick 정리
const cutAll = now - MAX_VOL_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].volume;
else break;
}
const exp = st.avgVol60 * (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 maybeCheckVolumeSpike(m, ratio, vols){
if(!ratio || ratio < VOL_SPIKE_RATIO) return;
const st = marketState[m];
const now = nowTs();
if(st.lastVolSpike && (now - st.lastVolSpike.ts) < SPIKE_COOLDOWN_MS) return;
st.lastVolSpike = {
ts: now,
price: st.lastPrice || null,
reacted: false,
strength: ratio,
v3: vols.v3,
v10: vols.v10,
vol60: vols.vol60
};
// 행 하이라이트
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초:${vols.v3.toFixed(4)}, 10초:${vols.v10.toFixed(4)}, 1분:${vols.vol60.toFixed(4)}`;
showToast(msg);
logMessage(msg);
playSound("spike");
}
function checkPriceReaction(m){
const st = marketState[m];
if(!st.lastVolSpike || st.lastVolSpike.reacted) return;
if(!st.lastVolSpike.price || !st.lastPrice) return;
const now = nowTs();
if(now - st.lastVolSpike.ts > PRICE_REACTION_WINDOW) return;
const base = st.lastVolSpike.price;
const pct = ((st.lastPrice - base)/base)*100;
if(Math.abs(pct) >= PRICE_REACTION_PCT){
st.lastVolSpike.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.atv ?? d.acc_trade_volume_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) updateDailyVolumeChange(m, acc, ts);
// 거래량 tick 적재 (수신시각 기준 ms)
if(vol>0){
st.volTicks.push({ts: nowTs(), volume: vol});
}
updateVolumeWindows(m);
updateVolumeChangeWindows(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'; ?>