봇 코드 · upbit-mm (Supabase Edge 함수)
릴스에서 「댓글에 클로드」로 안내한 그 코드. 봇·이 페이지·대본 전부 Claude Code 로 만들었고, 마지막 수정 커밋 98983e35 2026-09-13. ← 손익 페이지로
투자 권유가 아닙니다. 개인 실험의 공개 기록이며 이 코드로 돌린 봇은 첫 24시간에 5번 체결 4번 손실(-703원)을 냈습니다. 원금 손실이 날 수 있습니다. 그대로 돌리지 말고, 체결마다 왜 먹혔는지부터 적으세요.
돌리는 데 필요한 것
- Supabase 프로젝트 하나(무료). 테이블
ub_config · ub_ledger · ub_events · ub_status는 코드 상단 주석의 SQL 대로. - 업비트 Open API 키(자산 조회·주문 권한, 서버 IP 허용). 키는 코드가 아니라 Supabase
ub_secretsRPC 에 둔다(코드엔 키가 없다). - 배포:
supabase functions deploy upbit-mm --no-verify-jwt→ pg_cron 으로 1분마다 호출. - 한도·사다리(3.5/7/15/25%)·3분 급변 가드 2%·헤지 3분 지정가는
ub_config에서 바꾼다.
전체 소스 (326줄)
// upbit-mm , 업비트 BTC·USDT 마켓 메이커 봇 Supabase Edge Function 판 (2026-09-12, 사장님 "메인은 수파베이스 봇"). 로컬 mm_bot.js 를 그대로 옮겼다.
// 트리거: pg_cron 매분 ?mode=run&async=1 → ~55초 동안 3초 루프(호가·체결감시·헤지·리밸런스) → 상태 저장 후 종료. 겹침은 ub_state.run_lock_until 로 막는다.
// 서명 호출은 전부 DB 의 upbit_enqueue/upbit_result(pg_net, 고정 IP 43.201.156.141, Vault 키). 공개 시세는 여기서 직접(실패하면 DB 경유).
// 상태 ub_state.data(구 state.json) · 설정 ub_config(data, enabled) · 재보정 ub_params · 원장 ub_ledger · 대시보드 ub_status/ub_fills/ub_events.
// 안전: post_only 만 · 우리 uuid 만 관리(사장님 주문 불가침) · 거래유의 제외 · enabled=false 면 전량 취소 · 일일 손실 상한이면 취소+HALT · 데드맨(ub_deadman)은 그대로.
const SB = Deno.env.get("SUPABASE_URL") ?? "", SRK = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "";
const H = { apikey: SRK, Authorization: `Bearer ${SRK}`, "Content-Type": "application/json" };
const log = (...a: unknown[]) => console.log(new Date().toISOString(), ...a);
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const now = () => new Date().toISOString();
let SEC: Record<string, string> = {};
async function loadSecrets() { if (Object.keys(SEC).length) return; const r = await fetch(`${SB}/rest/v1/rpc/ub_secrets`, { method: "POST", headers: H, body: "{}" }); if (r.ok) SEC = await r.json(); }
// ---------- DB ----------
async function db(method: string, path: string, body?: unknown, prefer = "return=minimal") {
const r = await fetch(`${SB}/rest/v1/${path}`, { method, headers: { ...H, Prefer: prefer }, body: body === undefined ? undefined : JSON.stringify(body) });
const t = await r.text(); if (!r.ok) throw new Error(`db ${method} ${path} ${r.status} ${t.slice(0, 200)}`); return t ? JSON.parse(t) : null;
}
async function rpc(fn: string, args: unknown) { const r = await fetch(`${SB}/rest/v1/rpc/${fn}`, { method: "POST", headers: H, body: JSON.stringify(args) }); const t = await r.text(); if (!r.ok) throw new Error(`rpc ${fn} ${r.status} ${t.slice(0, 200)}`); return JSON.parse(t); }
// ---------- 업비트 서명 호출(DB 경유) ----------
const enc = (v: unknown) => encodeURIComponent(String(v));
const qsOf = (params: Record<string, unknown>) => Object.entries(params).flatMap(([k, v]) => Array.isArray(v) ? v.map((x) => `${k}[]=${enc(x)}`) : [`${k}=${enc(v)}`]).join("&");
async function api(method: string, path: string, params: Record<string, unknown> = {}) {
const id = await rpc("upbit_enqueue", method === "POST" ? { p_method: "POST", p_path: path, p_qs: "", p_body: params } : { p_method: method, p_path: path, p_qs: qsOf(params), p_body: null });
let j: any = null;
for (let i = 0; i < 4; i++) { j = await rpc("upbit_result", { p_id: id, p_wait_ms: 3000 }); if (j.status !== null && j.status !== undefined) break; }
if (!j || j.status === null || j.status === undefined) throw new Error(`${method} ${path} no response (${id})`);
if (j.status < 200 || j.status >= 300) throw new Error(`${method} ${path} ${j.status} ${String(j.body ?? "").slice(0, 200)}`);
return JSON.parse(j.body);
}
// ---------- 공개 시세 ----------
async function pub(path: string) {
for (let i = 0; i < 3; i++) {
try { const r = await fetch("https://api.upbit.com/v1" + path, { headers: { Accept: "application/json" } }); if (r.status === 429) { await sleep(400); continue; } if (r.ok) return r.json(); } catch { /* fallthrough */ }
await sleep(200);
}
// Edge 발신에서 막히면 DB 경유(서명 헤더가 붙지만 공개 API 는 무시한다)
return api("GET", path.split("?")[0], Object.fromEntries(new URLSearchParams(path.split("?")[1] ?? "")));
}
const chunk = <T,>(a: T[], n: number) => Array.from({ length: Math.ceil(a.length / n) }, (_, i) => a.slice(i * n, i * n + n));
// ---------- 상태·설정 ----------
type Order = { uuid: string; market: string; side: string; price: number; volume: number; executed: number; created: string; level?: number };
let CFG: any = {}, P: any = null, state: any = {}, ENABLED = false, DEADLINE = 0;
const tickOf: Record<string, number> = {};
const decimals = (t: number) => Math.max(0, Math.ceil(-Math.log10(t) - 1e-9));
const roundTick = (p: number, t: number, dir: string) => { const n = p / t; const k = dir === "up" ? Math.ceil(n - 1e-9) : dir === "down" ? Math.floor(n + 1e-9) : Math.round(n); return +(k * t).toFixed(decimals(t)); };
const krwTick = (p: number) => p >= 1e6 ? 1000 : p >= 5e5 ? 500 : p >= 1e5 ? 100 : p >= 5e4 ? 50 : p >= 1e4 ? 10 : p >= 5e3 ? 5 : p >= 100 ? 1 : p >= 10 ? 0.1 : p >= 1 ? 0.01 : p >= 0.1 ? 0.001 : p >= 0.01 ? 0.0001 : p >= 0.001 ? 0.00001 : p >= 0.0001 ? 0.000001 : p >= 0.00001 ? 0.0000001 : 0.00000001;
const ledgerBuf: { ev: string; data: unknown }[] = [];
const ledger = (rec: Record<string, unknown>) => { const { ev, ...data } = rec; ledgerBuf.push({ ev: String(ev), data }); };
async function flushLedger() { if (!ledgerBuf.length) return; const rows = ledgerBuf.splice(0); try { await db("POST", "ub_ledger", rows); } catch (e) { log("ledger fail", (e as Error).message); } }
async function loadAll() {
const [c, p, s] = await Promise.all([db("GET", "ub_config?id=eq.1", undefined, "return=representation"), db("GET", "ub_params?id=eq.1", undefined, "return=representation"), db("GET", "ub_state?id=eq.1", undefined, "return=representation")]);
CFG = c[0].data; ENABLED = !!c[0].enabled; P = p[0].data && Object.keys(p[0].data).length ? p[0].data : null; state = s[0].data ?? {};
state.orders ??= {}; state.pendingHedge ??= {}; state.pnlKrw ??= 0; state.rewardKrw ??= 0; state.fills ??= 0; state.hedges ??= 0; state.makerKrw ??= 0; state.pauseUntil ??= {}; state.fillCost ??= {}; state.lastFill ??= {};
if (P) { // 재보정 핫리로드(가드레일: 사다리 1.5~35%, 변동성 2~5%, 마켓 ≤80)
const lv = P.levels || [P.mTarget]; const ok = lv.every((x: number) => x >= 0.015 && x <= 0.35) && P.volMax >= 0.02 && P.volMax <= 0.05 && Array.isArray(P.markets) && P.markets.length <= 80;
if (ok) { CFG.levels = lv; CFG.mTarget = lv[0]; CFG.volMax = P.volMax; CFG.postFillPauseMs = P.postFillPauseMs ?? CFG.postFillPauseMs; if (P.sides?.length) CFG.sides = P.sides.filter((x: string) => (CFG.allowSides ?? ["ask", "bid"]).includes(x)); }
else { log("params 가드레일 밖 → 무시"); P = null; }
}
}
async function saveState() { await db("PATCH", "ub_state?id=eq.1", { data: state, updated_at: now() }); }
const ours = (): Order[] => Object.values(state.orders);
// ---------- 유니버스 ----------
let universe: any[] = [], warned = new Set<string>(), volRange: Record<string, number> = {};
async function refreshUniverse() {
const all: any[] = await pub("/market/all?isDetails=true"); const byM = Object.fromEntries(all.map((m) => [m.market, m]));
warned = new Set(all.filter((m) => m.market_event?.warning).map((m) => m.market));
const cands = all.filter((m) => CFG.quotes.includes(m.market.split("-")[0]) && byM["KRW-" + m.market.split("-")[1]] && !CFG.excludeBases.includes(m.market.split("-")[1]) && !m.market_event?.warning && !byM["KRW-" + m.market.split("-")[1]].market_event?.warning);
const tick: Record<string, any> = {}; for (const c of chunk([...cands.map((m) => m.market), ...cands.map((m) => "KRW-" + m.market.split("-")[1]), "KRW-USDT", "KRW-BTC"], 100)) { for (const t of await pub("/ticker?markets=" + c.join(","))) tick[t.market] = t; await sleep(120); }
const rate: Record<string, number> = { USDT: tick["KRW-USDT"].trade_price, BTC: tick["KRW-BTC"].trade_price };
const need = cands.map((m) => m.market).filter((m) => !tickOf[m]); for (const c of chunk(need, 60)) { for (const x of await pub("/orderbook/instruments?markets=" + c.join(","))) tickOf[x.market] = state.tickFix?.[x.market] || parseFloat(x.tick_size); await sleep(120); }
// 실측 우선: 최근 7일 손실 헤지 2회 이상 코인 제외
const lose = new Set<string>(); try { const rows = await db("GET", `ub_ledger?ev=eq.hedge&at=gte.${new Date(Date.now() - 7 * 86400000).toISOString()}&select=data`, undefined, "return=representation"); const cnt: Record<string, number> = {}; for (const r of rows) if ((r.data?.pnl ?? 0) < 0) cnt[r.data.market.slice(4)] = (cnt[r.data.market.slice(4)] || 0) + 1; for (const [b, n] of Object.entries(cnt)) if (n >= 2) lose.add(b); } catch { /* ignore */ }
const picked: any[] = [];
for (const m of cands) {
const [q, b] = m.market.split("-"); const t = tick[m.market], k = tick["KRW-" + b]; if (!t || !k || lose.has(b)) continue;
const volKrw = t.acc_trade_price_24h * rate[q];
if (k.acc_trade_price_24h < CFG.krwVol24hMin) continue; if (volKrw < CFG.targetVol24hMinKrw) continue; if (!tickOf[m.market] || tickOf[m.market] / t.trade_price > CFG.tickMaxRatio) continue;
picked.push({ market: m.market, quote: q, base: b, volKrw, gap: 0 });
}
const rank = new Map<string, number>(((P?.markets ?? []) as string[]).map((m, i) => [m, i]));
picked.sort((a, b) => ((rank.get(a.market) ?? 1e6) - (rank.get(b.market) ?? 1e6)) || b.volKrw - a.volKrw);
universe = picked.slice(0, CFG.maxMarkets); log(`유니버스 ${universe.length}종 (후보 ${cands.length}, warning ${warned.size}, 제외 ${[...lose].join(",") || "-"})`);
}
async function refreshVol() {
const bases = [...new Set(universe.map((u) => u.base))]; let i = 0;
for (const b of bases) { try { const c: any[] = await pub(`/candles/minutes/1?market=KRW-${b}&count=${CFG.volMinutes}`); const hi = Math.max(...c.map((x) => x.high_price)), lo = Math.min(...c.map((x) => x.low_price)); volRange[b] = (hi - lo) / c[0].trade_price; } catch { /* keep old */ } if (++i % 8 === 0) await sleep(900); else await sleep(110); }
const wild = Object.entries(volRange).filter(([, v]) => v > CFG.volMax); if (wild.length) log("변동성 제외:", wild.map(([b, v]) => `${b} ${(v * 100).toFixed(1)}%`).join(" "));
}
// ---------- 잔고 ----------
let bal: Record<string, { free: number; locked: number }> = {};
async function refreshBalances() { const a = await api("GET", "/v1/accounts"); bal = {}; for (const x of a) bal[x.currency] = { free: parseFloat(x.balance), locked: parseFloat(x.locked) };
// 보유코인 목록(이름만, 수량 없음)을 state 에 남긴다 → 재보정(GitHub Actions)이 /v1/accounts(IP 화이트리스트) 없이 매도 사다리 대상을 안다(2026-09-12)
state.held = Object.entries(bal).filter(([c, b]) => b.free + b.locked > 0 && !["KRW", "BTC", "ETH", "USDT", "XRP", "GWEI"].includes(c)).map(([c]) => c).sort(); state.heldAt = now(); }
const freeOf = (c: string) => Math.max(0, (bal[c]?.free || 0) - (CFG.keepBalance?.[c] || 0));
// ---------- 주문 ----------
async function placeMaker(market: string, side: string, price: number, volume: number, why: string, level: number) {
const identifier = `ub-L${Math.round(level * 1000)}-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
let r: any;
try { r = await api("POST", "/v1/orders", { market, side, ord_type: "limit", price: String(price), volume: String(volume), time_in_force: "post_only", identifier }); }
catch (e) {
// ★가격 단위 오류: /orderbook/instruments 의 tick_size 는 **지금 가격대** 기준이라 사다리 가격이 다른 가격대(예: XCN 0.0009→0.0011)로 넘어가면 단위가 달라진다(2026-09-12 v9 실측 8건/3분).
// 틱을 10배 굵게 다시 반올림해 한 번만 재시도하고, 그 마켓의 틱을 state.tickFix 에 기억한다(다음 실행에서도 적용).
if (!/invalid_price/.test(String((e as Error).message))) throw e;
const t2 = (tickOf[market] || 0) * 10; if (!t2) throw e; tickOf[market] = t2; state.tickFix = state.tickFix || {}; state.tickFix[market] = t2;
const p2 = roundTick(price, t2, side === "bid" ? "down" : "up"); volume = +((price * volume) / p2).toFixed(8); price = p2; log(`가격단위 재시도 ${market} tick ${t2} → ${price}`);
r = await api("POST", "/v1/orders", { market, side, ord_type: "limit", price: String(price), volume: String(volume), time_in_force: "post_only", identifier });
}
const o: Order = { uuid: r.uuid, market, side, price, volume, executed: 0, created: now(), level }; state.orders[o.uuid] = o; ledger({ ev: "order", ...o, why }); log(`주문 ${side} ${market} ${volume} @ ${price} (${why}) ${o.uuid.slice(0, 8)}`); return o;
}
// ★취소가 "이미 끝난 주문"으로 실패하면 그 주문은 체결됐을 수 있다. 상태에서 지우면 체결이 무기록이 된다(2026-09-13 00:36 BTC-MTL 68 MTL 3만 원이 그렇게 사라져 11시간 무헤지, 사장님이 직접 매도).
// 업비트 오류명은 done_order(옛 정규식 already 에 안 걸려 quote 단계 전체가 던져졌다). 미정산 체결이 있으면 남겨 두고 pollFills 가 정산·삭제하게 한다(v14).
async function cancelOurs(o: Order, why: string) {
try { await api("DELETE", "/v1/order", { uuid: o.uuid }); ledger({ ev: "cancel", uuid: o.uuid, market: o.market, why }); }
catch (e) { const m = (e as Error).message; if (!/order_not_found|done_order|already/i.test(m)) throw e;
let ex = o.executed; try { const g = await api("GET", "/v1/order", { uuid: o.uuid }); ex = parseFloat(g.executed_volume); } catch { /* 조회 실패면 남겨 둔다 */ ex = o.executed + 1; }
ledger({ ev: "cancel_skip", uuid: o.uuid, market: o.market, why, executed: ex, msg: m.slice(0, 160) });
if (ex > o.executed + 1e-12) { log(`취소 불가 ${o.market} ${o.uuid.slice(0, 8)} 미정산 체결 ${ex} → pollFills 가 정산`); return; } }
delete state.orders[o.uuid]; }
async function cancelAll(why: string) { for (const o of ours()) { try { await cancelOurs(o, why); } catch (e) { log("취소 실패", o.uuid, (e as Error).message); } } }
async function pollFills(rates: Record<string, number>) {
const list = ours(); if (!list.length) return;
for (const c of chunk(list, 100)) {
const r = await api("GET", "/v1/orders/uuids", { uuids: c.map((o) => o.uuid) });
for (const x of r) {
const o = state.orders[x.uuid]; if (!o) continue; const ex = parseFloat(x.executed_volume), d = ex - o.executed;
if (d > 0) {
o.executed = ex; state.fills++; const notionalQ = d * o.price, krw = notionalQ * rates[o.market.split("-")[0]]; state.rewardKrw += krw * 0.0004; state.makerKrw += krw;
const base = o.market.split("-")[1]; state.pendingHedge[base] = (state.pendingHedge[base] || 0) + (o.side === "ask" ? d : -d);
const prev = state.fillCost[base] || { qty: 0, krwPerUnit: 0 }; const nq = prev.qty + d; state.fillCost[base] = { qty: nq, krwPerUnit: (prev.krwPerUnit * prev.qty + krw) / nq };
state.pauseUntil[o.market] = Date.now() + CFG.postFillPauseMs; state.lastFill[base] = { uuid: x.uuid + ":" + ex, at: now(), market: o.market, side: o.side, price: o.price, vol: d, krw };
ledger({ ev: "fill", uuid: x.uuid, market: o.market, side: o.side, price: o.price, vol: d, quoteNotional: notionalQ, krw, paid_fee: x.paid_fee, state: x.state });
log(`★체결 ${o.side} ${o.market} ${d} @ ${o.price} = ${krw.toFixed(0)}원 (fee ${x.paid_fee}) → 헤지 대기 ${base} ${state.pendingHedge[base]}`);
}
if (x.state === "done" || x.state === "cancel") delete state.orders[x.uuid];
}
}
}
async function iocKrw(km: string, side: string, price: number, vol: number) {
const r = await api("POST", "/v1/orders", { market: km, side, ord_type: "limit", price: String(price), volume: String(vol), time_in_force: "ioc", smp_type: "cancel_taker" }); await sleep(300);
const g = await api("GET", "/v1/order", { uuid: r.uuid }); const ex = parseFloat(g.executed_volume), funds = (g.trades || []).reduce((s: number, t: any) => s + parseFloat(t.funds), 0), fee = parseFloat(g.paid_fee); return { uuid: r.uuid, ex, funds, fee, state: g.state };
}
async function rebalance(books: Record<string, any>, rates: Record<string, number>, force = false) {
if (!state.baseline) { state.baseline = { USDT: (bal.USDT?.free || 0) + (bal.USDT?.locked || 0), BTC: (bal.BTC?.free || 0) + (bal.BTC?.locked || 0) }; ledger({ ev: "baseline", ...state.baseline }); log("기준선", JSON.stringify(state.baseline)); }
for (const q of ["USDT", "BTC"]) {
const total = (bal[q]?.free || 0) + (bal[q]?.locked || 0), drift = total - state.baseline[q], driftKrw = drift * rates[q];
if (Math.abs(driftKrw) < (force ? 1000 : CFG.rebalanceMinKrw)) continue; if (force && drift < 0) continue;
const u = books["KRW-" + q]?.orderbook_units?.[0]; if (!u) continue;
const side = drift > 0 ? "ask" : "bid"; const raw = drift > 0 ? u.bid_price * 0.998 : u.ask_price * 1.002; const price = roundTick(raw, krwTick(raw), drift > 0 ? "down" : "up"); const vol = +Math.abs(drift).toFixed(8);
if (side === "ask" && (bal[q]?.free || 0) < vol) continue;
const r = await iocKrw("KRW-" + q, side, price, vol); ledger({ ev: "rebalance", ...r, market: "KRW-" + q, side, price, vol, driftKrw }); log(`리밸런스 ${side} KRW-${q} ${r.ex}/${vol} @ ${price} funds ${r.funds.toFixed(0)}원`);
}
}
// ★헤지는 "손익분기 지정가 먼저, 안 되면 시장가"(2026-09-13 v13). 우리 매수가가 먹히는 순간은 KRW 시세가 꼬리 끝(극저점)일 때다 , 차익거래자가 그때 판다.
// 그 자리에서 곧바로 IOC 로 팔면 꼬리 바닥값을 확정한다(VTHO 0.895 · BFC 46.50, 둘 다 같은 분 종가는 0.91 · 47.4 로 매수가 위였다). 백테스트도 분 종가 헤지를 가정했다.
// → 원가 x (1 + 수수료 0.05% + 여유 0.1%) 에 지정가를 걸고 기다린다. 시세가 원가 대비 hedgeStopPct(1.2%) 더 밀리거나 hedgeWaitMs(3분) 지나거나 시장 급락 가드가 켜지면 취소하고 IOC.
const KRW_FEE = 0.0005;
function account(base: string, km: string, side: string, price: number, ex: number, funds: number, fee: number, mode: string, uuid: string) {
const fc = state.fillCost[base]; const pnl = fc ? (side === "bid" ? fc.krwPerUnit * ex - (funds + fee) : (funds - fee) - fc.krwPerUnit * ex) : 0;
state.pendingHedge[base] = (state.pendingHedge[base] || 0) - (side === "bid" ? ex : -ex); state.pnlKrw += pnl; state.hedges++;
state.hedgeCycle ??= {}; const cyc = (state.hedgeCycle[base] ??= { pnl: 0, funds: 0, fee: 0, ex: 0, side, modes: [] as string[] }); cyc.pnl += pnl; cyc.funds += funds; cyc.fee += fee; cyc.ex += ex; if (!cyc.modes.includes(mode)) cyc.modes.push(mode);
{ const kd = new Date(Date.now() + 9 * 3600e3).toISOString().slice(0, 10); if (state.pnlDayKey !== kd) { state.pnlDayKey = kd; state.pnlDay = 0; } state.pnlDay = (state.pnlDay || 0) + pnl; }
ledger({ ev: "hedge", uuid, market: km, side, price, executed: ex, funds, fee, pnl, mode, remainingPending: state.pendingHedge[base] });
log(`헤지(${mode}) ${side} ${km} ${ex} @ ${price} funds ${funds.toFixed(0)}원 fee ${fee.toFixed(0)} 손익 ${pnl.toFixed(0)}원 (누적 ${state.pnlKrw.toFixed(0)})`);
}
async function finishHedge(base: string, km: string) {
const cyc = state.hedgeCycle?.[base]; if (!cyc) return; delete state.hedgeCycle[base];
const lf = state.lastFill[base]; const pnl = cyc.pnl; const hedgeSide = cyc.side;
await tg(`${pnl >= 0 ? "✅" : "🔻"} 업비트 체결 ${lf?.market ?? base} ${lf?.side === "ask" ? "매도" : "매수"} ${Math.round(lf?.krw ?? 0).toLocaleString()}원 → 헤지 ${km} ${hedgeSide === "bid" ? "매수" : "매도"} ${Math.round(cyc.funds).toLocaleString()}원 (${cyc.modes.join("+")})\n이번 ${Math.round(pnl).toLocaleString()}원 · 누적 ${Math.round(state.pnlKrw).toLocaleString()}원 + 리워드 예정 ${Math.round(state.rewardKrw).toLocaleString()}원 (체결 ${state.fills}건)\nhttps://upbit-log.pages.dev`);
if (lf) db("POST", "ub_fills", { uuid: lf.uuid, at: lf.at, market: lf.market, side: lf.side, price: lf.price, vol: lf.vol, krw: Math.round(lf.krw), hedge_krw: Math.round(hedgeSide === "bid" ? cyc.funds + cyc.fee : cyc.funds - cyc.fee), pnl_krw: Math.round(pnl), reward_krw: +(lf.krw * 0.0004).toFixed(2) }, "resolution=merge-duplicates,return=minimal").catch((e) => log("fills push", (e as Error).message));
if (state.pnlDay < -CFG.maxDailyLossKrw) { log(`★일일 손실 상한 → 전량 취소·정지`); ledger({ ev: "loss_stop", pnl: state.pnlKrw }); await cancelAll("loss_stop"); await db("PATCH", "ub_config?id=eq.1", { enabled: false, updated_at: now() }); await evt("정지", `일일 손실 상한(${CFG.maxDailyLossKrw}) 도달, 자동 정지 (누적 ${state.pnlKrw.toFixed(0)}원)`); await tg(`🛑 업비트 봇 자동 정지: 손실 상한 ${CFG.maxDailyLossKrw.toLocaleString()}원 도달 (누적 ${Math.round(state.pnlKrw).toLocaleString()}원). 봇 주문 전량 취소, enabled=false. 재개는 저한테 "재개" 라고.`); ENABLED = false; }
}
async function orderTotals(uuid: string) { const g = await api("GET", "/v1/order", { uuid }); return { state: g.state as string, ex: parseFloat(g.executed_volume), funds: (g.trades || []).reduce((s: number, t: any) => s + parseFloat(t.funds), 0), fee: parseFloat(g.paid_fee) }; }
async function hedge(books: Record<string, any>, rates: Record<string, number>) {
state.passiveHedge ??= {}; const nowMs = Date.now(); const forceAll = (state.globalPauseUntil || 0) > nowMs;
const stopPct = CFG.hedgeStopPct ?? 0.012, waitMs = CFG.hedgeWaitMs ?? 180000, margin = CFG.hedgeMargin ?? 0.001;
for (const base of Object.keys(state.pendingHedge)) {
const km = "KRW-" + base, b = books[km]; if (!b) continue; const u = b.orderbook_units[0]; const mid = (u.ask_price + u.bid_price) / 2; const fc = state.fillCost[base];
const ph = state.passiveHedge[base];
if (ph) { // 1) 걸어둔 손익분기 지정가 점검
let t = await orderTotals(ph.uuid); const step = (tt: any) => { const dEx = tt.ex - ph.ex; if (dEx > 1e-12) { account(base, km, ph.side, ph.price, dEx, tt.funds - ph.funds, tt.fee - ph.fee, "passive", ph.uuid); ph.ex = tt.ex; ph.funds = tt.funds; ph.fee = tt.fee; } };
step(t);
const left = state.pendingHedge[base] || 0; const dust = Math.abs(left) * mid < CFG.hedgeMinKrw;
if (t.state === "done" || t.state === "cancel" || dust) { if (t.state === "wait" && dust) await api("DELETE", "/v1/order", { uuid: ph.uuid }).catch(() => {}); delete state.passiveHedge[base]; if (dust) { state.pendingHedge[base] = 0; delete state.fillCost[base]; } await finishHedge(base, km); continue; }
const stop = !!fc && (ph.side === "ask" ? mid < fc.krwPerUnit * (1 - stopPct) : mid > fc.krwPerUnit * (1 + stopPct)); const timeout = nowMs - ph.at > waitMs;
if (!(stop || timeout || forceAll)) continue; // 아직 기다린다
const why = stop ? `stop ${((mid / fc.krwPerUnit - 1) * 100).toFixed(2)}%` : timeout ? "timeout" : "crash_global";
await api("DELETE", "/v1/order", { uuid: ph.uuid }).catch(() => {}); await sleep(300); t = await orderTotals(ph.uuid); step(t); delete state.passiveHedge[base];
ledger({ ev: "hedge_force", market: km, why, mid, cost: fc?.krwPerUnit, left: state.pendingHedge[base] }); log(`패시브 헤지 강제 전환 ${km}: ${why}`);
}
const qty = state.pendingHedge[base] || 0; if (Math.abs(qty) < 1e-12) { if (state.hedgeCycle?.[base]) await finishHedge(base, km); continue; }
if (Math.abs(qty) * mid < CFG.hedgeMinKrw) { if (state.hedgeCycle?.[base]) { state.pendingHedge[base] = 0; delete state.fillCost[base]; await finishHedge(base, km); } continue; }
const side = qty > 0 ? "bid" : "ask"; const vol = +Math.abs(qty).toFixed(8);
const rawIoc = qty > 0 ? u.ask_price * (1 + CFG.hedgeSlip) : u.bid_price * (1 - CFG.hedgeSlip);
if (qty > 0 && (bal.KRW?.free || 0) < vol * rawIoc * 1.001) { log("헤지용 KRW 부족 → 리밸런스 먼저"); await rebalance(books, rates, true); await refreshBalances(); if ((bal.KRW?.free || 0) < vol * rawIoc * 1.001) { log("KRW 여전히 부족, 다음 루프"); continue; } }
const be = fc ? (qty > 0 ? fc.krwPerUnit * (1 - KRW_FEE - margin) : fc.krwPerUnit * (1 + KRW_FEE + margin)) : 0; // 손익분기(수수료 포함)
const passiveOk = !!fc && !ph && !forceAll; // 방금 강제 전환했으면(ph 있었음) 남은 건 IOC
if (passiveOk && (qty > 0 ? u.ask_price > be : u.bid_price < be)) { // 지금 시세로는 손익분기가 안 나온다 → 지정가를 걸고 기다린다
const price = roundTick(be, krwTick(be), qty > 0 ? "down" : "up");
const r = await api("POST", "/v1/orders", { market: km, side, ord_type: "limit", price: String(price), volume: String(vol) });
state.passiveHedge[base] = { uuid: r.uuid, side, price, vol, at: nowMs, ex: 0, funds: 0, fee: 0 }; state.hedgeCycle ??= {}; state.hedgeCycle[base] ??= { pnl: 0, funds: 0, fee: 0, ex: 0, side, modes: [] };
ledger({ ev: "hedge_passive", uuid: r.uuid, market: km, side, price, vol, mid, cost: fc.krwPerUnit }); log(`패시브 헤지 ${side} ${km} ${vol} @ ${price} (원가 ${fc.krwPerUnit.toFixed(4)}, mid ${mid}) 최대 ${Math.round(waitMs / 60000)}분 대기`);
continue;
}
// 2) 손익분기가 지금 나오거나(IOC 를 손익분기 가격으로 = 그 이상에서만 체결) 강제 전환 잔량 → IOC
const raw = passiveOk ? be : rawIoc; const price = roundTick(raw, krwTick(raw), qty > 0 ? "up" : "down");
const r = await iocKrw(km, side, price, vol); account(base, km, side, price, r.ex, r.funds, r.fee, passiveOk ? "ioc@be" : "ioc", r.uuid);
const left = state.pendingHedge[base] || 0; if (Math.abs(left) * mid < CFG.hedgeMinKrw) { state.pendingHedge[base] = 0; delete state.fillCost[base]; await finishHedge(base, km); }
}
}
async function crashGuards(books: Record<string, any>, rates: Record<string, number>) {
state.btcHist ??= []; const nowMs = Date.now(); state.btcHist.push({ t: nowMs, p: rates.BTC }); state.btcHist = state.btcHist.filter((x: any) => nowMs - x.t < 300000);
const hi = Math.max(...state.btcHist.map((x: any) => x.p)); const drop = 1 - rates.BTC / hi;
if (drop > (CFG.crashGlobalPct ?? 0.02) && (state.globalPauseUntil || 0) < nowMs) {
state.globalPauseUntil = nowMs + (CFG.crashGlobalPauseMs ?? 600000); log(`★시장 급락 가드: KRW-BTC 5분 고점 대비 -${(drop * 100).toFixed(1)}% → 전량 취소, 10분 휴지`); ledger({ ev: "crash_global", drop, btc: rates.BTC, hi });
await cancelAll("crash_global"); await evt("가드", `시장 급락(KRW-BTC 5분 고점 대비 -${(drop * 100).toFixed(1)}%) → 봇 주문 전량 취소, 10분 휴지`); await tg(`⚠️ 업비트 봇: 시장 급락 가드(비트 5분 -${(drop * 100).toFixed(1)}%) → 주문 전량 취소, 10분 휴지`);
}
state.lastFair ??= {}; state.fairHist ??= {};
for (const u of universe) {
const kb = books["KRW-" + u.base]; if (!kb) continue; const ku = kb.orderbook_units[0]; const fair = ((ku.ask_price + ku.bid_price) / 2) / rates[u.quote];
const prev = state.lastFair[u.market]; state.lastFair[u.market] = { f: fair, t: nowMs };
// ★공정가 이력(3분, 15초 표본). quote() 가 매수 기준을 min(지금, 이력), 매도 기준을 max 로 잡는다(2026-09-13 09:03 VTHO -187원: 09:00 +3.4% 스파이크에 가드가 취소 → 2분 뒤 스파이크 고점(0.933) 기준으로 재호가 → 09:03 -4.8% 되돌림에 0.900 매수가 시장(0.895) 위에 남아 차익거래자에게 먹혔다)
const win = CFG.anchorWindowMs ?? 180000; const fh: { t: number; f: number }[] = (state.fairHist[u.market] ??= []); if (!fh.length || nowMs - fh[fh.length - 1].t >= 15000) fh.push({ t: nowMs, f: fair }); state.fairHist[u.market] = fh.filter((x) => nowMs - x.t <= win);
const pauseMs = CFG.crashMarketPauseMs ?? 600000; // ★2분 → 10분(스파이크는 되돌아온다. 2분 휴지는 되돌림 한가운데서 재진입시켰다)
// ★창 기준 가드(v13): 표본 사이 3초 변화만 보면 1분에 -4.3% 미끄러지는 하락(BFC 09:27)을 놓친다(표본마다 1.5% 미만). 3분 이력의 고점 대비 하락·저점 대비 상승이 crashWindowPct(2%) 를 넘으면 같은 처리
const hiH = Math.max(...state.fairHist[u.market].map((x: any) => x.f)), loH = Math.min(...state.fairHist[u.market].map((x: any) => x.f)); const wmv = Math.max(1 - fair / hiH, fair / loH - 1);
if (wmv > (CFG.crashWindowPct ?? 0.02) && (state.pauseUntil[u.market] || 0) < nowMs) { const mine = ours().filter((o) => o.market === u.market); if (mine.length) { log(`급변 가드(3분 창) ${u.market} ${(wmv * 100).toFixed(1)}% → ${mine.length}건 취소, ${Math.round(pauseMs / 60000)}분 휴지`); ledger({ ev: "crash_market", market: u.market, move: wmv, window: true }); for (const o of mine) await cancelOurs(o, "crash_market"); } state.pauseUntil[u.market] = nowMs + pauseMs; continue; }
if (prev && nowMs - prev.t <= 120000) { const mv = Math.abs(fair / prev.f - 1); if (mv > (CFG.crashMarketPct ?? 0.015)) { const mine = ours().filter((o) => o.market === u.market); if (mine.length) { log(`급변 가드 ${u.market} ${(mv * 100).toFixed(1)}%/${Math.round((nowMs - prev.t) / 1000)}s → ${mine.length}건 취소, ${Math.round(pauseMs / 60000)}분 휴지`); ledger({ ev: "crash_market", market: u.market, move: mv, secs: (nowMs - prev.t) / 1000 }); for (const o of mine) await cancelOurs(o, "crash_market"); } state.pauseUntil[u.market] = nowMs + pauseMs; } }
}
}
async function quote(books: Record<string, any>, rates: Record<string, number>) {
if ((state.globalPauseUntil || 0) > Date.now()) { if (ours().length) await cancelAll("global_pause"); return; }
// ★유니버스에서 빠진 마켓의 주문은 걷는다. 안 걷으면 재호가·급변 가드 밖에서 **몇 시간씩 옛 공정가에 서 있다가** 시세가 내려와 닿으면 "정상가 매수 → 정상가 헤지" = 수수료만 잃는다
// (2026-09-13 07:22 USDT-PIEVERSE: 00:49 에 -3.54% 로 건 주문이 6.5시간 방치, KRW 시세가 -3.7% 흘러내려 체결 → -45원).
{ const uni = new Set(universe.map((u) => u.market)); for (const o of ours()) if (!uni.has(o.market)) await cancelOurs(o, "out of universe"); }
const active: Record<string, Order> = {}; for (const o of ours()) active[o.market + ":" + o.side + ":" + (o.level ?? CFG.mTarget)] = o;
const LV: number[] = CFG.levels?.length ? CFG.levels : [CFG.mTarget]; let ops = 0; const used: Record<string, number> = {}; const freeNow = (c: string) => freeOf(c) - (used[c] || 0);
let lockedKrw = ours().reduce((s, o) => s + (o.volume - o.executed) * o.price * rates[o.market.split("-")[0]], 0);
for (const u of universe) {
if (warned.has(u.market) || warned.has("KRW-" + u.base)) { for (const o of ours().filter((o) => o.market === u.market)) await cancelOurs(o, "warning"); continue; }
const kb = books["KRW-" + u.base], tb = books[u.market]; if (!kb || !tb) continue;
const ku = kb.orderbook_units[0]; if ((ku.ask_price - ku.bid_price) / ku.ask_price > CFG.krwSpreadMax) continue;
const paused = (state.pauseUntil[u.market] || 0) > Date.now(); const fhv = ((state.fairHist?.[u.market] || []) as { f: number }[]).map((x) => x.f); const vr = Math.max(volRange[u.base] || 0, fhv.length > 1 ? (Math.max(...fhv) - Math.min(...fhv)) / fhv[fhv.length - 1] : 0);
if (paused || vr > CFG.volMax) { for (const o of ours().filter((o) => o.market === u.market)) await cancelOurs(o, paused ? "post-fill pause" : `volatile ${(vr * 100).toFixed(1)}%`); continue; }
const mid = ((ku.ask_price + ku.bid_price) / 2) / rates[u.quote]; const tick = tickOf[u.market]; if (!tick) continue;
// ★기준가는 스파이크를 쫓지 않는다: 매수는 min(지금, 3분 이력) · 매도는 max. 백테스트도 직전 분 종가를 기준으로 썼는데 실전은 순간 중간가라 스파이크 고점에 사다리를 걸었다(VTHO -187원)
const fhs = ((state.fairHist?.[u.market] || []) as { f: number }[]).map((x) => x.f); const bidFair = Math.min(mid, ...fhs), askFair = Math.max(mid, ...fhs);
const mineAll = ours().filter((o) => o.market === u.market);
const other = (side: string) => { for (const x of tb.orderbook_units) { const p = side === "bid" ? x.bid_price : x.ask_price, sz = side === "bid" ? x.bid_size : x.ask_size; const m = mineAll.find((o) => o.side === side && Math.abs(p - o.price) < 1e-12); if (m && sz <= (m.volume - m.executed) * 1.0001 + 1e-9) continue; return p; } const last = tb.orderbook_units[tb.orderbook_units.length - 1]; return side === "bid" ? last.bid_price : last.ask_price; };
const tu = { bid_price: other("bid"), ask_price: other("ask") }; const sizeQ = CFG.notionalKrw / rates[u.quote];
for (const side of CFG.sides as string[]) for (const lv of LV) {
let want: number; const fair = side === "ask" ? askFair : bidFair;
// ★단계 가격 = 공정가 x (1 ± lv) 그대로(백테스트와 동일). "남의 최우선 호가 한 틱 안쪽" 규칙은 사다리를 한 가격으로 뭉갰다(15:37 실측: BTC-APT 매도 4단이 전부 0.00001297). 책을 가로지르지만 않게 한다
if (side === "ask") { want = roundTick(fair * (1 + lv), tick, "up"); if (want <= tu.bid_price) want = roundTick(tu.bid_price + tick, tick, "up"); }
else { want = roundTick(fair * (1 - lv), tick, "down"); if (want >= tu.ask_price) want = roundTick(tu.ask_price - tick, tick, "down"); }
if (want <= 0) continue; const vol = +(sizeQ / want).toFixed(8);
const canAsk = freeNow(u.base) >= vol, canBid = freeNow(u.quote) >= sizeQ * 1.001; const cur = active[u.market + ":" + side + ":" + lv]; const tol = Math.max(CFG.requoteTol, lv / 10);
// ★잔고 검사는 **새 주문에만**. 기존 주문은 거래소에 이미 잠겨 있어 여유 잔고와 무관한데, 여유가 3만 밑이면 기존 주문까지 "no balance" 로 취소 → 잔고 풀림 → 재주문 → 반복(2026-09-12 12시간 취소 8,643건 중 7,652건, 사장님 "체결 너무 없어")
if (!cur && ((side === "ask" && !canAsk) || (side === "bid" && !canBid))) continue;
if (!cur && lockedKrw + CFG.notionalKrw > CFG.maxLockedKrw) continue;
const why = `L${(lv * 100).toFixed(0)} fair ${fair.toPrecision(6)}${Math.abs(fair / mid - 1) > 1e-6 ? ` (mid ${mid.toPrecision(6)})` : ""} ${((want / fair - 1) * 100).toFixed(2)}% best ${tu.bid_price}/${tu.ask_price}`;
if (Date.now() > DEADLINE) return; // 시간 예산 끝 → 새 주문 작업 안 함(다음 분 실행이 이어받는다)
try {
if (cur) { const off = Math.abs(cur.price - want) / want; if (off <= tol) continue; if (ops >= CFG.opsPerLoop) continue; ops++; await cancelOurs(cur, "requote"); await placeMaker(u.market, side, want, vol, "requote " + why, lv); }
else { if (ops >= CFG.opsPerLoop) continue; ops++; await placeMaker(u.market, side, want, vol, why, lv); }
used[side === "ask" ? u.base : u.quote] = (used[side === "ask" ? u.base : u.quote] || 0) + (side === "ask" ? vol : sizeQ); if (!cur) lockedKrw += CFG.notionalKrw;
} catch (e) { log("주문 실패", u.market, side, (e as Error).message.slice(0, 160)); ledger({ ev: "order_error", market: u.market, side, price: want, vol, msg: (e as Error).message.slice(0, 300) }); }
}
}
}
const evt = (kind: string, text: string) => db("POST", "ub_events", { kind, text: text.slice(0, 600) }).catch((e) => log("evt fail", (e as Error).message));
// 텔레그램(Vault tg_token/tg_chat). 세션이 없어도 사장님께 직접 간다: 체결+헤지 결과 · 손실 정지 · 정지/시작
async function tg(text: string) { try { if (!SEC.tg_token) return; await fetch(`https://api.telegram.org/bot${SEC.tg_token}/sendMessage`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ chat_id: SEC.tg_chat, text }) }); } catch (e) { log("tg fail", (e as Error).message); } }
async function pushStatus(rates: Record<string, number>, running: boolean, note: string | null = null) {
const lockedKrw = ours().reduce((s, o) => s + (o.volume - o.executed) * o.price * rates[o.market.split("-")[0]], 0);
await db("POST", "ub_status", { id: 1, updated_at: now(), running, mode: "edge", note, pot_krw: CFG.maxLockedKrw, locked_krw: Math.round(lockedKrw), open_orders: ours().length, universe: universe.length, fills: state.fills, hedges: state.hedges, maker_krw: Math.round(state.makerKrw), pnl_krw: Math.round(state.pnlKrw), reward_krw: Math.round(state.rewardKrw), ret_pct: +((state.pnlKrw + state.rewardKrw) / CFG.maxLockedKrw * 100).toFixed(3), usdt_krw: rates.USDT, btc_krw: rates.BTC, markets: universe.filter((u) => ours().some((o) => o.market === u.market)).map((u) => ({ market: u.market, gap: u.gap })), order_uuids: ours().map((o) => o.uuid) }, "resolution=merge-duplicates,return=minimal").catch((e) => log("status push", (e as Error).message));
}
// ---------- 한 번의 실행(~55초) ----------
async function run(dry: boolean) {
await loadAll();
if (!(await rpc("ub_try_lock", { p_seconds: 58 }))) { log("다른 실행이 잠금 중 → 건너뜀"); return { skipped: true }; }
const t0 = Date.now(); let loops = 0; let rates: Record<string, number> = { USDT: 0, BTC: 0 }; DEADLINE = t0 + 56000;
try {
if (!ENABLED) { if (ours().length) { log("enabled=false → 전량 취소"); await cancelAll("disabled"); await saveState(); } if (rates.USDT) await pushStatus(rates, false, "정지(enabled=false)"); else await db("POST", "ub_status", { id: 1, updated_at: now(), running: false, mode: "edge", note: "정지(enabled=false)", open_orders: 0, locked_krw: 0, markets: [], order_uuids: [] }, "resolution=merge-duplicates,return=minimal"); return { disabled: true }; }
// 시작 시 ①업비트 미체결에서 우리 표식(identifier ub-) 주문을 전부 입양(고아 방지) ②상태의 uuid 생존 확인
// ★②에서 done/cancel 이라도 미정산 체결(executed_volume > 기록)이 있으면 지우지 않는다 → 첫 루프 pollFills 가 정산·삭제(v14. 실행 경계에서 체결된 BTC-MTL 을 여기서 무기록 삭제해 11시간 무헤지였다)
const adoptNow = Date.now() - (state.adoptAt || 0) > 300000; if (adoptNow) { state.adoptAt = Date.now(); let adopted = 0; for (let page = 1; page <= 6; page++) { const r = await api("GET", "/v1/orders/open", { limit: 100, page }); for (const x of r) { if (String(x.identifier ?? "").startsWith("ub-") && !state.orders[x.uuid]) { const lvm = /^ub-L(\d+)-/.exec(x.identifier); state.orders[x.uuid] = { uuid: x.uuid, market: x.market, side: x.side, price: parseFloat(x.price), volume: parseFloat(x.volume), executed: parseFloat(x.executed_volume), created: x.created_at, level: lvm ? parseInt(lvm[1]) / 1000 : CFG.mTarget }; adopted++; } } if (r.length < 100) break; } if (adopted) { log(`고아 입양 ${adopted}건`); ledger({ ev: "adopt", n: adopted }); } }
if (adoptNow && ours().length) { const seen = new Set<string>(); for (const c of chunk(ours(), 100)) { const r = await api("GET", "/v1/orders/uuids", { uuids: c.map((o) => o.uuid) }); for (const x of r) { seen.add(x.uuid); if ((x.state === "done" || x.state === "cancel") && parseFloat(x.executed_volume) <= (state.orders[x.uuid]?.executed ?? 0) + 1e-12) delete state.orders[x.uuid]; } } for (const o of ours()) if (!seen.has(o.uuid)) delete state.orders[o.uuid]; } // ★uuids 는 100개까지(105개 → 400, 05:12~05:17 실행 전부 실패 → 데드맨 발동 실측)
await refreshUniverse(); volRange = state.volRange ?? volRange; if (Date.now() - (state.volAt || 0) > (CFG.volRefreshMs ?? 60000) * 5) { await refreshVol(); state.volAt = Date.now(); state.volRange = volRange; } // ★캔들 70종 조회가 매분 16초를 먹어 분당 36초가 눈감은 시간이었다(체결 4건 중 3건이 그 창에서). 5분마다만, 사이엔 3분 이력으로 보조
while (Date.now() - t0 < 53000) { // ★44→53초: 다음 분 실행은 잠금이 풀릴 때까지 기다리므로 겹치지 않는다
await refreshBalances();
const mk = [...new Set([...universe.map((u) => u.market), ...universe.map((u) => "KRW-" + u.base), "KRW-USDT", "KRW-BTC", ...Object.keys(state.pendingHedge).map((b) => "KRW-" + b)])];
const books: Record<string, any> = {}; for (const c of chunk(mk, 60)) { for (const b of await pub("/orderbook?markets=" + c.join(","))) books[b.market] = b; await sleep(120); }
const mid = (m: string) => { const u = books[m].orderbook_units[0]; return (u.ask_price + u.bid_price) / 2; }; rates = { USDT: mid("KRW-USDT"), BTC: mid("KRW-BTC") };
await rpc("ub_extend_lock", { p_seconds: 40 }).catch(() => {}); // 루프마다 잠금 연장(주문 작업이 길어져 잠금이 먼저 풀리면 다음 분 실행과 겹친다)
// 단계별로 감싼다: 한 단계(예: 리밸런스 KRW 부족)가 실패해도 실행 전체가 죽지 않는다(2026-09-12 15:02~15:05 매분 실행이 리밸런스에서 죽어 데드맨이 발동했다)
const step = async (name: string, fn: () => Promise<void>) => { try { await fn(); } catch (e) { const m = (e as Error).message; log(`❌ ${name}`, m.slice(0, 200)); ledger({ ev: "error", step: name, msg: m.slice(0, 400) }); if (name === "rebalance" && /insufficient_funds/.test(m)) { const nb = { USDT: (bal.USDT?.free || 0) + (bal.USDT?.locked || 0), BTC: (bal.BTC?.free || 0) + (bal.BTC?.locked || 0) }; ledger({ ev: "rebaseline", from: state.baseline, to: nb }); await evt("기준선", `리밸런스 자금 부족 → 기준선 재설정 USDT ${state.baseline?.USDT?.toFixed(2)}→${nb.USDT.toFixed(2)} · BTC ${state.baseline?.BTC?.toFixed(5)}→${nb.BTC.toFixed(5)} (외부 자금 이동 추정 → 15:2x 사장님 확인 "내가 옮겼어")`); state.baseline = nb; } } };
await step("pollFills", () => pollFills(rates)); await step("hedge", () => hedge(books, rates)); await step("rebalance", () => rebalance(books, rates)); await step("crashGuards", () => crashGuards(books, rates)); if (!dry) await step("quote", () => quote(books, rates));
await saveState(); await flushLedger(); await pushStatus(rates, true); loops++;
// 정지 신호는 매 루프 다시 읽는다(mode=stop 과 실행이 겹치면 메모리 플래그만 믿다가 취소된 주문을 다시 깐다)
try { const c = await db("GET", "ub_config?id=eq.1&select=enabled", undefined, "return=representation"); if (!c[0].enabled) { ENABLED = false; log("enabled=false 감지 → 전량 취소 후 종료"); await cancelAll("disabled"); await saveState(); await pushStatus(rates, false, "정지(enabled=false)"); break; } } catch { /* ignore */ }
await sleep(CFG.loopMs ?? 3000);
}
{ const kd = new Date(Date.now() + 9 * 3600e3).toISOString().slice(0, 10); const kh = new Date(Date.now() + 9 * 3600e3).getUTCHours(); if (kh >= 9 && state.dailyReportDay !== kd) { state.dailyReportDay = kd; await tg(`📊 업비트 봇 일일 ${kd}\n호가 ${ours().length}건 · 유니버스 ${universe.length}종 · 체결 누적 ${state.fills}건\n스프레드 손익 ${Math.round(state.pnlKrw).toLocaleString()}원 · 리워드 예정 ${Math.round(state.rewardKrw).toLocaleString()}원\nhttps://upbit-log.pages.dev`); } }
log(`실행 끝 루프 ${loops} 호가 ${ours().length}건 체결 ${state.fills} 헤지 ${state.hedges} 누적 ${state.pnlKrw.toFixed(0)}원`);
return { loops, orders: ours().length, fills: state.fills, pnl: state.pnlKrw };
} catch (e) { const m = (e as Error).message; log("❌", m); ledger({ ev: "error", step: "run", msg: m.slice(0, 400) }); await flushLedger();
try { await db("PATCH", "ub_status?id=eq.1", { note: "실행 오류: " + m.slice(0, 120), updated_at: now() }); if (!state.lastErrTg || Date.now() - state.lastErrTg > 600000) { state.lastErrTg = Date.now(); await tg(`⚠️ 업비트 봇 실행 오류: ${m.slice(0, 160)} (매분 재시도, 3분 계속되면 데드맨이 주문을 걷습니다)`); } } catch { /* ignore */ }
throw e; }
finally { await db("PATCH", "ub_state?id=eq.1", { run_lock_until: null }).catch(() => {}); await saveState().catch(() => {}); await flushLedger(); }
}
Deno.serve(async (req: Request) => {
await loadSecrets(); const u = new URL(req.url); const key = req.headers.get("x-bot-key") ?? u.searchParams.get("key");
if (!SEC.BOT_KEY || key !== SEC.BOT_KEY) return new Response("forbidden", { status: 403 });
const mode = u.searchParams.get("mode") ?? "status";
if (mode === "status") { await loadAll(); return Response.json({ enabled: ENABLED, orders: ours().length, fills: state.fills, hedges: state.hedges, pnlKrw: state.pnlKrw, rewardKrw: state.rewardKrw, params: P ? { levels: P.levels, volMax: P.volMax, markets: P.markets?.length } : null, cfg: { levels: CFG.levels, maxLockedKrw: CFG.maxLockedKrw, notionalKrw: CFG.notionalKrw } }); }
if (mode === "run") {
const dry = u.searchParams.get("dry") === "1";
if (u.searchParams.get("async") === "1") { (globalThis as any).EdgeRuntime.waitUntil(run(dry).catch((e) => log("run fail", e))); return Response.json({ accepted: true }, { status: 202 }); }
try { return Response.json(await run(dry)); } catch (e) { return Response.json({ error: (e as Error).message }, { status: 500 }); }
}
if (mode === "stop") {
await db("PATCH", "ub_config?id=eq.1", { enabled: false, updated_at: now() }); // ①먼저 끄고 ②돌고 있는 실행이 잠금을 놓을 때까지 기다린 뒤 ③남은 주문 취소
for (let i = 0; i < 20; i++) { const l = await db("GET", "ub_state?id=eq.1&select=run_lock_until", undefined, "return=representation"); if (!l[0].run_lock_until || new Date(l[0].run_lock_until) < new Date()) break; await sleep(3000); }
await loadAll(); await cancelAll("stop"); await saveState(); await flushLedger(); await db("POST", "ub_status", { id: 1, updated_at: now(), running: false, mode: "edge", note: "수동 정지", open_orders: 0, locked_krw: 0, markets: [], order_uuids: [] }, "resolution=merge-duplicates,return=minimal"); await evt("정지", "수동 정지(mode=stop), 봇 주문 전량 취소"); await tg("⏹ 업비트 봇 수동 정지, 봇 주문 전량 취소"); return Response.json({ stopped: true }); }
return new Response("unknown mode", { status: 400 });
});