Polymarket BTC/ETH Up/Down 5m trading bot with Chainlink-aligned price analysis, executable CLOB quotes, paper/live modes, risk limits, SQLite history, and Telegram notifications.
The bot is built for short-window binary markets where the settlement line is the window open price. It chooses a direction from the current move, estimates whether the market price is mispriced after fees, and enters only when signal, execution quality, timing, and risk gates all pass.
This bot does not guarantee profit. It can lose money, including all allocated capital. Do not trade live with money you cannot afford to lose.
- Tracks BTC and ETH 5-minute Up/Down markets on Polymarket.
- Uses Gamma for market metadata and CLOB token winners for official settlement data.
- Uses a fresh CLOB WebSocket order-book cache first, with REST order-book fallback for executable buy VWAP.
- Uses Chainlink RTDS for runtime open/current/close prices and Binance spot/futures as context data.
- Scores the move with delta, ATR, micro-momentum, volume heat, multi-timeframe confirmation, higher-timeframe trend, funding bias, market regime, and BTC/ETH correlation.
- Applies price, edge, quote-age, liquidity, spread, depth, push-risk, timing, and risk guards before entry.
- Supports paper and live modes.
- Stores snapshots, decisions, trades, and settlements in SQLite when storage is enabled.
- Sends Telegram alerts for startup, entries, execution blocks/failures, risk blocks, settlements, and summaries.
- Adds Telegram
/summaryand/walletcommands when Telegram is configured. - Keeps Telegram notifications event-based; ordinary console logs and debug noise are not mirrored to chat.
- cli: Typer commands:
run,doctor,healthcheck,show-config, andexport - bot: market loop, entry window handling, decisions, execution, settlement
- analysis: price sourcing, delta direction, ATR, momentum, volume, higher timeframes, funding
- probability: transparent heuristic probability model
- calibration: rolling isotonic probability calibration from settled trades
- strategy: dynamic delta skip, required edge, and position sizing helpers
- polymarket: Gamma market lookup, CLOB official resolution lookup, CLOB WS/REST order-book quotes
- executor: live CLOB buy execution and live order checks
- risk: run/day/notional/consecutive-loss, drawdown, VaR, and expected shortfall limits
- storage: SQLite persistence and migrations
- logging: plain/Rich or structured JSON console logs, Telegram notifications, and run summary
flowchart LR
G["Gamma markets"] --> B["Bot loop"]
CL["Chainlink RTDS"] --> A["Analysis"]
BN["Binance candles and funding"] --> A
CLOB["CLOB WebSocket and REST order book"] --> Q["Executable quote"]
B --> A
B --> Q
A --> D["Decision gates"]
Q --> D
D --> R["Risk limits"]
R --> X["Paper or live buy"]
X --> S["SQLite, logs, Telegram"]
G --> O["Official settlement"]
O --> S
- Compute the next 5-minute close and the corresponding market slug.
- Capture the Chainlink window open when possible. Missing or stale Chainlink data pauses entries. The default open/price freshness windows allow normal RTDS publication lag, while still rejecting genuinely old ticks. At a close boundary, the next window open is captured before settling the just-closed window, so settlement latency cannot make the next market miss its price-to-beat.
- Wake shortly before close and poll only inside the configured entry window.
- Fetch BTC and ETH market state in parallel.
- Analyze each market against the settlement line and apply the BTC/ETH correlation filter.
- Build a decision from signal quality, execution quote, timing, edge, and risk limits.
- Optionally wait for a better edge until the forced-decision cutoff.
- Enter once per market slug when every gate passes.
- After market close, fetch official settlement and update realized result metrics. If the
official result and a fresh Chainlink close tick are both unavailable, keep traded slugs pending
and retry instead of finalizing an
UNKNOWNresult.
The settlement line is the window open price. If the current price is above it, the considered side
is Up; if below it, the side is Down. The absolute move from the open is the main signal.
Very small moves are skipped through POLYBOT_DELTA_SKIP. That threshold is dynamic:
- late in the window it is multiplied by
POLYBOT_DELTA_SKIP_LATE_MULTIPLIER - as ATR ratio rises, it is discounted by
POLYBOT_DELTA_SKIP_ATR_DISCOUNT_* - in high-volatility candles it is multiplied by
POLYBOT_DELTA_SKIP_HIGH_VOL_MULTIPLIER
This keeps noise out early in the window while still allowing stronger late moves to pass.
Late relaxed entries have one extra guard: if the move is still below
POLYBOT_LATE_WEAK_SIGNAL_DELTA_MAX, the bot requires at least
POLYBOT_LATE_WEAK_SIGNAL_MIN_MOMENTUM_SCORE unless volume is strongly aligned. This blocks fragile
last-seconds near-line trades where a small reversal can flip the result.
The model is a transparent heuristic, not a trained model. It starts from an interpolated probability based on delta strength:
POLYBOT_DELTA_SKIPtoPOLYBOT_DELTA_WEAKPOLYBOT_DELTA_WEAKtoPOLYBOT_DELTA_STRONGPOLYBOT_DELTA_STRONGtoPOLYBOT_DELTA_VERY_STRONG
Then it adjusts probability with context:
- source quality: stale Chainlink source age is penalized
- ATR: overheated candles and high ATR ratios reduce confidence
- micro-momentum: recent 1-minute candles are volume-weighted; acceleration can add confidence
- volume heat: unusually high aligned Binance volume can add confidence
- multi-timeframe confirmation: the live 5-minute candle can add confidence or penalize a counter-signal
- higher timeframes: 15-minute and 1-hour candles can confirm or penalize the 5-minute impulse
- funding bias: Binance futures funding is treated as a directional crowding signal
- regime: trending and strong-trending regimes add confidence; high-volatility regimes penalize confidence
- correlation: BTC/ETH disagreement or a weak peer move dynamically penalizes the weaker signal
When SQLite contains enough settled trades, the final probability is passed through a rolling isotonic calibration curve. This does not make the model predictive by itself; it only shrinks repeated overconfidence or underconfidence observed in the bot's own settled history. Calibration is mode-specific, so live execution is not tuned from paper fills. The bot refreshes that calibrator after settled trades, so a long-running process can adapt without a restart.
The entry price is the executable buy VWAP from CLOB asks. Gamma prices are metadata only and are never used as entry quotes.
For each candidate, the bot computes:
breakeven = fee-adjusted entry price
edge = probability - breakeven
The trade must pass:
POLYBOT_MIN_CONFIDENCE- dynamic required confidence after late-entry and ATR discounts
- dynamic required edge from
POLYBOT_MIN_EDGE POLYBOT_MIN_EDGE_FLOORafter late-entry and ATR discounts- per-asset
POLYBOT_BTC_ENTRY_PRICE_MIN/POLYBOT_ETH_ENTRY_PRICE_MIN POLYBOT_ENTRY_PRICE_MAX
The bot also estimates near-line push risk. A tiny move can pass the late dynamic delta threshold, but still be rejected if the configured push-risk estimate is too high.
Weak moves just above the dynamic delta threshold are probability-capped before edge math. This prevents momentum/volume bonuses from turning a barely-off-the-line setup into a falsely strong 95%+ signal. Expensive entries also pay a small high-price premium: near the top of the binary price range, the required edge and confidence are slightly higher because one tick of slippage or one small reversal matters more.
If dynamic entry is enabled, a barely valid early trade can be delayed until either edge or early-window delta is clearly stronger, or until the forced-decision cutoff is reached.
Before entry, the bot checks that the market is tradable enough for the configured amount:
- minimum market liquidity
- known bid/ask spread
- max spread
- minimum book depth in shares
- maximum order book levels consumed
- maximum VWAP slippage versus best ask
- fresh CLOB quote age
- side-collapse guard when the considered side quote suddenly drops
- top-of-book bid/ask depth imbalance as a probability bonus or penalty
- one trade attempt per slug by default
Live mode repeats the edge check against the actual live order price before submitting the order.
It can also use a stricter live-only VWAP slippage cap through POLYBOT_LIVE_MAX_VWAP_TO_BEST_ASK_BPS.
Transient live order submit failures can be retried with a small exponential backoff; rejected or
ambiguous orders are not blindly retried. The live taker FAK/FOK BUY flow uses the CLOB
market-order API and sends the pUSD amount, matching Polymarket's UI-style cash sizing.
Position size starts from POLYBOT_AMOUNT and changes with edge quality:
- low edge near the threshold uses
POLYBOT_POSITION_SIZE_MIN_MULTIPLIER - strong edge can scale toward
POLYBOT_POSITION_SIZE_MAX_MULTIPLIER - strong-trending regime can scale size up, within the cap
- high ATR or high-volatility regime reduces size
- near-line distance reduces size when the move barely cleared the dynamic delta threshold
- late-window entries can slightly increase size
- fractional Kelly can cap the computed size using configured bankroll and fraction
- correlated same-window BTC/ETH exposure can reduce the second same-direction trade
All sizing is still bounded by the risk limits below.
POLYBOT_MAX_TRADES: max trades per process runPOLYBOT_MAX_TRADES_PER_DAY: max trades per UTC dayPOLYBOT_MAX_DAILY_NOTIONAL: max daily pUSD notionalPOLYBOT_MAX_CONSECUTIVE_LOSSES: stop after a configured losing streakPOLYBOT_MAX_DRAWDOWN_PCT: pause entries after realized drawdown exceeds the configured budgetPOLYBOT_VAR_*: block entries when historical tail loss exceeds the configured VaR thresholdPOLYBOT_EXPECTED_SHORTFALL_MAX_LOSS: block entries when average tail loss is too largePOLYBOT_MAX_CORRELATED_EXPOSURE: cap same-window, same-direction BTC/ETH exposurePOLYBOT_LIVE_PRIVATE_KEYandPOLYBOT_LIVE_WALLET: required only for live execution
The current live strategy is entry-and-settle. It does not actively sell positions before market resolution.
Install dependencies, create a local config, inspect it, check external services, and start the bot.
With no explicit mode set, run uses paper mode.
make install-deps
cp .env.example .env
uv run polybot show-config
uv run polybot doctor
uv run polybot runThe two modes are mutually exclusive. With nothing set, the bot defaults to paper so a forgotten env var cannot accidentally place real orders.
| Behavior | paper | live |
|---|---|---|
| Calls real CLOB execution | no | yes |
Records TradeRecord to storage |
yes | yes |
Writes Snapshot / Decision rows |
yes | yes |
| Entry price | quote VWAP | taker limit |
Requires POLYBOT_LIVE_PRIVATE_KEY + POLYBOT_LIVE_WALLET |
no | yes |
When to pick which:
- paper: realistic strategy simulation against live market data; default for research.
- live: actually submits orders. Repeats the edge check against the live order price before posting and can use a stricter live-only VWAP slippage cap.
uv run polybot run # start the trading loop in the configured mode
uv run polybot doctor # health check: gamma, clob, clob-ws, RTDS, storage, live env, telegram
uv run polybot healthcheck # Docker-safe local heartbeat check; exits 0 when fresh, 1 otherwise
uv run polybot show-config # print every effective setting with its env name (secrets masked)
uv run polybot export # export all stored trades as CSV to stdoutdoctor exits non-zero if any check fails, so it is safe to use as a pre-flight gate in CI or
deploy scripts. Run it before switching to live mode.
healthcheck is intentionally smaller than doctor: it only reads the local heartbeat file that
the running bot updates in POLYBOT_STORAGE_DATA_DIR, including the last local CLOB WebSocket
state. It does not call Polymarket, Binance, Chainlink, or Telegram, so transient third-party
outages do not restart the container.
- Live rollout: keep live mode off until paper metrics look stable.
- Storage data directory:
POLYBOT_STORAGE_DATA_DIRis created automatically when the parent path is writable. The bot storespolybot.sqlite3andpolybot.health.jsoninside it, so Docker deployments should mount a writable volume and setPOLYBOT_STORAGE_DATA_DIRto that mount path. - Live wallet setup: new Polymarket accounts commonly use the
POLY_1271/ deposit-wallet flow. In live mode,POLYBOT_LIVE_WALLETmust be the CLOB funder wallet for the configured private key, andPOLYBOT_LIVE_SIGNATURE_TYPEmust match that wallet type. For the current deposit-wallet flow this is usuallyPOLYBOT_LIVE_SIGNATURE_TYPE=3. - pUSD balance and allowances:
doctorchecks CLOB-reported pUSD balance and spender allowances. If it reportsclob-live: balance/allowance insufficient, the bot cannot trade from the configured wallet yet. Fix the private-key/wallet/signature-type combination first, then approve/sync pUSD allowances before running live. - CLOB API key creation warning: the client may print
Could not create api keywhile deriving an existing key. If subsequent CLOB calls work anddoctorpasses, that line is not a blocker. - Probability calibration warm-up: until SQLite has at least
POLYBOT_PROBABILITY_CALIBRATION_MIN_SAMPLESsettled trades (default 30), the calibrator is a no-op and probabilities come straight from the heuristic. Expect win rate to be noisy for the first ~30 settled trades in the current mode. - Chainlink RTDS session recycle: the Polymarket RTDS websocket forces a disconnect after about
120 minutes. The bot pre-emptively recycles the connection at
POLYBOT_CHAINLINK_MAX_SESSION_SECONDS(default 105 min), but only at a moment at leastPOLYBOT_CHAINLINK_RECONNECT_SAFE_DISTANCE_SECONDS(default 30 s) away from any 5-minute close, so the recycle never lands on a settlement boundary. If RTDS ticks become stale, the bot logs the stale/recovery cycle and reconnects the websocket. Telegram is only notified when the stale episode lasts at leastPOLYBOT_CHAINLINK_TELEGRAM_STALE_SECONDS(default 120 s;0disables RTDS Telegram alerts). Entries also pause for a short recovery cooldown after a stale episode so the first fresh tick after reconnect is not treated as an immediately stable feed. - CLOB WebSocket cache: the hot path uses the public market-channel websocket for active token
books. If the cache is empty or stale, the bot falls back to REST when enabled; stale quotes never
become tradable entries.
doctorandhealthcheckreport whether the stream is started, connected, subscribed, and receiving fresh book messages. - Settlement source: the bot prefers Polymarket's official result and uses its own close tick
if the official one is not available yet. If neither is usable at close, settlement remains
pending and is retried on later cycles. Stored trades carry the
settlement_sourceand settlement-line context so you can tell which path resolved each trade. - State retention: in-memory caches and slug sets are pruned to the last
POLYBOT_STATE_RETENTION_WINDOWSwindows, so a long-running process does not grow forever. SQLite history is unbounded. - Migrations and backup: storage applies migrations on startup via
yoyo. By default a timestamped*.bak-...is created next to the SQLite file before any migration is applied; disable withPOLYBOT_STORAGE_BACKUP_BEFORE_MIGRATION=false. - Stop and resume:
Ctrl-Crequests a graceful stop, finishes the current cycle, prints the run summary, and exits with code 130. Restarting the process re-reads recent trades from SQLite to reseed risk drawdown and calibration from the current mode. - Telegram visibility: when Telegram is configured, the bot sends startup, entries, results,
actionable runtime alerts, and a periodic run summary. Short RTDS stale/recovered cycles stay in
logs by default. Set
POLYBOT_TELEGRAM_SUMMARY_INTERVAL_SECONDS=0to disable the periodic summary.
Each in-window iteration prints a compact line per market. Reading it left to right:
ETH 47s Down@0.515 U:0.020 D:0.515 px:cl:2313.29 beat:2315.20 o:cl q:ws d:0.0824% p:93.2% be:53.3% e:+39.9% age:120ms qage:80ms s:0.42 skip: ...
| Token | Meaning |
|---|---|
ETH 47s |
asset and seconds left in the close window |
Down@0.515 |
Polymarket side considered and its current quote |
U:0.020 D:0.515 |
Up/Down market quotes side by side |
px: |
current price source: cl chainlink, -- none |
beat: |
settlement line (window open price) |
o: |
window-open source: cl chainlink, -- missing |
q: |
execution quote source: ws, clob, or -- none |
d: |
absolute move from window open in percent |
p: |
model probability |
be: |
fee-adjusted breakeven probability |
e: |
edge = probability - breakeven |
age: |
current-price source age in milliseconds |
qage: |
execution quote age or REST quote latency in milliseconds |
s: |
compact signal-strength score for logging and sizing context |
| trailing tag | enter, waiting-entry, monitor/no-dir: ..., or skip: ... |
Set POLYBOT_LOG_FORMAT=json to emit one JSON object per log event instead of Rich text. The same
snapshot data is kept as structured fields, for example crypto, seconds_left, pm_side,
pm_price, up_price, down_price, current_price, window_open, quote_source,
delta_pct, confidence, breakeven, edge, source_age_ms, quote_age_ms, and
signal_strength. Log source and severity are structured as component and level; plain mode
renders them as readable prefixes. Telegram still receives only event notifications, not
debug/snapshot noise.
All runtime settings are environment variables with the POLYBOT_ prefix. .env is loaded
automatically, and .env.example contains every available setting with its default or effective
default value.
Use uv run polybot show-config to see the parsed values. Non-empty secrets are masked.
Trade export is intentionally CLI-only instead of env-driven:
uv run polybot export
uv run polybot export --output-file exports/polybot-trades.csv
uv run polybot export --since 2026-05-08T00:00:00Z
uv run polybot export --current-run
uv run polybot export --format summaryexport reads SQLite in read-only mode and never applies migrations or creates the database. CSV
rows include the executable quote context used at entry (quote_source, quote_age_ms, best
bid/ask, book imbalance, and levels used). Missing, damaged, and incompatible databases fail with
explicit errors.
make install-deps
make lint
make check-types
make test
make check