TypeScript 실패 후 Rust로 만든 Polymarket 거래 봇
출처: Dev.to
요즘 제가 이야기한 트레이더가 한 말이 인상 깊게 남았습니다:
계좌를 단순히 느린 체결이나 주문 취소 실패만으로 탕진했어요.
그는 CEX perpetuals에 대해 얘기했지만, Polymarket의 CLOB에서도 동일한 문제점이 존재합니다. 단위는 밀리초가 아니라 초 단위로 측정됩니다.
제 TypeScript 봇은 Polymarket Central Limit Order Book(CLB)에서 신호 감지 → 주문 배치까지 평균 340ms를 차지했습니다. 5분짜리 시장에서 약 2.7초의 가격 오차 창이 존재한다면, 이는 전체 기회 창의 **12%**가 되며, 첫 번째 바이트가 Polymarket 서버에 도달하기 전부터 시간이 소모됩니다.
시그널을 감지할 때 **70¢**였지만 실제 체결 시점에서는 **74¢**가 되었습니다. 시장이 이미 저에게 불리하게 재평가되었습니다.
그래서 Rust로 다시 썼습니다. 이 문서는 제가 발견한 점, 변화된 점, 그리고 그urally 변하지 않은 점을 정확히 기록합니다.
배경: 제 봇이 했던 일
여러분이 이전 글(아키텍처, 켈리 기준 사이징, 마지막 60초 캡처)를 읽어보셨다면 contexte는 익숙할 것입니다. 간단히 말하면:
- 제 봇은 Polymarket의 5분 및 15분짜리 암호화폐 업/다운 이진 시장을 타깃으로 합니다(BTC, ETH, XRP, SOL, DOGE, BNB).
- 전략은 간단합니다: 실제 스폿 모멘텀에 비해 brevemente mispriced(오류가 있는) 시장을 찾아, 공정 가치보다 할인된 가격에 진입하고, 해결될 때까지 보유합니다.
5분짜리 “XRP Up” 시장이 70¢에 거래되지만, 실시간 스폿 모멘텀은 82% 확률을 의미하므로 **+12¢**의 엣지가 달러당 생깁니다. 하루에 50회 disciplined sizing(규칙적인 포지션 사이징)을 한다면, 수학적으로는 수익이 나지만 실제로 그 가격에 체결될 수 있어야 합니다.
문제: TypeScript 봇은 신호 감지 → 주문 전송까지 340ms가 걸렸습니다.
- 신호 감지(가격 폴 → 임계값 체크) → 평균 18ms, 전체의 5.3%
- 시장 데이터 JSON 파싱 → 12ms, 3.5%
- 주문 객체 생성 → 3ms, 0.9%
- DNS 조회 → 47ms, 13.8%
- TLS 핸드쉐이크 → 89ms, 26.2%
- HTTP 요청 직렬화 → 8ms, 2.4%
- 네트워크 전송 (Polygon RPC + CLOB) → 94ms, 27.6%
- CLOB 응답 파싱 → 11ms, 3.2%
- 주문 확인 로깅 → 58ms, 17.1%
총합: 340ms, 100%
주요 관찰
- **DNS 조회 (47ms)**는 거의 전적으로 제거 가능했습니다. Node.js는 기본적으로
clob.polymarket.com을 매 요청마다 재조회하고, 적절한 영구 연결과 풀링을 사용하면 거의 사라집니다. - **TLS 핸드쉐이크 (89ms)**도 동일 문제입니다. 새 HTTPS 연결을 매 주문에 열면서 keep‑alive를 활용하지 않았습니다. 기본 HTTP/1.1 규칙을 간과한 것이죠.
- **주문 확인 로깅 (58ms)**은
fs.writeFileSync로 JSON 파일을 기록하는_sync 호출이었습니다. 디버깅용으로 추가했지만, 이 blocking I/O가Critical Path에 존재한다는 점은 실망스럽습니다. - **네트워크 전송 (94ms)**은 제가 Europe에 위치해 Polymarket 인프라가 주로 US‑east에 위치해 있어, 클로저한 인프라를 배치하지 않는 한 제거할 수 없습니다. 이는 **불변의 바닥(floor)**입니다.
따라서 47 + 89 + 58 = 194ms의 피할 수 있는 지연이 존재했으며, 이를 통해 실제 네트워크 경로와 동일한 상황에서 ~146ms 정도의 이론적 최하한(latency floor)으로 내려갈 수 있었습니다.
더 깊은 문제: Node.js의 단일 스레드 이벤트 루프가 Head‑of‑Line 차단 생성
여러 시장을 동시에 스캔할 때, 한 시장의 느린 응답이 다른 시장의 주문 배치에 지연을 줍니다. 이벤트 루프는 latency를 없애지 않고 ** interleaving**합니다.
왜 Rust인가?
Rust가 제공하는 것과 그렇지 않은 점을 명확히 하고자 합니다.
Rust가 주는 것
tokio의 async 런타임과 멀티스레드 executor 덕분에 진정한 병렬성. 시장 스캔과 주문 배치가 별도 스레드에서 실행되어 느린 스캔이 뜨거운 주문 경로를 차단하지 않음.- Zero‑cost abstractions: 컴파일러가 JavaScript 런타임이 감당할 수 없는 오버헤드를 없앱.
serde로 JSON 디시리얼라이징은 복잡한 중첩 객체에서JSON.parse보다 3~5배 빠름. - 명시적 연결 풀 관리:
reqwest와 keep‑alive 옵션을 사용해 연결 재사용을 직접 제어. - GC 없는 실행: V8 GC가 예측 불가능한 latency 스파이크를 5~50ms까지 유발할 수 있으며, 평균 340ms에서는 노이즈에 불과하지만 평균 50ms이면 치명적입니다.
- 컴파일 타임 정합성: CLOB API 응답 형태를 compile‑time에 타입으로 정의하므로, 잘못된 응답은 런타임 크래시가 아니라 컴파일 오류 또는
Result처리 로직이 됩니다.
Rust가 주지 않는 것
- 네트워크 물리 법칙. 빛의 속도가 빠르다고 해서 Rust가 더 빨라지는 것은 아닙니다.
- Alpha 없음. 신호 로직이 잘못되어 있다면 Rust는 더 빠르게 실행할 뿐, 정확도는 변하지 않음.
- 개발 루프 간소화. Rust 첫 버전은 TypeScript 대비 3배 더 오래 걸렸습니다.
Rust가 적합한 이유는 CPU와 I/O 관리 문제(연결 풀, 이벤트 루프 경합)였기 때문입니다. 네트워크 자체 latency가 bottleneck이라면 Rust의 이점은 미미할 것입니다. 하지만 연결 재사용과 이벤트 루프 경쟁을 해결함으로써 실질적인 성능 향상을 얻었습니다.
Rust 아키텍처
adelan-bot-rs/
├── Cargo.toml
├── src/
│ ├── main.rs ← tokio runtime entrypoint
│ ├── scanner.rs ← market scanning (parallel tasks)
│ ├── strategy/
│ │ ├── mod.rs
│ │ ├── signal.rs ← 신호 감지 로직
│ │ └── sizing.rs ← Kelly / fixed sizing
│ ├── market/
│ │ ├── polymarket.rs ← CLOB + Gamma API 클라이언트
│ │ └── clob.rs ← 주문 배치, 연결 풀
│ ├── trading/
│ │ ├── paper.rs ← paper trading engine
│ │ └── portfolio.rs ← 포지션 + P&L 추적
│ └── types.rs ← 공유 타입
의존성 (Cargo.toml)
[dependencies]
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4"] }
tracing = "0.1"
tracing-subscriber = "0.3"
anyhow = "1"
tokio-tungstenite = "0.24" # WebSocket for real‑time price feed
rust_decimal = "1" # precise decimal arithmetic for prices
연결 풀: 가장 중요한 변경점
이 단일 변경 – 영구 HTTP 연결을 사용한 reqwest::Client 공유 –가 전체 latency 개선의 **약 60%**를 차지했습니다.
// polymarket.rs
use reqwest::Client;
use std::time::Duration;
pub struct PolymarketClient {
client: Client,
clob_base: String,
gamma_base: String,
}
impl PolymarketClient {
pub fn new() -> anyhow::Result<Self> {
let client = Client::builder()
// 연결 유지하여 요청당 TLS 핸드쉐이크를 없앱
.tcp_keepalive(Duration::from_secs(60))
.build()?;
Ok(Self {
client,
clob_base: "https://clob.polymarket.com".into(),
gamma_base: "https://gamma.polymarket.com".into(),
})
}
}
이렇게Persistent connections를 사용함으로써 TLS 핸드쉐이크와 DNS 조회 비용을 크게 줄일 수 있었습니다.