LLM-Auto-Trader 설치부터 실전까지 — LLM 이 제안하고 규칙이 막는 크립토 봇
CCXT 함수 열세 개만 구현한 셰임 하나로 이 봇의 체결을 통째로 바꿔 봤습니다. 설치·첫 주문·고리 한 바퀴·끊고 다시 붙이기까지 전부 실측입니다.
3편. Erfaniaa/LLM-Auto-Trader — 붙는 표면이 CCXT 함수 열세 개뿐인 봇이 글의 완료 조건: 독자는 규칙 레이어에 말도 안 되는 제안들을 던져 무엇이 어느 층에서 잘리는지 자기 화면으로 읽으면 성공입니다. LLM 호출 0회, 돈 0원으로 됩니다.
| 하는 일 | 몇 분에 한 번 시세·호가·펀딩·온체인·뉴스를 긁어 LLM 에게 "지금 뭘 해야 하나"를 묻고, 돌아온 JSON 을 결정론적 규칙 레이어에 통과시킨 뒤 살아남은 것만 거래소로 보냅니다 |
| 맞는 사람 | 크립토를 LLM 판단으로 돌리되 한도는 코드가 강제하길 바라는 사람. 자기 전략의 위험 규칙을 어떻게 짤지 고민 중이라면 src/risk/ 만 읽어도 본전은 뽑습니다 |
| 안 맞는 사람 | 한국 주식이 필요한 사람(크립토 전용) · 설치하자마자 도는 물건이 필요한 사람(기본 설정으로는 주문이 한 건도 안 나갑니다) · 봇을 꺼도 손절이 지켜져야 하는 사람 |
| 이 글의 결과물 | 규칙 레이어가 무엇을 어느 층에서 자르는지 적힌 판정 표 한 장, 그리고 이 봇의 주문이 weltiq 모의 계좌에서 체결된 화면 |
| 준비물·비용 | 코드 0원(MIT) · 모델 LLM 키 1개, 페이퍼 모드에도 필수입니다. 이 글 전체가 $0.1313 · 시세 0원(바이낸스 공개 시세) · weltiq 5절을 따라 할 때만 |
| 시간·난도 | 설치 20초, 첫 주기 몇 분. 난도 중 — 첫 실행 전에 버그 세 개를 직접 넘어야 합니다 |
| weltiq 와의 관계 | 붙습니다. 거래소에 닿는 자리가 CCXT 함수 열세 개뿐이라, 파일 하나로 체결을 통째로 옮길 수 있습니다 |
| 검증 환경·날짜 | macOS 26.6 · Apple Silicon · Python 3.12.13 · 2026-09-21 21:00~22:30 KST (일부 화면은 2026-09-22 재실행) |
| 제휴 고지 | 필자는 weltiq 로부터 이 연재의 원고료를 받습니다. 도구 평가는 필자가 직접 돌린 결과만 씁니다 |
1. 이게 뭔가요
Erfaniaa/LLM-Auto-Trader 는 크립토 자동매매 봇입니다. README 가 스스로 요약한 문장이 정확합니다 — "LLM 이 제안하고, 규칙이 처분한다(The LLM proposes; the risk layer disposes)." 판단은 LLM 이 하지만 주문이 나가는 경로에는 LLM 이 없습니다. 거기엔 손절 검사와 노출 한도와 차단기만 있습니다.
규칙 레이어가 본체입니다. src/risk/ 에만 모듈이 9개 있고(베팅 크기·차단기·쿨다운·일중 고점·증거금 감시·포지션 사이저·손절·변동성 타깃, 그리고 이 전부를 모으는 risk_manager), 백테스트 쪽에는 CPCV(조합적 교차검증)와 CSCV(과적합 확률)까지 들어 있습니다. LLM 이 낼 수 있는 답의 모양도 못 박혀 있습니다 — size_pct 25 이하, leverage 1~5, 새 포지션에는 손절 필수. 부탁이 아니라 pydantic 이 파싱 단계에서 자릅니다(src/llm/response_parser.py:49-50). 다만 아래쪽 경계는 강제가 아닙니다 — 하한이 1 이라 "자본의 2%" 같은 제안은 그냥 통과합니다. 잘리는 쪽은 언제나 위쪽입니다.
| 항목 | 값 | 확인 방법 |
|---|---|---|
| 주소 | github.com/Erfaniaa/LLM-Auto-Trader · PyPI 없음 |
|
| 라이선스 | MIT (LICENSE 파일). 다만 GitHub API 는 NOASSERTION 으로 잡습니다 |
파일 원문 + API |
| 별 수 | ★40 · 포크 7 · 열린 이슈 1 (관측일 2026-09-21) | GitHub API |
| 마지막 푸시 | 2026-07-21 (개설 2026-03-17) | GitHub API pushed_at |
| 규모 | src/ 파이썬 98개 · 16,659줄 · 테스트 59개 파일 |
실측 |
거래소에 닿는 자리는 여기뿐입니다
이 글의 전제입니다. 저장소 전체에서 CCXT 객체를 부르는 곳을 세어 봤습니다.
$ grep -rn "exchange\.\w*(" src/ scripts/ | grep -oE "exchange\.[a-z_]+\(" | sort | uniq -c | sort -rn
5 exchange.create_order( 2 exchange.fetch_ohlcv( 1 exchange.fetch_positions(
3 exchange.close( 2 exchange.fetch_funding_rate( 1 exchange.fetch_order_book(
2 exchange.market( 1 exchange.watch_ticker( 1 exchange.fetch_open_interest(
2 exchange.load_markets( 1 exchange.set_leverage( 1 exchange.fetch_balance(
1 exchange.fetch_ticker(
열세 개. 16,659줄짜리 저장소가 바깥 세상과 닿는 면적이 이겁니다. 게다가 거래소 객체를 이름으로 찾아옵니다 (src/data/market.py:101).
cls = getattr(ccxt, exchange_id) # exchange_id = 환경변수 EXCHANGE_NAME
그래서 ccxt 모듈에 다른 클래스를 weltiq 라는 이름으로 달아 두고 EXCHANGE_NAME=weltiq 라고만 하면, 저장소는 한 줄도 안 고치고 봇의 주문이 다른 곳으로 갑니다. 5절이 그 이야기입니다.
2. 설치
검증된 경로는 이겁니다.
git clone --depth 1 https://github.com/Erfaniaa/LLM-Auto-Trader.git
python3.12 -m venv /private/tmp/tut-venvs/llm-auto-trader
/private/tmp/tut-venvs/llm-auto-trader/bin/pip install -e "./LLM-Auto-Trader[local]"
19.76초에 51개 패키지가 깔립니다(그중 하나는 프로젝트 자신입니다. ccxt 4.5.81, pandas 3.0.6, statsmodels 0.15.0 포함). 테스트도 돌려 봤습니다.
$ pytest -q
621 passed, 3 skipped, 15 warnings in 23.31s
621개가 23초에 통과합니다. 네트워크를 안 타는 결정론적 테스트라 그렇습니다. 별 40개짜리에서 이런 숫자가 나오는 건 드뭅니다.
설치가 끝난 화면 — 버전 네 줄과 테스트 621개
위쪽 네 줄에 ccxt 4.5.81 이 보이면 거래소에 닿는 쪽이 준비된 것이고, 맨 아래 621 passed, 3 skipped 가 나오면 저장소가 성한 것입니다. 초는 보지 마십시오 — 이 화면은 다시 돌린 것이라 캐시가 더워져 5.72초가 나왔고, 위의 23.31초가 첫 실행 실측입니다.
⚠ 돈이 드는 도구입니다. 주기마다 LLM 을 부르고, 기본값이 Claude Opus 확장 사고 + 1M 컨텍스트에 하루 예산 $5 입니다. 그대로 켜 두면 한 달 150달러까지 갑니다. 이 글은 OpenRouter 의
openai/gpt-5-mini로 바꾸고 하루 예산을 $1 로 낮춰 돌렸습니다.LLM_DAILY_BUDGET_USD를 반드시 먼저 정하세요.
막히는 자리
여덟 군데서 막혔습니다. 위 셋은 첫 실행이 아예 안 뜨는 것, 아래 다섯은 뜨긴 하는데 주문이 안 나가는 것입니다.
| 증상 | 원인 | 대처 |
|---|---|---|
No module named 'yfinance' |
src/data/macro.py:10 이 무조건 import 합니다. 그런데 yfinance 는 선택 extra 입니다 |
pip install yfinance |
No module named 'telegram' |
src/telegram/reporter.py:7 도 마찬가지 |
pip install python-telegram-bot |
module 'logging' has no attribute 'handlers' |
src/utils/logger.py:21 이 logging.handlers.RotatingFileHandler 를 쓰면서 import logging.handlers 를 안 했습니다 |
실행 전에 한 번 import 해 주면 됩니다 |
TypeError: … NoneType.__format__ 가 매 주기 |
ccxt 가 바이낸스 무기한선물의 openInterestValue 를 None 으로 줍니다. 키가 존재하니 .get(…, 0)(market.py:225) 의 기본값이 안 걸리고, context_builder.py:469 가 그 None 을 :,.0f 로 포맷합니다. LLM 을 부르기 전에 매번 죽습니다 |
미결제약정 수집을 끄거나 None 을 걸러 줍니다 |
Position size too small after rounding 만 계속 |
src/bot.py:690 이 호가 깊이를 BTC 단위로 더해 orderbook_depth_usdt 인자로 넘깁니다. 20 BTC 가 "20달러"로 읽히니 크기가 0.001% 까지 줄어듭니다. 1만 달러로 BTC 는 한 주도 못 삽니다 |
깊이에 가격을 곱해 넘깁니다 |
R:R ratio 1.5 below minimum 1.5 로 모든 진입이 거절 |
모델이 정확히 1.5 를 겨냥하면 부동소수점에서 살짝 밑으로 떨어집니다(src/risk/stop_loss.py:94) |
문턱을 1.45 로 내립니다 |
| 페이퍼인데 자본이 0 | 페이퍼 모드도 크기를 계산할 자본을 거래소에서 읽어 옵니다(src/bot.py:284). 키가 없으면 0 이고, 0 이면 최소 단위를 못 넘습니다 |
README 의 "페이퍼는 거래소 키가 필요 없다"는 LLM 루프에만 해당합니다 |
| 30분에 한 번밖에 안 돈다 | 포지션이 없을 때 기본 간격이 1,800초이고, LLM 이 답에 적은 next_check_minutes 가 그보다 우선합니다 |
환경변수가 없어 설정 객체로 바꿔야 합니다 |
가운데 둘(미결제약정·호가 깊이)은 기본 설정으로 켜면 주문이 한 건도 안 나간다는 뜻입니다. 저장소를 고치지 않고 우회한 코드는 lat_workarounds.py 에 주석과 함께 있습니다.
3. 첫 결과
대시보드는 없습니다. 웹 UI·TUI·차트가 없고 streamlit·fastapi·flask 가 의존성에도 코드에도 없습니다. 바깥으로 나가는 창은 셋뿐입니다 — 터미널의 구조화 JSON 로그(기본값), logs/bot.log, 그리고 텔레그램(토큰과 대화 ID 둘 다 채우면 켜집니다. 이 글에서는 안 켰습니다). 그 JSON 이 한 줄에 250자를 넘어 그냥은 못 읽습니다 — 이 글의 화면들은 event 이름과 값만 남겨 접은 것이고, 접는 스크립트는 lat_view.py 입니다. 버리는 줄은 없고 폭만 줄입니다.
제일 작은 예제는 자기 페이퍼 모드를 한 주기 돌리는 것입니다. 주문이 밖으로 안 나가고 메모리에 가짜 체결을 하나 만듭니다.
이렇게 말합니다
OpenRouter 의 gpt-5-mini 로 바꿔서, 바이낸스 공개 시세만 보고 자기 페이퍼 모드로 한 주기만 돌려 줘. 자본은 1만 달러로 치고.
그러면 이런 게 돌아옵니다 (여러 줄을 한 칸에 모았고 로그는 줄였습니다)
exchange=binance mode=paper model=openai/gpt-5-mini equity=10000.0
trading_exchange_ready exchange=binance markets=4640
snapshot_collected symbols=4 has_onchain=true has_sentiment=true has_macro=true errors=0
llm_call_cost cost_usd=0.0043 input_tokens=4059 output_tokens=1652
llm_analysis_complete actions=["open_long"] trend=bullish confidence=0.76 next_check=15
risk_validation action=open_long symbol=BTC/USDT:USDT approved=true reason=OK
[FILL] ok=True open_long BTC/USDT:USDT long size=0.01 price=85237.8
fees=0.9146604869258574 id=paper_1789994466
이렇게 나오면 성공입니다. LLM 이 한 번 불려 0.5센트도 안 되는 돈을 쓰고, 규칙 레이어가 통과시키고, 페이퍼 체결이 하나 생겼습니다.
한 주기가 도는 전체 화면 — 자료 수집부터 체결까지
숫자보다 위에서 아래로 내려오는 순서를 보십시오 — snapshot_collected → llm_call_cost → llm_analysis_complete → risk_validation approved=True → [ASK ](무엇을 하자고 했나) → [FILL](체결). 맨 아랫줄에서 price=85806.1 과 current_price=85806.1 이 똑같은 것이 바로 아래 ①번입니다. 가운데 macro_ticker_failed 다섯 줄과 top_traders_api_error 는 지우지 않았습니다 — 기본 설정으로 켜면 원래 저렇게 납니다. (이 화면은 9월 22일에 같은 명령을 다시 돌린 것이라 위 출력과 시세·판단이 다릅니다. 순서는 같습니다.)
이 한 줄짜리 체결에 이 글의 나머지가 다 들어 있습니다.
① 체결가가 현재가와 소수점까지 같습니다. price=85237.8 인데 같은 사이클의 현재가도 85237.8. 슬리피지 0 입니다. README 는 "페이퍼는 중간가 + 슬리피지 추정으로 체결한다"고 적었지만, 실제 코드(src/execution/executor.py:141-158)는 현재가 그대로 채우고 수수료만 뗍니다. 슬리피지 모델은 백테스트 쪽(src/backtest/fill_model.py, 기본 5bp)에만 있습니다.
② 수수료 기준이 채워진 금액이 아닙니다. 의도 금액은 914.66달러인데 수량이 최소 단위로 내림돼 852.38달러어치가 채워졌습니다. 그런데 수수료 0.9147 은 914.66 기준입니다. 채워진 금액 기준이면 0.8524 여야 합니다. 작지만 방향이 있는 오차입니다.
③ 이 포지션은 어디에도 안 남습니다. 봇을 끄고 SQLite 를 열면 decisions: 1 rows·llm_calls: 1 rows, 그리고 열린 포지션을 담는 테이블 자체가 없습니다(trades: 0 rows). 프로세스가 죽으면 포지션도 죽습니다.
4. 제일 잘 쓰는 법 — LLM 을 부르기 전에 규칙 레이어부터 때려 보세요
이유가 있습니다. src/risk/ 는 봇과 독립적으로 import 되고, LLM 키도 네트워크도 필요 없습니다. 가장 터무니없는 제안을 지어내 던져 보고 무엇이 잘리는지 읽을 수 있습니다. 공짜고, 결정론적이고, 한 번 보고 나면 이 도구를 믿을지 말지가 정해집니다.
이렇게 말합니다
LLM 은 부르지 말고, 규칙 레이어한테만 말도 안 되는 제안들을 던져 봐. 자본 1만, BTC 85,000 기준으로.
그러면 이런 게 돌아옵니다
제안 판정 사유 / 조정
정상 진입 (10% · 2배 · RR 3:1) 승인 크기 조정 → 3.37% [AFML 베팅: ×0.34 (신뢰도 0.70)]
한도 초과 크기 (40%) 스키마거절 Input should be less than or equal to 25
한도 초과 배율 (10배) 스키마거절 Input should be less than or equal to 5
손절이 없다 스키마거절 Value error, stop_loss is required for new positions
보상/위험 0.5:1 (손절 -2%, 익절 +1%) 거절 SL/TP invalid: R:R ratio 0.5 below minimum 1.5
손절이 진입가 위 (롱인데) 거절 SL/TP invalid: SL must be below entry for long
이미 3종목 보유 — 4번째 거절 Already 3 positions (max 3)
총 노출 60% 초과 (5,500 보유 + 2,500 신규) 승인 크기 조정 → 5.00% [Size…]
허용 목록 밖 종목 거절 Symbol DOGE/USDT:USDT not in allowed list
--- 계좌 단위 차단기 (경고 4.5% · 정지 5%) ---
equity 9800 (-2.0%) 발동=- 거래허용=True
equity 9600 (-4.0%) 발동=daily_loss:halt_new 거래허용=False
equity 9550 (-4.5%) 발동=drawdown:close_all, daily_loss 거래허용=False
equity 9400 (-6.0%) 발동=drawdown:close_all, daily_loss 거래허용=False
차단기 발동 뒤 같은 진입 제안 → 승인=False Circuit breaker: Trading halted
규칙 레이어에 말도 안 되는 제안을 던진 화면
LLM 을 빼고 규칙 레이어만 때려 본 화면입니다. 가운데 열이 판정인데 스키마거절 세 줄과 거절 네 줄이 서로 다른 층에서 잘린 것이라는 점이 핵심입니다. 아래 계단에서는 equity 9600 줄부터 거래허용=False 로 바뀌는 지점을 보십시오 — 여기부터는 멀쩡한 제안도 안 받습니다. 맨 윗줄 명령에 적힌 대로 LLM 호출 0회라 돈이 한 푼도 안 듭니다.
읽을 것은 셋입니다. ① 두 겹이다 — 스키마가 먼저 자르고(크기·배율·손절 유무) 규칙이 그다음을 자릅니다(보상/위험·방향·개수·노출). ② 통과한 것도 깎인다 — 10% 제안이 신뢰도 0.70 때문에 3.37% 가 됐습니다. ③ 계좌 단위 차단기가 주문 단위 검사보다 위에 있다 — 한 번 발동하면 멀쩡한 제안도 전부 막힙니다.
프롬프트가 아니라 이 표가 이 저장소의 값입니다. 재현 스크립트는 scenario_d_riskgate.py 입니다.
그 4.93bp 는 누구 몫인가
체결을 밖으로 내보내면(5절) 슬리피지가 생깁니다. 봇이 본 값은 85,388.835 였고 체결은 85,430.92, 차이 +4.93bp. 이게 전부 weltiq 의 체결 모델 탓이면 정직하지 않은 계측기겠죠. 그래서 분리해 봤습니다 — 호가를 읽고 바로 그 자리에서 최소 수량을 세 번 던졌습니다.
quote: bid=85172.87 ask=85172.88 mid=85172.875 spread_bp=0.00 age=0.1s
fill : 85181.3972875 vs mid = +1.00 bp fee=0.00851814
quote: bid=85172.87 ask=85172.88 mid=85172.875 spread_bp=0.00 age=0.2s
fill : 85181.3972875 vs mid = +1.00 bp fee=0.00851814
quote: bid=85159.37 ask=85159.38 mid=85159.375 spread_bp=0.00 age=0.4s
fill : 85167.8959375 vs mid = +1.00 bp fee=0.00851679
세 번 다 정확히 +1.00bp. 체결 모델이 0.5 × 스프레드 + 1bp 이고 스프레드가 0이었으니 딱 1bp 입니다. 나머지 3.93bp 는 LLM 이 생각하는 동안 가격이 움직인 몫입니다 — 스냅샷을 찍고 모델이 20~30초 답하고 규칙을 통과한 뒤에야 주문이 나갑니다. 두 번째 주문에서는 +9.09bp 였습니다.
이 몫을 그 도구의 페이퍼는 0 으로 칩니다. 스냅샷 가격에 그대로 채워 주니까요. 판단 지연이 공짜라고 가정하는 셈인데, LLM 으로 도는 봇에서는 그게 제일 크게 새는 구멍일 수 있습니다. 재려면 체결을 밖에서 해야 합니다.
돌려 본 것 한눈에
5절의 셰임을 끼운 채로 돌린 것들입니다. 원문 로그는 검증 기록에 있습니다.
| 무엇을 | 결과 (전부 실측) |
|---|---|
| 주문 한 번 내기 | LLM 이 "12%·3배·손절 82,800·익절 89,400" 제안 → 규칙이 12% → 4.51% 로 깎음 → 0.00528 BTC 가 85,430.9222375 에 체결, 수수료 0.45107527, 주문번호 dq_…. 접수에서 체결까지 7밀리초 |
| 고리 한 바퀴 | 다음 주기에 봇이 잔고·포지션을 되읽고 두 번째 주문을 냄. 그때 자본이 equity=9997.293865902 — 봇이 지어낸 값이 아니라 원장의 체결·수수료가 반영된 값입니다. 0.00395 BTC @ 85,294.8286295 |
| 규칙이 LLM 을 거절 | 손절을 80,000 으로 넓히려 하자 Cannot move SL further from entry (increases risk) 로 거절. 다음 시도에서 83,900 으로 좁혀 와서 통과 |
| 중지 조건 발동 | 주문 한도 승인을 세션에서 폐기 → 같은 주문이 422 envelope_required. API 키로 한도를 새로 만들려 하면 403 session_required — 에이전트는 자기 테두리를 못 넓힙니다. 미확인: 봇이 도는 중에 이 벽에 부딪히는 장면은 못 잡았습니다 |
| 끊고 다시 붙이기 | SIGKILL 뒤 재기동에 positions=1 · equity=9998.421395316 로 복구 (5절) |
| 수수료 바꿔 재기 | 같은 체결 스트림(47건 · 거래대금 95,023.40 · 실수수료 94.69)을 요율만 바꿔 재계산 — 현물 0.1% 95.02, 무기한선물 0.05% 47.51(−49.8%), 전부 메이커였다면 19.00. 원장은 안 바뀝니다. 다만 범위가 계정 전체라 47건에는 이 봇 말고 다른 것도 섞여 있습니다 |
5. weltiq 로 옮기기 — CCXT 셰임 한 장
1절에서 센 열세 개 함수가 전부입니다. 그러니 그 열세 개를 weltiq API 로 구현한 CCXT 모양의 클래스를 하나 만들어 ccxt 모듈에 이름으로 달아 두면 끝입니다. 상대 저장소는 한 줄도 안 고칩니다.
- weltiq 가입({{CTA_URL}}) → 마이 → 연결 관리에서 API 키 발급. 그 키가 쓸 주문 한도 승인을 세션에서 만듭니다(종목·주문당 3,000달러·하루 20,000달러·유효 1일로 했습니다).
- 셰임
weltiq_ccxt.py를 받아ccxt에weltiq라는 이름으로 답니다. EXCHANGE_NAME=weltiq로 두고 봇을 띄웁니다. 첫 체결의 주문번호가paper_…가 아니라dq_…로 나오면 갈아 끼워진 것입니다.- 다음 주기에 봇이
fetch_balance·fetch_positions를 부르면, 셰임이 weltiq 주문 기록에서 자기 장부를 다시 만들어 돌려줍니다. 여기서 고리가 닫힙니다.
① 이름으로 찾아오게 만든다
def install() -> None:
import ccxt, ccxt.async_support as ccxt_async
for mod in (ccxt, ccxt_async):
setattr(mod, "weltiq", weltiq)
import ccxt.pro as ccxtpro
setattr(ccxtpro, "weltiq", weltiq) # watch_ticker 도 같은 클래스가 받습니다
② 주문 하나를 weltiq 수리 경로로
async def create_order(self, symbol, type, side, amount, price=None, params=None):
body = {"venue": self.venue, "symbol": to_weltiq(symbol), "side": side,
"type": type, "qty": _num(amount), "target": "paper"}
if not params.get("reduceOnly"):
# weltiq 는 무효 조건 없는 진입을 안 받습니다. 봇의 손절은 create_order 로
# 안 넘어오므로 셰임이 현재가 기준 퍼센트로 하나 만들어 넣습니다.
ref = (await self.fetch_ticker(symbol))["last"]
body["invalidation"] = {"px_below": _num(ref * (1 - self.inval_pct / 100))}
status, resp = await self._post("/v1/orders/oneoff", body)
return await self._await_fill(resp["queue_id"], symbol, side, amount, type)
거절은 ccxt 의 진짜 예외 타입으로 다시 던집니다. insufficient_cash 는 InsufficientFunds, lot_size·min_notional 은 InvalidOrder. 봇이 이미 그 예외들을 잡고 있어서 갈아 끼운 티가 안 납니다.
③ 남의 포지션을 건드리지 않게 선을 긋는다
weltiq 는 사용자당 마켓별 모의 계좌가 하나입니다. 상주 전략 컨테이너도 이 봇도 같은 장부에 기록하고, 계좌 요약은 종목당 한 줄로 합쳐져 나옵니다 — 그 안에 이 봇 몫이 얼마인지 외부 에이전트는 구별할 수 없습니다. 그대로 두면 봇이 "내 포지션 0.54 BTC" 로 알고 남의 것을 팝니다. 그래서 셰임이 이 API 키가 정해진 시각 이후에 낸 주문만 모아 자기 장부를 따로 만들고, 매도에 하드 가드를 겁니다.
if side == "sell":
own, _, _ = await self._own_book()
if float(amount) > own.get(wq, {}).get("qty", 0.0) + 1e-12:
raise InsufficientFunds("sell … > own inventory (나머지는 상주 컨테이너 몫)")
서버 기록에서 다시 만들기 때문에 프로세스가 죽어도 살아남습니다. 다만 주문 목록이 최근 30건만 돌려주고 커서가 없어서, 며칠 돌리면 셰임이 오래된 체결을 못 봅니다. 이건 weltiq 쪽 숙제이고, 이 연재의 다른 편에서도 같은 자리에서 걸렸습니다 — 조사 과정은 검증 기록에 있습니다.
끊었다 붙였을 때 일어난 일
포지션을 하나 들고 있는 상태에서 봇을 SIGKILL 로 죽이고 같은 설정으로 다시 띄웠습니다.
13:04:32.077 bot_initialized equity=9998.42 positions=1
13:04:32.093 circuit_breaker_connectivity CRITICAL seconds=1789995872.09
13:04:32.165 emergency_close symbol=BTC/USDT:USDT side=long
positions=1. 봇은 자기 포지션을 기억하지 못하는데 재기동하자마자 되찾았습니다. 셰임이 weltiq 주문 기록을 읽어 다시 만들어 줬기 때문입니다. 자기 페이퍼였다면 positions=0 이었을 겁니다 — 열린 포지션을 담는 테이블 자체가 없으니까요.
그리고 80밀리초 뒤에 봇 자신의 버그가 그 포지션을 닫았습니다. seconds=1789995872 — 유닉스 시각 그대로입니다. WebSocketManager.seconds_since_last_update 가 한 번도 갱신된 적 없을 때 time.time() 을 그대로 돌려주는데, 차단기는 그걸 "마지막 시세 갱신 이후 17억 초"로 읽고 연결 끊김이라 판정합니다(한도 60초). 판정은 CLOSE_ALL. 왕복이 이렇게 닫혔습니다 — 매수 85,430.92, 매도 85,208.84826250, 수수료 0.45107527 + 0.44990272, 손익 −2.07 USDT.
이게 이 글에서 제일 값진 한 줄입니다. 그 도구의 페이퍼였다면 "복구도 없었고 청산 기록도 없었을" 일이, 체결을 밖에서 하니까 마이크로초 단위로 남았습니다.
고리 한 바퀴 — 주문 한 건과 거절된 손절 이동 한 건
고리 한 바퀴가 한 화면에 들어 있습니다. 가운데 id=dq_… 가 봇의 판단이 weltiq 주문이 된 자리입니다 — paper_… 가 아닙니다. 그 아래 approved=False reason=Cannot move SL further from entry 가 규칙 레이어가 LLM 을 거절하는 장면이고, 네 줄 더 내려가면 같은 LLM 이 sl=83900.0 으로 좁혀 와 통과합니다. 맨 윗줄 positions=1 은 재기동 뒤에도 포지션이 남아 있었다는 뜻입니다.
weltiq 원장에 남은 그 주문
셰임을 지나온 주문이 weltiq 쪽에 남은 모습입니다. created_at 과 processed_at 을 나란히 보십시오 — .948906 과 .955931, 7밀리초입니다. 그 아래 origin 이 누가 냈는지(이 봇에게만 준 API 키)이고, 맨 아랫줄 fee_profile 이 요율을 바꿔 재계산할 때 쓰는 규칙입니다. 봇 쪽 기록에는 이 칸들이 아예 없습니다 — 거기엔 paper_1789994466 한 줄뿐이었습니다.
그래서 뭐가 달라졌나
같은 봇, 같은 모델, 같은 자본. 주문이 가는 곳만 바꿨습니다.
| 그 도구의 페이퍼 | weltiq 페이퍼 | |
|---|---|---|
| 체결가 | 현재가 그대로 (슬리피지 0) | 스냅샷 대비 +4.93bp · 두 번째 주문 +9.09bp |
| 수수료 | 의도 금액 기준 — 실제로 채워진 금액과 다릅니다 | 채워진 금액 기준, 원장 실차감액. 다른 요율로 재계산 가능 |
| 거부 사유 | 없음 — 페이퍼 경로에 거부가 없습니다 | 실제로 받아 본 것: direction_conflict, lot_size, min_notional, no_market_data, envelope_required, session_required |
| 주문 기록 | paper_1789994466 (f"paper_{int(time.time())}") |
dq_… · 시세 출처 · 접수/처리 시각 마이크로초 · 누가 냈는지 |
| 열린 포지션 | 파이썬 객체. 프로세스와 함께 사라짐 | 서버 원장. SIGKILL 뒤 재기동에 positions=1 로 복구 |
| 사람이 치는 테두리 | 없음 (설정 파일뿐) | 주문 한도 승인. 에이전트는 자기 한도를 못 넓힙니다 (403) |
| LLM 프롬프트 | 입력 4,005~4,059 토큰 | 입력 2,203~2,749 토큰 |
마지막 줄은 weltiq 가 지는 칸입니다. 프롬프트가 짧아진 건 좋아서가 아니라 펀딩·미결제약정·교차거래소 시세를 안 주기 때문입니다. 셰임이 그 셋을 NotSupported 로 돌려주고 봇이 조용히 건너뜁니다. 호가도 최우선 한 단계만 나갑니다.
해 주지 않는 것도 적습니다. 봇의 손절·익절은 weltiq 쪽에 안 걸립니다 — CCXT create_order 에 그 자리가 없어서요. 봇이 꺼져 있는 동안 지켜지는 건 셰임이 넣은 퍼센트 무효 조건뿐입니다. "에이전트가 잠들어도 규칙은 돈다"가 이 조합에서는 절반만 참입니다. weltiq 는 지금 모의만 합니다 — 실전 주문 경로가 아직 열려 있지 않습니다.
(설정 차이 둘을 밝힙니다. 셰임을 끼운 런에서만 LLM 호출 주기를 150초로 줄였고 최소 보상/위험 문턱을 1.45 로 내렸습니다. 각각 "한 자리에서 여러 바퀴를 보려고"와 "2절의 R:R 문턱 버그 때문"이고, 둘 다 체결가·크기 계산에는 영향이 없습니다.)
6. 써 보니
좋았던 것
- 판단과 실행이 진짜로 갈라져 있습니다. 거래소에 닿는 면이 열세 개 함수뿐이라 갈아 끼우는 데 300줄 남짓이면 됐습니다. 이런 저장소는 생각보다 드뭅니다.
- 규칙 레이어가 실제로 막습니다. 광고 문구가 아니라 눈으로 봤습니다 — LLM 이 손절을 넓히려 하자 거절당했고, 다음 시도에서 좁혀 왔습니다. 크기 25%·배율 5배·손절 필수는 그보다 앞선 스키마에서 잘립니다.
- 테스트가 621개입니다. 23초에 돌고 네트워크를 안 타서 남의 기계에서도 같은 결과가 나옵니다.
- LLM 없이도 쓸모가 있습니다.
src/risk/만 import 해서 자기 전략의 위생 검사기로 써도 됩니다(4절).
아쉬웠던 것
- 기본 설정으로는 주문이 한 건도 안 나갑니다. 미결제약정 None 으로 매 주기 죽고, 호가 깊이 단위 버그로 크기가 0 이 됩니다. 2026-07-21 이후 푸시가 없는 저장소라 당분간 그대로일 겁니다.
- 페이퍼가 낙관적입니다. 현재가 그대로 채우고, 거부가 없고, 부분체결이 없고, 호가를 안 봅니다. README 는 슬리피지를 넣는다고 적었지만 라이브 페이퍼 경로엔 없습니다.
- 열린 포지션도 손절도 프로세스와 함께 사라집니다. 24시간 돌릴 물건인데 재시작 복구가 없고, 손절은 봇 메모리의 숫자라 봇이 자면 손절도 잡니다.
- 비용 집계를 청구서로 믿으면 안 됩니다. 남아 있는 로그 기준 11회 호출에 봇은 $0.0489 를 셌는데, 버린 시도까지 포함한 OpenRouter 실사용량은 같은 시간에 $0.1313 늘었습니다. 예산 상한으로 쓰기엔 충분합니다.
비용
| 항목 | 값 |
|---|---|
| 코드·시세 | 0원 (MIT · 바이낸스 공개 시세) |
| 모델 | LLM 키 1개 필수. 이 글 전체 $0.1313. 기본값(Opus 확장 사고)으로 켜 두면 하루 예산 $5 가 기본이고 한 달 150달러까지 갑니다 |
| 시간 | 설치 19.76초 · 테스트 23.31초 · 한 주기 몇 분 |
7. 다음 행동
먼저 src/risk/ 만 떼어다 4절의 표를 자기 손으로 재현해 보세요. 돈이 안 들고, 이 도구를 계속 쓸지 말지가 거기서 정해집니다. 그다음 미결제약정·호가 깊이 두 군데를 우회하고 한 주기를 돌려 보시면 됩니다.
다음 편은 freqtrade 입니다. 별 5만 4천 개, 이 분야에서 제일 많이 쓰이는 크립토 봇이고 이번 편과 정반대 방향입니다 — LLM 이 매번 새로 판단하는 대신 사람이 쓴 전략 코드가 판단하고, 그 제품이 문서 첫 줄에 세워 둔 단어가 Dry-Run 입니다.
weltiq 준비 — 3줄 1. 가입: {{CTA_URL}} 2. 마이 → 연결 관리에서 API 키 발급 3.
claude mcp add weltiq --header "Authorization: Bearer <키>" https://weltiq.ai/mcp이 봇의 주문을 셰임 한 장으로 모의 계좌에 태워 24시간 돌려 볼 수 있습니다.
이 글의 모든 명령·출력·숫자는 2026-09-21 에 실제로 돌려서 얻었습니다. 거래소에는 공개 시세만 조회했고 실제 돈이 오간 주문은 없습니다. 어떤 매매 의견도 아닙니다. 여기 적지 않은 시행착오·원문 로그·결함 목록은 검증 기록에 있습니다.