Strategy API (ctx)

A strategy is one JavaScript function. Backtest and live use exactly the same interface — nothing exists on only one side.

function onUpdate(ctx) {
  // called per candle (backtest) / per update (live)
  // return null, one order object, or an array of orders
}

What can you build?

  • Markets: Upbit & Binance spot, Binance USDT-M & COIN-M futures — leverage, long/short, isolated/cross margin.
  • Multi-symbol scanner: one strategy watches many symbols (up to 200) on the same exchange — per-symbol positions, orders and state, separated automatically.
  • Ordering on two exchanges from one strategy (legs — cross-exchange arbitrage/hedging): backtests (incl. jobs), paper AND live real orders are supported. Per-leg wallets, fees and an interlocked kill-switch; live requires API keys for every leg before it starts (no half-executed hedge, no silent downgrade). P&L of legs in different currencies is never aggregated (an approximate USD figure is shown for reference only).
  • Aux-symbol reference: use another symbol/exchange's price as a signal (ctx.ref slots — e.g. watch Binance BTC while trading on Upbit). Works in backtests, paper and live.
  • Multiple exchanges at once: you can also launch one run per exchange — a single agent holds keys for several exchanges and runs them all in parallel (strategy A on Upbit + strategy B on Binance futures).
  • Multi-agent cluster: scanner symbols are distributed across several agents automatically and run in parallel (Elite plan + onboarding required).
  • Data: candles (1m–1d), volume, orderbook, funding rates, open interest/liquidations, base indicators (US Treasury yields, dollar, VIX, oil… — collected by your agent from FRED, read with ctx.data('key')), real FX rates (USD/KRW — ctx.fx; premium math is yours) and external signals (webhooks). Unknown values are null — never faked as 0.
  • Orders: market, limit, split entries, live-only smart orders (chasing/trigger).
  • Safety: order caps (guardrails), fee-aware loss kill-switch, dry-run.
  • Validation: backtests up to 100k bars, walk-forward via fixed end dates, plus a per-tick decision-time estimate in the studio and a live tick monitor (see on the run card whether your server keeps up).

Not supported yet (we don't pretend otherwise):

  • Live trading with legs split across agents — split execution (a compute agent plus per-leg execution agents) works in paper only. Live orders open after a two-device measurement. Live legs runs today use one agent that holds every leg's keys.
  • Automatic cluster rebalancing — distribution is static. If an agent drops, its symbols wait and the UI says so honestly (they are not silently moved).
  • Real-account verification of stock (KIS) live trading — KRX deploys, paper and live orders (KIS order TRs) are wired, but the order TRs and response fields have not yet been checked against a real account. If something is wrong, the broker's error code (msg_cd) is shown as-is. Start with a small amount.

Rules

  • Pure function — no network, no imports, no async. Only the ctx below.
  • qty is always an amount of coin (not cash).
  • Indicators return null without enough data — always null-check.
  • Keep your variables in ctx.state; they persist across calls.
  • ctx.ref slots use logical names ('hedge' etc.) — real symbols are bound at deploy time.
  • Do not feature-detect ctx (typeof ctx.watch === 'function' and the like). The platform checks at deploy time that the agent supports every ctx feature the strategy uses, and refuses with the required version if not.
  • ctx.watch only displays the prices you are waiting for on the chart and run card — it is not an order. Only the return value of onUpdate trades.
  • If the entry also needs non-price conditions (RSI etc.), add them as conds — with the same values you decided on (same period, same bar): conds: [{ label: 'RSI(14) close', now: rsi, op: '<', ref: 30, closed: true }]. If any is false the run card shows "waiting on conditions" and hides the arrival estimate.

Order objects

  • Market: { side: 'buy'|'sell', qty } — walks the book (slippage, partial fills, taker fee).
  • Limit: { side, qty, type: 'limit', price } — fills at your price with maker fee when touched. postOnly: true rejects a limit that would fill immediately.
  • Smart (work order): { side, qty, type: 'smart', chase?, slices?, trigger?, hardStopMs? } — executed over time: chasing (peg the book → cross at deadline), slicing, stop/trailing triggers, and a hard time budget, in live and paper runs. Backtests approximate it as a market order — validate with a live paper run.
  • Limit ladder: { book: [{ key, side, qty, price | at: 'top' }], tol?, refreshMs? } — return the list of limits that should be resting, every tick. It is reconciled with what is already resting: inside the tolerance nothing is sent, outside it the order is amended, keys you drop are cancelled. Tolerance = max(0.05%, distance from price × 0.2) — tight near the price, loose far away, nearest handled first. Maker-only (postOnly) by default. at: 'top' = best bid + 1 tick (or the best bid if that would cross). refreshMs re-prices inside the tolerance when the order is that old and ≥1 tick off (live: only while the rate limit has spare budget). A filled key is not placed again until you omit it once. Backtests have no tick size, so top-of-book joins the best price (noted in the result).
  • Cancel all: { cancel: 'all' } — cancels resting limits AND all work orders (chasing can't be stopped by canceling a single order).

Sending out (ctx.http) — alerts & external APIs

A strategy can call Telegram/Discord/Slack alerts or an external API (agent 1.348.0+). Keys never go in the code — keep them in your agent's HTTP vault; the code only names a slot.

const HTTP = { origins: ['https://api.telegram.org'], creds: { tg: 'https://api.telegram.org' } };
function onUpdate(ctx) {
  if (ctx.close > 100000) ctx.http.send({ id: 'alert', cred: 'tg', path: '/sendMessage', method: 'POST', json: { chat_id: 1, text: 'BTC > 100k' } });
  return null;
}
  • Nothing leaves for an origin not in the HTTP declaration. Each run needs your explicit approval of slot ↔ vault entry on the agent screen (re-approve when the declaration or code changes).
  • The vault shows the domain large when you add a key; lookalike (te1egram.org…) or unfamiliar domains must be retyped to save. A key is attached only for its origin and scrubbed from responses.
  • 5 sends per tick · 64KB responses · redirects not followed · private IPs blocked. Results arrive next tick via ctx.http.result(id).
  • Backtests never send — the result only counts them in httpNotSent.
  • Key-shaped strings in code: a warning on save, placeholders when an AI reads the code, and the strategy is skipped by the public git publish.

The table below is the entire ctx (generated from the same source as the editor autocompletion). To get strategy code from an AI, use Create an AI Strategy or paste the whole AI reference.

ctx

This table is generated from the same source as the editor autocompletion and the AI reference (/llms.txt) — functions not listed here do not exist.

상태

ctx.candle현재 봉 { t, o, h, l, c, v } — t 는 실데이터면 실제 봉 시각(epoch ms), 합성 시세만 봉 번호. ★라이브·페이퍼도 실제 봉 시작 시각(에이전트 1.66.0+ — 그 이전 라이브는 링버퍼 인덱스(499)로 굳어 있었다: 요일·시간대 로직을 쓰면 반드시 1.66.0 이상에서 확인할 것). 시각을 모르는 경로는 null(인덱스를 시각인 척 주지 않는다)
ctx.price현재가(종가)
ctx.closes현재 봉까지의 종가 **배열**(미래 없음) — 숫자가 아니다. 직전 마감 봉 종가 = ctx.closes.at(-2), 지금 봉 = ctx.closes.at(-1). 배열 자체를 비교·계산하면 던진다(2026-10-10)
ctx.highs봉별 고가 배열(closes 와 같은 길이). 모르면 null · 직전 마감 봉 = .at(-2)
ctx.lows봉별 저가 배열(highs 와 같은 규칙)
ctx.opens봉별 시가 배열(highs 와 같은 규칙)
ctx.times봉 시작 시각 배열(epoch ms) · 모르면 null
ctx.i현재 봉 번호
ctx.position보유 수량(코인). 선물(usdm)에선 부호 수량 — +롱/−숏
ctx.entryPx평균 진입가 — 선물: 포지션 평균 진입가 · spot: 보유 중 평균 매수 단가(체결가 가중, 수수료 제외 · 라이브/페이퍼는 이 실행이 직접 산 체결 기준 — 직접 안 산 보유가 섞이면 null · 코인을 들고 시작한 실행은 배포 때 유저가 넣은 매수 단가(기본 현재 시세, 비우면 null)). 포지션 없으면/모르면 null
ctx.liqPx(선물·CFD) 격리 청산가(해석해) — cfd 에선 스탑아웃 가격(마진 레벨 stopOut 도달점). 포지션 없으면/spot 이면 null
ctx.uPnl(선물) 미실현손익(quote) — spot 이면 null
ctx.leverage(선물) 현재 레버리지 — spot 이면 null
ctx.funding(선물) 직전 적용 펀딩 rate — 아직 없으면/spot 이면 null
ctx.marginRatio(선물 P2) cross 유지마진율 = 유지마진÷계정평가(1 이상 = 청산권). cross 포지션 없으면/spot 이면 null
ctx.marginMode(선물 P2) 현재 종목 마진 모드 'isolated'|'cross' — spot 이면 null
ctx.setMarginMode(sym, 'isolated'|'cross')(선물 P2) 마진 모드 전환 — 그 심볼 포지션이 없을 때만(거래소 동일). 반환: 적용된 모드 | null. 기본 isolated
ctx.cash주문 가능 현금
ctx.state호출 간 유지되는 내 변수 저장소 — ★ 실행 전체 공유(다종목이면 전 종목이 같은 객체). 종목별 값은 ctx.symState 에
ctx.fees수수료율 { maker, taker }

지표

ctx.sma(n, ago=0)단순이동평균. 데이터 부족이면 null
ctx.ema(n, ago=0)지수이동평균. 부족이면 null
ctx.rsi(n, ago=0)RSI 0~100. 부족이면 null
ctx.high(n, ago=0)최근 n봉 최고 종가
ctx.low(n, ago=0)최근 n봉 최저 종가
ctx.change(n, ago=0)n봉 전 대비 변화율 — 소수(0.05 = +5%. markets()의 ch* 퍼센트 단위와 다름!)
ctx.bb(n=20, k=2, ago=0)볼린저 밴드 → {upper, mid, lower, width} | null. width=(upper-lower)/mid (스퀴즈 판정용, mid=0이면 null)
ctx.macd(fast=12, slow=26, sig=9, ago=0)MACD → {macd, signal, hist} | null. 구간이 모자라면 signal·hist 만 null(macd 는 준다)
ctx.atr(n=14, ago=0)ATR(평균 진폭) → number | null. 변동성 기반 손절폭·포지션 크기에 쓴다. 구간이 모자라거나 라이브에서 고가·저가를 못 얻으면 null
ctx.stoch(n=14, d=3, ago=0)스토캐스틱 → {k, d} | null. %K=(종가−최저)/(최고−최저)×100, %D=%K의 SMA(d). 구간이 완전 횡보면 k=50(중립). d 구간이 모자라면 d만 null
ctx.wma(n, ago=0)가중이동평균 → number|null
ctx.stdev(n, ago=0)종가 표준편차(모집단) → number|null
ctx.zscore(n, ago=0)(종가−SMA)/표준편차 → number|null. 횡보면 0
ctx.cci(n=20, ago=0)CCI, TP=(고+저+종)/3 → number|null
ctx.willr(n=14, ago=0)윌리엄스 %R(0~−100) → number|null. 횡보면 −50
ctx.adx(n=14, ago=0){adx, plusDI, minusDI}|null. ★구간 합 정의(Wilder 평활 아님 — 타 차트와 값 다를 수 있음). 2n봉 필요
ctx.mfi(n=14, ago=0)MFI 0~100 → number|null. 거래량 필요 · 형성 중 봉은 null → ago=1 권장
ctx.vwap(n=20, ago=0)최근 n봉 이동 VWAP(세션 VWAP 아님) → number|null. 형성 중 봉 null → ago=1
ctx.obv(n=20, ago=0)최근 n봉 Σ부호×거래량(★누적 OBV 아님) → number|null. 형성 중 봉 null → ago=1
ctx.donchian(n=20, ago=0){upper, mid, lower}|null — 고가·저가 기준(ctx.high/low 는 종가 기준)
ctx.keltner(n=20, mult=2, atrN=10, ago=0){upper, mid, lower}|null. mid=EMA(n), ±mult×ATR(atrN)
ctx.ichimoku(t=9, k=26, s=52, ago=0){tenkan, kijun, spanA, spanB, cloudA, cloudB}|null. span=지금 계산값(차트에선 k봉 앞), cloud=지금 봉 위 구름(k봉 전 값). 후행스팬 없음
ctx.emaW(n, ago=0)표준 EMA(차트 도구 정의) — 20n 봉 필요, 모자라면 null. ctx.ema(n봉 창)와 값이 다르다
ctx.rsiW(n=14, ago=0)Wilder RSI — 20n 봉 필요 · 무변동 50
ctx.atrW(n=14, ago=0)Wilder ATR — 20n 봉 필요
ctx.adxW(n=14, ago=0)Wilder ADX {adx,plusDI,minusDI} — 20n 봉 필요

오더북

ctx.book()호가 전체 { bids, asks } (좋은 가격 순)
ctx.bid()최우선 매수호가
ctx.ask()최우선 매도호가
ctx.mid()중간가
ctx.spread()호가 차이(절대값)
ctx.spreadPct()호가 차이(중간가 대비 %)
ctx.fillPrice(side, qty)시장가 예상 체결 { avgPx, filled }. filled<qty면 유동성 부족
ctx.depth(side, px)px 까지 쌓인 누적 물량

주문

ctx.openOrders()대기 중인 지정가 목록(읽기 전용, 행에 ex — 작업주문은 workOrders 로)
ctx.workOrders()활성 작업주문(스마트 — 체이싱·분할·트리거 대기) 목록. 백테스트는 트리거 대기분만(체이싱은 봉 해상도에 개념 없음)

스캐너(여러 종목)

ctx.sym이번 호출의 종목(예: "BTC")
ctx.ex이번 틱 종목이 속한 거래소(예: 'upbit'). legs 백테스트·라이브에선 실값, 호출부가 거래소를 안 알려준 단일 백테스트는 null
ctx.quote이번 틱 다리의 표시통화(예: 'KRW', 'USDT'). 다리별 금액 계산(예: KRW 다리 6,000원 vs USDT 다리 6달러)의 분기 재료. 모르면 null — 지어내지 않는다
ctx.wallets()거래소(다리)별 지갑 맵 { upbit: { quote, cash, reserved? }, … } — 단일 실행도 한 다리짜리 맵(거래소를 모르면 'default' 키). 통화가 달라 합산값은 없다 — 환산은 ctx.fx 로 직접
ctx.marketType시장 종류 'spot'|'usdm'|'coinm'|'cfd' — spot 이 아니면 position 이 부호 수량(+롱/−숏), liqPx·uPnl·leverage·funding 필드가 채워진다(entryPx 는 spot 에서도 평균 매수 단가). forex 는 기본 cfd(펀딩 없음·liqPx=스탑아웃가)
ctx.syms시세를 받은 종목 이름 목록
ctx.markets()전 종목 요약 [{ sym, ex, price, ch1, ch5, ch60 }] — ch 는 퍼센트(2 = +2%. change()의 소수 단위와 다름!). 봉이 모자라면 null. 단일 종목 실행에선 그 한 종목만 담긴 배열(빈 배열 아님). ex 는 legs 면 그 다리 거래소, 모르면 null
ctx.scan스캔 실행의 이 종목 상태 { hit: { names:[조건 이름…], by:{ 이름:{ barTs, closeAt } } } | null, active, maxActive } — hit 은 받아들여진 적중(걸린 봉 **마감 뒤** holdBars 봉 동안, 조건별로 센다). onUpdate 는 활성 종목(적중·보유·주문 중)에만 불린다. SCAN 이 없으면 null
ctx.market(sym)한 종목 요약 (없으면 null)
ctx.pos(sym, ex?)그 종목 보유 수량(이번 틱 다리 기준). 2번째 인자로 다른 다리(거래소) 지정 조회 — 모르는 다리는 null(0 은 "없음"을 단정하는 거짓)
ctx.positions종목별 보유 수량 스냅샷 { SYM: qty } — 이미 든 종목을 피할 때
ctx.symState현재 종목 전용 저장소(자동 분리) — 종목별 손절가·보유 플래그는 여기에. state.__sym[sym] 과 같은 객체

거래량

ctx.vol현재 봉 거래량 (모르면 null)
ctx.volumes봉별 거래량 배열 (모르면 null)
ctx.volPrev직전 **확정** 봉의 거래량 (봉 중에도 값이 있다 · 모르면 null). 1.86.0 미만 라이브에서는 항상 null
ctx.avgVol(n)최근 n개 **확정** 봉의 평균 거래량 (형성 중 봉은 제외 · 봉이 모자라거나 모르면 null). ★1.86.0 미만 라이브은 형성 중 봉을 평균에 넣었다 — 값이 낮게 나오고 봉 안에서 답이 바뀐다

멀티에셋·환율

ctx.ref(slot)보조 종목 현재가. 슬롯명은 논리명(예: 'hedge')
ctx.refs사용 가능한 슬롯명 목록
ctx.fx(quote)1 USD 당 해당 통화 값(예: ctx.fx('KRW')). USD 환산은 직접
ctx.fxQuotes사용 가능한 통화 목록

파생 심리(OI·청산)

ctx.binanceOi()(라이브·페이퍼) 현재 종목 최신 미결제약정 { ts, oi, oiUsd } — 바이낸스 USDT-M 5분 집계. 백테스트·수집 전·모르면 null
ctx.binanceLiqs(n)(라이브·페이퍼) 최근 n분(기본 5·최대 60) 강제청산 합계 { longUsd, shortUsd, cnt } — longUsd=롱 청산(하방 압력의 해소). 백테스트·모르면 null. ★행이 없는 분은 "청산 0" 과 "수집 공백" 을 구분할 수 없음

외부 신호(웹훅)

ctx.signal(slot)외부 신호(TradingView 웹훅 등) 조회 { payload, at, age } — payload 는 보낸 JSON 그대로(파싱·해석 안 함), at 은 신호 시각(epoch ms), age 는 경과 초(내림). 백테스트는 현재 봉 시각 이하의 가장 최근 신호(as-of — 미래 신호는 안 보인다), 라이브·페이퍼는 60초 캐시 스냅샷. 신호가 아직 없거나 모르는 slot 이면 null(0·빈객체로 지어내지 않음 — 반드시 null 검사)
ctx.data(key)외부 데이터셋 as-of 값(예: ctx.data('fg')) — **기본 지표**도 같은 이름으로 읽는다(예: ctx.data('base_ust10y')·ctx.data('base_vix')·ctx.data('base_dxy_h10') — 에이전트가 FRED 에서 모으는 일별 값, 선언 불필요. 목록은 에이전트가 보고한 것만, AI 는 list_datasets 의 baseIndicators. 시각은 **알 수 있게 된 때**(관측일 + 발표 지연 lagDays, 받은 시각이 더 이르면 그 시각)라 미래를 안 본다). 값을 그대로 돌려준다(봉투 없음). null = 모른다 — 데이터셋 미등록·해당 시점 행 없음·조회 실패 전부 null 이고, 구별은 결과의 dataMeta 로. 백테스트는 현재 봉 시각 이하 최신 행(look-ahead 금지), 라이브·페이퍼는 러너가 주입한 최신값. ★행의 **시각(ts) 기준**이다 — 그 행이 DB 에 언제 들어왔는지는 안 본다(event-only, 결과 dataAxis:'ts' + [PIT] 고지). 늦게 적재되거나 나중에 고쳐지는 데이터면 백테스트가 낙관될 수 있다. 반드시 null 검사
ctx.dataKeys주입된 데이터셋 키 목록(빈 배열 = 이 실행에 외부 데이터 없음)

로그

ctx.log(...)디버그. 라이브는 분당 20건 서버 전송 제한 — 초과분은 VPS 로컬 파일(runstate/run-<id>.log)에 전량 보존. 백테스트는 500건 상한
ctx.logOnce(...)디버그 1회 — ctx.log 와 같지만 **같은 문장은 실행당 한 번만** 찍는다(서식 문장 200자가 같으면 같은 문장). 시작 설정·"데이터 부족" 같은 매 틱 반복 안내용. 숫자가 바뀌는 문장은 다른 문장이다(가격을 넣으면 매번 찍힌다). 라이브·페이퍼는 에이전트 재시작·코드 재배포 뒤 다시 한 번 찍는다. 기억 상한(라이브 200·백테스트 1000문장)을 넘은 새 문장은 1회 보장 없이 ctx.log 처럼 찍힌다. 라이브에서 1회 문장은 분당 20건 제한을 받지 않는다(상한이 있어서)
ctx.warn(...)중요. 라이브에서 스로틀 없이 항상 서버 전송 + 직전 로그 맥락 동봉 — 촘촘한 검증·이상 신호는 log 대신 이걸 쓰세요
ctx.watch(list)체결 조건 선언 — [{side:'buy'|'sell', price?:number, trigger?:'above'|'below', at?:epochSec, qty?:number, note?:string, sym?:string}] 를 매 틱 선언하면 run 카드가 '[선언] 매수 61,200 (−1.8%)' 로, 실거래·페이퍼 차트가 그 가격에 **점선**과 도달 추정 범위('약 5m~20m', 최근 14봉 ATR 기준 — 시각 예측 아님)로 그린다. 가격 없는 선언(at·이벤트)은 카드에만. 다종목(syms) 전략은 **종목 틱마다** 그 종목 것만 선언한다 — 종목별로 따로 보관되고(sym 생략 = 이번 틱 종목), 그 틱에 안 부르면 그 종목 선언만 지워진다(에이전트 1.243.0+, 이전엔 마지막 종목 것만 남았다). 틱마다 덮어씀(누적 아님), 최대 8개, note 60자. ★ trigger = 가격이 어느 쪽에서 닿아야 하나 — 'below'(내려와 닿으면: 저점 매수·손절/트레일 스탑 매도) / 'above'(올라와 닿으면: 익절 매도·돌파 매수). 주면 카드가 '조건 충족'을 판정하고 스탑이면 '스탑 매도'로 표시한다. 안 주면 거리만 보이고 충족 판정은 안 한다(방향을 지어내지 않는다, 에이전트 1.273.0+). ★ 가격 외 조건이 더 있으면 conds:[{label, now, op:'<'|'<='|'>'|'>=', ref, closed?}, {label, ok:true|false}] (최대 4) — **판단에 쓴 그 값**을 싣는다(같은 기간·같은 봉). 충족은 플랫폼이 now·op·ref 로 판정하고, 하나라도 거짓이면 카드가 '조건 대기'로 그리고 도달 추정을 안 보인다(에이전트 1.278.0+, 이전엔 조건만 빠진다). closed:true = 마감 봉 값(다음 마감까지 안 바뀜). 백테스트는 무시, 라이브·페이퍼만 보고. 반환값 없음
ctx.http.send(req)HTTP — const HTTP={origins:[...],creds:{slot:origin}} 선언 + run 승인 필요. send({id,url}|{id,cred,path,method,json}) 큐잉 → 다음 평가 ctx.http.result(id): null·{ok:false,code}·{ok:true,status,json|text}. 비밀은 보관소(코드가 못 봄). 백테스트는 안 보냄(code:'backtest')
ctx.alert(msg)조건 근접 알림 — 전략이 "진입 조건에 근접했다"고 스스로 판정해 유저에게 알림을 보낸다. msg 는 문자열로 강제(200자 컷)·반환값 없음. 라이브·페이퍼는 run 당 60초 쿨다운으로 발송(60초 내 재호출은 무시 — 로그에 남음), 백테스트는 발송 없이 로그에 [알림] 으로만 기록(상한 50개, 초과는 개수만 alertsDropped)

Order object keys

side'buy' | 'sell'
qty수량(코인). 현금이 아니다
type'limit'=지정가, 'smart'=작업 주문(시간에 걸쳐 집행 — 체이싱·분할·트리거). 기본은 시장가. smart 는 라이브·페이퍼에서 집행되고 백테스트는 시장가로 근사
price지정가일 때의 가격
postOnlytrue 면 즉시 체결될 지정가는 거부(메이커만)
chasesmart: 호가 추격 { escalateMs?=20000, deadlineMs?=45000, bandTicks?, minRepegMs? } — 호가 조인(메이커)→1틱 전진→마감 시 크로스(테이커). 기본 켜짐, false 면 끔
slicessmart: 분할 { n, everyMs } — 수량을 n조각으로 everyMs 간격 집행
triggersmart: 조건 대기 { type:'stop'|'trail', px?, offset? } — 조건이 닿기 전엔 주문이 안 나간다. 실데이터 백테스트도 봉 h/l 로 발동 판정(2026-08, 체결 라벨 stop/trail)
hardStopMssmart: 이 시간이 지나면 무조건 종료(남은 수량 포기)
cancel'all' 이면 대기 주문 전체 취소
book지정가 사다리: [{ key, side, qty, price | at:'top', postOnly?(기본 true), reduceOnly? }] — 매 틱 같은 목록을 내면 걸린 것과 맞춘다(한도 안이면 호출 0). 상세는 llms '## Limit ladder'
tol사다리 허용 한도 { minPct?=0.05, ratio?=0.2 } — max(minPct, 거리% × ratio)
refreshMs사다리 주기 갱신 — 한도 안이어도 이만큼 지나고 1틱 이상 다르면 고친다(실거래는 유량 여유 있을 때만)