CORE TERMINAL
올드보이 & 마리아 백업
바이비트 API 베스트 플렛폼 수집 데몬 - 구 버전 : 마지막 버전
DATE: 2026-03-03 12:23
핵심 항목
테마2.0
내용
* 바이비트 API 베스트 플렛폼 수집 데몬 - 구 버전 : 마지막 버전
1. 구버전 마지막 버전
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);
}
최근 "데몬" 데이터