CORE TERMINAL
올드보이 & 마리아 백업

바이비트 API 베스트 플렛폼 수집 데몬 - 구 버전 : 마지막 버전

DATE: 2026-03-03 12:23
분류 정보
핵심 항목
테마2.0
내용
* 바이비트 API 베스트 플렛폼 수집 데몬 - 구 버전 : 마지막 버전
1. 구버전 마지막 버전
추가 내용
<?php
/**
 * daemon_bybit_Ticker.php
 * Bybit 시장 데이터 수집 데몬 — DB 연결 수정판
 *
 * 수정 내역:
 *  - createPdo()  : $DB_HOST 변수 의존 제거 → $db_upbit PDO 객체 직접 재사용
 *  - DB 변수 체크 : $DB_HOST 대신 $db_upbit instanceof PDO 로 확인
 */

declare(strict_types=1);
set_time_limit(0);

// ════════════════════════════════════════════════════════════════
// 0. 상수
// ════════════════════════════════════════════════════════════════
//const DAEMON_FILE     = \\\'daemon_bybit_Ticker.php\\\';
define(\\\'DAEMON_FILE\\\', basename(__FILE__));
const DAEMON_NAME     = \\\'Bybit Best 수집 데몬\\\';
const DAEMON_CATEGORY = \\\'BYBIT\\\';
const DAEMON_KIND     = \\\'BEST\\\';

const CYCLE_SEC              = 1;
const INSTRUMENT_REFRESH_SEC = 14400;
const KLINE_CYCLE            = 60;
const TRADE_CYCLE            = 30;
const RISK_CYCLE             = 3600;
const RATIO_CYCLE            = 300;   // account-ratio 주기 (5분)
const MKLINE_CYCLE           = 300;   // mark/index/premium kline 주기 (5분)

const KLINE_INTERVAL         = \\\'1\\\';
const KLINE_LIMIT            = 1;

const BYBIT_BASE_URL         = \\\'https://api.bybit.com\\\';
const BYBIT_CALL_GAP_MS      = 120;
const BYBIT_MAX_RETRY        = 3;
const BYBIT_RETRY_WAIT_US    = 400000;

const PID_FILE               = \\\'/tmp/daemon_bybit_Best.pid\\\';
const DB_RECONNECT_WAIT_SEC  = 3;
const DB_RECONNECT_MAX_TRY   = 10;
const BULK_CHUNK_SIZE        = 20;

// 그누보드 종목 소스
const GNUBOARD_SYMBOL_TABLE  = \\\'g5_write_daemon_best_bybit\\\';
const GNUBOARD_SYMBOL_COL    = \\\'wr_subject\\\';
const SYMBOL_REFRESH_SEC     = 300;   // 종목 목록 5분마다 갱신

const COALESCE_COLS = [
    \\\'m_close\\\', \\\'i_close\\\', \\\'p_close\\\',
    \\\'buyRatio\\\', \\\'sellRatio\\\',
    \\\'period\\\', \\\'value\\\',
    \\\'coin\\\', \\\'balance\\\',
];

// ════════════════════════════════════════════════════════════════
// 1. DB 설정 전역 로드
// ════════════════════════════════════════════════════════════════
require \\\'/home/www/DB/db_upbit.php\\\';

if (!isset($DB_HOST, $DB_NAME, $DB_USER, $DB_PASS)) {
    fwrite(STDERR, \\\"[FATAL] db_upbit.php 에서 DB 변수를 읽을 수 없습니다.\\\\n\\\");
    exit(1);
}


// ════════════════════════════════════════════════════════════════
// 2. PID flock 락
// ════════════════════════════════════════════════════════════════
function acquirePidLock(): void
{
    global $pidFh;

    $pidFh = fopen(PID_FILE, \\\'c+\\\');
    if ($pidFh === false) {
        fwrite(STDERR, \\\'[FATAL] PID 파일을 열 수 없습니다: \\\' . PID_FILE . \\\"\\\\n\\\");
        exit(1);
    }

    if (!flock($pidFh, LOCK_EX | LOCK_NB)) {
        fseek($pidFh, 0);
        $oldPid = (int)trim(fread($pidFh, 20));

        if ($oldPid > 0 && file_exists(\\\"/proc/{$oldPid}\\\")) {
            fwrite(STDERR, \\\"[FATAL] 이미 실행 중입니다. PID={$oldPid}\\\\n\\\");
            fclose($pidFh);
            exit(1);
        }

        fwrite(STDERR, \\\"[WARN] 좀비 PID 파일 감지(PID={$oldPid}), 강제 제거 후 재시작\\\\n\\\");
        fclose($pidFh);
        @unlink(PID_FILE);

        $pidFh = fopen(PID_FILE, \\\'c+\\\');
        if ($pidFh === false || !flock($pidFh, LOCK_EX | LOCK_NB)) {
            fwrite(STDERR, \\\"[FATAL] PID 락 재획득 실패\\\\n\\\");
            exit(1);
        }
    }

    ftruncate($pidFh, 0);
    fwrite($pidFh, (string)getmypid());
    fflush($pidFh);

    register_shutdown_function(static function () use (&$pidFh): void {
        if (is_resource($pidFh)) {
            flock($pidFh, LOCK_UN);
            fclose($pidFh);
        }
        @unlink(PID_FILE);
    });
}

// ════════════════════════════════════════════════════════════════
// 3. DB 연결 — $DB_HOST 전역 변수 직접 사용 (require 로 풀림)
// ════════════════════════════════════════════════════════════════
function createPdo(): PDO
{
    global $DB_HOST, $DB_NAME, $DB_USER, $DB_PASS;

    $conn = new PDO(
        \\\"mysql:host={$DB_HOST};dbname={$DB_NAME};charset=utf8mb4\\\",
        $DB_USER,
        $DB_PASS,
        [
            PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_PERSISTENT         => false,
        ]
    );
    return $conn;
}

// ════════════════════════════════════════════════════════════════
// 4. DB 재연결
// ════════════════════════════════════════════════════════════════
function ensurePdo(PDO &$pdo): bool
{
    try {
        $pdo->query(\\\'SELECT 1\\\');
        return true;
    } catch (Throwable) {
        stderr(\\\'[WARN] DB 연결 끊김 — 재연결 시도\\\');

        for ($i = 1; $i <= DB_RECONNECT_MAX_TRY; $i++) {
            sleep(DB_RECONNECT_WAIT_SEC);
            try {
                $pdo = createPdo();
                stderr(\\\"[INFO] DB 재연결 성공 ({$i}회 시도)\\\");
                return true;
            } catch (Throwable $e) {
                stderr(\\\"[WARN] DB 재연결 실패 ({$i}/\\\" . DB_RECONNECT_MAX_TRY . \\\"): \\\" . $e->getMessage());
            }
        }
        return false;
    }
}

// ════════════════════════════════════════════════════════════════
// 5. 데몬 컨텍스트
// ════════════════════════════════════════════════════════════════
final class DaemonContext
{
    public readonly int    $pid;
    public readonly string $ip;
    public readonly string $startTime;

    public function __construct()
    {
        $this->pid       = getmypid();
        $this->ip        = $this->resolveIp();
        $this->startTime = date(\\\'Y-m-d H:i:s\\\');
    }

    private function resolveIp(): string
    {
        if (function_exists(\\\'shell_exec\\\')) {
            $ip = trim(shell_exec(\\\"hostname -I 2>/dev/null | awk \\\'{print $1}\\\'\\\") ?: \\\'\\\');
            if ($ip !== \\\'\\\') return $ip;
        }
        $ip = gethostbyname(gethostname());
        return ($ip !== gethostname()) ? $ip : \\\'0.0.0.0\\\';
    }
}

// ════════════════════════════════════════════════════════════════
// 6. 출력 헬퍼
// ════════════════════════════════════════════════════════════════
function stderr(string $msg): void
{
    fwrite(STDERR, \\\'[\\\' . date(\\\'Y-m-d H:i:s\\\') . \\\'] \\\' . $msg . \\\"\\\\n\\\");
}

function stdout(string $msg): void
{
    echo \\\'[\\\' . date(\\\'Y-m-d H:i:s\\\') . \\\'] \\\' . $msg . \\\"\\\\n\\\";
}

// ════════════════════════════════════════════════════════════════
// 7. daemon_record 갱신
// ════════════════════════════════════════════════════════════════
function updateDaemonRecord(PDO $pdo, DaemonContext $ctx, string $status, string $memo = \\\'\\\'): void
{
    try {
        $sql = \\\"INSERT INTO daemon_record
                    (d_id, d_category, d_pid, d_status, d_heartbeat,
                     d_ip, d_start_time, d_memo, d_kill_flag, d_name, d_kind)
                VALUES
                    (:id, :cat, :pid, :status, NOW(),
                     :ip, :start, :memo, 0, :name, :kind)
                ON DUPLICATE KEY UPDATE
                    d_pid       = VALUES(d_pid),
                    d_status    = VALUES(d_status),
                    d_heartbeat = NOW(),
                    d_ip        = VALUES(d_ip),
                    d_memo      = VALUES(d_memo),
                    d_kill_flag = 0\\\";

        $pdo->prepare($sql)->execute([
            \\\':id\\\'     => DAEMON_FILE,
            \\\':cat\\\'    => DAEMON_CATEGORY,
            \\\':pid\\\'    => $ctx->pid,
            \\\':status\\\' => $status,
            \\\':ip\\\'     => $ctx->ip,
            \\\':start\\\'  => $ctx->startTime,
            \\\':memo\\\'   => mb_substr($memo, 0, 500),
            \\\':name\\\'   => DAEMON_NAME,
            \\\':kind\\\'   => DAEMON_KIND,
        ]);
    } catch (Throwable $e) {
        stderr(\\\'[WARN] daemon_record 갱신 실패: \\\' . $e->getMessage());
    }
}

// ════════════════════════════════════════════════════════════════
// 8. 제어 플래그
// ════════════════════════════════════════════════════════════════
function isRunFlagOn(PDO $pdo): bool
{
    try {
        $st = $pdo->prepare(\\\"SELECT is_run FROM daemon_control WHERE d_id = :id LIMIT 1\\\");
        $st->execute([\\\':id\\\' => DAEMON_FILE]);
        $row = $st->fetch();
        return ($row === false) || ((int)$row[\\\'is_run\\\'] === 1);
    } catch (Throwable) {
        return true;
    }
}

function isKillFlagSet(PDO $pdo): bool
{
    try {
        $st = $pdo->prepare(\\\"SELECT d_kill_flag FROM daemon_record WHERE d_id = :id LIMIT 1\\\");
        $st->execute([\\\':id\\\' => DAEMON_FILE]);
        $row = $st->fetch();
        return ($row !== false) && ((int)$row[\\\'d_kill_flag\\\'] === 1);
    } catch (Throwable) {
        return false;
    }
}

// ════════════════════════════════════════════════════════════════
// 9. Bybit API 헬퍼
// ════════════════════════════════════════════════════════════════
$g_lastApiCallMs = 0;

function bybitGet(string $endpoint, array $params = []): ?array
{
    global $g_lastApiCallMs;

    $now = (int)(microtime(true) * 1000);
    $gap = $now - $g_lastApiCallMs;
    if ($gap < BYBIT_CALL_GAP_MS) {
        usleep((BYBIT_CALL_GAP_MS - $gap) * 1000);
    }

    $url = BYBIT_BASE_URL . $endpoint;
    if (!empty($params)) {
        $url .= \\\'?\\\' . http_build_query($params);
    }

    for ($attempt = 1; $attempt <= BYBIT_MAX_RETRY; $attempt++) {
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT        => 10,
            CURLOPT_USERAGENT      => \\\'daemon_bybit_Ticker/3.1\\\',
            CURLOPT_SSL_VERIFYPEER => true,
        ]);
        $resp     = curl_exec($ch);
        $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $curlErr  = curl_error($ch);
        curl_close($ch);

        $g_lastApiCallMs = (int)(microtime(true) * 1000);

        if ($curlErr || $resp === false) {
            stderr(\\\"[WARN] cURL 오류 ({$endpoint}): {$curlErr}\\\");
            usleep(BYBIT_RETRY_WAIT_US);
            continue;
        }

        if ($httpCode === 429) {
            stderr(\\\"[WARN] Bybit 429 RateLimit — 사이클 스킵: {$endpoint}\\\");
            return null;
        }

        if ($httpCode >= 500) {
            stderr(\\\"[WARN] Bybit {$httpCode} — 재시도 {$attempt}/\\\" . BYBIT_MAX_RETRY . \\\": {$endpoint}\\\");
            usleep(BYBIT_RETRY_WAIT_US);
            continue;
        }

        if ($httpCode !== 200) {
            stderr(\\\"[WARN] Bybit HTTP {$httpCode}: {$endpoint}\\\");
            return null;
        }

        $decoded = json_decode($resp, true);
        if (!is_array($decoded)) return null;

        if (($decoded[\\\'retCode\\\'] ?? -1) !== 0) {
            stderr(\\\"[WARN] Bybit retCode={$decoded[\\\'retCode\\\']} msg={$decoded[\\\'retMsg\\\']} ep={$endpoint}\\\");
            return null;
        }

        return $decoded[\\\'result\\\'] ?? null;
    }

    return null;
}

// ════════════════════════════════════════════════════════════════
// 10. 수집 함수
// ════════════════════════════════════════════════════════════════
// ════════════════════════════════════════════════════════════════
// 그누보드에서 종목 목록 로드
// ════════════════════════════════════════════════════════════════
function getGnuPdo(): PDO
{
    // db_gnu.php 는 return $pdo_gnu 구조 — include 로 받아야 함
    $pdo_gnu = include \\\'/home/www/DB/db_gnu.php\\\';
    if (!$pdo_gnu instanceof PDO) {
        throw new RuntimeException(\\\"db_gnu.php 에서 PDO를 가져오지 못했습니다.\\\");
    }
    return $pdo_gnu;
}

function loadWatchlist(): array
{
    try {
        $gnu = getGnuPdo();
        $st  = $gnu->query(
            \\\"SELECT \\\" . GNUBOARD_SYMBOL_COL . \\\" FROM \\\" . GNUBOARD_SYMBOL_TABLE . \\\" WHERE \\\" . GNUBOARD_SYMBOL_COL . \\\" != \\\'\\\' ORDER BY wr_num LIMIT 50\\\"
        );
        $list = [];
        while ($row = $st->fetch(PDO::FETCH_NUM)) {
            $sym = strtoupper(trim($row[0]));
            if ($sym) $list[] = $sym;
        }
        return array_unique($list);
    } catch (Throwable $e) {
        stderr(\\\'[WARN] 종목 목록 로드 실패: \\\' . $e->getMessage());
        return [];
    }
}

function fetchInstruments(): array
{
    $map    = [];
    $cursor = \\\'\\\';

    do {
        $params = [\\\'category\\\' => \\\'linear\\\', \\\'limit\\\' => 1000];
        if ($cursor !== \\\'\\\') $params[\\\'cursor\\\'] = $cursor;

        $result = bybitGet(\\\'/v5/market/instruments-info\\\', $params);
        if (!$result || empty($result[\\\'list\\\'])) break;

        foreach ($result[\\\'list\\\'] as $item) {
            $sym = $item[\\\'symbol\\\'] ?? \\\'\\\';
            if (!$sym) continue;

            $li  = $item[\\\'lotSizeFilter\\\']  ?? [];
            $pi  = $item[\\\'priceFilter\\\']    ?? [];
            $lev = $item[\\\'leverageFilter\\\'] ?? [];

            $map[$sym] = [
                \\\'status\\\'              => $item[\\\'status\\\']              ?? null,
                \\\'baseCoin\\\'            => $item[\\\'baseCoin\\\']            ?? null,
                \\\'quoteCoin\\\'           => $item[\\\'quoteCoin\\\']           ?? null,
                \\\'settleCoin\\\'          => $item[\\\'settleCoin\\\']          ?? null,
                \\\'optionsType\\\'         => $item[\\\'optionsType\\\']         ?? null,
                \\\'launchTime\\\'          => $item[\\\'launchTime\\\']          ?? null,
                \\\'deliveryTime\\\'        => $item[\\\'deliveryTime\\\']        ?? null,
                \\\'deliveryFeeRate\\\'     => $item[\\\'deliveryFeeRate\\\']     ?? null,
                \\\'priceScale\\\'          => $item[\\\'priceScale\\\']          ?? null,
                \\\'minLeverage\\\'         => $lev[\\\'minLeverage\\\']          ?? null,
                \\\'maxLeverage\\\'         => $lev[\\\'maxLeverage\\\']          ?? null,
                \\\'leverageStep\\\'        => $lev[\\\'leverageStep\\\']         ?? null,
                \\\'minPrice\\\'            => $pi[\\\'minPrice\\\']              ?? null,
                \\\'maxPrice\\\'            => $pi[\\\'maxPrice\\\']              ?? null,
                \\\'tickSize\\\'            => $pi[\\\'tickSize\\\']              ?? null,
                \\\'maxOrderQty\\\'         => $li[\\\'maxOrderQty\\\']           ?? null,
                \\\'minOrderQty\\\'         => $li[\\\'minOrderQty\\\']           ?? null,
                \\\'qtyStep\\\'             => $li[\\\'qtyStep\\\']               ?? null,
                \\\'postOnlyMaxOrderQty\\\' => $li[\\\'postOnlyMaxOrderQty\\\']   ?? null,
                \\\'unifiedMarginTrade\\\'  => $item[\\\'unifiedMarginTrade\\\']  ?? null,
                \\\'fundingInterval\\\'     => $item[\\\'fundingInterval\\\']     ?? null,
                \\\'copyTrading\\\'         => $item[\\\'copyTrading\\\']         ?? null,
                \\\'upperLimitPrice\\\'     => $item[\\\'upperLimitPrice\\\']     ?? null,
                \\\'lowerLimitPrice\\\'     => $item[\\\'lowerLimitPrice\\\']     ?? null,
            ];
        }

        $cursor = $result[\\\'nextPageCursor\\\'] ?? \\\'\\\';
    } while ($cursor !== \\\'\\\');

    return $map;
}

function fetchTickers(): array
{
    $result = bybitGet(\\\'/v5/market/tickers\\\', [\\\'category\\\' => \\\'linear\\\']);
    if (!$result || empty($result[\\\'list\\\'])) return [];

    $map = [];
    foreach ($result[\\\'list\\\'] as $item) {
        $sym = $item[\\\'symbol\\\'] ?? \\\'\\\';
        if (!$sym) continue;
        $map[$sym] = [
            \\\'lastPrice\\\'              => $item[\\\'lastPrice\\\']              ?? null,
            \\\'indexPrice\\\'             => $item[\\\'indexPrice\\\']             ?? null,
            \\\'markPrice\\\'              => $item[\\\'markPrice\\\']              ?? null,
            \\\'prevPrice24h\\\'           => $item[\\\'prevPrice24h\\\']           ?? null,
            \\\'price24hPcnt\\\'           => $item[\\\'price24hPcnt\\\']           ?? null,
            \\\'highPrice24h\\\'           => $item[\\\'highPrice24h\\\']           ?? null,
            \\\'lowPrice24h\\\'            => $item[\\\'lowPrice24h\\\']            ?? null,
            \\\'prevPrice1h\\\'            => $item[\\\'prevPrice1h\\\']            ?? null,
            \\\'openInterest\\\'           => $item[\\\'openInterest\\\']           ?? null,
            \\\'openInterestValue\\\'      => $item[\\\'openInterestValue\\\']      ?? null,
            \\\'turnover24h\\\'            => $item[\\\'turnover24h\\\']            ?? null,
            \\\'volume24h\\\'              => $item[\\\'volume24h\\\']              ?? null,
            \\\'fundingRate\\\'            => $item[\\\'fundingRate\\\']            ?? null,
            \\\'nextFundingTime\\\'        => $item[\\\'nextFundingTime\\\']        ?? null,
            \\\'predictedDeliveryPrice\\\' => $item[\\\'predictedDeliveryPrice\\\'] ?? null,
            \\\'basisRate\\\'              => $item[\\\'basisRate\\\']              ?? null,
            \\\'ask1Size\\\'               => $item[\\\'ask1Size\\\']               ?? null,
            \\\'bid1Price\\\'              => $item[\\\'bid1Price\\\']              ?? null,
            \\\'ask1Price\\\'              => $item[\\\'ask1Price\\\']              ?? null,
            \\\'bid1Size\\\'               => $item[\\\'bid1Size\\\']               ?? null,
        ];
    }
    return $map;
}

function fetchKlineChunk(array $symbols, int &$offset): array
{
    $total     = count($symbols);
    $chunkSize = (int)ceil($total / max(1, (int)(KLINE_CYCLE / CYCLE_SEC)));
    $chunk     = array_slice($symbols, $offset, $chunkSize);
    $offset    = ($offset + $chunkSize) % max(1, $total);

    $map = [];
    foreach ($chunk as $symbol) {
        $result = bybitGet(\\\'/v5/market/kline\\\', [
            \\\'category\\\' => \\\'linear\\\',
            \\\'symbol\\\'   => $symbol,
            \\\'interval\\\' => KLINE_INTERVAL,
            \\\'limit\\\'    => KLINE_LIMIT,
        ]);
        if ($result === null) return $map;
        if (empty($result[\\\'list\\\'])) continue;

        $k = $result[\\\'list\\\'][0];
        $map[$symbol] = [
            \\\'start\\\'    => isset($k[0]) ? (int)$k[0] : null,
            \\\'open\\\'     => $k[1] ?? null,
            \\\'high\\\'     => $k[2] ?? null,
            \\\'low\\\'      => $k[3] ?? null,
            \\\'close\\\'    => $k[4] ?? null,
            \\\'volume\\\'   => $k[5] ?? null,
            \\\'turnover\\\' => $k[6] ?? null,
        ];
    }
    return $map;
}

function fetchTradeChunk(array $symbols, int &$offset): array
{
    $total     = count($symbols);
    $chunkSize = (int)ceil($total / max(1, (int)(TRADE_CYCLE / CYCLE_SEC)));
    $chunk     = array_slice($symbols, $offset, $chunkSize);
    $offset    = ($offset + $chunkSize) % max(1, $total);

    $map = [];
    foreach ($chunk as $symbol) {
        $result = bybitGet(\\\'/v5/market/recent-trade\\\', [
            \\\'category\\\' => \\\'linear\\\',
            \\\'symbol\\\'   => $symbol,
            \\\'limit\\\'    => 1,
        ]);
        if ($result === null) return $map;
        if (empty($result[\\\'list\\\'])) continue;

        $t = $result[\\\'list\\\'][0];
        $map[$symbol] = [
            \\\'execId\\\'      => $t[\\\'execId\\\']                                              ?? null,
            \\\'price\\\'       => $t[\\\'price\\\']                                               ?? null,
            \\\'size\\\'        => $t[\\\'qty\\\']                                                 ?? null,
            \\\'side\\\'        => $t[\\\'side\\\']                                                ?? null,
            \\\'isBlockTrade\\\'=> isset($t[\\\'isBlockTrade\\\']) ? (int)(bool)$t[\\\'isBlockTrade\\\'] : null,
            \\\'isAdlTrade\\\'  => null,
            \\\'mPnL\\\'        => null,
        ];
    }
    return $map;
}

function fetchRiskChunk(array $symbols, int &$offset): array
{
    $total     = count($symbols);
    $chunkSize = (int)ceil($total / max(1, (int)(RISK_CYCLE / CYCLE_SEC)));
    $chunk     = array_slice($symbols, $offset, $chunkSize);
    $offset    = ($offset + $chunkSize) % max(1, $total);

    $map = [];
    foreach ($chunk as $symbol) {
        $result = bybitGet(\\\'/v5/market/risk-limit\\\', [
            \\\'category\\\' => \\\'linear\\\',
            \\\'symbol\\\'   => $symbol,
        ]);
        if ($result === null) return $map;
        if (empty($result[\\\'list\\\'])) continue;

        $r = $result[\\\'list\\\'][0];
        $map[$symbol] = [
            \\\'riskId\\\'            => $r[\\\'id\\\']               ?? null,
            \\\'isLowestRisk\\\'      => isset($r[\\\'isLowestRisk\\\']) ? (int)(bool)$r[\\\'isLowestRisk\\\'] : null,
            \\\'maintenanceMargin\\\' => $r[\\\'maintenanceMargin\\\'] ?? null,
            \\\'initialMargin\\\'     => $r[\\\'initialMargin\\\']    ?? null,
            \\\'limit\\\'             => $r[\\\'limit\\\']            ?? null,
        ];
    }
    return $map;
}

function fetchRatioChunk(array $symbols, int &$offset): array
{
    $total     = count($symbols);
    $chunkSize = (int)ceil($total / max(1, (int)(RATIO_CYCLE / CYCLE_SEC)));
    $chunk     = array_slice($symbols, $offset, $chunkSize);
    $offset    = ($offset + $chunkSize) % max(1, $total);

    $map = [];
    foreach ($chunk as $symbol) {
        $result = bybitGet(\\\'/v5/market/account-ratio\\\', [
            \\\'category\\\' => \\\'linear\\\',
            \\\'symbol\\\'   => $symbol,
            \\\'period\\\'   => \\\'1h\\\',
            \\\'limit\\\'    => 1,
        ]);
        if ($result === null) return $map;
        if (empty($result[\\\'list\\\'])) continue;

        $r = $result[\\\'list\\\'][0];
        $map[$symbol] = [
            \\\'buyRatio\\\'  => $r[\\\'buyRatio\\\']  ?? null,
            \\\'sellRatio\\\' => $r[\\\'sellRatio\\\'] ?? null,
            \\\'period\\\'    => \\\'1h\\\',
            \\\'value\\\'     => null,
        ];
    }
    return $map;
}

function fetchMarkKlineChunk(array $symbols, int &$offset): array
{
    $total     = count($symbols);
    $chunkSize = (int)ceil($total / max(1, (int)(MKLINE_CYCLE / CYCLE_SEC)));
    $chunk     = array_slice($symbols, $offset, $chunkSize);
    $offset    = ($offset + $chunkSize) % max(1, $total);

    $map = [];
    foreach ($chunk as $symbol) {
        $result = bybitGet(\\\'/v5/market/mark-price-kline\\\', [
            \\\'category\\\' => \\\'linear\\\',
            \\\'symbol\\\'   => $symbol,
            \\\'interval\\\' => KLINE_INTERVAL,
            \\\'limit\\\'    => 1,
        ]);
        if ($result === null) return $map;
        if (empty($result[\\\'list\\\'])) continue;

        $k = $result[\\\'list\\\'][0];
        $map[$symbol] = [\\\'m_close\\\' => $k[4] ?? null];
    }
    return $map;
}

function fetchIndexKlineChunk(array $symbols, int &$offset): array
{
    $total     = count($symbols);
    $chunkSize = (int)ceil($total / max(1, (int)(MKLINE_CYCLE / CYCLE_SEC)));
    $chunk     = array_slice($symbols, $offset, $chunkSize);
    $offset    = ($offset + $chunkSize) % max(1, $total);

    $map = [];
    foreach ($chunk as $symbol) {
        $result = bybitGet(\\\'/v5/market/index-price-kline\\\', [
            \\\'category\\\' => \\\'linear\\\',
            \\\'symbol\\\'   => $symbol,
            \\\'interval\\\' => KLINE_INTERVAL,
            \\\'limit\\\'    => 1,
        ]);
        if ($result === null) return $map;
        if (empty($result[\\\'list\\\'])) continue;

        $k = $result[\\\'list\\\'][0];
        $map[$symbol] = [\\\'i_close\\\' => $k[4] ?? null];
    }
    return $map;
}

function fetchPremiumKlineChunk(array $symbols, int &$offset): array
{
    $total     = count($symbols);
    $chunkSize = (int)ceil($total / max(1, (int)(MKLINE_CYCLE / CYCLE_SEC)));
    $chunk     = array_slice($symbols, $offset, $chunkSize);
    $offset    = ($offset + $chunkSize) % max(1, $total);

    $map = [];
    foreach ($chunk as $symbol) {
        $result = bybitGet(\\\'/v5/market/premium-index-price-kline\\\', [
            \\\'category\\\' => \\\'linear\\\',
            \\\'symbol\\\'   => $symbol,
            \\\'interval\\\' => KLINE_INTERVAL,
            \\\'limit\\\'    => 1,
        ]);
        if ($result === null) return $map;
        if (empty($result[\\\'list\\\'])) continue;

        $k = $result[\\\'list\\\'][0];
        $map[$symbol] = [\\\'p_close\\\' => $k[4] ?? null];
    }
    return $map;
}

// ════════════════════════════════════════════════════════════════
// 11. 컬럼 / 정규화 / Bulk upsert
// ════════════════════════════════════════════════════════════════
function getColumns(): array
{
    return [
        \\\'symbol\\\',\\\'time\\\',\\\'status\\\',\\\'baseCoin\\\',\\\'quoteCoin\\\',\\\'settleCoin\\\',
        \\\'optionsType\\\',\\\'launchTime\\\',\\\'deliveryTime\\\',\\\'deliveryFeeRate\\\',
        \\\'priceScale\\\',\\\'minLeverage\\\',\\\'maxLeverage\\\',\\\'leverageStep\\\',
        \\\'minPrice\\\',\\\'maxPrice\\\',\\\'tickSize\\\',\\\'maxOrderQty\\\',\\\'minOrderQty\\\',
        \\\'qtyStep\\\',\\\'postOnlyMaxOrderQty\\\',\\\'unifiedMarginTrade\\\',
        \\\'fundingInterval\\\',\\\'copyTrading\\\',\\\'upperLimitPrice\\\',\\\'lowerLimitPrice\\\',
        \\\'lastPrice\\\',\\\'indexPrice\\\',\\\'markPrice\\\',\\\'prevPrice24h\\\',\\\'price24hPcnt\\\',
        \\\'highPrice24h\\\',\\\'lowPrice24h\\\',\\\'prevPrice1h\\\',\\\'openInterest\\\',
        \\\'openInterestValue\\\',\\\'turnover24h\\\',\\\'volume24h\\\',\\\'fundingRate\\\',
        \\\'nextFundingTime\\\',\\\'predictedDeliveryPrice\\\',\\\'basisRate\\\',
        \\\'ask1Size\\\',\\\'bid1Price\\\',\\\'ask1Price\\\',\\\'bid1Size\\\',
        \\\'execId\\\',\\\'price\\\',\\\'size\\\',\\\'side\\\',\\\'isAdlTrade\\\',\\\'mPnL\\\',\\\'isBlockTrade\\\',
        \\\'start\\\',\\\'open\\\',\\\'high\\\',\\\'low\\\',\\\'close\\\',\\\'volume\\\',\\\'turnover\\\',
        \\\'m_close\\\',\\\'i_close\\\',\\\'p_close\\\',\\\'buyRatio\\\',\\\'sellRatio\\\',
        \\\'period\\\',\\\'value\\\',\\\'riskId\\\',\\\'isLowestRisk\\\',
        \\\'maintenanceMargin\\\',\\\'initialMargin\\\',\\\'limit\\\',\\\'coin\\\',
        \\\'balance\\\',\\\'updated_at\\\',
    ];
}

function normalizeRow(array $raw, string $updatedAt): array
{
    static $allowed = null;
    if ($allowed === null) $allowed = array_flip(getColumns());

    $row = array_intersect_key($raw, $allowed);
    foreach (getColumns() as $col) {
        if (!array_key_exists($col, $row)) {
            $row[$col] = null;
        } else {
            // 빈 문자열 → null 변환 (decimal/int 컬럼 에러 방지)
            if ($row[$col] === \\\'\\\') $row[$col] = null;
        }
    }
    $row[\\\'updated_at\\\'] = $updatedAt;
    return $row;
}

function bulkUpsert(PDO $pdo, array $rows): void
{
    if (empty($rows)) return;

    $cols        = getColumns();
    $coaleseCols = array_flip(COALESCE_COLS);

    $colDefs = implode(\\\', \\\', array_map(fn($c) => \\\"`{$c}`\\\", $cols));

    $updateDefs = implode(\\\', \\\', array_map(static function ($c) use ($coaleseCols) {
        if (isset($coaleseCols[$c])) {
            return \\\"`{$c}` = COALESCE(VALUES(`{$c}`), `{$c}`)\\\";
        }
        return \\\"`{$c}` = VALUES(`{$c}`)\\\";
    }, $cols));

    foreach (array_chunk($rows, BULK_CHUNK_SIZE) as $chunk) {
        $placeholders = [];
        $bindings     = [];
        $rowIdx       = 0;

        foreach ($chunk as $row) {
            $ph = [];
            foreach ($cols as $col) {
                $key            = \\\":{$col}_{$rowIdx}\\\";
                $ph[]           = $key;
                $bindings[$key] = $row[$col] ?? null;
            }
            $placeholders[] = \\\'(\\\' . implode(\\\', \\\', $ph) . \\\')\\\';
            $rowIdx++;
        }

        $sql = \\\"INSERT INTO daemon_bybit_Ticker ({$colDefs})
                VALUES \\\" . implode(\\\', \\\', $placeholders) . \\\"
                ON DUPLICATE KEY UPDATE {$updateDefs}\\\";

        try {
            $pdo->prepare($sql)->execute($bindings);
        } catch (Throwable $e) {
            stderr(\\\'[WARN] bulkUpsert 청크 실패, 단건 fallback: \\\' . $e->getMessage());
            foreach ($chunk as $singleRow) {
                try {
                    $ph2  = implode(\\\', \\\', array_map(fn($c) => \\\":{$c}\\\", $cols));
                    $sql2 = \\\"INSERT INTO daemon_bybit_Ticker ({$colDefs})
                             VALUES ({$ph2})
                             ON DUPLICATE KEY UPDATE {$updateDefs}\\\";
                    $bind = [];
                    foreach ($cols as $col) $bind[\\\":{$col}\\\"] = $singleRow[$col] ?? null;
                    $pdo->prepare($sql2)->execute($bind);
                } catch (Throwable $e2) {
                    stderr(\\\'[WARN] singleUpsert 실패 (\\\' . ($singleRow[\\\'symbol\\\'] ?? \\\'?\\\') . \\\'): \\\' . $e2->getMessage());
                }
            }
        }
    }
}

// ════════════════════════════════════════════════════════════════
// 12. 메인
// ════════════════════════════════════════════════════════════════
acquirePidLock();
$ctx = new DaemonContext();

// DB 최초 연결
try {
    $pdo = createPdo();
} catch (Throwable $e) {
    fwrite(STDERR, \\\'[FATAL] DB 최초 연결 실패: \\\' . $e->getMessage() . \\\"\\\\n\\\");
    exit(1);
}

updateDaemonRecord($pdo, $ctx, \\\'RUNNING\\\', \\\'데몬 시작\\\');
stdout(DAEMON_NAME . \\\" started. PID={$ctx->pid}\\\");

// ── 캐시 / 오프셋 초기화 ──────────────────────────────────────
$instrumentCache     = [];
$klineCache          = [];
$tradeCache          = [];
$riskCache           = [];

$lastInstrumentFetch = 0;
$lastKlineFetch      = 0;
$lastTradeFetch      = 0;
$lastRiskFetch       = 0;

$klineOffset         = 0;
$tradeOffset         = 0;
$riskOffset          = 0;
$ratioOffset         = 0;
$markKlineOffset     = 0;
$indexKlineOffset    = 0;
$premiumKlineOffset  = 0;

$ratioCache          = [];
$markKlineCache      = [];
$indexKlineCache     = [];
$premiumKlineCache   = [];

$lastRatioFetch      = 0;
$lastMKlineFetch     = 0;

// ── 종목 목록 초기 로드 (그누보드) ──────────────────────────
stdout(\\\'종목 목록 로드 중...\\\');
$watchlist           = loadWatchlist();   // [\\\'BTCUSDT\\\', \\\'ETHUSDT\\\', ...]
$lastSymbolFetch     = time();

if (empty($watchlist)) {
    stderr(\\\'[FATAL] 종목 목록 로드 실패 — 종료\\\');
    updateDaemonRecord($pdo, $ctx, \\\'ERROR\\\', \\\'종목 목록 로드 실패\\\');
    exit(1);
}
stdout(\\\'종목 로드 완료: \\\' . count($watchlist) . \\\'개\\\');

// ── instruments 초기 로드 (watchlist 기준으로 필터) ──────────
stdout(\\\'instruments-info 초기 로드 중...\\\');
$instrumentCache     = fetchInstruments();
$lastInstrumentFetch = time();

// watchlist 에 있는 심볼만 필터링
$instrumentCache = array_intersect_key($instrumentCache, array_flip($watchlist));
stdout(\\\'instruments-info 로드 완료: \\\' . count($instrumentCache) . \\\'개 심볼\\\');

// ════════════════════════════════════════════════════════════════
// 메인 루프
// ════════════════════════════════════════════════════════════════
while (true) {

    $cycleStart = microtime(true);

    if (!ensurePdo($pdo)) {
        stderr(\\\'[FATAL] DB 재연결 불가 — 종료\\\');
        exit(1);
    }

    if (!isRunFlagOn($pdo)) {
        updateDaemonRecord($pdo, $ctx, \\\'STOPPED\\\', \\\'웹 관리툴에 의해 정지\\\');
        stdout(\\\'is_run=0 → 정상 종료\\\');
        exit(0);
    }
    if (isKillFlagSet($pdo)) {
        updateDaemonRecord($pdo, $ctx, \\\'KILLED\\\', \\\'kill_flag 강제 종료\\\');
        stdout(\\\'kill_flag=1 → 강제 종료\\\');
        exit(0);
    }

    $now = time();

    // 종목 목록 갱신 (5분 주기)
    if (($now - $lastSymbolFetch) >= SYMBOL_REFRESH_SEC) {
        $fresh = loadWatchlist();
        if (!empty($fresh)) {
            $watchlist       = $fresh;
            $lastSymbolFetch = $now;
            stdout(\\\'종목 갱신: \\\' . count($watchlist) . \\\'개\\\');
        }
    }

    // instruments 갱신 (4시간 주기)
    if (($now - $lastInstrumentFetch) >= INSTRUMENT_REFRESH_SEC) {
        $fresh = fetchInstruments();
        if (!empty($fresh)) {
            $instrumentCache     = $fresh;
            $lastInstrumentFetch = $now;
        }
        // watchlist 기준 필터
        $instrumentCache = array_intersect_key($instrumentCache, array_flip($watchlist));
        stdout(\\\'instruments 갱신: \\\' . count($instrumentCache) . \\\'개 심볼\\\');
    }

    // watchlist 기준으로 심볼 목록 확정
    $symbols = array_values(array_intersect(array_keys($instrumentCache), $watchlist));

    // tickers 전체 일괄
    $tickers = fetchTickers();
    if (empty($tickers)) {
        updateDaemonRecord($pdo, $ctx, \\\'RUNNING\\\', \\\'tickers 응답 없음 — 사이클 스킵\\\');
        $elapsed = microtime(true) - $cycleStart;
        $sleep   = max(0, CYCLE_SEC - (int)$elapsed);
        if ($sleep > 0) sleep($sleep);
        continue;
    }

    // 라운드로빈: kline
    if (($now - $lastKlineFetch) >= KLINE_CYCLE) {
        $klineOffset    = 0;
        $lastKlineFetch = $now;
    }
    $newKline = fetchKlineChunk($symbols, $klineOffset);
    foreach ($newKline as $sym => $data) $klineCache[$sym] = $data;

    // 라운드로빈: recent-trade
    if (($now - $lastTradeFetch) >= TRADE_CYCLE) {
        $tradeOffset    = 0;
        $lastTradeFetch = $now;
    }
    $newTrade = fetchTradeChunk($symbols, $tradeOffset);
    foreach ($newTrade as $sym => $data) $tradeCache[$sym] = $data;

    // 라운드로빈: risk-limit
    if (($now - $lastRiskFetch) >= RISK_CYCLE) {
        $riskOffset    = 0;
        $lastRiskFetch = $now;
    }
    $newRisk = fetchRiskChunk($symbols, $riskOffset);
    foreach ($newRisk as $sym => $data) $riskCache[$sym] = $data;

    // 라운드로빈: account-ratio
    if (($now - $lastRatioFetch) >= RATIO_CYCLE) {
        $ratioOffset    = 0;
        $lastRatioFetch = $now;
    }
    $newRatio = fetchRatioChunk($symbols, $ratioOffset);
    foreach ($newRatio as $sym => $data) $ratioCache[$sym] = $data;

    // 라운드로빈: mark/index/premium kline
    if (($now - $lastMKlineFetch) >= MKLINE_CYCLE) {
        $markKlineOffset    = 0;
        $indexKlineOffset   = 0;
        $premiumKlineOffset = 0;
        $lastMKlineFetch    = $now;
    }
    $newMark    = fetchMarkKlineChunk($symbols, $markKlineOffset);
    foreach ($newMark as $sym => $data) $markKlineCache[$sym] = $data;

    $newIndex   = fetchIndexKlineChunk($symbols, $indexKlineOffset);
    foreach ($newIndex as $sym => $data) $indexKlineCache[$sym] = $data;

    $newPremium = fetchPremiumKlineChunk($symbols, $premiumKlineOffset);
    foreach ($newPremium as $sym => $data) $premiumKlineCache[$sym] = $data;

    // 데이터 병합 + 정규화
    $nowMs     = (int)(microtime(true) * 1000);
    $updatedAt = date(\\\'Y-m-d H:i:s\\\');
    $rows      = [];

    foreach ($symbols as $symbol) {
        if (!isset($tickers[$symbol])) continue;

        $raw = array_merge(
            [\\\'symbol\\\' => $symbol],
            [\\\'time\\\'   => $nowMs],
            $instrumentCache[$symbol]   ?? [],
            $tickers[$symbol]           ?? [],
            $klineCache[$symbol]        ?? [],
            $tradeCache[$symbol]        ?? [],
            $riskCache[$symbol]         ?? [],
            $ratioCache[$symbol]        ?? [],
            $markKlineCache[$symbol]    ?? [],
            $indexKlineCache[$symbol]   ?? [],
            $premiumKlineCache[$symbol] ?? []
        );

        $rows[] = normalizeRow($raw, $updatedAt);
    }

    // Bulk upsert
    try {
        bulkUpsert($pdo, $rows);
    } catch (Throwable $e) {
        stderr(\\\'[ERROR] bulkUpsert 예외: \\\' . $e->getMessage());
    }

    // heartbeat
    $elapsed = round(microtime(true) - $cycleStart, 2);
    $memo    = sprintf(
        \\\'upsert %d건 / %.2fs | kline_off=%d trade_off=%d risk_off=%d\\\',
        count($rows), $elapsed, $klineOffset, $tradeOffset, $riskOffset
    );
    updateDaemonRecord($pdo, $ctx, \\\'RUNNING\\\', $memo);
    stdout($memo);

    // 다음 사이클 대기
    $sleep = max(0, CYCLE_SEC - (int)$elapsed);
    if ($sleep > 0) sleep($sleep);
}
최근 "데몬" 데이터
# 바이비트 # API # 실시간 # 플렛폼 # 수집 # 데몬 # 구 버전 # 마지막