155 lines
9.0 KiB
Markdown
155 lines
9.0 KiB
Markdown
# 01 — Current State Audit (v1)
|
||
|
||
Honest inventory of what exists today, what's broken, and what dies in v2.
|
||
This is the baseline the rest of the scoping docs build on.
|
||
|
||
## What v1 is
|
||
|
||
A single-process Python market maker (~2,700 LOC) plus a separate market-scanner
|
||
script, glued together through a **Google Sheet** that acts as config store,
|
||
market database, and dashboard at the same time.
|
||
|
||
```
|
||
main.py entry point: REST polling thread + 2 websockets
|
||
trading.py the entire strategy (perform_trade, 470 lines)
|
||
update_markets.py scanner: crawls all markets, writes to Google Sheets hourly
|
||
update_stats.py writes account stats/earnings back to Google Sheets
|
||
poly_data/ client wrapper, global mutable state, WS handlers, book
|
||
poly_utils/ Google Sheets access (incl. a read-only CSV-export hack)
|
||
data_updater/ scanner internals (reward math, volatility fetch)
|
||
poly_stats/ earnings/positions summary -> Sheets
|
||
poly_merger/ Node.js subprocess to merge YES+NO via Gnosis Safe
|
||
positions/*.json ad-hoc risk-off state files written next to the code
|
||
```
|
||
|
||
### Data flow today
|
||
|
||
1. `update_markets.py` (separate process, ideally separate IP) crawls
|
||
`clob.polymarket.com/sampling-markets` page by page, then fetches **one order
|
||
book per market** via REST plus one `prices-history` call per market to
|
||
compute volatility — thousands of sequential REST calls per scan, ~hourly.
|
||
Results are pasted into the "All Markets" / "Volatility Markets" sheets.
|
||
2. A human picks rows into the "Selected Markets" sheet and tunes the
|
||
"Hyperparameters" sheet.
|
||
3. `main.py` re-reads the sheet **every 30 seconds** (network call to Google in
|
||
the hot path), polls positions/orders REST every 5s in a background *thread*,
|
||
and maintains order books from the market websocket in the asyncio loop.
|
||
4. Every `book`/`price_change`/user event spawns `perform_trade(market)` as a
|
||
new asyncio task, serialized per market by an `asyncio.Lock`.
|
||
|
||
## What's structurally wrong
|
||
|
||
### Concurrency model
|
||
- **threading + asyncio mixed**: a daemon thread mutates `global_state` (dicts of
|
||
positions/orders) while asyncio tasks read them. No coherent locking
|
||
(`global_state.lock` exists but is essentially unused).
|
||
- **Blocking I/O inside the event loop**: `py-clob-client` and `requests` are
|
||
synchronous. Every order placement/cancel blocks the loop — while it's
|
||
signing + POSTing, the bot is blind to every other market's book updates.
|
||
- **`perform_trade` holds the per-market lock through a `gc.collect()` and a
|
||
hard-coded `await asyncio.sleep(2)`** (trading.py:470). Every quote decision
|
||
on a market therefore takes ≥2s and events pile up behind the lock. This
|
||
alone caps reactivity at ~0.5 Hz per market in a game where queue position
|
||
and reaction to sweeps decide profitability.
|
||
- Event storm handling is "spawn a task per WS message" — no coalescing, no
|
||
debounce; the lock queue grows unboundedly under load.
|
||
|
||
### State management
|
||
- One module of **global mutable dicts** (`global_state.py`) shared across a
|
||
thread and dozens of tasks. Position tracking is a merge of three sources
|
||
(data-api REST, user WS fills, on-chain balance) reconciled with the
|
||
`performing` set + 15-second staleness hack — a hand-rolled, race-prone
|
||
answer to "did my fill land yet?".
|
||
- `set_order()` in `poly_data/data_utils.py:136` **overwrites both sides** of a
|
||
token's order record with a dict containing only the side that just updated —
|
||
the other side's `{price, size}` silently vanishes until the next 5s REST poll.
|
||
- Local order book is keyed by condition_id, stores only the YES token book,
|
||
derives the NO view by `1 - price`. Fine in principle, but book resync after
|
||
WS reconnect relies on the server re-sending a `book` snapshot; there's no
|
||
sequence-gap detection or hash check (the WS actually provides a book `hash`).
|
||
|
||
### Google Sheets as infrastructure
|
||
- Config store, market DB, ops dashboard, and inter-process message bus are all
|
||
one spreadsheet. It's rate-limited (Sheets API quota), slow (hundreds of ms
|
||
per read), needs service-account credentials, and pulls a network dependency
|
||
into the trading loop. The "read-only mode" fallback in
|
||
`poly_utils/google_utils.py` literally guesses gid numbers 0–4 against a CSV
|
||
export URL.
|
||
- The scanner and the bot communicate *through* the sheet, so market metadata
|
||
in the hot path can be an hour stale (`row['best_bid']` used as a sanity
|
||
reference in trading.py:356 comes from the sheet).
|
||
|
||
### Code quality
|
||
- Bare `except:` everywhere (~30 sites), `print()` as the only logging, no
|
||
tests at all, no types, dead code left as comments, `gc.collect()` sprinkled
|
||
as a superstition (7 call sites), `deets` reused as both the outcome list and
|
||
the book snapshot inside the same loop (trading.py:158/197).
|
||
- `data_updater/` duplicates `poly_utils` (two `google_utils.py`, two
|
||
`trading_utils.py` with different contents).
|
||
- Pinned to **Python 3.9** (.python-version = 3.9.18, EOL Oct 2025) and
|
||
`py-clob-client==0.28.0`. That client line is **dead**: Polymarket cut over
|
||
to CLOB V2 on 2026-04-28 (new order signing, new contracts) and archived
|
||
py-clob-client — **v1 of this bot cannot place an order at all anymore**
|
||
(see 03). This isn't a refactor of a working bot; it's a rebuild of a
|
||
non-functional one.
|
||
- A **Node.js subprocess** (`poly_merger/`) exists solely to call
|
||
`mergePositions` through a Gnosis Safe — a whole second runtime, package.json
|
||
and ethers v5 dependency for one contract call that web3.py can do.
|
||
|
||
### Strategy weaknesses (why it loses money — detailed fix in 04-strategy.md)
|
||
- **Quote logic is penny-jumping**: `bid = best_bid + tick`, `ask = best_ask -
|
||
tick`, with a couple of size-based exceptions. No fair-value estimate, no
|
||
microprice, no inventory skew — position size only gates *whether* to quote,
|
||
never *where*.
|
||
- **Adverse selection is handled after the fact**: the only defenses are an
|
||
hourly 3-hour volatility number from the spreadsheet and a stop-loss that
|
||
**crosses the spread** (sells at best bid — a taker exit, exactly what we
|
||
want to ban) then sleeps the market for hours via a JSON file on disk.
|
||
- **Rewards are a filter, not an objective**: the scanner computes
|
||
reward-per-100 carefully, but the live quoter only checks "is my bid inside
|
||
the incentive band" as a boolean. Placement within the band (where the
|
||
reward-vs-fill-risk tradeoff actually lives) is never optimized.
|
||
- Hard-coded magic everywhere: 0.1–0.9 price band, 250 share absolute cap,
|
||
0.005 reprice threshold, 15s staleness, 0.95/0.97/1.01 ratios, `max_size*2`
|
||
total-exposure allowance.
|
||
- Take-profit reprices only when 2% away from target; sell-side quote sits at
|
||
`avgPrice` floor (trading.py / trading_utils.py:136) — i.e. the bot refuses
|
||
to exit below cost, which turns losers into resolution lottery tickets.
|
||
|
||
## What's worth keeping (conceptually)
|
||
|
||
- **The two-token + merge insight**: buying YES and NO such that both fill and
|
||
merging the pair back to USDC is a maker-only exit that never crosses the
|
||
spread. v2 promotes this from an afterthought to a core mechanism.
|
||
- Book maintenance via `SortedDict` and the YES/NO mirror trick — cheap and
|
||
correct.
|
||
- The desired-vs-existing order diffing idea in `send_buy_order`/`send_sell_order`
|
||
(only cancel/replace on meaningful change) — right instinct, generalized into
|
||
a proper reconciler in v2.
|
||
- The scanner's reward-per-$100 estimator (`add_formula_params` implements the
|
||
official S(v,s) = ((v−s)/v)² scoring) — the math moves into v2's selector.
|
||
- The `performing` concept (in-flight fills gate REST reconciliation) — becomes
|
||
a real state machine instead of dicts of sets.
|
||
|
||
## What dies in v2
|
||
|
||
| Component | Fate |
|
||
|---|---|
|
||
| Google Sheets (gspread, gspread-dataframe, google-auth, service accounts) | **Deleted.** Local files + SQLite (02/05) |
|
||
| `poly_merger/` Node.js + ethers + package.json | **Deleted.** Native Python Safe execution (03) |
|
||
| `update_stats.py`, `poly_stats/` (Sheets dashboards) | **Deleted.** Replaced by local status CLI/log (05) |
|
||
| `data_updater/` sampling-markets crawl + per-market book fetch | **Deleted.** Gamma API scanner, minutes → seconds (03/05) |
|
||
| `global_state.py` module-level dicts | **Deleted.** Typed state store owned by the engine (02) |
|
||
| Background REST polling thread | **Deleted.** Single asyncio loop; REST only for reconciliation (02) |
|
||
| `positions/*.json` risk files | **Deleted.** SQLite state (02) |
|
||
| Python 3.9 pin, py-clob-client 0.28 (archived, V1-only) | Upgraded: 3.12+, py-clob-client-v2 + own async HTTP (03) |
|
||
| `print()` logging, bare excepts, gc.collect() | Replaced: structured logging, real error policy (02/05) |
|
||
|
||
## Repo facts for reference
|
||
|
||
- uv is **already adopted** (uv.lock, pyproject.toml, hatchling build) — v2
|
||
keeps uv and finishes the job: bump `requires-python` to ≥3.12, refresh all
|
||
pins, add ruff + mypy + pytest as dev group, `src/` layout.
|
||
- Git history is shallow and healthy; no CI exists; MIT licensed; README
|
||
already warns the v1 strategy is unprofitable.
|