kachence/polymm · Python 99.8% · 비동기 asyncio/aiohttp · Polymarket CLOB + the-odds-api + Supabase · MIT · src 62개 모듈 · 테스트 56개 파일 · "은퇴한 파이썬판"(현재는 Rust로 재작성, 이 저장소엔 없음) · 클론 소스 직접 분석)
같은 경기의 승패를 두고 스포츠 배당업체(북메이커)와 예측시장 Polymarket이 서로 다른 가격을 매긴다. 배당업체가 "A팀 승리 확률 63%"라 보는데 Polymarket에서 "A팀 승리"가 55센트($0.55, 즉 55% 확률로 값이 매겨짐)에 팔리고 있다면, 그 8%의 틈이 먹을 수 있는 마진(edge)이다.
봇은 이 틈을 자동으로 찾아 주문을 걸고, 한쪽이 체결되면 반대편 결과도 사서 "누가 이기든 이득"인 상태를 만든다. 핵심 재료는 딱 두 가지 — 더 신선한 배당과 더 빠른 속도다. 저자는 이 둘을 잃는 순간 봇이 돈을 못 벌게 됐다고 솔직하게 밝힌다.
먼저 무대를 이해하자. Polymarket은 "이 사건이 일어날까?"에 돈을 거는 예측시장(prediction market)이다. 각 결과는 0~1달러 사이의 "주식"처럼 거래된다. 예를 들어 "T1이 이번 경기를 이긴다" 토큰이 $0.60이면, 시장이 그 확률을 60%로 본다는 뜻이고, 실제로 T1이 이기면 그 토큰은 $1이 되고 지면 $0이 된다. polymm은 이 시장에서 사람 대신 24시간 지정가 주문을 걸고 관리하는 봇이다.
왜 "차익거래(arbitrage)"인가? 봇은 한 시장에서 방향에 베팅해 "맞히기"를 노리는 게 아니다. 두 시장의 가격 차이만 먹는다. 이상적으로는 A팀 토큰과 B팀 토큰을 합쳐 $1 미만에 사두면, 경기 결과와 무관하게 둘 중 하나가 반드시 $1이 되므로 그 차액이 확정 이익이 된다. 이게 polymm이 노리는 구조다. 물론 현실은 이 이상이 항상 성립하지 않고, 바로 그 균열이 이 프로젝트의 가장 흥미로운 이야기(4장·2장)로 이어진다.
polymm = 두 환전소의 시세 차이를 노리는 발 빠른 환전상. A 환전소는 달러를 1,300원에 팔고, B 환전소는 1,320원에 산다. 그 20원 틈을 아는 사람이 A에서 사서 B에 넘기면, 환율이 오르든 내리든 상관없이 20원을 남긴다.
polymm이 다른 점은 속도와 신선도가 전부라는 것. 남들도 같은 틈을 보고 달려들기 때문에, 배당이 조금만 늦거나 주문이 조금만 느리면 그 20원은 이미 사라진 뒤다. 저자의 봇이 "너무 느려져서" 돈을 못 벌게 된 게 정확히 이 지점이다.
polymm은 은퇴한 파이썬 버전이다. 저자가 실제로 돌리는 봇은 Rust로 재작성됐고 이 저장소엔 없다. 결정적으로 스크레이퍼(신선한 배당을 긁어오는 파이프라인)가 빠져있다 — 그게 엣지의 절반이었다. 남은 건 유료 공개 API(the-odds-api) 경로뿐이라, 그대로 돌리면 "더 빠른 봇에게 기부"하게 된다.
이 문서는 돈벌이 안내가 아니라 소프트웨어 공부 자료다. 예측시장 거래에는 실제 금전 손실 위험이 있고, 지역에 따라 법적 제약이 다르다. 우리가 배울 것은 "돈 버는 법"이 아니라 비동기 오케스트레이션·반응형 이벤트 설계·정직한 엔지니어링이다.
GitHub에는 "Polymarket 봇 만들었다"는 저장소가 넘친다. 대부분은 돌려본 적 없는 코드와 장밋빛 README뿐이다. polymm이 눈에 띈 이유는 정반대의 태도 때문이다. 저자는 세 가지를 한꺼번에 공개했다.
@b00k13에 남아 대조가 가능하다.| 구분 | 손익 | 설명 |
|---|---|---|
| 차익(arb) 부분 | +$8,293 | 양쪽을 다 잠근 "진짜 차익거래"에서 번 돈 |
| 방향성 잔여분(residual) | -$3,184 | 헤지가 안 붙어 억지로 떠안은 한쪽 베팅에서 잃은 돈 |
| 순수익 | ≈ +$5,000 | 몇 달간 실거래 결과. 그리고 점차 수익성이 사라짐 |
여기서 이 프로젝트의 가장 값진 교훈이 나온다. 봇이 건 모든 방향성 베팅은 개별적으로는 +7% 이상 엣지를 가진, 이론상 이겨야 할 베팅이었다. 그런데 그것들을 모아놓으니 돈을 잃었다. 범인은 역선택(adverse selection)이다.
역선택 = 중고장터에서 "시세보다 싸게 삽니다" 팻말을 걸어둔 상황. 팻말을 보고 굳이 찾아와 파는 사람은, 대체로 그 물건에 내가 모르는 문제가 있다는 걸 아는 사람이다. 팻말 하나하나(=각 베팅)는 "시세보다 이득"처럼 보여도, 실제로 팔러 오는 표본이 죄다 하자품 쪽으로 쏠려 있으면 전체로는 손해다.
배울 점은 "평균적으로 이득인 전략"과 "실제로 체결되는 표본이 이득인 전략"은 다르다는 것. 이건 트레이딩뿐 아니라 모든 종류의 "선택되는 데이터"(설문 응답자, 병원에 온 환자, 광고를 클릭한 사용자)에 똑같이 적용되는 통계적 함정이다. polymm은 이 함정을 코드로 겪고 글로 남겼다.
저자는 봇을 공개하면서 "코드가 어려운 부분이었던 적이 없다"고 말한다. 진짜 어려움은 신선한 배당과 속도였고, 그 둘은 이제 읽는 사람의 몫이라고 넘긴다. 이 솔직함, 그리고 "AI로 거의 다 짰지만 그게 품질의 변명은 아니다"라는 태도가 개발자들의 신뢰를 샀다.
polymm의 기술 선택은 화려하지 않다. 오히려 그 절제가 배울 점이다. 무거운 프레임워크(Django·FastAPI 같은 것) 없이, 파이썬 표준 asyncio 하나로 열 개 남짓한 동시 루프를 돌려 실시간 트레이딩을 처리한다. 외부 의존성은 "꼭 필요한 것"만 골라 얇게 붙였다.
| 계층 | 핵심 기술 | 역할 |
|---|---|---|
| 언어/동시성 | Python 3.10+ · asyncio | ~10개 코루틴 루프를 한 이벤트 루프에서 동시 실행 |
| 네트워크 I/O | aiohttp · requests | 비동기 HTTP + 웹소켓 연결(배당·주문·호가) |
| 거래소 클라이언트 | py-clob-client · web3 | Polymarket CLOB(중앙지정가호가창) 주문·서명, 지갑 연동 |
| 데이터 저장 | supabase(Postgres) | 배당·체결·포지션을 읽고 기록하는 중앙 저장소 |
| 저지연 캐시 | upstash-redis | 봇 간 상태 공유·프리픽스 네임스페이스(SPORT_/LIVE_) |
| 배당 소스 | the-odds-api · playwright(스크레이퍼) | 공개 유료 API 경로 + (제거된) 사설 스크레이핑 파이프라인 |
| 알림 | Telegram Bot API | 체결·오류를 텔레그램으로 실시간 통보 |
| 테스트/품질 | pytest · pytest-asyncio · mypy | 56개 테스트 파일, 비동기 테스트, 정적 타입 검사 |
| 분석 대시보드 | streamlit | 공개 지갑 손익을 뜯어보는 애널리틱스 스크립트 |
| (차세대) 스캐너 | Rust 사이드카 바이너리 | 속도 병목인 스캔 루프만 Rust로 재작성, NDJSON IPC로 연결 |
py-clob-client는 그 호가창에 주문을 넣고·취소하고·서명하는 공식 파이썬 라이브러리다. polymm은 지갑 개인키에서 API 자격증명(key/secret/passphrase)을 자동으로 파생해 시작한다.await) 이벤트 루프가 다른 작업으로 넘어간다. polymm은 "배당 갱신", "기회 스캔", "주문 감시", "웹소켓 수신", "헬스 서버" 등을 각각 코루틴으로 만들어 한 스레드 안에서 동시에 돌린다. 트레이딩처럼 "대부분 기다리고, 가끔 폭발적으로 바쁜" 작업에 딱 맞는 모델이다.asyncio 이벤트 루프 = 혼자 여러 냄비를 보는 요리사. 스레드를 여러 개 쓰는 건 요리사를 여러 명 고용하는 것(비싸고 서로 부딪힘)이고, asyncio는 한 요리사가 냄비 열 개를 순회하며 끓는 것만 재빨리 손보는 방식이다. 트레이딩 봇은 대부분 "물 끓기(네트워크 응답)를 기다리는" 시간이라, 요리사 한 명으로도 충분히 냄비 열 개를 감당한다.
봇의 핵심 데이터 흐름은 한 줄로 요약된다: 배당 소스 → Supabase → 기회 스캔 → 주문 실행 → Polymarket. 그리고 Polymarket에서 오는 두 개의 웹소켓(호가·체결)이 이 흐름에 실시간으로 피드백을 준다.
모든 것의 출발점은 de-vig(디빅, 마진 제거)다. 스포츠 배당업체가 내건 배당에는 그들의 수수료(vig)가 숨어있어서, 배당을 그냥 확률로 바꾸면 합이 100%를 넘는다(그 초과분이 업체 마진). 이걸 벗겨내야 진짜 확률이 나온다.
polymm의 vig_removal.py는 이걸 가장 단순한 비례(proportional) 방법으로 처리한다 — 각 확률을 전체 합으로 나눠 100%에 맞춘다. 실제 코드의 계산을 숫자로 따라가 보자(배당 1.50 / 2.60인 경우):
배당: A팀 1.50 · B팀 2.60
암시확률: 1/1.50 = 66.7% · 1/2.60 = 38.5%
합(overround) = 105.1% → vig = 5.1% # 이 5.1%가 업체 마진
공정확률 = 각 확률 ÷ 105.1%
A팀 fair = 66.7% / 105.1% = 63.4%
B팀 fair = 38.5% / 105.1% = 36.6% # 합 = 100%, 마진 제거 완료
이제 Polymarket을 본다. 만약 "A팀 승리" 토큰이 $0.55(=시장이 55%로 봄)에 팔린다면, 봇의 계산으로는 진짜 확률이 63.4%다. 그 차이 63.4% − 55% = 8.4%가 엣지(edge)다. 이게 기준선 min_edge = 7%를 넘으니 봇은 매수 주문을 건다. 만약 $0.58이었다면 엣지가 5.4%뿐이라 그냥 넘긴다.
vig_removal.py 주석에는 뼈아픈 고백이 있다. 원래 더 정교한 Shin 방법을 method="shin"으로 고를 수 있게 해놨는데, 사실 구현이 안 돼 있어서 말없이 비례 방법으로 넘어가고 있었다. "Shin을 쓴다"고 믿었던 모든 스크레이퍼가 실은 몇 달간 비례 방법을 쓰고 있었던 것. 저자는 이 죽은 파라미터를 제거했다.
교훈: "기본값으로 조용히 넘어가는(silent fallthrough)" 코드는 버그의 온상이다. 지원 안 하는 옵션은 조용히 무시하지 말고 큰 소리로 에러를 내야 한다.
엣지를 보고 A팀을 샀다고 끝이 아니다. A팀이 지면 손해다. 그래서 한쪽이 체결되면 봇은 곧바로 반대편(B팀)도 사서 양쪽을 합쳐 $1 미만으로 만든다. 이게 hedge_finder.py의 일이다. 숫자로 보자:
# A팀 10주를 $0.55에 매수 체결
진입 비용 = 10주 × $0.55 = $5.50
# B팀 fair value($0.366)에 헤지 주문 → 체결되면
헤지 비용 = 10주 × $0.366 = $3.66
총비용 = $5.50 + $3.66 = $9.16 # 양쪽 10주씩 확보
정산금 = 누가 이기든 이긴 쪽 10주 × $1 = $10.00
확정이익 = $10.00 − $9.16 = $0.84 (약 +9.2%) # 결과 무관
봇은 이 헤지가 최소 이익(min_profit, 기본 7%)을 못 넘기면 아예 걸지 않는다. 문제는 4-1에서 봤듯 헤지가 항상 체결되진 않는다는 것. 헤지가 안 붙으면 A팀 한쪽만 든 방향성 잔여분(residual)이 남고, 이 표본에 역선택이 작용해 -$3,184가 났다(2장).
polymm에서 가장 배울 만한 설계는 반응형(reactive) 이벤트 아키텍처다. 봇은 "15초마다 전부 다시 계산"하는 폴링에만 기대지 않는다. 대신 BotState가 상태 변화를 이벤트로 발행하고, ReactiveHandler가 그 이벤트를 듣고 즉시 결정한다.
state_events.py에 정의된 이벤트 종류를 보면 봇이 무엇에 반응하는지 한눈에 보인다:
| 이벤트 | 의미 · 반응 |
|---|---|
| ORDER_FILLED | 주문 체결됨 → 헤지를 걸거나 자연차익 확인 |
| FAIR_PROBS_UPDATED | 배당이 갱신됨 → 걸어둔 주문들이 아직 엣지가 있나 재검토 |
| BID_UPDATED | 호가 변동 → 누가 내 위를 덮었나(outbid) 확인 |
| EDGE_LOST | 엣지가 기준 밑으로 → 주문이 잡아먹히기 전에 취소 |
| STALE_ODDS | 배당이 너무 오래됨 → 믿을 수 없으니 취소 |
| MATCH_GONE_LIVE | 경기가 시작됨 → 프리매치 주문 정리 |
| NATURAL_ARB_DETECTED | 이미 양쪽이 싸다 → 헤지 없이도 차익, 스킵 처리 |
폴링 = 5초마다 창밖을 내다보는 경비원, 반응형 = 문이 열리면 울리는 센서. 폴링은 아무 일 없어도 계속 내다봐야 하고, 하필 두 번 내다보는 사이에 도둑이 들면 놓친다. 반응형은 변화(문 열림)가 생기는 그 순간 신호가 오므로 즉각 대응한다. 트레이딩에서 "즉각"은 곧 돈이라, 엣지가 사라지는 순간을 웹소켓으로 감지해 주문이 역선택당하기 전에 취소하는 게 이 설계의 핵심이다.
세 종류의 봇이 이 공통 뼈대(BaseBot 추상클래스)를 공유한다: SportsBot(프리매치 e스포츠·럭비), LiveBot(경기 진행 중, 더 빠르고 빡빡한 기준), SpreadBot(놀랍게도 날씨 기온 마켓!). 전략별로 다른 부분(_scan_and_execute 등)만 갈아끼우고, 웹소켓·상태·헬스서버 같은 공통 인프라는 부모가 다 제공한다.
polymm의 src/는 기능 계층별로 폴더를 나눈 교과서적 구조다. 4장의 데이터 흐름이 폴더 이름 순서와 거의 그대로 맞아떨어진다.
match_id.py 맨 위엔 "팀명 정규화·매치ID 생성은 오직 이 파일에서만. 다른 데 구현하지 말고 import 해서 써라"는 규칙이 못 박혀 있다. e스포츠 팀명은 "OMG = Oh My God", "NAVI = Natus Vincere"처럼 표기가 제각각인데, 이걸 한 곳(단일 진실원, Single Source of Truth)에서만 처리하게 강제한 것. 같은 로직이 여기저기 흩어지면 반드시 어긋나기 때문이다.
"상태를 바꾸는 쪽(BotState)"과 "상태 변화에 반응하는 쪽(ReactiveHandler)"을 분리하고, 그 사이를 이벤트(StateEvent)로 잇는다. 이 패턴은 트레이딩 봇뿐 아니라 실시간 대시보드·게임·협업 도구·알림 시스템 어디에나 쓰인다. 배울 것: "무엇이 바뀌었나"를 값이 아니라 이벤트 타입으로 표현하면, 반응 로직을 한곳에 모아 테스트하기 쉬워진다.
base_bot.py는 asyncio로 스캔·감시·웹소켓·헬스체크를 동시에 돌리면서, 종료 시그널(SIGINT/SIGTERM)을 받으면 모든 태스크를 깔끔하게 취소한다. "Task was destroyed but it is pending!" 같은 흔한 async 잡음을 없애는 우아한 종료(graceful shutdown) 패턴은 그 자체로 좋은 교본이다.
match_id.py가 팀명 정규화를 독점하듯, "여러 곳에서 같은 판단을 하면 반드시 어긋난다 → 한 곳에서만"이라는 원칙. 설정값도 config.py 한 곳에 모아 세 봇이 공유한다. 규모가 커질수록 위력이 커지는 습관이다.
타임스탬프 찍힌 print 오버라이드, 텔레그램 실시간 알림, 그리고 "아무 공개 지갑에나 물려 손익을 뜯어보는" 분석 스크립트 5종. 특히 polymarket_attribution.py는 손익을 차익 vs 방향성 잔여분으로 쪼개, "어디서 벌고 어디서 잃었나"를 정확히 짚는다. 자기 시스템을 사후에 해부할 수 있게 만들어 두는 습관이 이 프로젝트의 백미다.
속도 병목인 스캐너만 Rust로 다시 쓰되, 한 번에 갈아엎지 않는다. shadow_diff.py가 파이썬과 Rust를 동시에 돌려 결과를 비교(shadow mode)하고, 완전히 일치할 때만 Rust를 주력(primary)으로 승격한다. 리스크 큰 재작성을 안전하게 굴리는 실전 전략이다.
차익거래에 관심 없어도 좋다. 반응형 설계, async 종료 처리, 단일 진실원, 부검 가능한 로깅, 그림자 재작성 — 이 다섯은 어떤 실시간 백엔드에도 그대로 옮겨진다.
polymm은 계산이 무거운 프로젝트가 아니다. GPU도, 큰 메모리도 필요 없다. 진짜 요구사항은 속도(지연시간)와 외부 서비스 연결이다.
| 항목 | 요구사항 |
|---|---|
| 런타임 | Python 3.10+ (비동기 asyncio). 평범한 VPS/노트북으로 충분 |
| Polymarket 지갑 | 개인키 + 펀더 주소. CLOB 자격증명은 개인키에서 자동 파생. 실제 자금 필요 |
| 배당 API | the-odds-api 키(유료 공개 API). 봇은 배당 소스를 당신이 대야 돈다 |
| Supabase | Postgres 프로젝트 + sql/의 테이블 7개 생성. 배당·체결·포지션 저장소 |
| Redis(선택) | Upstash Redis — 봇 간 저지연 상태 공유 |
| Telegram(선택) | 봇 토큰 + 챗 ID — 체결·오류 실시간 알림 |
| 지연시간 | 가장 중요. "신선한 배당 + 빠른 실행"이 엣지 그 자체. 느리면 손해 |
실행은 세 모드로 갈린다: python src/main.py(프리매치 스포츠), --live(라이브), --spread(날씨). 기본 주문 크기는 10주로 아주 작게 시작하도록 설정돼 있다 — 저자가 "규모를 키우기 전에 한동안 지켜보고, 부검 글부터 읽으라"고 당부하는 이유다.
저자가 못박는다: 이건 상자에서 꺼내 바로 돌아가지 않는다. 엣지의 절반이던 사설 스크레이퍼가 빠져 있고, 실제 봇은 Rust판이다. 여기 있는 파이썬판은 "작동하는 토대이자 학습 자료"이지 완제품 수익기가 아니다. 예측시장 거래는 실제 금전 손실과 지역별 법적 위험을 동반한다 — 공부용으로 읽고, 실거래는 신중히.
src/core/vig_removal.py는 파일 하나로 독립 실행된다(python src/core/vig_removal.py). 여러 배당 쌍에 대해 vig와 공정확률을 출력한다. 여기에 내가 아는 실제 배당(예: 좋아하는 팀 경기)을 넣어보고, "배당업체 마진이 몇 %인지", "Polymarket 가격과 비교하면 엣지가 있는지" 손으로 확인해 보라. 트레이딩의 출발점을 몸으로 익히는 과제.
analytics/의 다섯 스크립트는 아무 공개 Polymarket 지갑 주소에나 물려 돌아간다(사설 데이터 불필요). capital_analysis.py(전체 손익), realized_pnl_by_month.py(월별)를 읽고 구조를 파악한 뒤, 유명한 공개 지갑에 물려 결과를 재현해 보라. "블록체인 공개 데이터로 남의 성적표를 읽는" 경험 자체가 배울 거리다.
state/state_events.py와 reactive_handler.py의 구조만 떼어내, 트레이딩과 무관한 장난감 도메인(예: 재고 관리 — "재고 부족" 이벤트가 뜨면 발주)으로 옮겨 구현해 보라. @dataclass 이벤트 + 이벤트 타입 Enum + 콜백 등록 패턴을 손에 익히는 게 목표. polymm의 진짜 자산은 이 뼈대다.
56개 테스트 파일이 있다. pytest를 돌려 초록불을 확인한 뒤, hedge_finder.py의 헤지 계산에 새 규칙(예: "수수료 2%를 뺀 뒤에도 이익이 나야 헤지")을 추가해 보라. test_hedge_finder.py가 잡아주는 범위 안에서 테스트가 있는 코드를 고치는 감각을 익히는 과제.
2장의 교훈을 코드로 증명해 보라. 각 베팅이 개별적으로 +7% 엣지인데도, "가격이 나쁜 쪽으로 움직이기 직전에만 체결된다"는 편향을 넣으면 전체 손익이 마이너스로 갈 수 있음을 몬테카를로 시뮬레이션으로 보이는 것. de-vig로 공정확률을 만들고, 체결 확률을 "가격 움직임과 상관"시켜 표본을 편향시키면 된다. 통계적 함정을 뼈저리게 이해하는 최고의 방법.
| 주차 | 주제 | 할 일 |
|---|---|---|
| 1주차 | 비동기 파이썬 기초 | asyncio 코루틴·await·이벤트 루프 이해. aiohttp로 API 두 개를 동시에 호출하는 작은 스크립트 작성 |
| 2주차 | 확률·배당·de-vig | 배당→암시확률→overround→vig→공정확률 흐름을 손으로 계산. vig_removal.py를 읽고 2-way/3-way 차이 파악 |
| 3주차 | 예측시장·CLOB | Polymarket이 어떻게 작동하는지(토큰=확률, 정산), 지정가 호가창(CLOB)의 매수·매도·체결 개념 학습 |
| 4주차 | 이벤트 기반 설계 | state_events.py+reactive_handler.py 정독. 장난감 도메인으로 이벤트→반응 시스템 복제(실습 3) |
| 5주차 | 웹소켓 실시간 처리 | book_websocket.py/user_websocket.py로 실시간 스트림 수신·재연결·상태 갱신 패턴 학습 |
| 6주차 | 역선택·시장미시구조 | 2장 부검 글 3편 정독 → 실습 5(역선택 시뮬레이션). "왜 +EV가 지는가"를 코드로 증명 |
코드를 "위에서 아래로" 읽지 말고 "데이터 흐름"을 따라 읽어라. 4장의 ASCII 파이프라인을 옆에 띄워두고, 배당 한 건이 services/ → core/vig_removal → scanning/ → execution/를 통과하는 여정을 파일을 오가며 따라가면, 62개 모듈이 하나의 이야기로 꿰어진다.
min_edge(7%) 이상일 때만 주문한다.min_profit(7%) 못 넘기면 안 건다.README.md — 전략·아키텍처·손익 요약(먼저 읽을 것)src/core/vig_removal.py — de-vig 수학(독립 실행 가능)src/state/state_events.py · reactive_handler.py — 반응형 두뇌의 뼈대src/execution/hedge_finder.py — 헤지 계산·수익성 검증src/bots/base_bot.py — 비동기 오케스트레이션·우아한 종료analytics/polymarket_attribution.py — 손익을 차익/방향성으로 쪼개는 분석