# coinsori — AI reference (llms.txt) > Machine-oriented reference for AI assistants. Human docs: /docs > Generated from the single source of truth (utils/ctxApi.mjs). Do not hand-edit — run `node scripts/gen-llms.mjs`. ## What coinsori is coinsori (코인소리) is a global crypto community + market data + **non-custodial** trading platform. - Community: multilingual board, realtime chat, crypto news — server-side machine translation into ~50 languages. - Market data: multi-exchange live prices over WebSocket deltas (seq/epoch consistency), candles served from coinsori's own store, the cross-exchange premium (local vs global price gap) computed against the **real USD/KRW FX rate** (never KRW-USDT). - Trading: paper trading (server-simulated) and live trading through a **local agent** on the user's own machine/VPS. Strategy studio with backtesting; strategies are written as a single JavaScript function. ## Non-negotiable architecture invariants 1. **Non-custodial**: exchange API keys NEVER reach coinsori servers in plaintext. Signing and order submission happen only on the user's device (local agent). The server never initiates trades. 2. Remote agent control is **E2E encrypted** (ECDH P-256 + HKDF + AES-256-GCM). The server relays ciphertext only — it cannot read, create, or forge orders. TOFU fingerprints detect MITM. 3. API keys must have **no withdrawal permission** — the agent verifies and rejects keys that do. 4. User strategy code never runs on the server (browser Web Worker for backtests, local agent for live). 5. **Honesty rule**: unknown values are `null`, never fabricated. No FX rate → no conversion (blank, not a guess). Indicators return null until enough data exists. Volume is null when the feed can't know it. 6. No "guaranteed profit" language. coinsori provides tools, not investment advice. ## Strategy contract (backtest and live are IDENTICAL) Write ONE JavaScript function: ```js function onUpdate(ctx) { // called once per candle (backtest) / on each update (live) // return null, ONE order object, or an ARRAY of order objects } ``` Rules: - Pure function. No network, no imports, no async, no external data. Only ctx helpers below. - `qty` is always an amount of COIN, not cash. - Market order `{ side: 'buy'|'sell', qty }` walks the orderbook: slippage applies, large orders may partially fill, taker fee (`ctx.fees.taker`). Buys auto-shrink to available cash; sells cap at holdings. - Limit order `{ side, qty, type: 'limit', price }` rests until touched, fills at the limit price with maker fee. Add `postOnly: true` to reject a limit that would cross immediately. - `{ cancel: 'all' }` cancels all resting orders. - Return ONE order object **or an ARRAY of orders** — an array is processed in order within the same bar, e.g. `return [{ cancel:'all' }, { side:'sell', qty, type:'smart', trigger:{...} }]`. ★ If you re-install trigger orders every bar (ratchet stops etc.), ALWAYS lead with `{ cancel:'all' }`. Without it pending orders ACCUMULATE; when they fire together the total exceeds what you meant to sell (in futures this can flip your position — real case 2026-08: a paper strategy went −100% in all 4 walk-forward windows from accumulated stops). - Valid `type` values: omitted (= market), `'limit'`, `'smart'`. Anything else — including `type:'stop'` — is REJECTED with a warn log (2026-08; backtest and live identical). A stop is always `type:'smart'` + `trigger:{type:'stop', px}`, never a standalone type. - Always null-guard indicators (early candles). Persist your own variables in `ctx.state`. - Use logical slot names for `ctx.ref` ('hedge', 'global') — real symbols are bound at deploy time. - Quote currency differs by exchange (Upbit=KRW, Binance=USDT); normalize with `ctx.fx` yourself. ## Order object keys - `side` — 'buy' | 'sell' - `qty` — 수량(코인). 현금이 아니다 - `type` — 'limit'=지정가, 'smart'=작업 주문(시간에 걸쳐 집행 — 체이싱·분할·트리거). 기본은 시장가. smart 는 라이브·페이퍼에서 집행되고 백테스트는 시장가로 근사 - `price` — 지정가일 때의 가격 - `postOnly` — true 면 즉시 체결될 지정가는 거부(메이커만) - `chase` — smart: 호가 추격 { escalateMs?=20000, deadlineMs?=45000, bandTicks?, minRepegMs? } — 호가 조인(메이커)→1틱 전진→마감 시 크로스(테이커). 기본 켜짐, false 면 끔 - `slices` — smart: 분할 { n, everyMs } — 수량을 n조각으로 everyMs 간격 집행 - `trigger` — smart: 조건 대기 { type:'stop'|'trail', px?, offset? } — 조건이 닿기 전엔 주문이 안 나간다. 실데이터 백테스트도 봉 h/l 로 발동 판정(2026-08, 체결 라벨 stop/trail) - `hardStopMs` — smart: 이 시간이 지나면 무조건 종료(남은 수량 포기) - `cancel` — 'all' 이면 대기 주문 전체 취소 ## ctx API — complete list (nothing else exists) **Agent version matters.** Entries marked `requires agent >= X` do not exist (or silently return null) on older agents — the user's agent runs on their own machine and is updated **manually, by them**, so it can lag this document by weeks. Before writing a strategy that uses a marked entry: 1. Read `agentVersion` from a backtest result, or the `hint` field returned by `POST /api/dev/backtests`. The server compares the strategy code against these minimums and tells you if the agent is too old. 2. If it is too old, say so plainly and tell the user to run `--update` — do **not** silently rewrite the strategy to avoid the indicator without telling them, and do **not** hand-roll a replacement (a hand-written ATR over closes only is not an ATR; it has no high/low). ### 상태 (state) - `ctx.candle` — 현재 봉 { t, o, h, l, c, v } — t 는 실데이터면 실제 봉 시각(epoch ms), 합성 시세만 봉 번호. ★라이브·페이퍼도 실제 봉 시작 시각(에이전트 1.66.0+ — 그 이전 라이브는 링버퍼 인덱스(499)로 굳어 있었다: 요일·시간대 로직을 쓰면 반드시 1.66.0 이상에서 확인할 것). 시각을 모르는 경로는 null(인덱스를 시각인 척 주지 않는다) **(requires agent >= 1.66.0 — older agents throw or return null)** - `ctx.price` — 현재가(종가) - `ctx.closes` — 현재 봉까지의 종가 배열(미래 없음) - `ctx.i` — 현재 봉 번호 - `ctx.position` — 보유 수량(코인). 선물(usdm)에선 부호 수량 — +롱/−숏 - `ctx.entryPx` — (선물) 평균 진입가 — 포지션 없으면/spot 이면 null - `ctx.liqPx` — (선물) 격리 청산가(해석해) — 포지션 없으면/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 } ### 지표 (indicator) - `ctx.sma(n)` — 단순이동평균. 데이터 부족이면 null - `ctx.ema(n)` — 지수이동평균. 부족이면 null - `ctx.rsi(n)` — RSI 0~100. 부족이면 null - `ctx.high(n)` — 최근 n봉 최고 종가 - `ctx.low(n)` — 최근 n봉 최저 종가 - `ctx.change(n)` — n봉 전 대비 변화율 — 소수(0.05 = +5%. markets()의 ch* 퍼센트 단위와 다름!) - `ctx.bb(n=20, k=2)` — 볼린저 밴드 → {upper, mid, lower, width} | null. width=(upper-lower)/mid (스퀴즈 판정용, mid=0이면 null) - `ctx.macd(fast=12, slow=26, sig=9)` — MACD → {macd, signal, hist} | null. 구간이 모자라면 signal·hist 만 null(macd 는 준다) - `ctx.atr(n=14)` — ATR(평균 진폭) → number | null. 변동성 기반 손절폭·포지션 크기에 쓴다. 구간이 모자라거나 라이브에서 고가·저가를 못 얻으면 null **(requires agent >= 1.73.0 — older agents throw or return null)** - `ctx.stoch(n=14, d=3)` — 스토캐스틱 → {k, d} | null. %K=(종가−최저)/(최고−최저)×100, %D=%K의 SMA(d). 구간이 완전 횡보면 k=50(중립). d 구간이 모자라면 d만 null **(requires agent >= 1.73.0 — older agents throw or return null)** ### 오더북 (book) - `ctx.book()` — 호가 전체 { bids, asks } (좋은 가격 순) - `ctx.bid()` — 최우선 매수호가 - `ctx.ask()` — 최우선 매도호가 - `ctx.mid()` — 중간가 - `ctx.spread()` — 호가 차이(절대값) - `ctx.spreadPct()` — 호가 차이(중간가 대비 %) - `ctx.fillPrice(side, qty)` — 시장가 예상 체결 { avgPx, filled }. filled= 1.48.0 — older agents throw or return null)** - `ctx.refs` — 사용 가능한 슬롯명 목록 **(requires agent >= 1.48.0 — older agents throw or return null)** - `ctx.fx(quote)` — 1 USD 당 해당 통화 값(예: ctx.fx('KRW')). USD 환산은 직접 **(requires agent >= 1.51.0 — older agents throw or return null)** - `ctx.fxQuotes` — 사용 가능한 통화 목록 **(requires agent >= 1.51.0 — older agents throw or return null)** ### 파생 심리(OI·청산) (derivs) - `ctx.binanceOi()` — (라이브·페이퍼) 현재 종목 최신 미결제약정 { ts, oi, oiUsd } — 바이낸스 USDT-M 5분 집계. 백테스트·수집 전·모르면 null - `ctx.binanceLiqs(n)` — (라이브·페이퍼) 최근 n분(기본 5·최대 60) 강제청산 합계 { longUsd, shortUsd, cnt } — longUsd=롱 청산(하방 압력의 해소). 백테스트·모르면 null. ★행이 없는 분은 "청산 0" 과 "수집 공백" 을 구분할 수 없음 ### 거시 지표(달러·금리·주가·금) (macro) - `ctx.macro('dxy')` — 거시 지표 { value, day, chg1d, chg7d, chg30d(%) } — 계열: dxy(달러지수)·ust10y(미국채 10년 금리)·ndx(나스닥100)·gold(금). ★전일(완결된 날) 값 — 당일 값은 마감 전 미존재 정보라 주지 않음. 데이터 없거나 14일 이상 낡으면 null. dxy 는 ICE 공식(고정 가중치 동일)을 현물 UTC 종가로 재구성한 값 — 원본 지수 호가와 스냅샷 시각·현물/선물 차이로 ±0.1~0.3 정도 다를 수 있음(추세·변화율 무영향). ust10y 는 공표 지연으로 보통 1~2영업일 늦음(구조적 — chg1d 는 마지막 두 공표일 비교). ★chg 는 **달력일 as-of**: chgNd = (현재값 − '기준일−N일 이하의 가장 최근 관측값')/그 값 — 배열의 N행 전이 아니다(주말·휴장이 있으므로 7행 전 ≠ 7일 전). 외부에서 재현할 땐 이 정의로. 데이터가 첨부한 격자는 **관측일 그대로**(공개 API 기본과 동일 — GET /api/market/macro 는 grid:'raw', ?ffill=1 은 표시용 채움) - `ctx.macroSeries` — 사용 가능한 거시 계열 이름 배열(데이터가 실제로 첨부된 것만) ### 외부 신호(웹훅) (signal) - `ctx.signal(slot)` — 외부 신호(TradingView 웹훅 등) 조회 { payload, at, age } — payload 는 보낸 JSON 그대로(파싱·해석 안 함), at 은 신호 시각(epoch ms), age 는 경과 초(내림). 백테스트는 현재 봉 시각 이하의 가장 최근 신호(as-of — 미래 신호는 안 보인다), 라이브·페이퍼는 60초 캐시 스냅샷. 신호가 아직 없거나 모르는 slot 이면 null(0·빈객체로 지어내지 않음 — 반드시 null 검사) **(requires agent >= 1.64.0 — older agents throw or return null)** ### 로그 (log) - `ctx.log(...)` — 디버그. 라이브는 분당 20건 서버 전송 제한 — 초과분은 VPS 로컬 파일(runstate/run-.log)에 전량 보존. 백테스트는 500건 상한 - `ctx.warn(...)` — 중요. 라이브에서 스로틀 없이 항상 서버 전송 + 직전 로그 맥락 동봉 — 촘촘한 검증·이상 신호는 log 대신 이걸 쓰세요 - `ctx.alert(msg)` — 조건 근접 알림 — 전략이 "진입 조건에 근접했다"고 스스로 판정해 유저에게 알림을 보낸다. msg 는 문자열로 강제(200자 컷)·반환값 없음. 라이브·페이퍼는 run 당 60초 쿨다운으로 발송(60초 내 재호출은 무시 — 로그에 남음), 백테스트는 발송 없이 로그에 [알림] 으로만 기록(상한 50개, 초과는 개수만 alertsDropped) **(requires agent >= 1.62.0 — older agents throw or return null)** ## `null` means **unknown**, not "not implemented" — read this before reporting a gap This API distinguishes three states everywhere: **has a value** / **`null` = we do not know** / **absent = not applicable**. `null` is never a placeholder for zero, empty, or missing features. An external reviewer read `agentClaiming: null` and `execQuality.byType: null` and reported both features as **not implemented** (2026-08). Both were live. The values were null because the preconditions had not been met yet — which is exactly what null is for. **Before you conclude a feature is missing:** 1. Check whether the field **exists** in the response. A present field with `null` means the feature is there and the answer is not known yet. An **absent** field means it is not built. 2. Read the accompanying `note` / `hint` — where a null needs explaining, we explain it there. 3. Check the agent version. Several fields only fill in from a given agent version onward, and the user updates their agent manually, so a lag is normal (see "Agent version matters" above). **Fields that are commonly null on a fresh account, and why:** - `agentClaiming` — null until the agent has polled the job queue at least once. It is deliberately NOT reported as `false`, because false means "connected but not reading the queue" — a real fault. Calling an unknown a fault would put a red warning on every healthy agent. - `execQuality.byType` — null until there are enough measurable fills (`minSample`, see `measurable` and `unknown` counts). We do not publish an average over 3 fills; a small-sample average points the wrong way and people plan with it. - `ctx.*` indicators (`atr`, `stoch`, `vol`, `macro`, `signal`, …) — null when the input does not exist for that bar. Never substitute 0. A volume filter reading 0 for "unknown" silently inverts. - `fxSource`, premium values — null when no real FX rate is available. We do not fabricate a premium from USDT or a hard-coded rate; a wrong number in a trading screen is worse than none. **How to report this to the user:** say "not measured yet, because X" — not "0%", not "unavailable", not "the platform does not support it". The difference matters: the first is a status, the other two are wrong. ## Checking what a backtest CANNOT tell you — `execQuality` (paper runs) Partial fills and slippage are **not modelled by backtests**. If a strategy's edge depends on getting filled at the price it saw (breakout confirmation, tight scalps), a backtest cannot validate it — that is what paper trading is for. `GET /api/dev/runs/:id/fills` returns `execQuality` alongside the fills: ``` { total, measurable, unknown, minSample, byType: { market: { n, slipMedianPct, slipAvgPct, fillRateAvg, partial } , limit: {…} } | null, note } ``` - `slip*Pct` is **signed so positive = worse for the user** (bought higher / sold lower than intended). Median is given first: one bad fill drags the average badly. - `fillRateAvg` is filled ÷ ordered. `partial` counts fills below 100% — an average of 97% can mean "everything nearly filled" or "a few filled at 30%", and only the second is dangerous. - Market and limit orders are reported **separately**. Never average them together: a limit order fills at its price by definition, so mixing them hides how bad the market fills were. - `byType` is `null` when the sample is under `minSample` or when no order intent was reported (agent < 1.78.0 did not send it). In that case **say it is not measured** — do not report "0% slippage". Read `note`; it states which case it is and how many fills were unmeasurable. ## Reading a backtest result honestly — `approxNotes` Every backtest result carries **`approxNotes`: string[]** — the list of things this run handled **differently from live**. Empty array means nothing was approximated; the field is always present (an absent field would be indistinguishable from "we didn't check"). Read it before you compare numbers. Typical entries: - `smart→market` — a trigger-less `type:'smart'` order executed as a MARKET order. This is why adding `smart` can leave the return unchanged: the plan was not executed, it was **replaced**. Do not tell the user "smart made no difference" — tell them the backtest could not model it. - `chase 무시` / `slices 무시` / `hardStopMs 무시` — the engine does not parse these at all (chasing and time-slicing have no meaning at bar resolution). They are dropped, and now said so. - `postOnly 무시` — on a smart order, post-only has no effect in the backtest. **Fee implication (asked by a tester, 2026-08):** because trigger-less smart becomes a market order, it pays the **taker** fee in the backtest even though live chasing would often earn the **maker** fee. If you want to see that difference, run the same backtest twice with different `market.fees` ({maker, taker}) and present it as a **scenario**, clearly labelled as an assumption — never as a measured result. The honest statement is "if X% of fills were maker, the return would be Y", not "smart improves returns by Y". Related existing flags on the result: `syntheticBookUsed` (the strategy read a synthetic order book), `gapPct` / `dataWarn` (missing bars), `tFrom`/`tTo` (the period actually used). ## Advanced orders — smart (work orders) `{ side, qty, type: 'smart', chase?, slices?, trigger?, hardStopMs? }` runs a **work order**: a plan the agent executes over time (live AND paper runs). All plan fields are optional and combine: - `chase` — peg to the book: join the best quote (maker) → after `escalateMs` (default 20s) step one tick forward → after `deadlineMs` (default 45s) cross the spread (taker). Enabled by default for smart orders; `chase: false` disables it (goes straight to taker). - `slices: { n, everyMs }` — split qty into n child orders spaced everyMs apart. - `trigger: { type: 'stop'|'trail', px?, offset? }` — wait for a price condition before starting. - `hardStopMs` — absolute time budget; the work order ends no matter what. Stopping semantics (important): chasing constantly re-posts and cancels exchange orders, so you cannot stop it by canceling one exchange order. `{ cancel: 'all' }` from the strategy cancels resting limits AND all work orders of the run. Stopping the run or tripping the kill-switch also cancels everything open. Stopping the agent process does NOT cancel exchange orders — they are tracked and reconciled after restart. **Backtest approximation**: chasing cannot be simulated honestly at candle resolution (it would fabricate better fills), so trigger-less `type:'smart'` executes as a MARKET order in backtests. Triggers (`stop`/`trail`) DO wait and fire intrabar on real-data backtests (2026-08, see pitfalls). Validate chasing/slicing behavior in a live paper run — backtests cannot show it. Limit orders (`type:'limit'`, postOnly, cancel) execute in backtest, paper, and live with the same semantics: a crossing limit executes as market, postOnly rejects crossing, resting orders fill at the limit price (maker). Live fills are reconciled from the exchange (partial fills recorded incrementally; unknown values stay null). ## Multi-exchange legs (MULTI-EX-LEGS P3, 2026-08) — backtest jobs + PAPER deploys One strategy (a single onUpdate) can trade on SEVERAL exchanges at once — kimchi-premium arbitrage, cross-exchange hedges, multi-venue market making. Honest status (agent >= 1.54.0): - **Backtest jobs accept `legs`**: `POST /api/dev/strategies/:id/backtests` with `legs: [{ex, sym|syms, marketType?, futures?, cash}, ...]` — the server derives each leg's quote (upbit→KRW, binance/binanceusdm→USDT, coinm→the coin itself), `cash` is REQUIRED per leg in that leg's quote (no invented default), per-leg guard caps go in `guard.legs[ex]`. Mutually exclusive with top-level sym/syms/marketType/futures/cash. Max 40 (exchange,symbol) pairs total. legs+saved refs is rejected (pass `refs:'off'` to run without them). - **Paper deploys accept `legs`** (dev API `POST /api/dev/runs` and assignRun): per-leg caps (normalized in that leg's quote), per-leg funds via `paperSeed` — `cash` is accepted as an ALIAS (same meaning as in backtest legs; sending both in one leg is a 400). Default = that exchange's default seed; COIN-M default 10 — COIN units. The agent runs per-leg wallets/guardrails/feeds and routes orders by `ex`. **Dev-API deploys are paper-only. LIVE legs runs exist (P4c, 2026-08, agent 1.61.0+) but only via the web UI, by the human** — per-leg loss caps (kill-switch, in that leg's quote), per-exchange API keys on the agent (any leg missing a key → the agent refuses to start — no silent paper downgrade), interlocked kill-switch cancels resting AND working orders on every leg. A live legs deploy through this API is still a 400; never pretend otherwise. Smart orders (type:'smart' — chasing/slices/ triggers) work on legs runs with a per-leg working-order manager, and paper funding IS settled on usdm/coinm legs at 8h boundaries into that leg's wallet (both agent 1.56.0+; older agents reject smart on legs and skip funding). `cancel:'all'` and the interlocked kill-switch cancel resting AND working orders on ALL legs. Deploy prep: if the strategy has saved refs, clear them with `PATCH /api/dev/strategies/:id/meta {config:{legs:[...], refs:[]}}` — ★ `config` REPLACES the whole saved setup (no partial merge), so ALWAYS include the legs: `{config:{refs:[]}}` alone ERASES the saved legs (real incident 2026-08). Top-level refs or config.refs:null do NOT clear. Run wallet snapshots for legs runs live under `wallet.legs[ex]` in run responses (the flat single-run wallet fields stay null — no currency merging). - **SAVE THE SETUP for legs (2026-08)**: persist a validated multi-exchange setup with `PATCH /api/dev/strategies/:id/meta {config:{legs:[{ex, sym|syms, marketType?, futures?, cash}, ...], interval?}}` — mutually exclusive with config.ex/sym/syms/marketType/cash (the legs ARE the run setup); per-leg `cash` is REQUIRED in that leg's quote (currencies differ, no invented default); validated and normalized like a deploy (duplicate ex 400, futures legs forced to binanceusdm/binancecoinm, coinm single-symbol). On save the server fills in `legs[].quote` and `marketType` from the exchange — the echoed config differing from what you sent is normal, not corruption. config.legs + non-empty refs = 400 (legs runs do not support refs). The web deploy modal prefills paper AND live legs deploys from saved config.legs (paper: the user edits per-leg seeds; live: per-leg loss caps) — update it whenever your best-known legs setup changes. The legs pipeline (P1~P4c incl. the live gate) is complete as of 2026-08. - Spec: `legs: [{ ex, quote, cash, marketType: 'spot'|'usdm'|'coinm', futures?, funding?, fees?, syms: [{ sym, prices, times, opens?, highs?, lows?, volumes? }] }, ...]` — wallet, fees, futures config and guard caps are PER LEG (`guard.legs[ex]`, in that leg's quote units; a shared top-level cap is rejected — units would mix). `cash` is required per leg (no invented default). Real candle `times` are REQUIRED: all legs' bars are merged in true time order (time, legIdx, symIdx) with sec/ms auto-normalization — synthetic prices are rejected (the merge cannot be fabricated). Mutually exclusive with `multi`/single specs; legs+refs is rejected (the legs themselves carry the other exchange's prices); legs+fx is allowed (`ctx.fx` is one shared series, aligned to each symbol's own time axis). The same exchange twice is rejected (a real account has ONE wallet). - **Funding on legs backtest jobs is INJECT-ONLY — never auto-attached (by design)**: legs mix exchanges and marketTypes, so a server-side auto attach would make it opaque which rate hit which leg (same reasoning as coinm). To include funding on a futures leg, inject `legs[].funding` yourself — real measured rates: `GET /api/market/funding?ex=&sym=&from=&to=` (epoch ms). Without injection that leg's result carries `fundingMissing: true` — the numbers EXCLUDE funding; it is not faked as 0. ⚠ Single-symbol usdm jobs have the OPPOSITE default (server auto-attaches, `spec.fundingSource: 'server'`) — always check which default applied before comparing the two paths. Legs PAPER runs settle funding for REAL at 8h boundaries (agent 1.56.0+), so paper results can differ from an uninjected backtest — that gap is the funding itself, not a bug. - ctx: `ctx.ex` = current tick's exchange; `ctx.wallets()` = per-exchange wallet map (single runs get a one-entry map — no legs-only branching); `ctx.pos(sym, ex?)` — 2nd arg queries another leg, unknown exchange → null (not 0); `ctx.markets()` rows carry `ex` (single runs too — null when the caller didn't provide one). Orders route with `{ side, qty, sym?, ex? }`: `ex` omitted = current leg (backward compatible); an exchange outside the legs is rejected + warn log (same principle as unknown symbols). Futures ctx fields (entryPx/liqPx/uPnl/leverage/funding/ marginRatio/marginMode) are per-CURRENT-leg — all null on spot-leg ticks. `ctx.positions` is the current leg's snapshot only (cross-leg reads go through `pos(sym, ex)`/`wallets()`). - Results: `legsMode: true`; per-leg report in `perLeg[ex]` = { quote, marketType, cash0, return, mdd, final, trades, feesPaid, perSym, + futures diagnostics (liquidations, fundingPaid, fundingApplied, fundingMissing) }; `perLegEquity[ex]` curves on the shared merged event axis. **Top-level return/mdd/final/feesPaid/equityCurve are NULL — currencies differ, an aggregate number would be a lie. Consumers MUST read perLeg.** `fills` rows carry the symbol (6th element) and exchange (7th). `tFrom`/`tTo` echo the merged timeline (ms). - `approx` (2026-08) — an OPTIONAL USD-converted aggregate-return REFERENCE: `{ quote:'USD', return, usdtPeg?:true, fxDay0, fxDay1 }` = Σ finalUSD(at tTo) ÷ Σ cash0USD(at tFrom) − 1, converted **per-date** (start and end each at that date's real FX — FX moves are part of the result, by design). Per leg: USDT/USD → 1 (peg assumption, flagged `usdtPeg:true`); fiat → the attached `data.fx` series as-of that UTC day (same forward-fill as ctx.fx, no look-ahead — the as-of days used are echoed in `fxDay0`/`fxDay1` per quote); coinm → that leg's own USD price series first/last close. If ANY leg cannot be converted (quote missing from fx, or no fx data on/before the needed day), `approx` is OMITTED entirely — a partial sum would distort, nothing is invented. There is deliberately NO aggregate MDD (path-dependent — false without a converted time series). When you show approx to a user, ALWAYS label it as a USD-converted reference — never as "the" return. - Kill-switch is INTERLOCKED: per-leg `maxLossQuote` (that leg's quote); when ANY leg trips, ALL legs switch to reduceOnly and all resting orders are cancelled (halting only one side would turn a hedge into a directional position). `halted` = `{ ex, reason }` naming the tripped leg. ## Non-expert user protection (MANDATORY unless the user is clearly a developer) Assume the user has NO technical or trading background. Six real traps in one day of development all shared one property: **the numbers looked normal**. Your job is to doubt results on the user's behalf and explain your doubts in plain language. These rules are duties, not suggestions: - **A1. Self-verify before showing any number.** Check in order; if any trips, investigate BEFORE presenting results: (1) gapPct > 2 → missing bars, never compare symbols on it; (2) tFrom/tTo differ from the comparison target → re-measure on the same window; (3) symsIdle present → suspect a symState scoping bug first; (4) syntheticBookUsed → book-based signals cannot be judged by backtest, say so; (5) trades == 0 or abnormally few → determine bug vs no-signal; (6) deploy caps < 10% of seed → strategy is distorted, warn the user (the server also returns configWarn for this); (7) run interval ≠ backtest interval → stop and say so immediately. - **A2. Never conclude from a single window.** Do not say "good / deploy it" from the latest window alone. Use at least 3 non-overlapping past windows (fixed `end`) before using the word "verified". If it fails them, lead with that fact. - **A3. Plain language.** In user-facing sentences avoid: MDD, walk-forward, Sharpe, slippage, overfitting, lookback, parameter. Rephrase: "how much it shrank at its worst", "whether it also worked in other past periods", "whether it was tuned to just that period". Numbers must carry meaning: not "+45%" but "10,000,000 KRW becoming 14,500,000 KRW". - **A4. Failures first, and bigger.** If 2 of 20 strategies passed, report "18 failed the bar; 2 remain" — not "2 successes". Mis-set expectations are the platform's biggest churn risk. - **A5. No certainty language.** Never "this earns money / is safe / is good". Instead: "it was positive in all 4 past periods tested; that does not guarantee the future." State at least once per result presentation that backtests are estimates. ## Units, pitfalls, and debugging (READ CAREFULLY — real AI mistakes happened here) - **candle.t is now real time (2026-08)**: in real-data backtests `ctx.candle.t` carries the actual bar epoch (same contract as live) — time-of-day / weekday strategies work identically in both. Only synthetic-market backtests (no real candles) fall back to the bar index. Epoch units follow the candle source (verify against `tFrom`). - **ctx.i semantics differ between backtest and live (2026-08, defect fix — run 42)**: in backtests `ctx.i` is the 0-based bar index. In live/paper it is a MONOTONIC bar-clock counter (epoch ÷ bar interval) — it advances by exactly 1 per bar and survives agent restarts, but its absolute value is large and unrelated to the backtest index. **Only DIFFERENCES of ctx.i are meaningful** (cooldowns `ctx.i - s.lastExitI`, holding periods, evaluation windows — these work identically in both). Never use absolute comparisons like `ctx.i === 10` outside backtests, and never index `closes[ctx.i]` in live (the closes window keeps only the last 500 bars — use `closes.at(-1)` / `ctx.price`). Before this fix live `ctx.i` froze at 499 once the window filled, silently disabling every cooldown — if your strategy ran on agent < 1.44.1, re-check time-based logic. - **ctx.state is RUN-scoped, not per-symbol (2026-08, defect found in all 34 strategies)**: in a multi-symbol run every symbol's call shares ONE `ctx.state` object. Storing per-symbol values flat (`state.stopPx`, `state.holding`) makes symbols overwrite each other — one symbol trades once and the whole run stalls. Use `ctx.symState` (auto-scoped to the current symbol, same object as `state.__sym[ctx.sym]`) for per-symbol values; keep `ctx.state` for run-wide values only. BAD: `ctx.state.stopPx = px` · GOOD: `ctx.symState.stopPx = px`. - **Universe size: use `ctx.syms`, not `ctx.markets().length` (2026-08)**: `markets()` only contains symbols whose data has arrived — on early calls that can be just one row, which broke per-symbol budgeting (budget = equity/1 = everything). `ctx.syms` lists the FULL assigned universe from the first call, in backtest and live alike. - **Idle-symbol hint (multi-symbol results)**: if some symbols traded 0 times while others traded, the result carries `symsIdle: [...]`. It cannot distinguish "no signal" from a state-scoping bug — treat it as a pointer to check `symState` usage first. - **exec.fillPct is a RATIO**: 0.5 = 50% fills, valid range 0 0) return { side: 'sell', qty: ctx.position }` every bar until position is 0 — a single sell may not close you. - **openOrders() shape**: array of `{ id, side, qty, price }` (resting limits only; market orders never rest). `{cancel:'all'}` cancels the whole run's resting orders — per-symbol cancel does not exist, so in scanner runs keep resting orders on one symbol at a time. - **Backtest window defaults to the latest N bars** — the anchor drifts between runs. For fixed periods and walk-forward validation pass `end` (dev API backtest jobs — see the dev loop section) and verify via `tFrom`/`tTo` in the result. The studio web backtest is still latest-N only. - **Fees**: backtest defaults are maker 0.05% / taker 0.1% (override via `market.fees`). Live/paper uses per-exchange estimates — read `ctx.fees` at runtime instead of hardcoding. - **ctx.signal(slot) — external signals: webhook + polling (agent 1.64.0, 2026-08)**: two ways in, ONE namespace (a slot name is either a webhook slot or a polling job — never both; collisions are 409). Slot count is plan-based (free 3 → elite 30; over the cap: 400 `LIMIT_SLOTS`). · **Webhook (push)** — the user gets a URL (trading settings → 외부 신호) and points TradingView alerts or any external source at it with `?slot=name`. · **Polling (pull)** — the user defines a job {url(https), method(GET|POST), body(POST only — GraphQL/filter JSON, stored encrypted like headers), headers(API key), interval, slot} and **their own agent** calls it (per-user IP + per-user key = what data providers expect; our servers never call third-party APIs on your behalf). Minimum interval is plan-based (free 3600s / basic 1800s / pro 900s / elite 60s). The server RECORDS/RELAYS only — the payload is stored as-is (JSON verbatim; non-JSON bodies become `{text}`) and never interpreted. Optional per-job hints `itemsPath`+`timeField` (both required together) expand an array response into many rows so a single poll can backfill history; without them the whole response is one row. Reads return `{ payload, at, age }` or null: live/paper runs read the latest per slot (local archive first, 60s server snapshot as fallback) — `age` in seconds tells staleness, gate on it yourself. Backtest jobs resolve as-of each bar (no look-ahead), fed from the **agent's own local archive** (unbounded — the user's disk is the limit) merged with the server's short buffer; results carry a `dataWarn` saying the signal history is that agent's local record, so **two agents can legitimately produce different backtest results** (each archives only while it was running). **Honest limitation: backtests only cover the period SINCE collection started** — there is no history before the webhook/job existed, and we do not reconstruct TradingView logic (fabrication). Payload shape is whatever the source sends — parse defensively, null-guard everything. - **ctx.alert(msg) — near-signal alerts (agent 1.62.0, 2026-08)**: the strategy itself decides "the entry condition is close" and calls `ctx.alert('BTC 김프 2.9% — 진입 3.0% 근접')` — the system cannot infer proximity from arbitrary code, so YOU write the proximity check (e.g. 80~90% of the entry threshold). Delivery: live/paper runs push a site banner + Telegram (if linked), with a 60s per-run cooldown (extra calls are dropped and logged — code your own hysteresis so you don't spam the boundary). Backtests only LOG alerts ('[알림] ' prefix, max 50 per result — no delivery, honestly). msg is forced to string, 200 chars. Returns undefined in all contexts. If the user asks to "watch" or "be notified near entry", add a ctx.alert call — do not promise push channels that do not exist. - **ctx.ref() works everywhere since agent 1.48.0 (2026-08)** — save slot bindings on the strategy via `PATCH /api/dev/strategies/:id/meta {config:{refs:[{slot,sym,ex?}]}}`; backtest jobs then fetch ref candles per slot automatically, and deploys PIN the bindings (live prices per slot; stale >5min → null). Honest failure modes: a slot symbol that does not exist on its exchange FAILS the job with a slot-attributed error (a dead slot must not complete quietly); a symbol that exists but has 0 candles in the window completes with `dataWarn` and null reads. **Works with multi-symbol (`syms`) jobs since agent 1.65.0** — ref slots are ONE shared set across all symbols, resolved as-of each symbol's own bar timestamps, so `ctx.ref('slot')` reads the same slot on every symbol's tick (this is what relative-strength scanners need). To run multi WITHOUT the refs, pass `refs:"off"` in the job body (saved refs are kept). `legs` runs still reject saved refs (a leg IS another venue's feed) — pass `refs:'off'`. Older agents (<1.48.0) still return null in jobs — check `agentVersion` in results. - **ctx.fx works in backtest jobs (agent 1.49.0+) and LIVE/paper runs (agent 1.51.0+)** — jobs get the real daily USD/KRW series attached by the server (same data the studio binds); live runs read the agent's 60s cache of the server's current real rates (`ctx.fx('KRW')`, `ctx.fxQuotes`). If no real rate exists it is null — never fabricated; null-guard `ctx.fx` in strategy code. Premium(kimchi) math stays yours — no conversion is forced. Multi-symbol jobs still do not get the fx series (engine limit). To validate premium/FX math OUTSIDE the strategy, use the public data endpoints: `GET /api/market/candles?ex=&sym=&interval=&limit=500[&before=]` (paginate with `before`) and `GET /api/market/fx?quote=KRW` (daily FX) — compute externally, then confirm the strategy itself in the studio. Also public (collection started 2026-08 — history grows from there, no backfill exists): `GET /api/market/oi?sym=&limit=500[&before=]` (Binance USDT-M open interest, 5m rows {ts, oi(COIN qty), oiUsd(USD notional|null)}; response carries `gapPct` = missing-row rate % against the 5m grid of the queried window (same fingerprint idea as candle gapPct; null when <2 rows). Exchange keeps only 30 days, so our DB is the long-term record) and `GET /api/market/liqs?sym=&limit=500[&before=]` (per-minute forced-liquidation aggregates {ts, longUsd, shortUsd, cnt}; realtime-only stream, gaps mean the collector was down — never interpolate). Both are raw material for crowding/positioning ideas (funding-filter lineage). Symbol discovery: `GET /api/market/symbols` lists ALL tradeable universes in one call — per exchange {kind: 'crypto'|'stock-kr', quote, spot, intervals, count, syms[]} (crypto syms = currently tracked universe; the candles API may additionally serve any exchange-listed symbol on demand — 'tracked' and 'servable' differ). Korean stocks (ex 'kis', 6-digit KRX codes): 1d has ~2y backfill; 1m and the 5m/15m/1h nightly aggregates exist only from collection start. (`GET /api/market/kis/symbols` is the stocks-only subset of the same data.) In LIVE/PAPER runs the same data is available as `ctx.binanceOi()` / `ctx.binanceLiqs(n)` (agent ≥ v1.45; Binance-prefixed because the source is Binance USDT-M regardless of the run's exchange); in BACKTESTS both return null until history reaches walk-forward depth — a strategy using them must null-guard and cannot be validated by backtest yet (paper-validate instead). - **Multi-symbol fills index**: in multi-symbol results `fills[i][0]` is the index in the MERGED event sequence (all symbols, time-sorted) — NOT a uniform bar grid. Never convert it to a timestamp via `tFrom + idx × interval` (real case: "last signal −3992h in the future"). If you need fill times, measure with a single-symbol job. - **Unit mismatch trap**: `ctx.markets()` rows carry `ch1/ch5/ch60` in **PERCENT** (2 means +2%), but `ctx.change(n)` returns a **FRACTION** (0.02 means +2%). They differ by 100x — never compare one against a threshold written for the other. (A real strategy required "+200% in 1h" this way and traded zero times.) Ranking/sorting by markets() ch values is unit-safe; thresholds are not. - **Single-symbol runs**: in a single-symbol run or backtest, `ctx.markets()` contains exactly ONE row (that symbol) — it is never empty while the price is known. Do not early-return on `rows.length < 2` unless you intend to skip single-symbol mode. - **Warmup**: every indicator returns null until n candles exist. If the backtest period is shorter than your longest lookback, the strategy never fires. Ensure period ≫ max(n). In live/paper runs (2026-08) **ALL watched symbols are backfilled** from coinsori's candle store at start, in the run's own bar interval (up to 500 bars each) — a 4h/55-lookback strategy starts with warm indicators instead of waiting 9 days. The run's bar interval is chosen at deploy time and should MATCH the backtest interval (the live ring buffer builds bars at that interval; on a mismatch the same strategy sees different signals). Startup logs report per-symbol backfilled bars and, when short, the estimated warmup-complete time — still null-guard indicators. - **Thin synthetic book (backtest)**: the orderbook is derived from candle volatility, so large qty can partially fill or slip hard. Check `ctx.fillPrice(side, qty)` before sizing big orders. - **Backtest data shortfall**: candles load in pages (up to 500/request, paginated to the requested count, max 5000). If the exchange has less history than requested, the UI shows "requested X bars → only Y loaded" — treat Y as the real sample size. A zero-trade result on a short sample often means the move you filter for simply never happened in that window, not that the strategy is broken. - **Zero-trades debugging pattern**: count every rejection reason in `ctx.state` and log a summary periodically, e.g. ```js const s = ctx.state; s.blocked = s.blocked || {} // ... s.blocked.rsi = (s.blocked.rsi||0) + 1 ... if (ctx.i % 500 === 0) ctx.log('blocked', JSON.stringify(s.blocked)) ``` This shows which filter blocks entries instead of guessing. - `qty` is coin amount; to spend all cash at market use `qty: ctx.cash / ctx.price` (fees auto-shrink buys). - Volume (`ctx.vol`/`volumes`/`avgVol`) can be **null** (unknown ≠ 0) — a volume filter without a null check silently inverts on exchanges where volume is unavailable. ## Execution model - Backtest: runs in a browser Web Worker on coinsori candle history; the orderbook is synthesized from candle volatility (deterministic approximation) — treat slippage as an estimate. **Model change (2026-08):** spread is now normalized to per-minute volatility (interval-aware) — long-interval bars no longer produce absurdly wide books (market orders used to cost ~5% round trip on 1h bars; that was a model artifact, not reality). Results before this change are not comparable. - Live: the local agent subscribes to exchange feeds, calls onUpdate with the same ctx, enforces user-set guardrails (order caps, loss kill-switch with fee-aware P&L ledger), then signs and submits orders directly to the exchange. Kill-switch release is a monotonic timestamp the agent compares — the server cannot command trading on. - Scanner runs: one deployment may watch many symbols; ctx.sym tells which symbol this call is for and ctx.markets() gives compact rows for all watched symbols (never full candle arrays for other symbols). - Cluster M1 (2026-08, live): a scanner strategy can be deployed across SEVERAL of the user's agents at once — the server partitions the symbol set via rendezvous(HRW) hashing and creates one ordinary run per agent (siblings tagged cluster i/n in the UI). Static split, no rebalancing yet (M2). Capacity is 200 symbols per agent; overflow is reported honestly as "unassigned", never silently dropped. **Gated**: Elite plan AND a cluster-onboarding flag enabled by the operator (contact-us) — paying alone does not open it. Web UI only (deploy modal → "cluster deploy"); this dev API does not create clusters. The server still cannot create orders — each agent runs its share exactly like a normal run. - Tick decision-time visibility (2026-08): the studio backtest reports decision time per tick (avg/max ms) and warns when max approaches the live per-tick budget of 150 ms — over-budget ticks on live are DROPPED (decision and orders discarded), so heavy strategies should be lightened before deploying. Live runs report measured decision time from the user's own server every 30 s; run cards show "decision avg/max · N ticks · age" with timeout counts. Missing report shows as "not measured", never as 0 ms. ## Automated dev loop (dev API, elite plan) — for AI agents developing strategies - **API host**: use the brand domain from your start prompt (e.g. https://soritrading.com) for every /api/dev call — all brand domains serve the same API and account data. `GET /api/dev/context` echoes `apiBase`/`llmsUrl` for the host you called; if they show a different (internal) host, keep using your prompt's domain — do not switch. - **OFFLINE ANALYSIS — explore freely, but returns come from THIS engine only**: fetching market data yourself for cheap exploration (indicator research, regime stats, narrowing parameter ranges) is encouraged — it saves backtest quota. But NEVER report offline-simulated PnL/returns as results: your simulation is not this engine (different fill model, fees, funding, look-ahead guards), and offline numbers do not transfer. Any return/mdd you show the user MUST come from a backtest job here ("backtest = live, same engine" is the platform contract); present offline numbers only as hypotheses, labeled as such, then verify with a job. - **Session start protocol (new chat / new token)**: call `GET /api/dev/context` FIRST and treat the SERVER as the source of truth — not your own memory of previous sessions. Then reconcile: - Your memory references a strategy the server does NOT list → the token is almost certainly `scope=own` (isolated: it only sees strategies it created — an empty list does NOT mean the account is empty). Say so explicitly and offer the user exactly two paths: (a) issue a token with "All my strategies" scope to CONTINUE the existing strategy (recommended when resuming work — notes/backtest history stay attached), or (b) start a FRESH strategy under this isolated token (new project). NEVER reconstruct the old strategy from memory — the rebuilt code will differ in details and every comparison against its previous backtests becomes invalid. - You have no memory of this account → just proceed: create a strategy and start the loop (announce "scoped token — starting a new strategy" so the user isn't surprised by an empty list). - With a personal access token (`csd_...`, Bearer auth) you can iterate without the human copy-pasting: `GET /api/dev/context` (start here) → read code → `PUT .../code` (always pass `note`: what/why) → `POST /api/dev/strategies/:id/backtests` {ex, sym, interval, cash, market?, **EITHER bars<=100000 (+ end?) OR from (+ to?)**, syms?, exec?, guard?, marketType?, futures?, funding?} (`market.book`/`market.fees` numeric overrides pass through — e.g. `{book:{liqNotional:5e8}}`. `end` = epoch sec/ms or ISO, exclusive — backtests the `bars` bars BEFORE that time. `end` must be in the PAST — today/future values are rejected with 400. Omit `end` for the latest window, but note the anchor then drifts per run — ALWAYS fix `end` for comparisons. **`from`/`to` — explicit date range (2026-08).** Same formats as `end` (epoch sec/ms or ISO); `to` defaults to now. The server derives the bar count from the range, so the period you asked for is the period you get. **Use this whenever your report or your reasoning attaches a date label to a run** ("the 2021–22 drawdown", "Q1 2024") — a bar count silently drifts away from the period you meant: 5000 1h-bars from a chosen `end` can reach back into a bull run you did not intend to include, and the resulting number then travels under the wrong name. `from`/`to` CANNOT be combined with `end` or `bars` — that would be ambiguous, so it is a 400, not a silent pick. If the range exceeds your plan's bar cap you get a 400 stating both numbers; it is never silently truncated (a truncated range labelled with your dates is the exact failure this feature exists to prevent). Ranges shorter than 100 bars are also rejected. Unknown body keys are rejected with 400 (never silently ignored). The result echoes `tFrom`/`tTo` (epoch sec of the first/last bar actually used) and `gapPct` (missing-bar rate % — when high, the same bar count spans a LONGER period; a dataWarn fires above 5%. Never compare symbols whose gapPct or spans differ materially). Candle API errors are typed: **404 = no data for that symbol/exchange (retrying is pointless — pick another), 5xx = transient (retry once)**. Always verify the period from the result itself; without `end` the latest-N anchor drifts between runs, so fix `end` for any comparison. Use it for walk-forward validation: tune on one period, then verify the SAME params on disjoint earlier periods. If results only hold on the tuning period, the strategy is overfit.) → poll - **Multi-symbol backtests (2026-08)**: pass `syms: ["BTC","ETH","SOL"]` (2–10, replaces `sym`) to run ONE shared wallet across all symbols with bars merged in true time order — allocation contention ("first signal drains the cash") is now reproducible in backtests. Same live contract: `ctx.sym` is the symbol of the current bar, `ctx.markets()` lists all, orders route via `{sym}` (outside the universe → rejected + warn log). Result adds `syms`, `perSym` {trades, realized, position, lastPx}, portfolio-level return/mdd, and fills carry the symbol as the 6th element. `bars` is capped at 100000/len(syms). **`refs` and `fx` combine freely with `syms` (agent 1.65.0)**: both are ONE shared series set aligned as-of each symbol's own bar timestamps — relative-strength / relative-value strategies (scanner + benchmark symbol or FX) work now. `legs`+refs stays 400. - **Execution model (2026-08)**: `exec: {delayBars?: 0–10, fillPct?: 00 means longs pay. Result echoes `fundingPaid` (+paid/−received), `fundingApplied`, and — CRITICAL — `fundingMissing` (ALWAYS boolean on usdm results): `true` = the run effectively had NO funding (no data, or the series never overlapped the run window — a warn log names the cause); `false` positively asserts funding WAS applied. Never compare or report a perp result without checking this flag AND `spec.fundingSource` — checking only result fields caused a real misreading (a comparison flipped when server auto-attach kicked in between measurements). **Funding is AUTO-ATTACHED (P4, 2026-08)**: for `marketType:'usdm'` jobs with no injected `funding`, the server attaches real Binance funding history covering the run window and marks `spec.fundingSource: 'server'` on the job (your injected series takes precedence and is marked `'injected'`). If the store has no coverage for that symbol/period nothing is attached and the result honestly shows `fundingMissing: true`. Read `spec.fundingSource` from the job object to know which funding a result used — never assume. - **Futures P2 (2026-08) — limit/trigger orders, cross margin, multi-symbol**: · Limit/stop/trail orders now work in usdm with the same order shapes as spot, plus `reduceOnly` (clamped at fill time — never flips the position). Futures limit orders reserve NOTHING at placement; margin is checked at fill (insufficient → scaled down + warn log, same rule as market orders). Resting fills pay maker fee, triggers pay taker. · `futures.marginMode: 'isolated'(default) | 'cross'` — the default stays isolated for spec continuity with P1 results. Per-symbol switch via `ctx.setMarginMode(sym, mode)` (only while that symbol has no position, like real exchanges). · Cross semantics: no per-symbol liqPx (`ctx.liqPx` is null for cross — watch `ctx.marginRatio` = maintenance/equity, liquidation at >= 1). When account equity drops to the total maintenance margin, ALL cross positions are force-closed at adverse bar extremes with a 2x taker liquidation fee; the wallet absorbs losses and CAN go negative (no insurance fund modeled — deliberately worse than real exchanges). · Multi-symbol futures: `syms` works with `marketType:'usdm'` — shared wallet, per-symbol positions/margin modes. Funding must be a PER-SYMBOL map `{SYM: {times, rates}}` (a single series for all symbols would be fabricated data — rejected). `perSym` gains `entryPx`, `liquidations`, `fundingPaid`, `marginMode` so you can see which symbol liquidated or paid. - **COIN-M inverse (FUTURES P3, 2026-08)**: `marketType:'coinm'` + `ex:'binancecoinm'` — backtests AND paper deploys (`POST /api/dev/runs`; dev API is paper-only as always; live exists via web UI). SINGLE-symbol only (each symbol settles in its own coin — a shared wallet would mix coins; run one deploy per symbol, which matches the exchange: Binance has no cross-coin margin sharing either). Paper `paperSeed` is in COIN units (10 = 10 BTC — a USD-sized default would be absurd); guardrail `maxLossQuote` is in coin too, while `maxOrderNotional`/`maxPosNotional` stay USD. Units flip: `qty` = CONTRACTS (1 contract = `futures.ctrSize` USD, default 100 for BTC / 10 otherwise — the binance rule; result echoes `ctrSize`), while `cash`/margin/PnL/fees are in the COIN (e.g. BTC), so `cash:10` means 10 BTC, and `return`/`final` are coin-denominated. PnL = qty×ctrSize×(1/entry − 1/exit) (longs still profit when price rises). `ctx.liqPx` uses the inverse analytic solution; a 1x short is fully hedged in coin terms → `liqPx` null and it can never liquidate (this is correct, not a bug). Guardrail notionals (maxOrderNotional/maxPosNotional) are in USD (|contracts|×ctrSize). Funding: injected series only — the server does NOT auto-attach (its store holds USDT-M rates, which would be wrong data for coinm); without injection `fundingMissing:true` as usual. Funding pay = qty×ctrSize/price×rate, settled in coin. - **Futures PAPER runs (P6, 2026-08)**: `POST /api/dev/runs` accepts `marketType:'usdm'` + `futures:{leverage,marginMode}` — the dev API is ALWAYS paper (live futures exist as of P7 but only through the web UI; dev tokens can never initiate live orders — non-negotiable), custom strategies only, and `ex` MUST be `'binanceusdm'` (futures prices only — spot prices for a perp rehearsal would be wrong data). ctx carries the same futures fields as backtests (signed position, entryPx, liqPx, marginRatio, marginMode); liquidations sweep every tick. Funding settles in paper (P6b, 2026-08, agent >= 1.40): every 8h boundary (00/08/16 UTC) the agent fetches the SETTLED binance rate and applies `pay = qty×px×rate` to the wallet (same formula as backtests; sign as-is — longs pay positive rates, shorts receive). `ctx.funding` is the last settled rate, null before the first settlement (never fabricated). Missed boundaries (agent down) are back-applied up to 3, at current price (logged as approximation). Positions opened AFTER a boundary are never charged retroactively. `reduceOnly` now applies to `smart` orders too. Remaining paper-vs-backtest difference (deliberate, honest): insufficient-margin entries are REJECTED rather than scaled down. NOT yet supported (explicit 400/log, never silently ignored): live futures (P7), COIN-M (P3), hedge mode (v2), margin add/remove on isolated positions (v2). - **Jobs interrupted by an agent restart/update auto-recover — do NOT resubmit**: on a graceful shutdown (update/restart) the agent RELEASES running jobs back to the queue instantly (0s wait); on a crash, the heartbeat stops and the job is reclaimed within ~90s. The job object shows `stale: true` while a dead execution awaits reclaim ("will re-run — just wait"). Resubmitting duplicates the work and burns the daily quota. - **After an agent update, do NOT restart finished batches blindly — check `engineHash`**: every job result carries `engineHash` (backtest-engine fingerprint) and `agentVersion`. Results with the SAME engineHash are comparable regardless of when they ran — an agent update that doesn't change the engine leaves your previous batch fully valid. Only when engineHash differs must the jobs you want to compare be re-run (mixing engineHash values in one comparison is invalid). `GET /api/dev/backtests/:id` until status done|failed → analyze result → repeat. Leave findings as notes. - **Don't over-anchor on the current code.** Before tuning parameters, ask whether the approach itself is right — parameter-tweaking a bad idea converges to a well-tuned bad idea. Keep separate draft strategies (`POST /api/dev/strategies`) to A/B genuinely different approaches (trend / mean-reversion / scanner momentum) instead of endlessly mutating one strategy. - **Reference other strategies.** Public strategies are fully open by design (open-code policy): `GET /api/studio/strategies?sort=perf` (ranking), code at `GET /api/studio/strategies/:id`. Reading how others structure entries/exits is encouraged — credit borrowed ideas in your notes. - **Strategy bookkeeping (multi-day)**: `PATCH /api/dev/strategies/:id/meta` sets `status` (draft/testing/adopted/rejected/archived) and `tags` (e.g. 'axis:sizing,breakout'). The strategy list returns both — the next session reads the list, not 30 notes, to know where things stand. Update status whenever a verdict is reached. The same endpoint saves the validated run setup on the strategy: `config:{ex, syms, interval, marketType?, cash?}` — or for multi-exchange strategies `config:{legs:[{ex, sym|syms, marketType?, futures?, cash}, ...]}` (mutually exclusive with ex/sym/syms/marketType/cash). The studio and the web deploy modal prefill backtests/deploys from it. META FIRST: PATCH meta {status:'testing', tags, config} right after POST /strategies — a strategy left draft/config:null looks broken in the user's work-history screen even when backtests/notes/progress are all recorded. ★ `config` REPLACES the whole saved setup (no partial merge): ALWAYS send the COMPLETE final setup — sending one key wipes the rest (real incident: `{config:{refs:[]}}` alone erased the saved legs; correct: `{config:{legs:[...], refs:[]}}`). - **Cross-comparison**: `GET /api/dev/backtests?limit=100` returns recent jobs across strategies with return/mdd/trades/gapPct/tFrom/tTo in one call — build comparison tables from this. - **Paper vs backtest**: `GET /api/dev/runs/:id/fills` returns fills + wallet for a run — auto-compare what the backtest predicted against what paper actually filled. - **Paper deploys via dev API (2026-08, PAPER-ONLY by user decision)**: `GET /api/dev/runs` (all runs), `POST /api/dev/runs` {strategyId, ex, syms|sym, interval?, paperSeed?, caps?, agentId?, allowDup?} creates a PAPER run (agentId omitted → most recent online agent). `PATCH /api/dev/runs/:id` {status: active|paused} and `DELETE /api/dev/runs/:id` work on paper runs only — live runs return 403 (live deploys/controls are human-only, web UI). Deploy pins the code AT DEPLOY TIME: after editing a strategy, redeploy (delete + POST) or the old code keeps running. Duplicate deploys of the same strategy/agent/symbols are rejected with 409 unless allowDup:true (orders would double). Deploy warnings ride INSIDE the run object — `{ run: { id, ..., configWarn?: [...] } }`, NOT top-level (a real defect report was filed from looking at the top level only). configWarn lists contradictory settings that make numbers look normal while distorting the strategy (paper caps < 10% of seed; coinm seed > 1000 coins; 2+ symbols without a `symState` trace). Warnings, not blocks — read them and fix the deploy. - Every note stores a code snapshot — the notes timeline IS the version history. Read any version with `GET /api/dev/notes/:id/code`; restoring is available to the human in the studio (and you via re-PUT). - **Token scope**: scope=own tokens (the default) see only strategies created with that token — an empty strategy list does NOT mean the account is empty. Create your own and proceed. - Empty account? `POST /api/dev/strategies` {name, code} creates a draft custom strategy — don't stall waiting for the human. Drafts stay private; **deploying to live still happens only in the web UI, by the human**. - Backtest jobs run on the **user's own agent** (non-custodial — the server never executes user code). If the response says agentOnline=false the job waits until the agent connects; tell the user honestly. - **Backtest jobs are single-symbol** (one `sym` per job). Multi-symbol scanner backtesting does not exist yet — ctx.markets() contains only that symbol, so a scanner's "picking among symbols" part is NOT validated. To compare symbols, submit one job per symbol (mind the concurrency limit). - Set `cash` to market scale (KRW markets ~10000000, USDT ~10000) — wrong scale makes qty look broken. - **Work on your own strategy.** Unless the user pointed you at a specific one, create yours with `POST /api/dev/strategies` and develop there — existing strategies are the user's assets. - **Job submission pacing**: leave ~2s between job submissions — rapid-fire submissions can trip candle-API rate limits (502s that look like outages). Parallelism comes from the concurrency slots, not from submission speed. ## Idea space — axes to explore (pick an UNEXPLORED axis, not another variant) A strategy idea is a combination of these axes. When one axis stops yielding, that is NOT "out of ideas" — it is "that axis is explored". Move to an unexplored axis. 1. Signal source: price / volume / bar shape (o,h,l,c) / orderbook·liquidity / cross-symbol relations 2. Signal horizon: 1 bar ~ hundreds of bars (short = weak persistence, long = few trades — find balance) 3. Entry structure: single signal / multi-signal voting / regime switching / added filters 4. Entry price: market now / resting limit on pullback / staged entries 5. Bet sizing: all-in / fixed-risk / performance-scaled / pyramiding 6. Exit style: channel exit / trigger stop / trailing / time-based / partial take-profit 7. Portfolio: single strategy / parallel strategies / core holding + overlay 8. Target: symbol set, bar interval (changing WHERE it runs, not the strategy) Rule of thumb: axes 1–3 (signal) have little room left once a good strategy exists; axes 4–7 (money & execution) usually hold the remaining edge. ★ Axis limits: orderbook/liquidity signals CANNOT be judged by backtest (the book is synthetic) — validate those on paper from the start. ★ Re-arming stops WITHOUT reduceOnly (real bug, 2026-08): if you re-place a stop every time your level moves and reduceOnly is unavailable (e.g. paper smart orders), the old stops STAY armed — they accumulate, fire together, and can flip you short past your position (-100% across 4 windows, measured). Always `{cancel:'all'}` before re-arming, or track and cancel the previous order id. ★ Futures stop placement (measured, 2026-08): at high leverage a PERCENTAGE safety margin collapses — at 20x a "30% margin" stop sat only 2.1% above the liquidation price (one bar's range). Size the stop-to-liqPx gap in ABSOLUTE terms (e.g. N× recent bar range), not as a percentage of margin, and always verify `ctx.liqPx` (isolated) or `ctx.marginRatio` (cross) at entry time. ## Multi-day development — pacing rules - The dev loop is a MULTI-DAY effort. Do not try to exhaust the idea space in one session. - Spend ~20–30% of the daily backtest quota per session (e.g. 200–300 of 1000). Keep the rest for fair-comparison re-measurements and data verification. - End every session with a **backlog note**: axes tried, axes remaining, next 3 candidates. The next session starts by reading that note. - "Nothing left to improve" is close to a forbidden conclusion. Instead choose one of: (a) move to an unexplored axis, (b) wait for paper results as new material, (c) change the target (symbols/interval). - Diminishing returns is not failure — it is the completion signal for that axis. Record it as such. ## Comparison hygiene — mistakes that flip conclusions (all happened for real) Before comparing anything, check: 1. **tFrom/tTo** — did both strategies see the SAME period? Different warmups shift the window. When comparing against a long-warmup strategy, re-measure the baseline on the same bars. (Real case: a regime-switch strategy looked 3x better — re-measured on the same window, it lost.) 2. **gapPct** — with different missing-bar rates, equal bar counts mean different periods. (Real case: 43% gaps made 2000 bars span 586 days instead of 333.) 3. **Fix `end`** — without it the latest-N anchor drifts and results are not reproducible. 4. **Never judge on the current period alone** — recent winners often collapse in past regimes. (Real case: +313% on the current period, −25% on the one before.) 5. Some axes are untestable in backtests (synthetic book) — see Idea space above; paper-validate. 6. **Futures: match `spec.fundingSource`** ('server'|'injected') on BOTH sides before comparing — server funding auto-attach changes returns for the identical spec (real case: −27.02% → −27.60% was reported as an engine regression; it was funding attach turning on). The flag is echoed in the job's `spec`, not in `result`. To exclude funding entirely, inject `funding:{times:[],rates:[0]}`. - Limits: concurrent jobs per plan (elite: 3 — submit several symbols/intervals in parallel and poll each), 1000 jobs/day, 30 req/min per token. No deploy/live-trading endpoints exist by design. ## Guiding the user to run a strategy (paper / live) — exact steps, do not improvise Deployment is human-only (web UI); there is no deploy API. When the user asks "how do I run this", give these exact steps instead of inferring: 1. **Agent first**: Trading room → Agents (/trading/assets) — install & pair the local agent (one-line install command shown there). The agent must be online; live orders also need exchange API keys registered *in the agent* (keys never touch the server — non-custodial). 2. **Save the strategy**: Strategy Studio (/trading/backtest) — only saved strategies can be deployed. 3. **Deploy**: in the Studio's runs tab press deploy — the modal asks: agent, exchange (paper: any exchange / live: only ones with registered keys), symbol(s), **paper or live**, initial cash (paper), and guardrail caps (order size / loss kill-switch — explicit consent). 4. **Watch**: the run appears in the runs tab and the dashboard (/trading/dashboard) — logs, fills, and wallet snapshots report there. Paper wallets are per-exchange and charge fees (honest results). 5. **Stop**: from the run card; the kill-switch cancels all open orders and halts the run. 6. **Caps vs. returns**: live default caps are conservative (e.g. 300k KRW total exposure) — the strategy only works up to the cap, so account-wide returns look small by design. Judge performance against exposure, and tell the user to raise caps at deploy time once trust is earned. Paper runs default caps to the full paper cash (no distortion). ## Service surface (for context, not for strategies) - Web app (Nuxt): / home & market overview /market live prices, exchange premium, charts (Lightweight Charts; up=red, down=blue, KR style) /news translated crypto news /community board + realtime chat /trading dashboard, paper, live, strategy studio, backtest, agents /docs human documentation /admin admin console (users, reports with chat context snapshots, audit log, traffic) - Backend (Express): /api/auth, /api/board, /api/chat (WS), /api/news, /api/market, /api/paper, /api/agent (pairing + E2E relay), /api/studio (strategies/backtest — Strategy Studio; /api/botlab is a legacy alias), /api/admin. - Every admin mutation passes an audit-log wrapper (log failure = action failure; no delete API). ## Answering user questions (guidance for AI assistants) - **Always reply in the user's language** (the language they wrote in). This document is in English for machine precision, but coinsori users speak ~50 languages — explanations, warnings, and code comments should be in the user's language. Code identifiers stay in English (`onUpdate`, ctx names). - Never claim coinsori holds funds or keys — it does not (non-custodial, see invariants). - Never promise profits; backtest results are estimates on synthetic liquidity. - When writing strategies, output only the onUpdate function, using only the ctx API above. ## Not a strategy question? Use the right document (three exist) This file is the **development contract** — how to write strategy code. It is the wrong document for most customer questions, and answering them from here means guessing. - `/support.txt` (ko) · `/support-en.txt` — **customer support manual**: screen paths, FAQ, pricing & plan limits, refunds, and where to escalate. Use this for "how much is it", "where do I click", "why is my agent offline", "can I get a refund", "how do I stop everything". - `/guide.txt` (ko) · `/guide-en.txt` — **feature guide**: the full human documentation, merged. - **This file** — ctx API, units, pitfalls, the automated dev loop. If a question is not answered by the document you are holding, say so and point the user to `/support` rather than inferring an answer. A wrong answer costs the user more than no answer.