first commit
deploy GitHub Pages / deploy (push) Has been cancelled
tests / test (push) Has been cancelled

This commit is contained in:
2026-07-22 18:53:44 +08:00
commit 9259325d8a
60 changed files with 11081 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
# Prevent Jekyll from trying to process the site; we ship plain HTML/CSS/JS.
theme: null
plugins: []
include:
- assets
+487
View File
@@ -0,0 +1,487 @@
# Polymarket NegRisk Reference (Polygon mainnet)
Single-page reference for arbitraging categorical Polymarket events where
`Σ(best-ask of every outcome) < $1`. Covers the contracts, ABI, lifecycle, Gamma
API detection, ID derivation, a runnable web3.py snippet, and gotchas.
All sources cited inline. Last verified: April 2026.
---
## 1. Concept
Vanilla Polymarket markets use Gnosis's **Conditional Token Framework (CTF)**:
each binary market mints a YES and a NO ERC-1155 token, fully collateralized 1:1
by USDC.e. Splitting 1 USDC.e gives `1 YES + 1 NO`; merging the pair returns
1 USDC.e; after resolution the winning side redeems for 1 USDC.e each.
A **categorical event** ("Who wins the 2028 US Election?") is modeled as N
independent binary markets — one per candidate. Without negRisk these markets
are unconnected, which means a holder of `NO` on every candidate is locked up
even though, by construction, exactly one of them must resolve YES. NegRisk
fixes this: the **NegRiskAdapter** wraps the underlying CTF and adds a
`convertPositions` operation: 1 NO share in market *i* of an event can be
atomically converted into 1 YES share in **every other** market of that event.
That makes a complete set of YES tokens (one per outcome) economically
equivalent to $1 USDC.e and lets capital be freed early instead of waiting for
oracle resolution. This is the property the arb strategy exploits — when the
best-ask sum of every outcome's YES token is below $1, you can buy a complete
set, redeem (or convert+redeem), and lock in the spread.
([NegRisk overview](https://docs.polymarket.com/developers/neg-risk/overview),
[neg-risk-ctf-adapter README](https://github.com/Polymarket/neg-risk-ctf-adapter),
[ChainSecurity audit, Apr 2024](https://old.chainsecurity.com/wp-content/uploads/2024/04/ChainSecurity_Polymarket_NegRiskAdapter_audit.pdf))
---
## 2. Contract addresses (Polygon mainnet, chainId 137)
Source: [Polymarket Contract Addresses](https://docs.polymarket.com/resources/contract-addresses),
cross-checked on PolygonScan.
| Contract | Address | PolygonScan |
|---|---|---|
| **NegRiskAdapter** | `0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296` | [link](https://polygonscan.com/address/0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296) |
| **NegRiskCtfExchange** | `0xC5d563A36AE78145C45a50134d48A1215220f80a` | [link](https://polygonscan.com/address/0xc5d563a36ae78145c45a50134d48a1215220f80a) |
| **NegRiskFeeModule** | `0x78769D50Be1763ed1CA0D5E878D93f05aabff29e` | [link](https://polygonscan.com/address/0x78769d50be1763ed1ca0d5e878d93f05aabff29e) |
| **CTFExchange** (vanilla) | `0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E` | [link](https://polygonscan.com/address/0x4bfb41d5b3570defd03c39a9a4d8de6bd8b8982e) |
| **ConditionalTokens (CTF)** | `0x4D97DCd97eC945f40cF65F87097ACe5EA0476045` | [link](https://polygonscan.com/address/0x4d97dcd97ec945f40cf65f87097ace5ea0476045) |
| **USDC.e (collateral)** | `0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174` | [link](https://polygonscan.com/address/0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174) |
| **UmaCtfAdapter** (oracle) | `0x6A9D222616C90FcA5754cd1333cFD9b7fb6a4F74` | [link](https://polygonscan.com/address/0x6A9D222616C90FcA5754cd1333cFD9b7fb6a4F74) |
| **UMA Optimistic Oracle** | `0xCB1822859cEF82Cd2Eb4E6276C7916e692995130` | [link](https://polygonscan.com/address/0xCB1822859cEF82Cd2Eb4E6276C7916e692995130) |
> The collateral is **USDC.e** (the bridged PoS USDC), **not** native
> Circle-issued USDC (`0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359`). Confusing
> these two will silently break allowance checks. See Gotchas §8.
---
## 3. Key ABI signatures
The NegRiskAdapter exposes both vanilla CTF-shaped overloads (so it can act as
a drop-in `IConditionalTokens` proxy) and negRisk-specific entrypoints. Source:
[`NegRiskAdapter.sol`](https://github.com/Polymarket/neg-risk-ctf-adapter/blob/main/src/NegRiskAdapter.sol),
[`docs/NegRiskAdapter.md`](https://github.com/Polymarket/neg-risk-ctf-adapter/blob/main/docs/NegRiskAdapter.md).
### 3.1 Position management (call these on the NegRiskAdapter)
```solidity
// Mint a complete set: deposits `_amount` USDC.e, mints `_amount` of each YES & NO.
function splitPosition(bytes32 _conditionId, uint256 _amount) external;
// Burn a complete set (YES + NO of every outcome of the condition) for USDC.e.
function mergePositions(bytes32 _conditionId, uint256 _amount) external;
// After UMA resolution, redeem held outcome tokens for USDC.e payout.
// _amounts[i] is the amount of outcome-i token to burn.
function redeemPositions(bytes32 _conditionId, uint256[] calldata _amounts) external;
// negRisk-specific: convert NO shares in markets selected by `_indexSet`
// (a bitmask over the marketId's questions) into YES shares of the rest + collateral.
function convertPositions(bytes32 _marketId, uint256 _indexSet, uint256 _amount) external;
```
There are also legacy 5-arg overloads kept for `IConditionalTokens` API parity
(unused by clients in practice):
```solidity
function splitPosition(address _collateralToken, bytes32, bytes32 _conditionId,
uint256[] calldata, uint256 _amount) external;
function mergePositions(address _collateralToken, bytes32, bytes32 _conditionId,
uint256[] calldata, uint256 _amount) external;
```
### 3.2 ID lookups (view)
```solidity
function getConditionId(bytes32 _questionId) external view returns (bytes32);
function getPositionId(bytes32 _questionId, bool _outcome) external view returns (uint256);
function balanceOf(address _owner, uint256 _id) external view returns (uint256);
function balanceOfBatch(address[] memory _owners, uint256[] memory _ids)
external view returns (uint256[] memory);
```
### 3.3 Admin / oracle (you will not call these, but useful for tracing)
```solidity
function prepareMarket(uint256 _feeBips, bytes calldata _metadata) external returns (bytes32);
function prepareQuestion(bytes32 _marketId, bytes calldata _metadata) external returns (bytes32);
function reportOutcome(bytes32 _questionId, bool _outcome) external; // onlyOperator
```
### 3.4 Required ERC-20 / ERC-1155 approvals
Before any of the above, set:
```solidity
// USDC.e:
IERC20(USDC_E).approve(NegRiskAdapter, type(uint256).max);
// ERC-1155 outcome tokens (for merge / redeem / convert):
IConditionalTokens(CTF).setApprovalForAll(NegRiskAdapter, true);
```
(Source: [`NegRiskAdapter.sol`](https://raw.githubusercontent.com/Polymarket/neg-risk-ctf-adapter/main/src/NegRiskAdapter.sol))
---
## 4. End-to-end arb lifecycle
For a categorical event with N outcomes where `Σ best_ask_i < 1`:
1. **Approvals (one-time per wallet)**
- `USDC.e.approve(NegRiskAdapter, 2^256-1)`
- `ConditionalTokens.setApprovalForAll(NegRiskAdapter, true)`
- Approvals required for the **NegRiskCtfExchange** (`0xC5d5...80a`) for
trading: `USDC.e.approve(exchange, ...)` and
`ConditionalTokens.setApprovalForAll(exchange, true)`.
2. **Buy a complete set via the Exchange**
- Use the CLOB (`py-clob-client`, set `neg_risk=True` on the order options)
to lift the best ask of each of the N outcome tokens for `size` shares.
Total USDC.e spent ≈ `size * Σ best_ask_i`, which is < `size * $1`.
- Equivalent: hit each `clobTokenIds[YES]` from the Gamma `markets[]` array.
3. **Redeem on resolution OR free capital early**
- **Patient path**: wait for UMA to resolve the event and call
`NegRiskAdapter.redeemPositions(conditionId_winner, [size, 0])` on the
winning binary market. Payout = `size * 1 USDC.e`. Profit
= `size * (1 Σ best_ask_i)` minus gas and fees.
- **Capital-recycling path** (the negRisk superpower): once you hold one
YES of every outcome of the event, that bundle is economically `$1` per
unit. Rather than redeem on each binary, you can `convertPositions` to
consolidate, or simply burn the bundle: per the adapter, holding the
full YES set is interchangeable with USDC.e, so a `mergePositions` on
each conditionId (each binary has its own NO if you also hold it, or
use `convert`) returns USDC.e instantly without waiting for the oracle.
Practically: most arb bots redeem after resolution because acquiring a
full NO+YES pair on every binary defeats the point — you bought only the
YES legs for the discount.
4. **USDC.e arrives in your wallet.** Fees: NegRisk markets pay a small
protocol fee on conversion (defined by `_feeBips` at `prepareMarket`
time, paid to the Vault); redeem itself has no Polymarket fee.
---
## 5. Detecting negRisk markets via the Gamma API
Endpoint: `https://gamma-api.polymarket.com/events?...`
The two flags that matter on each `event` JSON object:
| JSON field | Type | Meaning |
|---|---|---|
| `negRisk` | bool | `true` → categorical event; outcomes are tied via the NegRiskAdapter. |
| `negRiskMarketID` | hex string (`bytes32`) | The shared `marketId` that links every binary in this event. Same value also appears on each child `markets[i].negRiskMarketID`. |
| `enableNegRisk` | bool | Set on a market when it can be added later as a new outcome (placeholder-capable). |
| `negRiskAugmented` | bool | Indicates the event has been augmented with such placeholder markets. |
The relevant fields inside each `markets[i]` element:
| JSON field | Use |
|---|---|
| `conditionId` (`bytes32`) | Pass to `redeemPositions` / `splitPosition`. |
| `questionID` (`bytes32`) | Source of `conditionId` via `getConditionId(questionID)`. |
| `clobTokenIds` | `[YES_tokenId, NO_tokenId]` as decimal strings. These are the ERC-1155 ids you reference to the CLOB order book. |
| `outcomePrices` | `["yes", "no"]` last-trade probabilities. Use `book` REST/WS for live best ask. |
Sample (trimmed) — `2026 FIFA World Cup Winner` event:
```json
{
"id": "12345",
"negRisk": true,
"negRiskMarketID": "0xb5c32a9acd39848acad4913ac4cd49c5de2afcc9d23a8a7ba2419375fab87400",
"markets": [
{
"questionID": "0x...",
"conditionId": "0x7976b8dbacf9077eb1453a62bcefd6ab2df199acd28aad276ff0d920d6992892",
"clobTokenIds": ["4394372887385518214471608448209527405727552777602031099972143344338178308080",
"112680630004798425069810935278212000865453267506345451433803052322987302357330"],
"outcomePrices": ["0.1715","0.8285"],
"negRiskMarketID": "0xb5c32a9acd39848acad4913ac4cd49c5de2afcc9d23a8a7ba2419375fab87400"
},
...
]
}
```
Sample query to surface candidates:
```python
import requests
events = requests.get(
"https://gamma-api.polymarket.com/events",
params={"closed": "false", "limit": 200, "order": "volume24hr",
"ascending": "false"},
timeout=15,
).json()
neg_risk_events = [e for e in events if e.get("negRisk")]
for e in neg_risk_events:
yes_asks = [float(m["outcomePrices"][0]) for m in e["markets"]]
if sum(yes_asks) < 0.99: # candidate; verify against live book
print(e["title"], sum(yes_asks))
```
(Source: live Gamma API response; field list cross-checked against
[`Polymarket/agents/agents/polymarket/gamma.py`](https://github.com/Polymarket/agents/blob/main/agents/polymarket/gamma.py)
and [docs.polymarket.com/developers/neg-risk/overview](https://docs.polymarket.com/developers/neg-risk/overview).)
---
## 6. How `tokenId` and `conditionId` are derived
NegRisk markets reuse the underlying CTF derivation rules but plug a
**WrappedCollateral** ERC-20 in place of raw USDC.e. That changes which
collateral address goes into `positionId` — the one number that frequently
trips up new integrators.
### 6.1 Vanilla CTF (used by non-negRisk binary markets)
```text
conditionId = keccak256( oracle ‖ questionId ‖ outcomeSlotCount )
collectionId = EC point-add of (parentCollectionId, hashToCurve(conditionId ‖ indexSet))
positionId = uint256( keccak256( collateralToken ‖ collectionId ) )
```
For a vanilla binary market: `oracle = UmaCtfAdapter`, `outcomeSlotCount = 2`,
`collateralToken = USDC.e`, `indexSet = 1` for YES and `2` for NO.
(Source: [CTHelpers.sol](https://raw.githubusercontent.com/Polymarket/neg-risk-ctf-adapter/main/src/libraries/CTHelpers.sol))
### 6.2 NegRisk markets (the difference)
For each outcome of a categorical event, NegRisk creates an independent
binary CTF condition, **but** with two changes:
1. The CTF `oracle` field is set to the **NegRiskAdapter address**
(`0xd91E…5296`) — not UmaCtfAdapter. The NegRiskAdapter is itself the
thing that calls `reportPayouts` upstream.
2. The `collateralToken` baked into `positionId` is the
**WrappedCollateral** ERC-20 (deployed by the adapter), not USDC.e. The
adapter holds USDC.e and mints/burns wrapper tokens 1:1 against it.
Practically:
- `marketId` (the negRisk-level grouping) =
`keccak256(operator ‖ feeBips ‖ metadata ‖ nonce)` — assigned at
`prepareMarket` time and is what `negRiskMarketID` in Gamma exposes.
- `questionId` for the i-th binary in the event =
`keccak256(marketId ‖ i)` (the index byte is the `_questionIndex`),
which keeps all questions of a categorical event derivable from the
single marketId.
- `conditionId = NegRiskAdapter.getConditionId(questionId)`
= `keccak256(NegRiskAdapter ‖ questionId ‖ 2)`.
- `positionId(YES) = NegRiskAdapter.getPositionId(questionId, true)`
`positionId(NO) = NegRiskAdapter.getPositionId(questionId, false)`
these match the decimal `clobTokenIds` returned by the Gamma API.
> **In practice you do not recompute these.** Pull `conditionId` and
> `clobTokenIds` straight from Gamma; only call `getPositionId` /
> `getConditionId` if you want to verify against on-chain truth.
(Source: [`NegRiskAdapter.sol`](https://github.com/Polymarket/neg-risk-ctf-adapter/blob/main/src/NegRiskAdapter.sol),
[`MarketStateLib`](https://github.com/Polymarket/neg-risk-ctf-adapter/tree/main/src/libraries),
ChainSecurity audit §2.1)
---
## 7. End-to-end Python (web3.py) — approve + simulate redeem
Self-contained, structurally complete. Uses placeholder `0x...` for the
private key only. The redeem call is built but NOT broadcast — `call()` runs
it as an `eth_call` simulation.
```python
"""
Polymarket NegRisk arb — approval + redeem simulation on Polygon.
Requires: web3>=6.20, requests
"""
import os
import requests
from web3 import Web3
from web3.middleware import ExtraDataToPOAMiddleware # PoA chain (Polygon)
# ---- 1. Connect ----------------------------------------------------------
RPC_URL = os.getenv("POLYGON_RPC", "https://polygon-rpc.com")
w3 = Web3(Web3.HTTPProvider(RPC_URL))
w3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0)
assert w3.is_connected(), "RPC down"
# ---- 2. Addresses (Polygon mainnet) --------------------------------------
NEG_RISK_ADAPTER = Web3.to_checksum_address("0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296")
NEG_RISK_EXCHANGE = Web3.to_checksum_address("0xC5d563A36AE78145C45a50134d48A1215220f80a")
CTF = Web3.to_checksum_address("0x4D97DCd97eC945f40cF65F87097ACe5EA0476045")
USDC_E = Web3.to_checksum_address("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174")
# ---- 3. Wallet (placeholder) --------------------------------------------
PRIVATE_KEY = os.getenv("PK", "0x" + "11" * 32) # placeholder
acct = w3.eth.account.from_key(PRIVATE_KEY)
ME = acct.address
# ---- 4. Minimal ABIs -----------------------------------------------------
ERC20_ABI = [
{"name":"approve","type":"function","stateMutability":"nonpayable",
"inputs":[{"name":"spender","type":"address"},{"name":"amount","type":"uint256"}],
"outputs":[{"type":"bool"}]},
{"name":"allowance","type":"function","stateMutability":"view",
"inputs":[{"name":"o","type":"address"},{"name":"s","type":"address"}],
"outputs":[{"type":"uint256"}]},
]
CTF_ABI = [
{"name":"setApprovalForAll","type":"function","stateMutability":"nonpayable",
"inputs":[{"name":"operator","type":"address"},{"name":"approved","type":"bool"}],
"outputs":[]},
]
NEG_RISK_ADAPTER_ABI = [
{"name":"splitPosition","type":"function","stateMutability":"nonpayable",
"inputs":[{"name":"_conditionId","type":"bytes32"},
{"name":"_amount","type":"uint256"}], "outputs":[]},
{"name":"mergePositions","type":"function","stateMutability":"nonpayable",
"inputs":[{"name":"_conditionId","type":"bytes32"},
{"name":"_amount","type":"uint256"}], "outputs":[]},
{"name":"redeemPositions","type":"function","stateMutability":"nonpayable",
"inputs":[{"name":"_conditionId","type":"bytes32"},
{"name":"_amounts","type":"uint256[]"}], "outputs":[]},
{"name":"convertPositions","type":"function","stateMutability":"nonpayable",
"inputs":[{"name":"_marketId","type":"bytes32"},
{"name":"_indexSet","type":"uint256"},
{"name":"_amount","type":"uint256"}], "outputs":[]},
{"name":"getConditionId","type":"function","stateMutability":"view",
"inputs":[{"name":"_questionId","type":"bytes32"}],
"outputs":[{"type":"bytes32"}]},
{"name":"getPositionId","type":"function","stateMutability":"view",
"inputs":[{"name":"_questionId","type":"bytes32"},
{"name":"_outcome","type":"bool"}],
"outputs":[{"type":"uint256"}]},
]
usdc = w3.eth.contract(address=USDC_E, abi=ERC20_ABI)
ctf = w3.eth.contract(address=CTF, abi=CTF_ABI)
adapter = w3.eth.contract(address=NEG_RISK_ADAPTER, abi=NEG_RISK_ADAPTER_ABI)
# ---- 5. Approvals (idempotent) -------------------------------------------
MAX = 2**256 - 1
def ensure_approvals():
if usdc.functions.allowance(ME, NEG_RISK_ADAPTER).call() < 10**18:
tx = usdc.functions.approve(NEG_RISK_ADAPTER, MAX).build_transaction({
"from": ME, "nonce": w3.eth.get_transaction_count(ME),
"maxFeePerGas": w3.to_wei(100, "gwei"),
"maxPriorityFeePerGas": w3.to_wei(30, "gwei"),
"chainId": 137,
})
# signed = acct.sign_transaction(tx); w3.eth.send_raw_transaction(signed.raw_transaction)
print("[would broadcast] USDC.e.approve(adapter)")
# also need 1155 approval for merge/redeem/convert legs
print("[would broadcast] CTF.setApprovalForAll(adapter, true)")
ensure_approvals()
# ---- 6. Pull a candidate event from Gamma --------------------------------
events = requests.get(
"https://gamma-api.polymarket.com/events",
params={"closed":"false","limit":50,"order":"volume24hr","ascending":"false"},
timeout=15,
).json()
neg = next(e for e in events if e.get("negRisk") and e.get("markets"))
mkt = neg["markets"][0]
condition_id_hex = mkt["conditionId"] # 0x...
print(f"event: {neg['title']!r} conditionId: {condition_id_hex}")
# ---- 7. Simulate redeem on the YES leg of one binary ---------------------
# amounts MUST line up with outcome slot count (2 for binary): [yes_qty, no_qty]
SIZE = 1_000_000 # 1.0 USDC.e (6 dp); placeholder until balances are real
amounts = [SIZE, 0]
redeem_call = adapter.functions.redeemPositions(
Web3.to_bytes(hexstr=condition_id_hex),
amounts,
)
# eth_call simulation (no broadcast). Will revert if the market is unresolved
# or if you don't actually hold the tokens — both are expected for a dry run.
try:
sim = redeem_call.call({"from": ME})
print("simulated redeemPositions OK; return:", sim)
except Exception as exc:
print("simulated redeemPositions reverted (expected for dry run):", exc)
# Gas estimate for a real broadcast:
try:
gas = redeem_call.estimate_gas({"from": ME})
print("gas estimate:", gas)
except Exception as exc:
print("estimate_gas reverted (likely unresolved or no balance):", exc)
```
---
## 8. Gotchas
1. **USDC.e ≠ native USDC.** Polymarket exclusively uses **bridged USDC.e**
`0x2791…84174`. Approving native Circle USDC `0x3c499…3359` will silently
fail every order placement and adapter call. Confirm balance with
`usdc.functions.symbol().call() == "USDC"` *and* address match.
2. **Two distinct allowances.** You must `approve(USDC.e → NegRiskAdapter)`
*and* `setApprovalForAll(CTF → NegRiskAdapter, true)`. The second is
needed for `mergePositions`, `redeemPositions`, and `convertPositions`
because the adapter pulls your ERC-1155 outcome tokens before burning.
Trading additionally requires the same two approvals targeting the
**NegRiskCtfExchange** address.
3. **Gas estimates (Polygon, ~April 2026 baseline).** Approximate, varies
±30% with calldata size:
- `splitPosition` ~ 200250 k gas
- `mergePositions` ~ 200250 k gas
- `redeemPositions(N=2)` ~ 150220 k gas (single binary)
- `convertPositions` ~ 250400 k gas (depends on `indexSet` popcount)
At ~50 gwei `maxFeePerGas`, redeem costs roughly $0.005$0.02 of MATIC.
At Polygon gas spikes (>500 gwei) this can rise 10×; size the arb spread
accordingly.
4. **Resolution dependency on UMA.** Redeem only works after the
UmaCtfAdapter has called `reportPayouts` upstream and (for negRisk)
the NegRiskOperator has called `reportOutcome`. Until then
`redeemPositions` reverts with `MarketNotResolved`/payout-vector-empty.
UMA's optimistic oracle has a **2-hour liveness window** (default) per
question; disputes extend by days.
5. **No-winner / all-NO is an invalid state.** NegRisk *requires* exactly
one question per market to resolve YES. Per the adapter docs and audit:
if the oracle tries to report a second YES, `reportOutcome` reverts and
the market is stuck pending manual operator action; if all questions go
NO the system is "designed to prevent" that scenario but has no
automatic refund path. Polymarket's stated stance after past disputes
has been **no refunds for resolution disagreements**. Architect the
strategy so you can hold or sell tokens before final resolution if
ambiguity emerges.
([NegRisk docs](https://github.com/Polymarket/neg-risk-ctf-adapter/blob/main/docs/NegRiskAdapter.md),
[Coindesk UMA/Polymarket dispute, Mar 2025](https://www.coindesk.com/markets/2025/03/27/polymarket-uma-communities-lock-horns-after-usd7m-ukraine-bet-resolves))
6. **Operator-only `safeTransferFrom`.** The adapter's `safeTransferFrom`
has an `onlyAdmin` modifier. Don't try to ERC-1155-transfer wrapped
positions through the adapter; transfer directly through the underlying
ConditionalTokens contract.
7. **`negRiskAugmented` events.** When `enableNegRisk` is true, new
outcomes (questions) can be appended to a marketId after creation. Your
"Σ asks" snapshot can become stale if a new candidate is added between
detection and trade — re-pull the event before lifting offers.
8. **CLOB order flag.** When placing orders against negRisk markets, you
must pass `neg_risk=True` in the order options of `py-clob-client` so
the order is signed for the NegRiskCtfExchange (`0xC5d5…80a`) instead
of the vanilla CTFExchange. Wrong exchange → orders rejected.
([NegRisk overview](https://docs.polymarket.com/developers/neg-risk/overview))
---
## Sources
- [Polymarket Contract Addresses](https://docs.polymarket.com/resources/contract-addresses)
- [Polymarket NegRisk Overview](https://docs.polymarket.com/developers/neg-risk/overview)
- [Polymarket CTF Overview](https://docs.polymarket.com/developers/CTF/overview)
- [neg-risk-ctf-adapter (repo)](https://github.com/Polymarket/neg-risk-ctf-adapter)
- [`NegRiskAdapter.sol`](https://raw.githubusercontent.com/Polymarket/neg-risk-ctf-adapter/main/src/NegRiskAdapter.sol)
- [`docs/NegRiskAdapter.md`](https://github.com/Polymarket/neg-risk-ctf-adapter/blob/main/docs/NegRiskAdapter.md)
- [`CTHelpers.sol`](https://raw.githubusercontent.com/Polymarket/neg-risk-ctf-adapter/main/src/libraries/CTHelpers.sol)
- [ctf-exchange (repo)](https://github.com/Polymarket/ctf-exchange)
- [ChainSecurity NegRiskAdapter audit (Apr 2024)](https://old.chainsecurity.com/wp-content/uploads/2024/04/ChainSecurity_Polymarket_NegRiskAdapter_audit.pdf)
- [Polymarket Resolution docs](https://docs.polymarket.com/concepts/resolution)
- [Coindesk: Polymarket/UMA Ukraine bet dispute (Mar 2025)](https://www.coindesk.com/markets/2025/03/27/polymarket-uma-communities-lock-horns-after-usd7m-ukraine-bet-resolves)
- PolygonScan verifications: [NegRiskAdapter](https://polygonscan.com/address/0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296), [NegRiskCtfExchange](https://polygonscan.com/address/0xc5d563a36ae78145c45a50134d48a1215220f80a), [ConditionalTokens](https://polygonscan.com/address/0x4d97dcd97ec945f40cf65f87097ace5ea0476045), [USDC.e](https://polygonscan.com/address/0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174)
+578
View File
@@ -0,0 +1,578 @@
# Polymarket CLOB Order Signing Cookbook (`py-clob-client`)
A copy-pasteable reference for signing and submitting Polymarket CLOB orders from
Python. Verified against `py-clob-client` **v0.34.6** (released 2026-02-19).
All citations point at `Polymarket/py-clob-client@main` on GitHub. Update the pin
when bumping.
---
## 1. Install
Pin the exact version your executor was tested against. As of April 2026 the
latest published release is **0.34.6**.
```bash
pip install py-clob-client==0.34.6
```
Source: <https://pypi.org/project/py-clob-client/0.34.6/> /
[`setup.py` L7-L25](https://github.com/Polymarket/py-clob-client/blob/main/setup.py#L7-L25)
Transitive deps it pulls in (from `setup.py`):
- `eth-account>=0.13.0`
- `eth-utils>=4.1.1`
- `poly_eip712_structs>=0.0.1`
- `py-order-utils>=0.3.2`
- `py-builder-signing-sdk>=0.0.2`
- `httpx[http2]>=0.27.0`
- `python-dotenv`
Requires Python **3.9.10+**.
The HTTP client under the hood is `httpx` (sync). All `client.*` methods are
**blocking**. See the `asyncio` pattern in section 7.
---
## 2. One-Time Setup: Derive L2 API Credentials
L2 (HMAC) creds — `api_key`, `api_secret`, `api_passphrase` — are deterministic
for a given `(wallet, nonce)`. You generate them once and store them. The
`create_or_derive_api_creds()` helper tries `POST /auth/api-key` first, and falls
back to `GET /auth/derive-api-key` if the key already exists.
Reference:
[`py_clob_client/client.py` L211-L260](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py#L211-L260)
```python
# scripts/bootstrap_clob_creds.py
"""
Run ONCE per wallet to mint L2 API credentials, then store the three
strings (api_key, api_secret, api_passphrase) in your secret manager.
"""
import os
from py_clob_client.client import ClobClient
from py_clob_client.constants import POLYGON
HOST = "https://clob.polymarket.com"
PRIVATE_KEY = os.environ["POLY_PK"] # 0x-prefixed hex
CHAIN_ID = POLYGON # 137 (mainnet) or AMOY=80002
# L1 client = host + chain + key. No creds needed yet.
client = ClobClient(HOST, key=PRIVATE_KEY, chain_id=CHAIN_ID)
# Idempotent: creates if missing, derives if existing. Returns ApiCreds.
creds = client.create_or_derive_api_creds()
print("CLOB_API_KEY =", creds.api_key)
print("CLOB_SECRET =", creds.api_secret)
print("CLOB_PASS_PHRASE =", creds.api_passphrase)
```
`ApiCreds` is a dataclass with three string fields:
[`clob_types.py` L19-L23](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/clob_types.py#L19-L23).
> Polymarket prints a giant warning that creds **cannot be recovered** if lost
> — store them in your secrets backend immediately.
> See [`constants.py` L7-L10](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/constants.py#L7-L10).
---
## 3. Client Init
```python
import os
from py_clob_client.client import ClobClient
from py_clob_client.clob_types import ApiCreds
from py_clob_client.constants import POLYGON
client = ClobClient(
host="https://clob.polymarket.com",
key=os.environ["POLY_PK"], # private key of the *signer* EOA
chain_id=POLYGON, # 137
creds=ApiCreds(
api_key=os.environ["CLOB_API_KEY"],
api_secret=os.environ["CLOB_SECRET"],
api_passphrase=os.environ["CLOB_PASS_PHRASE"],
),
signature_type=2, # see table below
funder="0xYourPolymarketProxyAddress", # USDC-holding address
)
```
Constructor signature:
[`client.py` L116-L165](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py#L116-L165).
### `signature_type`
The integer is forwarded to `OrderBuilder.__init__` and stamped into the EIP-712
order payload as `signatureType`
([`order_builder/builder.py` L40-L49](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/order_builder/builder.py#L40-L49),
[L143](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/order_builder/builder.py#L143)).
| `signature_type` | Wallet Model | When to use |
| ---------------- | ------------------------------------- | ------------------------------------------------------------------------------------------ |
| `0` (default, `EOA`) | Plain EOA / MetaMask / hardware | Signer EOA *is* the funder. USDC and CTF tokens sit on the same address that signs. |
| `1` (`POLY_PROXY`) | Polymarket proxy (Magic / email) | Signer EOA is the session key; funds live in a Polymarket-deployed proxy contract. |
| `2` (`POLY_GNOSIS_SAFE`) | Gnosis Safe / browser proxy | Signer EOA is an owner; funds live in a Safe / proxy contract. |
Default if omitted is `EOA` (0). See `EOA` constant in
`py_order_utils.model` re-exported via `builder.py` L4.
### `funder`
The address that **holds USDC and conditional tokens**. This goes into the
`maker` field of the signed order
([`builder.py` L132-L144](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/order_builder/builder.py#L132-L144)),
while `signer` is set to the address derived from your private key.
- If `funder` is omitted, it defaults to `signer.address()`.
- For arbitrage from a Polymarket UI account, `funder` = your visible Polymarket
proxy address (look it up on polygonscan or in the UI), and `key` = the
session/EOA key Polymarket gave you.
- For a pure EOA setup, leave `funder=None` (or pass the same address as the
signer) and use `signature_type=0`.
### Read-only mode
Drop `creds`, `key`, `signature_type`, `funder` for L0 (public endpoints only):
```python
client = ClobClient("https://clob.polymarket.com")
client.get_order_book(token_id)
```
---
## 4. Place an Order
### 4a. Limit order — GTC (resting)
Source pattern:
[`examples/order.py` L1-L36](https://github.com/Polymarket/py-clob-client/blob/main/examples/order.py).
```python
from py_clob_client.clob_types import OrderArgs, OrderType
from py_clob_client.order_builder.constants import BUY, SELL
order_args = OrderArgs(
token_id="71321045679252212594626385532706912750332728571942532289631379312455583992563",
price=0.42, # USD per share, between 0.0 and 1.0
size=100.0, # shares
side=BUY, # or SELL
)
signed = client.create_order(order_args) # builds EIP-712 + signs
resp = client.post_order(signed, OrderType.GTC)
# resp == {"success": True, "orderID": "0x...", "status": "matched"|"live"|...}
```
`OrderArgs` fields (from
[`clob_types.py` L42-L83](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/clob_types.py#L42-L83)):
`token_id`, `price`, `size`, `side`, `fee_rate_bps=0`, `nonce=0`,
`expiration=0`, `taker=ZERO_ADDRESS`.
`create_order` automatically:
- fetches and caches the market's `tick_size` and `neg_risk` flag
([`client.py` L402-L448, L492-L535](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py#L492-L535)),
- validates your price against the tick,
- picks the correct exchange contract for `neg_risk` markets,
- signs an EIP-712 order with `maker=funder`, `signer=EOA`, `signatureType=...`.
### 4b. Limit order — GTD (good-till-date)
Source: [`examples/GTD_order.py`](https://github.com/Polymarket/py-clob-client/blob/main/examples/GTD_order.py).
```python
order_args = OrderArgs(
token_id="...",
price=0.50,
size=100.0,
side=BUY,
expiration="1000000000000", # unix seconds; must be > now+60s
)
signed = client.create_order(order_args)
resp = client.post_order(signed, OrderType.GTD)
```
### 4c. FOK (Fill-Or-Kill)
FOK requires the entire size to fill immediately at the limit price or better,
otherwise the whole order is cancelled. Polymarket uses FOK for *market* buys
priced in dollars (`MarketOrderArgs.amount`).
Source: [`examples/market_buy_order.py`](https://github.com/Polymarket/py-clob-client/blob/main/examples/market_buy_order.py).
```python
from py_clob_client.clob_types import MarketOrderArgs, OrderType
mo = MarketOrderArgs(
token_id="...",
amount=100.0, # BUY: USDC to spend. SELL: shares to sell.
side=BUY,
)
signed = client.create_market_order(mo)
resp = client.post_order(signed, orderType=OrderType.FOK)
```
`MarketOrderArgs` defaults `order_type=OrderType.FOK`
([`clob_types.py` L86-L122](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/clob_types.py#L86-L122)).
### 4d. FAK / IOC (Fill-And-Kill, a.k.a. Immediate-Or-Cancel)
`OrderType.FAK` is Polymarket's IOC variant — fills as much as possible
immediately, cancels the unfilled remainder. Use this for arbitrage legs where
partial fills are acceptable.
```python
from py_clob_client.clob_types import OrderArgs, OrderType
from py_clob_client.order_builder.constants import BUY
order_args = OrderArgs(token_id="...", price=0.42, size=100.0, side=BUY)
signed = client.create_order(order_args)
resp = client.post_order(signed, OrderType.FAK) # IOC behavior
```
Enum values:
[`clob_types.py` L11-L16`](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/clob_types.py#L11-L16):
```python
class OrderType(enumerate):
GTC = "GTC" # resting limit
FOK = "FOK" # all-or-nothing immediate
GTD = "GTD" # resting limit with expiry
FAK = "FAK" # IOC: fill what you can, cancel rest
```
> **TL;DR for an arbitrage executor**: use `FAK` for legs where you want IOC
> semantics on a limit order, and `FOK` for $-denominated market-sweep buys
> where you only want the trade if the full notional clears.
`post_only=True` is only legal with `GTC` / `GTD`
([`client.py` L623-L628](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py#L623-L628)).
---
## 5. Cancel Orders
Source: [`examples/cancel_order.py`](https://github.com/Polymarket/py-clob-client/blob/main/examples/cancel_order.py),
[`examples/cancel_orders.py`](https://github.com/Polymarket/py-clob-client/blob/main/examples/cancel_orders.py).
```python
# single
client.cancel(order_id="0xabc...")
# batch
client.cancel_orders(["0xabc...", "0xdef..."])
# all open orders for this API key
client.cancel_all()
# all orders on a market or token
client.cancel_market_orders(market="0x...condition_id...", asset_id="")
client.cancel_market_orders(market="", asset_id="<token_id>")
```
Implementations:
[`client.py` L663-L748](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py#L663-L748).
---
## 6. Get Positions / Open Orders / Fills
py-clob-client does **not** ship a `get_positions()` method — Polymarket exposes
"positions" via the Data-API (separate service). Within `py-clob-client` you use
**balance/allowance** for current token holdings, **`get_orders`** for open
orders, and **`get_trades`** for fills.
### Balance / position per token
Source: [`examples/get_balance_allowance.py`](https://github.com/Polymarket/py-clob-client/blob/main/examples/get_balance_allowance.py).
```python
from py_clob_client.clob_types import BalanceAllowanceParams, AssetType
# USDC balance + exchange allowance
usdc = client.get_balance_allowance(
BalanceAllowanceParams(asset_type=AssetType.COLLATERAL)
)
# Conditional-token (outcome share) balance for one token_id
shares = client.get_balance_allowance(
BalanceAllowanceParams(
asset_type=AssetType.CONDITIONAL,
token_id="71321045679252212594626385532706912750332728571942532289631379312455583992563",
)
)
```
Note: `BalanceAllowanceParams.signature_type` defaults to `-1` and is auto-filled
from the client.
### Open orders
```python
from py_clob_client.clob_types import OpenOrderParams
orders = client.get_orders(OpenOrderParams()) # all
orders = client.get_orders(OpenOrderParams(market="0x...condition")) # one market
orders = client.get_orders(OpenOrderParams(asset_id="<token_id>")) # one token
```
`get_orders` paginates internally with `next_cursor`
([`client.py` L750-L769](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py#L750-L769)).
### Fills (trades)
Source: [`examples/get_trades.py`](https://github.com/Polymarket/py-clob-client/blob/main/examples/get_trades.py).
```python
from py_clob_client.clob_types import TradeParams
trades = client.get_trades(
TradeParams(
maker_address=client.get_address(),
market="0x5f65177b394277fd294cd75650044e32ba009a95022d88a0c1d565897d72f8f1",
)
)
```
---
## 7. Multi-Leg / Parallel Order Submission
`py-clob-client` is **synchronous** (it uses `httpx` in blocking mode). For
arbitrage you have two good options:
### Option A — Server-side batch (preferred when atomicity matters less)
`post_orders` ships N orders in one HTTP round-trip. Lower latency than N
parallel calls, but the server processes them serially.
Source: [`examples/orders.py`](https://github.com/Polymarket/py-clob-client/blob/main/examples/orders.py).
```python
from py_clob_client.clob_types import OrderArgs, PostOrdersArgs, OrderType
from py_clob_client.order_builder.constants import BUY, SELL
resp = client.post_orders([
PostOrdersArgs(
order=client.create_order(OrderArgs(
token_id="...YES_TOKEN_ID...",
price=0.50, size=100, side=BUY)),
orderType=OrderType.FAK,
postOnly=False,
),
PostOrdersArgs(
order=client.create_order(OrderArgs(
token_id="...NO_TOKEN_ID...",
price=0.51, size=100, side=BUY)),
orderType=OrderType.FAK,
postOnly=False,
),
])
```
Implementation: [`client.py` L592-L621](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py#L592-L621).
### Option B — `asyncio.gather` over a thread pool (true parallel HTTP)
When you want each leg to be a separate request fired concurrently (useful for
hitting the matching engine at the same time across markets), wrap the sync
client with `loop.run_in_executor`:
```python
import asyncio
from concurrent.futures import ThreadPoolExecutor
from py_clob_client.clob_types import OrderArgs, OrderType
from py_clob_client.order_builder.constants import BUY
# One executor for the whole process is fine. Size = max parallel legs.
_EXECUTOR = ThreadPoolExecutor(max_workers=16)
async def submit_leg(client, order_args: OrderArgs, order_type: OrderType):
loop = asyncio.get_running_loop()
# create_order signs (CPU + 1 cached HTTP call for tick/neg_risk),
# post_order does the actual order POST.
signed = await loop.run_in_executor(_EXECUTOR, client.create_order, order_args)
return await loop.run_in_executor(
_EXECUTOR, client.post_order, signed, order_type
)
async def execute_arb(client, legs: list[tuple[OrderArgs, OrderType]]):
return await asyncio.gather(
*(submit_leg(client, args, ot) for args, ot in legs),
return_exceptions=True, # don't let one failure cancel the others
)
# Usage
legs = [
(OrderArgs(token_id=YES, price=0.50, size=100, side=BUY), OrderType.FAK),
(OrderArgs(token_id=NO, price=0.51, size=100, side=BUY), OrderType.FAK),
]
results = asyncio.run(execute_arb(client, legs))
```
Notes:
- The `ClobClient` instance is safe to share across threads for read paths and
for `post_order`. Each call constructs its own `httpx` request.
- Pre-warm tick/`neg_risk` caches by calling `client.get_tick_size(token_id)`
and `client.get_neg_risk(token_id)` once at startup so the hot path skips two
HTTP round trips per `create_order`. Caches live on the client
([`client.py` L156-L160, L402-L448](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py#L402-L448)).
- The tick-size cache TTL is configurable via the `tick_size_ttl` ctor arg
(default 300s).
---
## 8. NegRisk Markets
NegRisk ("negative-risk") markets are Polymarket's multi-outcome markets where
the YES tokens of all outcomes sum to ~$1. They use a **different exchange
contract** than vanilla binary markets, but the SDK handles the routing for you.
### What you do NOT need to do
`OrderArgs` is **identical** for negRisk and vanilla tokens — you still pass
`token_id`, `price`, `size`, `side`. There is no `neg_risk` field on
`OrderArgs`.
### What the SDK does behind the scenes
When you call `client.create_order(...)`, it:
1. Calls `GET /neg-risk?token_id=...` to learn whether the token belongs to a
negRisk market (cached forever per `token_id`
[`client.py` L441-L448](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py#L441-L448)).
2. Calls `get_contract_config(chain_id, neg_risk=True/False)` to pick the right
exchange address ([`builder.py` L146-L154](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/order_builder/builder.py#L146-L154)).
3. Signs the EIP-712 payload against that exchange's domain separator.
### When you DO need to override
If you already know the market is negRisk and want to skip the lookup, pass
`PartialCreateOrderOptions`:
```python
from py_clob_client.clob_types import PartialCreateOrderOptions
signed = client.create_order(
order_args,
options=PartialCreateOrderOptions(neg_risk=True, tick_size="0.01"),
)
```
(definition:
[`clob_types.py` L165-L172](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/clob_types.py#L165-L172).)
### Allowances (one-time, per signer)
For EOAs (`signature_type=0`) you must approve **both** the vanilla CTF Exchange
**and** the NegRisk CTF Exchange + NegRisk Adapter on Polygon mainnet.
Magic/proxy wallets (`signature_type=1` or `2`) have allowances set
automatically. From the README:
| Token | Approve for |
| --------------------------------------- | --------------------------------------------- |
| USDC (`0x2791Bca1...A84174`) | `0x4bFb41d5...8B8982E` (CTF Exchange) |
| Conditional Tokens (`0x4D97DCd9...476045`) | `0xC5d563A3...220f80a` (NegRisk Exchange) |
| | `0xd91E80cF...0DA35296` (NegRisk Adapter) |
Reference allowance script (linked in the README):
<https://gist.github.com/poly-rodr/44313920481de58d5a3f6d1f8226bd5e>
---
## 9. Error Handling
### Exception hierarchy
All client exceptions live in
[`py_clob_client/exceptions.py`](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/exceptions.py):
```python
class PolyException(Exception):
msg: str
class PolyApiException(PolyException):
status_code: int | None # HTTP status from the failed response
error_msg: dict | str # parsed JSON or raw text body
```
`PolyApiException` is raised inside `http_helpers/helpers.py` for any non-2xx
HTTP response. `httpx` exceptions (`httpx.RequestError`,
`httpx.TimeoutException`, `httpx.HTTPError`) can leak through on network/DNS
failures.
### Recommended catch ladder
```python
import httpx
from py_clob_client.exceptions import PolyApiException, PolyException
try:
resp = client.post_order(signed, OrderType.FAK)
except PolyApiException as e:
# API-layer failure: invalid price, insufficient balance, market closed, 4xx/5xx
if e.status_code in (429, 502, 503, 504):
# rate-limited or transient — retry with backoff
...
elif e.status_code in (400, 422):
# client error — DO NOT retry; surface to operator
...
else:
...
except (httpx.TimeoutException, httpx.RequestError) as e:
# Network-layer failure — safe to retry idempotently if you used a fresh nonce
...
except PolyException as e:
# Local SDK validation (e.g., invalid tick size, bad side)
...
```
### Retry guidance for an arbitrage executor
- **Idempotency**: Polymarket assigns the order ID on the server side from the
EIP-712 hash, so re-posting the *exact same signed payload* is naturally
idempotent for `GTC`/`GTD`. For `FAK`/`FOK`, the hash includes the salt so a
fresh `create_order` call generates a *new* order — only retry if you
confirmed via `get_orders` / `get_trades` that the original did not fill.
- **Cap retries at 1-2** for any order-placement call; latency-sensitive
arbitrage prefers fast failure over duplicated risk.
- **Pre-flight checks** before any loop: `client.get_balance_allowance(...)`
for both legs, `client.get_tick_size(token_id)` to warm the cache.
- **Heartbeat-based dead-man switch**: `client.post_heartbeat(heartbeat_id)`
cancels all your orders if no heartbeat arrives within 10s — useful as a
safety net while the executor is running
([`client.py` L713-L727](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py#L713-L727)).
### Common API-side rejection reasons
- `not enough balance / allowance` — top up USDC or re-run the allowance script.
- `min size not met``OrderBookSummary.min_order_size`.
- `tick size invalid` — your `price` doesn't fit the market's tick. Use
`client.get_tick_size(token_id)` and round.
- `market not active` — market is paused, resolved, or closed.
- `order expired` — for `GTD`, `expiration` must be > `now + 60s`.
---
## 10. Constants Cheat Sheet
```python
from py_clob_client.constants import POLYGON, AMOY # 137, 80002
from py_clob_client.order_builder.constants import BUY, SELL # "BUY", "SELL"
from py_clob_client.clob_types import OrderType, AssetType
# OrderType.GTC | FOK | GTD | FAK
# AssetType.COLLATERAL | CONDITIONAL
```
`POLYGON = 137`, `AMOY = 80002` (testnet) — see
[`constants.py`](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/constants.py).
Default CLOB host: `https://clob.polymarket.com`.
+484
View File
@@ -0,0 +1,484 @@
/*
* Live Polymarket arbitrage scanner — portfolio demo.
*
* What this does, in plain English:
* - Loads a list of 68 currently-active categorical Polymarket events
* from a static JSON file that a GitHub Action refreshes every hour.
* - Opens a WebSocket to Polymarket's public CLOB and subscribes to every
* outcome token of those events.
* - Maintains order book state per outcome and recomputes "basket cost" —
* the total cost of buying one share of every answer — on every update.
* - If that basket cost drops below $1.00, it's a risk-free arbitrage and
* the page flashes green.
*
* This is a browser-only port of the Python engine in the repo. No backend,
* no keys, no orders submitted.
*/
const EVENTS_URL = "./data/events.json";
const WS_URL = "wss://ws-subscriptions-clob.polymarket.com/ws/market";
const MAX_EVENTS_TO_WATCH = 6;
const PING_INTERVAL_MS = 10_000;
const NEAR_ARB_THRESHOLD = 0.01; // $0.01 above $1 counts as "near miss"
// --- DOM shortcuts ---------------------------------------------------------
const $ = (sel) => document.querySelector(sel);
const el = {
connDot: $("#conn-dot"),
connLabel: $("#conn-label"),
aLine: $("#a-line"),
aSub: $("#a-sub"),
watchCount: $("#watch-count"),
eventList: $("#event-list"),
eventTitle: $("#event-title"),
eventSub: $("#event-subtitle"),
whyBtn: $("#why-this-event"),
basketCost: $("#basket-cost"),
basketCostSub:$("#basket-cost-sub"),
basketPnl: $("#basket-pnl"),
basketPnlSub: $("#basket-pnl-sub"),
basketStatus: $("#basket-status"),
basketFill: $("#basket-fill"),
scaleButtons: $("#scale-buttons"),
scaleOut: $("#scale-out"),
outcomeList: $("#outcome-list"),
statEvents: $("#stat-events"),
statTokens: $("#stat-tokens"),
statBooks: $("#stat-books"),
statMsgs: $("#stat-msgs"),
statUptime: $("#stat-uptime"),
modal: $("#modal"),
modalContent: $("#modal-content"),
};
// --- state ----------------------------------------------------------------
const state = {
events: {},
books: {},
tokenToEvent: {},
selectedEventId: null,
msgCount: 0,
startTime: Date.now(),
ws: null,
pingTimer: null,
scale: 1,
};
class Ladder {
constructor() { this.m = {}; this.sortedKeys = null; }
set(price, size) { if (size <= 0) this.del(price); else { this.m[price] = size; this.sortedKeys = null; } }
del(price) { delete this.m[price]; this.sortedKeys = null; }
clear() { this.m = {}; this.sortedKeys = null; }
size() { return Object.keys(this.m).length; }
keys() { if (this.sortedKeys === null) this.sortedKeys = Object.keys(this.m).map(parseFloat).sort((a,b)=>a-b); return this.sortedKeys; }
best(ascending) {
const ks = this.keys();
if (!ks.length) return null;
const p = ascending ? ks[0] : ks[ks.length - 1];
return { price: p, size: this.m[p] };
}
}
// --- bootstrap ------------------------------------------------------------
bootstrap().catch((err) => {
console.error("bootstrap failed", err);
setConn("bad", "unable to load events — " + err.message);
el.aLine.innerHTML = `<span class="verdict no">Couldn't load.</span>`;
el.aSub.textContent = "Refresh the page in a minute — the events list refreshes hourly.";
});
async function bootstrap() {
setConn("pend", "loading active events…");
const events = await fetchActiveNegRiskEvents();
if (!events.length) {
setConn("bad", "no active events found");
return;
}
for (const e of events) {
state.events[e.id] = e;
for (const o of e.outcomes) state.tokenToEvent[o.token_id] = e.id;
}
state.selectedEventId = events[0].id;
el.watchCount.textContent = events.length;
el.statEvents.textContent = events.length;
el.statTokens.textContent = Object.keys(state.tokenToEvent).length;
renderEventList();
renderSelectedEvent();
wireInteractions();
startUptimeTicker();
connectWS();
}
// --- REST (same-origin events.json — GitHub Action refreshes hourly) ------
async function fetchActiveNegRiskEvents() {
const resp = await fetch(EVENTS_URL + "?t=" + Date.now());
if (!resp.ok) throw new Error("events.json " + resp.status);
const payload = await resp.json();
const raw = Array.isArray(payload?.events) ? payload.events : [];
const kept = [];
for (const ev of raw) {
if (!Array.isArray(ev.outcomes) || ev.outcomes.length < 2) continue;
kept.push({
id: String(ev.id),
title: String(ev.title || "Event"),
slug: ev.slug,
outcomes: ev.outcomes.map(o => ({ token_id: String(o.token_id), name: String(o.name) })),
sum: null,
lastUpdate: 0,
});
if (kept.length >= MAX_EVENTS_TO_WATCH) break;
}
return kept;
}
// --- WebSocket ------------------------------------------------------------
function connectWS() {
const tokenIds = Object.keys(state.tokenToEvent);
if (!tokenIds.length) { setConn("bad", "no tokens"); return; }
setConn("pend", "opening connection to Polymarket…");
const ws = new WebSocket(WS_URL);
state.ws = ws;
ws.addEventListener("open", () => {
setConn("on", `live · reading order books for ${Object.keys(state.events).length} events`);
ws.send(JSON.stringify({ type: "market", assets_ids: tokenIds, custom_feature_enabled: true }));
if (state.pingTimer) clearInterval(state.pingTimer);
state.pingTimer = setInterval(() => { try { ws.send("PING"); } catch {} }, PING_INTERVAL_MS);
});
ws.addEventListener("message", (ev) => {
const raw = ev.data;
if (typeof raw === "string") {
const t = raw.trim();
if (t === "PONG" || t === "PING") return;
try { handlePayload(JSON.parse(t)); } catch {}
}
});
ws.addEventListener("close", () => {
setConn("bad", "connection dropped — reconnecting…");
if (state.pingTimer) clearInterval(state.pingTimer);
setTimeout(connectWS, 3000);
});
ws.addEventListener("error", () => setConn("bad", "connection error"));
}
function handlePayload(payload) {
if (Array.isArray(payload)) for (const m of payload) dispatch(m);
else if (payload && typeof payload === "object") dispatch(payload);
}
function dispatch(msg) {
state.msgCount++;
el.statMsgs.textContent = state.msgCount.toLocaleString();
const t = msg.event_type;
if (t === "book") applyBookSnapshot(msg);
else if (t === "price_change") applyPriceChange(msg);
}
function applyBookSnapshot(msg) {
const assetId = msg.asset_id;
if (!assetId || !state.tokenToEvent[assetId]) return;
const book = getBook(assetId);
book.bids.clear(); book.asks.clear();
for (const lvl of (msg.bids || [])) {
const p = parseFloat(lvl.price), s = parseFloat(lvl.size);
if (p > 0 && s > 0) book.bids.set(p, s);
}
for (const lvl of (msg.asks || [])) {
const p = parseFloat(lvl.price), s = parseFloat(lvl.size);
if (p > 0 && s > 0) book.asks.set(p, s);
}
onBookUpdate(assetId);
}
function applyPriceChange(msg) {
if (!Array.isArray(msg.price_changes)) return;
const touched = new Set();
for (const c of msg.price_changes) {
const assetId = c.asset_id;
if (!assetId || !state.tokenToEvent[assetId]) continue;
const p = parseFloat(c.price), s = parseFloat(c.size);
if (!(p > 0) || isNaN(s)) continue;
const book = getBook(assetId);
const side = (c.side || "").toUpperCase();
if (side === "BUY") book.bids.set(p, s);
else if (side === "SELL") book.asks.set(p, s);
else continue;
touched.add(assetId);
}
for (const id of touched) onBookUpdate(id);
}
function getBook(tokenId) {
if (!state.books[tokenId]) {
state.books[tokenId] = { bids: new Ladder(), asks: new Ladder() };
el.statBooks.textContent = Object.keys(state.books).length.toLocaleString();
}
return state.books[tokenId];
}
// --- engine ---------------------------------------------------------------
function onBookUpdate(tokenId) {
const eventId = state.tokenToEvent[tokenId];
if (!eventId) return;
evaluateEvent(eventId);
renderEventList();
if (eventId === state.selectedEventId) renderSelectedEvent();
renderHeroAnswer();
}
function evaluateEvent(eventId) {
const ev = state.events[eventId];
if (!ev) return;
let sum = 0, missing = 0;
for (const o of ev.outcomes) {
const b = state.books[o.token_id];
const best = b && b.asks.best(true);
if (!best) { missing += 1; continue; }
sum += best.price;
}
ev.sum = missing > 0 ? null : sum;
ev.lastUpdate = Date.now();
}
// --- rendering ------------------------------------------------------------
function setConn(cls, text) {
el.connDot.className = "dot " + cls;
el.connLabel.textContent = text;
}
function renderHeroAnswer() {
// Find the cheapest basket across all events
let bestEv = null, bestSum = Infinity;
for (const id of Object.keys(state.events)) {
const ev = state.events[id];
if (ev.sum === null || ev.sum === undefined) continue;
if (ev.sum < bestSum) { bestSum = ev.sum; bestEv = ev; }
}
if (!bestEv) {
el.aLine.innerHTML = `<span class="spinner"></span><span class="muted">checking live prices…</span>`;
return;
}
const cls = classForSum(bestSum);
const gap = bestSum - 1;
if (cls === "arb") {
const bps = Math.round(-gap * 10_000);
el.aLine.innerHTML = `<span class="verdict yes">Yes!</span> <span class="detail">The cheapest bundle is</span> <span class="cost-chip yes">$${bestSum.toFixed(4)}</span><span class="detail">— free ${Math.abs(bps)} bps (${(-gap*100).toFixed(2)}¢) per $1 bet</span>`;
el.aSub.innerHTML = `On <strong>${escapeHtml(bestEv.title)}</strong>. Click it on the left to see the details.`;
} else if (cls === "near") {
el.aLine.innerHTML = `<span class="verdict near">Almost.</span> <span class="detail">The cheapest bundle is</span> <span class="cost-chip near">$${bestSum.toFixed(4)}</span><span class="detail">— you'd lose ${(gap*100).toFixed(2)}¢ per $1 bet</span>`;
el.aSub.innerHTML = `On <strong>${escapeHtml(bestEv.title)}</strong>. Watch it — if another trader sells off this might flip into arbitrage.`;
} else {
el.aLine.innerHTML = `<span class="verdict no">Not right now.</span> <span class="detail">The cheapest bundle is</span> <span class="cost-chip no">$${bestSum.toFixed(4)}</span><span class="detail">— you'd lose ${(gap*100).toFixed(2)}¢ per $1 bet</span>`;
el.aSub.innerHTML = `Watching <strong>${Object.keys(state.events).length} events</strong>. This is the normal state — arbitrage windows are rare.`;
}
}
function renderEventList() {
el.eventList.innerHTML = "";
for (const id of Object.keys(state.events)) {
const ev = state.events[id];
const cls = classForSum(ev.sum);
const costText = ev.sum === null ? "waiting" : "$" + ev.sum.toFixed(3);
const costCls = ev.sum === null ? "" : cls === "arb" ? "yes" : cls === "near" ? "near" : "no";
const card = document.createElement("div");
card.className = "event-card" + (id === state.selectedEventId ? " active" : "");
card.innerHTML = `
<div class="ec-title">${escapeHtml(ev.title)}</div>
<div class="ec-meta">
<span class="ec-count">${ev.outcomes.length} possible answers</span>
<span class="ec-cost ${costCls}">${costText}</span>
</div>
`;
card.addEventListener("click", () => {
state.selectedEventId = id;
renderEventList();
renderSelectedEvent();
});
el.eventList.appendChild(card);
}
}
function renderSelectedEvent() {
const ev = state.events[state.selectedEventId];
if (!ev) return;
el.eventTitle.textContent = ev.title;
el.eventSub.textContent = `${ev.outcomes.length} possible answers · exactly one will win and pay $1`;
let sum = 0, complete = true;
const rows = [];
for (const o of ev.outcomes) {
const b = state.books[o.token_id];
const best = b && b.asks.best(true);
if (!best) { complete = false; rows.push({ ...o, price: null, size: null }); }
else { sum += best.price; rows.push({ ...o, price: best.price, size: best.size }); }
}
rows.sort((a, b) => (b.price ?? -1) - (a.price ?? -1));
renderCalcCard(complete ? sum : null);
renderOutcomeList(rows, complete ? sum : null);
}
function renderCalcCard(sum) {
if (sum === null) {
el.basketCost.textContent = "—";
el.basketCost.className = "cell-val";
el.basketCostSub.textContent = "waiting for every answer's order book…";
el.basketPnl.textContent = "—";
el.basketPnl.className = "cell-val";
el.basketPnlSub.textContent = "per one-set bet";
el.basketStatus.textContent = "loading";
el.basketStatus.className = "status fair";
el.basketFill.style.width = "0%";
el.basketFill.className = "fill";
el.scaleOut.innerHTML = "";
return;
}
const cls = classForSum(sum);
const delta = sum - 1;
// scale
const q = state.scale;
const totalCost = sum * q;
const totalGet = 1 * q;
const pnl = totalGet - totalCost;
el.basketCost.textContent = "$" + sum.toFixed(4);
el.basketCost.className = "cell-val";
el.basketCostSub.textContent = q === 1 ? "for one full set" : `× ${q} sets = $${totalCost.toFixed(2)} total`;
el.basketPnl.textContent = (pnl >= 0 ? "+" : "") + "$" + pnl.toFixed(q >= 100 ? 2 : 4);
el.basketPnl.className = "cell-val " + (cls === "arb" ? "pos" : cls === "near" ? "near" : "neg");
el.basketPnlSub.textContent = q === 1
? (cls === "arb" ? "guaranteed — buy + redeem" : cls === "near" ? "you'd lose a tiny bit" : "you'd lose this no matter what")
: `on a ${q}-set bet`;
el.basketStatus.textContent = cls === "arb" ? "ARBITRAGE" : cls === "near" ? "NEAR MISS" : "NO ARB";
el.basketStatus.className = "status " + cls;
// interpretation line
if (cls === "arb") {
el.scaleOut.innerHTML = `
Pay <strong>$${totalCost.toFixed(2)}</strong>,
receive <strong>$${totalGet.toFixed(2)}</strong> when the event resolves,
pocket <span class="gain">+$${pnl.toFixed(2)}</span> risk-free.
Every leg fills and the winning outcome pays $1.
`;
} else {
const perSet = delta;
el.scaleOut.innerHTML = `
Pay <strong>$${totalCost.toFixed(2)}</strong>,
receive <strong>$${totalGet.toFixed(2)}</strong> when the event resolves,
net <span class="loss">$${Math.abs(pnl).toFixed(2)}</span>.
That's ${(perSet*100).toFixed(2)}¢ too expensive per set — no arbitrage.
`;
}
const pct = Math.max(0, Math.min(100, ((sum - 0.80) / 0.40) * 100));
el.basketFill.style.width = pct.toFixed(1) + "%";
el.basketFill.className = "fill" + (cls === "arb" ? " arb" : cls === "near" ? " near" : "");
}
function renderOutcomeList(rows, totalSum) {
el.outcomeList.innerHTML = "";
for (const r of rows) {
const hasPrice = r.price !== null;
const pct = hasPrice ? (r.price * 100) : 0;
const highlighted = hasPrice && totalSum !== null && classForSum(totalSum) === "arb";
const row = document.createElement("div");
row.className = "outcome-row" + (!hasPrice ? " empty" : "") + (highlighted ? " highlighted" : "");
row.innerHTML = `
<div class="left">
<div class="outcome-name">${escapeHtml(r.name)}</div>
<div class="prob-bar"><div class="prob-fill" style="width: ${Math.min(100, pct).toFixed(1)}%"></div></div>
</div>
<div class="right">
<div class="pct">${hasPrice ? pct.toFixed(1) + "%" : "waiting"}</div>
<div class="price-size">${hasPrice ? `$${r.price.toFixed(4)} · ${formatSize(r.size)} available` : "no offers yet"}</div>
</div>
`;
el.outcomeList.appendChild(row);
}
}
function classForSum(sum) {
if (sum === null || sum === undefined) return "fair";
if (sum < 1.0) return "arb";
if (sum <= 1.0 + NEAR_ARB_THRESHOLD) return "near";
return "fair";
}
// --- interactions ---------------------------------------------------------
function wireInteractions() {
// scale buttons
el.scaleButtons.addEventListener("click", (e) => {
const btn = e.target.closest("button[data-qty]");
if (!btn) return;
state.scale = parseInt(btn.dataset.qty, 10) || 1;
for (const b of el.scaleButtons.querySelectorAll("button")) b.classList.toggle("active", b === btn);
renderSelectedEvent();
});
// "what is this event" modal
el.whyBtn.addEventListener("click", () => {
const ev = state.events[state.selectedEventId];
if (!ev) return;
const pmUrl = ev.slug ? `https://polymarket.com/event/${encodeURIComponent(ev.slug)}` : "https://polymarket.com";
el.modalContent.innerHTML = `
<h3>${escapeHtml(ev.title)}</h3>
<p>
This is a real, currently-open question on Polymarket. There are
<strong>${ev.outcomes.length} possible answers</strong>, and when the real
event resolves, one will be declared the winner. Shares of the winning
answer pay $1. Shares of every losing answer pay $0.
</p>
<p>
The numbers on this page come straight from Polymarket's live order book —
same feed their website uses. See the original market here:
</p>
<p><a href="${pmUrl}" target="_blank" rel="noopener">View on polymarket.com →</a></p>
`;
el.modal.hidden = false;
});
// modal close
el.modal.addEventListener("click", (e) => {
if (e.target.dataset?.close !== undefined) el.modal.hidden = true;
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") el.modal.hidden = true;
});
}
function startUptimeTicker() {
setInterval(() => {
const secs = Math.floor((Date.now() - state.startTime) / 1000);
const mm = String(Math.floor(secs / 60)).padStart(2, "0");
const ss = String(secs % 60).padStart(2, "0");
el.statUptime.textContent = `${mm}:${ss}`;
}, 1000);
}
// --- utils ----------------------------------------------------------------
function formatSize(s) {
if (s >= 1_000_000) return (s / 1_000_000).toFixed(2) + "M";
if (s >= 10_000) return (s / 1_000).toFixed(1) + "k";
if (s >= 1_000) return (s / 1_000).toFixed(2) + "k";
return s.toFixed(0);
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
+333
View File
@@ -0,0 +1,333 @@
/* Lab page — extends style.css with strategy-lab-specific layout */
.lab-shell { min-height: 100vh; background: var(--bg); }
/* Tabs */
.tabs {
background: var(--surface);
border-bottom: 1px solid var(--border);
position: sticky; top: 0; z-index: 10;
}
.tabs-inner {
max-width: 1120px; margin: 0 auto; padding: 0 1.5rem;
display: flex; gap: 0.2rem;
}
.tab {
background: transparent; border: 0;
color: var(--text-3); font-size: 0.95rem; font-weight: 500;
padding: 1rem 1.4rem; cursor: pointer;
border-bottom: 2px solid transparent;
transition: color 0.1s ease, border-color 0.1s ease;
display: flex; align-items: center; gap: 0.5rem;
}
.tab:hover { color: var(--text); }
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.tab-count {
font-family: var(--mono); font-size: 0.78rem;
padding: 1px 7px; border-radius: 999px;
background: var(--surface-2); color: var(--text-3);
}
.tab.active .tab-count { background: var(--accent); color: white; }
.panel { display: none; }
.panel.active { display: block; }
/* Results tab: hero */
.lab-hero {
background: linear-gradient(180deg, #ffffff 0%, var(--bg) 100%);
padding: 2.5rem 1.5rem 2rem;
border-bottom: 1px solid var(--border);
}
.lab-hero-inner { max-width: 1120px; margin: 0 auto; }
.hero-strategy-row {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 0.7rem; flex-wrap: wrap; gap: 0.6rem;
}
.strategy-badge {
font-size: 0.78rem; font-weight: 600; letter-spacing: 0.08em;
text-transform: uppercase; color: var(--accent);
padding: 4px 10px; background: rgba(45,156,219,0.08);
border-radius: 4px;
}
.switch-btn {
background: var(--surface); border: 1px solid var(--border);
color: var(--text-2); font-weight: 500; font-size: 0.88rem;
padding: 0.45rem 0.9rem; border-radius: 8px;
cursor: pointer; transition: all 0.1s ease;
display: flex; align-items: center; gap: 0.4rem;
}
.switch-btn:hover { border-color: var(--accent); color: var(--accent); }
.switch-btn .arrow { font-family: var(--mono); }
.lab-hero h1 {
font-size: 2rem; font-weight: 700; letter-spacing: -0.02em;
margin: 0 0 0.5rem; color: var(--text);
}
.lab-hero .lead {
font-size: 1rem; color: var(--text-2);
max-width: 740px; margin: 0; line-height: 1.55;
}
.bankroll-row {
margin-top: 1.4rem; padding: 0.9rem 1rem;
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius);
display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem;
}
.bankroll-lbl { font-size: 0.9rem; color: var(--text-2); font-weight: 500; }
.bankroll-choices { display: flex; gap: 0.3rem; flex-wrap: wrap; }
.bankroll-choices button {
background: var(--surface); border: 1px solid var(--border);
color: var(--text-2); font-size: 0.9rem; font-weight: 600;
padding: 0.35rem 0.75rem; border-radius: 6px; cursor: pointer;
font-family: var(--mono);
transition: all 0.1s ease;
}
.bankroll-choices button:hover { border-color: var(--border-2); color: var(--text); }
.bankroll-choices button.active { background: var(--accent); border-color: var(--accent); color: white; }
.bankroll-note {
flex-basis: 100%; font-size: 0.8rem; color: var(--text-3);
margin-top: 0.2rem;
}
/* Verdict section */
.verdict-section { padding: 2.2rem 1.5rem; background: var(--surface); border-bottom: 1px solid var(--border); }
.verdict-inner { max-width: 1120px; margin: 0 auto; }
.verdict-card {
display: flex; gap: 1.25rem; align-items: center;
padding: 1.4rem 1.6rem; border-radius: var(--radius-lg);
margin-bottom: 1.25rem;
border: 1px solid var(--border); background: var(--surface-2);
}
.verdict-card.win { background: var(--pos-soft); border-color: rgba(22,163,74,0.3); }
.verdict-card.loss { background: var(--neg-soft); border-color: rgba(220,38,38,0.3); }
.verdict-card.flat { background: var(--warn-soft); border-color: rgba(217,119,6,0.3); }
.verdict-icon {
font-size: 2.4rem; line-height: 1;
width: 3.8rem; height: 3.8rem;
display: flex; align-items: center; justify-content: center;
background: var(--surface); border-radius: 50%; flex-shrink: 0;
box-shadow: var(--shadow);
}
.verdict-card.win .verdict-icon { color: var(--pos); }
.verdict-card.loss .verdict-icon { color: var(--neg); }
.verdict-card.flat .verdict-icon { color: var(--warn); }
.verdict-label {
font-size: 1.35rem; font-weight: 700; letter-spacing: -0.01em;
line-height: 1.2;
}
.verdict-card.win .verdict-label { color: var(--pos); }
.verdict-card.loss .verdict-label { color: var(--neg); }
.verdict-card.flat .verdict-label { color: var(--warn); }
.verdict-detail { color: var(--text-2); margin-top: 0.3rem; font-size: 0.94rem; }
.verdict-stats {
display: grid; gap: 0.75rem;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
margin-bottom: 1.25rem;
}
.vstat {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 1rem 1.1rem;
}
.vstat-val {
font-family: var(--mono); font-size: 1.4rem; font-weight: 700;
color: var(--text); letter-spacing: -0.01em;
}
.vstat-val.pos { color: var(--pos); }
.vstat-val.neg { color: var(--neg); }
.vstat-lbl {
font-size: 0.78rem; color: var(--text-3); margin-top: 0.2rem;
font-weight: 500;
}
.verdict-explainer {
font-size: 0.93rem; line-height: 1.6; color: var(--text-2);
max-width: 820px; margin: 0;
padding: 1rem 1.1rem; background: var(--bg); border: 1px solid var(--border);
border-radius: var(--radius);
}
/* Trades table */
.trades-section { padding: 2.2rem 1.5rem; border-bottom: 1px solid var(--border); }
.trades-inner { max-width: 1120px; margin: 0 auto; }
.section-header { margin-bottom: 1.25rem; }
.section-header h2 { font-size: 1.2rem; font-weight: 700; margin: 0 0 0.3rem; letter-spacing: -0.01em; }
.section-header p { color: var(--text-2); font-size: 0.93rem; margin: 0 0 0.9rem; max-width: 740px; }
.trade-filter { display: flex; gap: 0.4rem; flex-wrap: wrap; }
.filter-btn {
background: var(--surface); border: 1px solid var(--border);
color: var(--text-2); font-size: 0.85rem; font-weight: 500;
padding: 0.4rem 0.85rem; border-radius: 999px; cursor: pointer;
display: inline-flex; gap: 0.4rem; align-items: center;
}
.filter-btn:hover { border-color: var(--border-2); color: var(--text); }
.filter-btn.active { background: var(--text); color: white; border-color: var(--text); }
.filter-btn span {
font-family: var(--mono); font-size: 0.75rem;
color: var(--text-3); font-weight: 600;
}
.filter-btn.active span { color: rgba(255,255,255,0.85); }
.trade-list { display: flex; flex-direction: column; gap: 0.5rem; }
.trade-row {
display: grid; grid-template-columns: 1fr auto auto; gap: 1rem;
align-items: center;
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 0.85rem 1.1rem;
}
.trade-row.win { border-left: 3px solid var(--pos); }
.trade-row.loss { border-left: 3px solid var(--neg); }
.trade-row.skip { opacity: 0.72; border-left: 3px solid var(--border-2); }
.trade-event { min-width: 0; }
.trade-title {
font-size: 0.95rem; font-weight: 500; color: var(--text);
margin-bottom: 0.2rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.trade-meta { font-size: 0.8rem; color: var(--text-3); }
.trade-action {
font-family: var(--mono); font-size: 0.82rem; color: var(--text-2);
text-align: right; white-space: nowrap;
}
.trade-result {
font-family: var(--mono); font-size: 1rem; font-weight: 700;
min-width: 90px; text-align: right;
}
.trade-result.pos { color: var(--pos); }
.trade-result.neg { color: var(--neg); }
.trade-result.neutral { color: var(--text-3); font-weight: 400; font-size: 0.85rem; }
.trade-show-more {
background: var(--surface); border: 1px dashed var(--border-2);
color: var(--text-2); font-size: 0.88rem;
padding: 0.8rem 1.1rem; border-radius: var(--radius);
text-align: center; cursor: pointer;
transition: all 0.1s ease;
}
.trade-show-more:hover { border-color: var(--accent); color: var(--accent); }
.trade-count-footer {
text-align: center; padding: 1rem; margin-top: 0.4rem;
font-size: 0.85rem; color: var(--text-3);
}
.trade-count-footer strong { color: var(--text); font-family: var(--mono); }
.trade-count-footer a { color: var(--accent); text-decoration: underline; }
.trade-count-footer a:hover { color: var(--accent-dark); }
.data-section { padding: 2rem 1.5rem; background: var(--surface); }
.data-inner { max-width: 840px; margin: 0 auto; }
.data-inner h2 { font-size: 1.05rem; font-weight: 600; margin: 0 0 0.5rem; }
.data-inner p { color: var(--text-2); font-size: 0.9rem; line-height: 1.6; margin-bottom: 0.8rem; }
.data-inner strong { color: var(--text); }
/* Strategies tab */
.strategies-hero { padding: 2.5rem 1.5rem 1.5rem; border-bottom: 1px solid var(--border); background: linear-gradient(180deg, #ffffff 0%, var(--bg) 100%); }
.strategies-hero-inner { max-width: 1120px; margin: 0 auto; }
.strategies-hero h1 { font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem; letter-spacing: -0.02em; }
.strategies-hero p { color: var(--text-2); max-width: 720px; margin: 0; font-size: 1rem; }
.strategy-grid-section { padding: 2rem 1.5rem; }
.strategy-grid {
max-width: 1120px; margin: 0 auto;
display: grid; gap: 1rem;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}
.strat-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 1.3rem 1.4rem;
cursor: pointer; transition: all 0.12s ease;
display: flex; flex-direction: column;
}
.strat-card:hover { border-color: var(--accent); box-shadow: var(--shadow-lg); transform: translateY(-2px); }
.strat-card.active { border-color: var(--accent); background: #f0f9ff; }
.strat-card-head {
display: flex; align-items: start; justify-content: space-between;
gap: 0.5rem; margin-bottom: 0.6rem;
}
.strat-card-name {
font-size: 1.05rem; font-weight: 700; color: var(--text);
letter-spacing: -0.01em;
}
.strat-card-badge {
font-size: 0.7rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
padding: 3px 9px; border-radius: 4px; flex-shrink: 0;
}
.strat-card-badge.win { background: var(--pos-soft); color: var(--pos); }
.strat-card-badge.loss { background: var(--neg-soft); color: var(--neg); }
.strat-card-badge.flat { background: var(--warn-soft); color: var(--warn); }
.strat-card-desc {
font-size: 0.88rem; color: var(--text-2);
line-height: 1.5; margin: 0 0 1rem;
}
.strat-card-metric {
font-family: var(--mono); font-size: 1.8rem; font-weight: 700;
letter-spacing: -0.02em; line-height: 1;
}
.strat-card-metric.pos { color: var(--pos); }
.strat-card-metric.neg { color: var(--neg); }
.strat-card-metric.flat { color: var(--warn); }
.strat-card-metric-sub { font-size: 0.78rem; color: var(--text-3); margin-top: 0.2rem; }
.strat-card-stats {
display: flex; gap: 1.1rem; margin-top: 0.9rem;
padding-top: 0.9rem; border-top: 1px solid var(--border);
}
.strat-card-stat { font-size: 0.8rem; color: var(--text-3); }
.strat-card-stat strong { color: var(--text); font-family: var(--mono); font-weight: 600; }
.strat-card-learn {
margin-top: 0.9rem; font-size: 0.82rem; color: var(--accent); font-weight: 500;
}
/* Strategy detail modal */
.modal-wide { max-width: 720px; }
.strategy-detail h2 {
font-size: 1.4rem; font-weight: 700; margin: 0 0 0.4rem;
letter-spacing: -0.01em;
}
.strategy-detail .detail-verdict {
display: inline-block; padding: 4px 12px; border-radius: 999px;
font-weight: 700; font-size: 0.78rem; letter-spacing: 0.05em; text-transform: uppercase;
margin-bottom: 1rem;
}
.strategy-detail .detail-verdict.win { background: var(--pos-soft); color: var(--pos); }
.strategy-detail .detail-verdict.loss { background: var(--neg-soft); color: var(--neg); }
.strategy-detail .detail-verdict.flat { background: var(--warn-soft); color: var(--warn); }
.strategy-detail .detail-rule {
background: var(--surface-2); border: 1px solid var(--border);
padding: 0.85rem 1rem; border-radius: var(--radius);
font-size: 0.92rem; color: var(--text); margin-bottom: 1rem;
}
.strategy-detail .detail-rule strong { font-weight: 600; }
.strategy-detail .detail-section { margin: 1.1rem 0; }
.strategy-detail h3 {
font-size: 0.78rem; letter-spacing: 0.06em; text-transform: uppercase;
color: var(--text-3); font-weight: 600; margin: 0 0 0.4rem;
}
.strategy-detail p { color: var(--text-2); line-height: 1.6; margin: 0 0 0.7rem; font-size: 0.93rem; }
.strategy-detail p strong { color: var(--text); }
.strategy-detail .detail-stats {
display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.7rem;
}
.strategy-detail .dstat {
background: var(--surface-2); border: 1px solid var(--border);
border-radius: var(--radius); padding: 0.7rem 0.85rem;
}
.strategy-detail .dstat-val { font-family: var(--mono); font-size: 1.15rem; font-weight: 700; }
.strategy-detail .dstat-val.pos { color: var(--pos); }
.strategy-detail .dstat-val.neg { color: var(--neg); }
.strategy-detail .dstat-lbl { font-size: 0.72rem; color: var(--text-3); margin-top: 0.1rem; }
.strategy-detail .cta-row {
margin-top: 1.3rem; display: flex; gap: 0.6rem; flex-wrap: wrap;
}
.strategy-detail .cta-primary {
background: var(--accent); color: white; border: 0;
padding: 0.7rem 1.3rem; border-radius: 8px;
font-weight: 600; font-size: 0.95rem; cursor: pointer;
transition: background 0.1s ease;
}
.strategy-detail .cta-primary:hover { background: var(--accent-dark); }
.strategy-detail .cta-secondary {
background: var(--surface-2); color: var(--text-2); border: 1px solid var(--border);
padding: 0.7rem 1.3rem; border-radius: 8px;
font-weight: 500; font-size: 0.95rem; cursor: pointer;
}
.strategy-detail .cta-secondary:hover { color: var(--text); border-color: var(--border-2); }
+675
View File
@@ -0,0 +1,675 @@
/*
* Polymarket Strategy Lab — pure browser-side backtester.
*
* Loads 100+ REAL resolved Polymarket categorical events from docs/data/
* historical-events.json, runs five different strategies against them,
* and shows you honest results — no cherry-picking.
*
* Each strategy is a pure function: given an event (outcomes + their
* last-trade prices + who actually won), it returns what it would have
* bought, how much it paid, and how much it got back. The backtest runner
* tallies these across every event.
*
* The strategies live here, open and readable. You can read exactly what
* each rule is doing.
*/
// ============================== DATA ====================================
const DATA_URL = "./data/historical-events.json";
// ========================== STRATEGIES ==================================
//
// A strategy is: strategy(event) -> { action, cost, payout, note }
// action: "trade" if we bought anything, "skip" if we passed
// cost: total dollars paid (at last-trade prices)
// payout: total dollars received after resolution
// note: short plain-English description of what happened
//
// Every strategy bets into the same event in its own way. Results are tallied
// across all events in the dataset.
const STRATEGIES = [
{
key: "basket-arb",
name: "Basket Arbitrage",
oneLiner: "Buy one share of every outcome — but only when the total cost is under $1.",
rule: "If the sum of every outcome's last trade price is below $1.00, buy one share of every outcome. Otherwise skip. Exactly one outcome will win and pay $1, so you profit the gap.",
why: "This is the textbook risk-free trade. It's the one real arbitrage on prediction markets. The question is: does it ever actually trigger in practice, on resting prices, for a retail bot that isn't co-located next to the exchange? The historical data tells the truth.",
run(ev, window) {
const prices = ev.outcomes.map(o => priceAt(o, ev, window));
if (prices.some(p => p == null || p <= 0 || p >= 1)) {
return { action: "skip", cost: 0, payout: 0, sum: null, note: "No price data at this window for at least one outcome." };
}
const sum = prices.reduce((a, b) => a + b, 0);
if (sum >= 1.0) {
return { action: "skip", cost: 0, payout: 0, sum, note: `Total cost $${sum.toFixed(3)}, above $1. No arbitrage — skipped.` };
}
return { action: "trade", cost: sum, payout: 1.0, sum, note: `Total cost $${sum.toFixed(3)}. Bought the full set — guaranteed $1 payout.` };
},
},
{
key: "favorite",
name: "Bet the Favorite",
oneLiner: "On every event, buy the single outcome the market thinks is most likely.",
rule: "For each event, buy one share of whichever outcome has the highest price at the chosen time window. If that outcome wins you get $1, otherwise $0.",
why: "Conventional wisdom: the market knows. If the favorite wins often enough you make money; if favorites are over-priced you lose. Tests whether Polymarket's top-line pricing has any slack.",
run(ev, window) {
let best = null, bestPrice = -1;
for (const o of ev.outcomes) {
const p = priceAt(o, ev, window);
if (p == null) continue;
if (p > bestPrice) { best = o; bestPrice = p; }
}
if (!best || bestPrice <= 0) return { action: "skip", cost: 0, payout: 0, note: "No valid prices." };
return {
action: "trade",
cost: bestPrice,
payout: best.yes_final_price,
note: `Bought "${best.name}" at $${bestPrice.toFixed(3)}. ${best.yes_final_price === 1 ? "Won — payout $1." : "Lost — payout $0."}`,
};
},
},
{
key: "longshot",
name: "Bet the Longshot",
oneLiner: "On every event, buy the cheapest outcome. Pray it wins.",
rule: "For each event, buy one share of whichever outcome has the lowest positive price at the chosen window.",
why: "The market prices longshots low for a reason. But if underdogs win more often than prices imply (a classic bias), this pays. Direct test.",
run(ev, window) {
let best = null, bestPrice = Infinity;
for (const o of ev.outcomes) {
const p = priceAt(o, ev, window);
if (p == null || p <= 0) continue;
if (p < bestPrice) { best = o; bestPrice = p; }
}
if (!best) return { action: "skip", cost: 0, payout: 0, note: "No valid prices." };
return {
action: "trade",
cost: bestPrice,
payout: best.yes_final_price,
note: `Bought "${best.name}" at $${bestPrice.toFixed(3)}. ${best.yes_final_price === 1 ? "Won — payout $1." : "Lost — payout $0."}`,
};
},
},
{
key: "equal-split",
name: "Equal Split",
oneLiner: "Buy one share of every outcome, always — no matter the price.",
rule: "For each event, buy one share of every outcome. You pay the sum of prices. You receive $1 (exactly one wins).",
why: "Basket Arbitrage without the safety condition. Every event is a tiny guaranteed loss equal to the &ldquo;vig&rdquo; — the amount by which Polymarket's prices overshoot $1. A baseline for what the market's rounding costs.",
run(ev, window) {
const prices = ev.outcomes.map(o => priceAt(o, ev, window));
// If any outcome lacks price data at this window, skip (we can't evaluate)
if (prices.some(p => p == null)) {
return { action: "skip", cost: 0, payout: 0, sum: null, note: "No price data at this window for at least one outcome." };
}
if (prices.some(p => p == null || p <= 0)) {
return { action: "skip", cost: 0, payout: 0, note: "Missing prices." };
}
const cost = prices.reduce((a, b) => a + b, 0);
return { action: "trade", cost, payout: 1.0, note: `Paid $${cost.toFixed(3)} for every outcome. Guaranteed $1 payout.` };
},
},
{
key: "top-three",
name: "Top Three",
oneLiner: "Buy the three outcomes the market thinks are most likely. Win if any of them wins.",
rule: "For each event with 3+ outcomes, buy one share of the three highest-priced outcomes at the chosen window. Pay the sum. Win $1 if any of those three wins.",
why: "A hedged bet — buying most of the probability mass but skipping the tail. If the hit rate is high enough, it pays.",
run(ev, window) {
const priced = ev.outcomes.map(o => ({ o, p: priceAt(o, ev, window) })).filter(x => x.p != null && x.p > 0);
if (priced.length < 3) return { action: "skip", cost: 0, payout: 0, note: "Fewer than 3 priced outcomes." };
const top = [...priced].sort((a, b) => b.p - a.p).slice(0, 3);
const cost = top.reduce((s, x) => s + x.p, 0);
const won = top.some(x => x.o.yes_final_price === 1);
return {
action: "trade",
cost,
payout: won ? 1.0 : 0.0,
note: `Bought top 3 (total $${cost.toFixed(3)}). ${won ? "One won — payout $1." : "None won — payout $0."}`,
};
},
},
];
// ========================== BACKTEST RUNNER =============================
function runBacktest(strategy, events, window) {
const rows = [];
let totalCost = 0, totalPayout = 0;
let trades = 0, wins = 0, losses = 0, skipped = 0;
for (const ev of events) {
const result = strategy.run(ev, window);
const pnl = (result.payout || 0) - (result.cost || 0);
const row = { event: ev, result, pnl };
rows.push(row);
if (result.action === "trade") {
trades += 1;
totalCost += result.cost || 0;
totalPayout += result.payout || 0;
if (pnl > 0) wins += 1;
else if (pnl < 0) losses += 1;
} else {
skipped += 1;
}
}
const pnlAbs = totalPayout - totalCost;
const roi = totalCost > 0 ? pnlAbs / totalCost : 0;
const winRate = trades > 0 ? wins / trades : null;
return {
rows,
totalCost, totalPayout, pnlAbs, roi,
trades, wins, losses, skipped,
winRate,
eventCount: events.length,
};
}
// ========================== STATE =======================================
const state = {
events: [],
results: {},
activeKey: "basket-arb",
tradeFilter: "all",
bankroll: 1000,
priceWindow: "24h", // key from WINDOWS below
};
// Time windows: how many seconds before close to read the price.
const WINDOWS = {
"close": { label: "at close", seconds: 0 },
"1h": { label: "1h before close", seconds: 3600 },
"6h": { label: "6h before close", seconds: 6*3600 },
"24h": { label: "24h before close", seconds: 24*3600 },
"3d": { label: "3 days before close", seconds: 3*24*3600 },
"7d": { label: "7 days before close", seconds: 7*24*3600 },
};
/**
* Return the actual price a trader would have seen on Polymarket at a specific
* time. Uses real historical price data pulled from Polymarket's public CLOB
* price-history endpoint — not estimates.
*/
function priceAt(outcome, ev, windowKey) {
const hist = outcome.history;
if (!hist || !hist.length) return null;
const w = WINDOWS[windowKey] || WINDOWS["close"];
const closeTs = ev._closeTs; // precomputed
if (closeTs == null) return null;
const targetTs = closeTs - w.seconds;
// If the target is before any recorded data, no price
if (hist[0].t > targetTs) return null;
// Binary search for the last point with t <= targetTs
let lo = 0, hi = hist.length - 1;
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2);
if (hist[mid].t <= targetTs) lo = mid;
else hi = mid - 1;
}
return hist[lo].p;
}
// ========================== DOM =========================================
const $ = (s) => document.querySelector(s);
const el = {
tabResults: $("#tab-results"),
tabStrategies: $("#tab-strategies"),
panelResults: $("#panel-results"),
panelStrategies:$("#panel-strategies"),
eventCountInline: $("#event-count-inline"),
eventCountStrat: $("#event-count-strat"),
activeLabel: $("#active-strategy-label"),
activeName: $("#active-strategy-name"),
activeDesc: $("#active-strategy-desc"),
switchBtn: $("#switch-btn"),
verdictCard: $("#verdict-card"),
verdictIcon: $("#verdict-icon"),
verdictLabel: $("#verdict-label"),
verdictDetail: $("#verdict-detail"),
vstatPnl: $("#vstat-pnl"),
vstatPnlLbl: $("#vstat-pnl-lbl"),
vstatRoi: $("#vstat-roi"),
vstatTrades: $("#vstat-trades"),
vstatWinrate: $("#vstat-winrate"),
vstatAnnual: $("#vstat-annual"),
bankrollChoices: $("#bankroll-choices"),
bankrollNote: $("#bankroll-note"),
verdictExplainer: $("#verdict-explainer"),
cntAll: $("#cnt-all"),
cntTrades: $("#cnt-trades"),
cntWins: $("#cnt-wins"),
cntLosses: $("#cnt-losses"),
cntSkipped: $("#cnt-skipped"),
tradeList: $("#trade-list"),
strategyGrid: $("#strategy-grid"),
modal: $("#strategy-modal"),
modalContent: $("#strategy-modal-content"),
};
// ========================== BOOT ========================================
boot().catch(err => {
console.error("lab boot failed", err);
el.verdictLabel.textContent = "Couldn't load historical data";
el.verdictDetail.textContent = String(err.message || err);
});
async function boot() {
const resp = await fetch(DATA_URL + "?t=" + Date.now());
if (!resp.ok) throw new Error("historical-events.json " + resp.status);
const payload = await resp.json();
state.events = Array.isArray(payload?.events) ? payload.events : [];
if (!state.events.length) throw new Error("No events found in dataset");
// Precompute the close timestamp (seconds since epoch) for each event, so
// priceAt() can do a cheap binary search per lookup.
for (const ev of state.events) {
const raw = ev.closed_time || ev.end_date || "";
const iso = String(raw).replace(" +00", "+00:00").replace("Z", "+00:00");
const d = new Date(iso);
ev._closeTs = isNaN(d.getTime()) ? null : Math.floor(d.getTime() / 1000);
}
const ends = state.events
.map(e => e._closeTs ? new Date(e._closeTs * 1000) : null)
.filter(d => d != null)
.sort((a, b) => a - b);
state.spanFirst = ends[0];
state.spanLast = ends[ends.length - 1];
state.spanDays = Math.max(1, (state.spanLast - state.spanFirst) / (1000 * 60 * 60 * 24));
el.eventCountInline.textContent = `${state.events.length} events · ${formatSpanDescription(state.spanFirst, state.spanLast)}`;
el.eventCountStrat.textContent = state.events.length;
rerunBacktests();
wireInteractions();
renderStrategyGrid();
renderActiveStrategy();
}
function rerunBacktests() {
for (const s of STRATEGIES) {
state.results[s.key] = runBacktest(s, state.events, state.priceWindow);
}
}
function formatSpanDescription(first, last, withMonths = true) {
if (!first || !last) return "";
const fmt = { month: "short", year: "numeric" };
const range = `${first.toLocaleDateString(undefined, fmt)} ${last.toLocaleDateString(undefined, fmt)}`;
if (!withMonths) return range;
const months = (state.spanDays / 30).toFixed(1);
return `${range} (${months} months)`;
}
function pluralize(n, word) {
return n === 1 ? `1 ${word}` : `${n} ${word}s`;
}
function wireInteractions() {
// Tabs
el.tabResults.addEventListener("click", () => switchTab("results"));
el.tabStrategies.addEventListener("click", () => switchTab("strategies"));
// "Change strategy" button on results page -> jumps to strategies tab
el.switchBtn.addEventListener("click", () => switchTab("strategies"));
// Trade filter buttons
document.querySelectorAll(".filter-btn").forEach(btn => {
btn.addEventListener("click", () => {
state.tradeFilter = btn.dataset.filter;
document.querySelectorAll(".filter-btn").forEach(b => b.classList.toggle("active", b === btn));
renderTradeList();
});
});
// CSV download
const dl = document.getElementById("csv-download");
if (dl) dl.addEventListener("click", (e) => { e.preventDefault(); downloadCsv(); });
// Bankroll selector
el.bankrollChoices.addEventListener("click", (e) => {
const btn = e.target.closest("button[data-bankroll]");
if (!btn) return;
state.bankroll = parseInt(btn.dataset.bankroll, 10) || 1000;
[...el.bankrollChoices.querySelectorAll("button")].forEach(b => b.classList.toggle("active", b === btn));
renderActiveStrategy();
renderStrategyGrid();
});
// Price-window selector
const windowChoices = document.getElementById("window-choices");
windowChoices.addEventListener("click", (e) => {
const btn = e.target.closest("button[data-window]");
if (!btn) return;
state.priceWindow = btn.dataset.window;
[...windowChoices.querySelectorAll("button")].forEach(b => b.classList.toggle("active", b === btn));
rerunBacktests();
renderActiveStrategy();
renderStrategyGrid();
});
// Modal close
el.modal.addEventListener("click", (e) => {
if (e.target.dataset?.close !== undefined) el.modal.hidden = true;
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") el.modal.hidden = true;
});
}
function switchTab(which) {
const isResults = which === "results";
el.tabResults.classList.toggle("active", isResults);
el.tabStrategies.classList.toggle("active", !isResults);
el.panelResults.classList.toggle("active", isResults);
el.panelStrategies.classList.toggle("active", !isResults);
window.scrollTo({ top: 0, behavior: "smooth" });
}
// ========================== RENDER: RESULTS TAB =========================
function renderActiveStrategy() {
const strategy = STRATEGIES.find(s => s.key === state.activeKey);
if (!strategy) return;
const result = state.results[strategy.key];
el.activeLabel.textContent = "Active strategy";
el.activeName.textContent = strategy.name;
el.activeDesc.textContent = strategy.oneLiner;
renderVerdict(strategy, result);
renderTradeList();
}
function verdictClass(result) {
const pnl = result.pnlAbs;
if (Math.abs(pnl) < 0.005) return "flat";
return pnl > 0 ? "win" : "loss";
}
function renderVerdict(strategy, result) {
const cls = verdictClass(result);
el.verdictCard.className = "verdict-card " + cls;
el.verdictIcon.textContent = cls === "win" ? "✓" : cls === "loss" ? "✗" : "≈";
const { roi, trades, wins, losses, eventCount } = result;
const totalPnl = roi * state.bankroll * trades; // bet $bankroll each trade, PnL per trade = roi*bankroll
const months = (state.spanDays / 30).toFixed(1);
const span = formatSpanDescription(state.spanFirst, state.spanLast, false);
const firedN = pluralize(trades, "time");
if (trades === 0) {
el.verdictLabel.textContent = "Strategy never triggered";
el.verdictDetail.textContent = `Over ${months} months of real Polymarket events (${span}), this strategy's rule never fired even once. Pure arbitrage on resting prices almost never exists — bots eat any gap in milliseconds.`;
} else if (cls === "win") {
el.verdictLabel.textContent = "Made money on this dataset";
el.verdictDetail.textContent = `Over ${months} months (${span}) this strategy fired ${firedN} across ${eventCount} events. ${wins} wins, ${losses} losses. At a $${state.bankroll.toLocaleString()} bankroll per trade, total profit was ${formatSignedDollar(totalPnl)}.`;
} else if (cls === "loss") {
el.verdictLabel.textContent = "Lost money on this dataset";
el.verdictDetail.textContent = `Over ${months} months (${span}) this strategy fired ${firedN} across ${eventCount} events. ${wins} wins, ${losses} losses. At a $${state.bankroll.toLocaleString()} bankroll per trade, total loss was ${formatSignedDollar(totalPnl)}.`;
} else {
el.verdictLabel.textContent = "Roughly break-even";
el.verdictDetail.textContent = `Over ${months} months (${span}) this strategy fired ${firedN} across ${eventCount} events. Total profit with a $${state.bankroll.toLocaleString()} bankroll was ${formatSignedDollar(totalPnl)} — essentially nothing.`;
}
el.vstatPnl.textContent = formatSignedDollar(totalPnl);
el.vstatPnl.className = "vstat-val " + (totalPnl > 0.005 ? "pos" : totalPnl < -0.005 ? "neg" : "");
el.vstatPnlLbl.textContent = `Total profit at $${state.bankroll.toLocaleString()} per trade`;
el.vstatRoi.textContent = trades > 0 ? formatSignedPct(roi) : "—";
el.vstatRoi.className = "vstat-val " + (roi > 0.0001 ? "pos" : roi < -0.0001 ? "neg" : "");
el.vstatTrades.textContent = `${trades} of ${eventCount}`;
el.vstatTrades.className = "vstat-val";
el.vstatWinrate.textContent = trades > 0 ? `${(result.winRate * 100).toFixed(1)}%` : "—";
el.vstatWinrate.className = "vstat-val";
// Annualized profit: scale the total by (365 / span)
const annualPnl = totalPnl * (365 / state.spanDays);
el.vstatAnnual.textContent = trades > 0 ? formatSignedDollar(annualPnl) : "—";
el.vstatAnnual.className = "vstat-val " + (annualPnl > 0.005 ? "pos" : annualPnl < -0.005 ? "neg" : "");
el.verdictExplainer.innerHTML = strategy.why;
}
function renderTradeList() {
const strategy = STRATEGIES.find(s => s.key === state.activeKey);
const result = state.results[strategy.key];
const all = result.rows;
const filters = {
all: (r) => true,
trades: (r) => r.result.action === "trade",
wins: (r) => r.result.action === "trade" && r.pnl > 0,
losses: (r) => r.result.action === "trade" && r.pnl < 0,
skipped: (r) => r.result.action === "skip",
};
const filtered = all.filter(filters[state.tradeFilter]);
// counts
el.cntAll.textContent = all.length;
el.cntTrades.textContent = all.filter(filters.trades).length;
el.cntWins.textContent = all.filter(filters.wins).length;
el.cntLosses.textContent = all.filter(filters.losses).length;
el.cntSkipped.textContent = all.filter(filters.skipped).length;
// sort: trades first (by |pnl| desc), then skipped
filtered.sort((a, b) => {
const aAct = a.result.action === "trade" ? 0 : 1;
const bAct = b.result.action === "trade" ? 0 : 1;
if (aAct !== bAct) return aAct - bAct;
return Math.abs(b.pnl) - Math.abs(a.pnl);
});
el.tradeList.innerHTML = "";
if (!filtered.length) {
const empty = document.createElement("div");
empty.className = "trade-show-more";
empty.style.cursor = "default";
empty.textContent = "No trades match this filter.";
el.tradeList.appendChild(empty);
return;
}
// Show every row. If you claim N trades, you show N trades.
for (const r of filtered) {
el.tradeList.appendChild(renderTradeRow(r));
}
const footer = document.createElement("div");
footer.className = "trade-count-footer";
footer.innerHTML = `Showing all <strong>${filtered.length}</strong> ${filtered.length === 1 ? "row" : "rows"} · <a href="#" id="csv-download">download as CSV</a>`;
el.tradeList.appendChild(footer);
const dl = document.getElementById("csv-download");
if (dl) dl.addEventListener("click", (e) => { e.preventDefault(); downloadCsv(); });
}
function downloadCsv() {
const strategy = STRATEGIES.find(s => s.key === state.activeKey);
const result = state.results[strategy.key];
const rows = [["event_title", "neg_risk", "num_outcomes", "action", "cost", "payout", "pnl", "note"]];
for (const r of result.rows) {
rows.push([
r.event.title,
String(r.event.neg_risk),
String(r.event.num_outcomes),
r.result.action,
(r.result.cost || 0).toFixed(4),
(r.result.payout || 0).toFixed(4),
r.pnl.toFixed(4),
(r.result.note || "").replace(/[\r\n]+/g, " "),
]);
}
const csv = rows.map(row => row.map(v => {
const s = String(v);
return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
}).join(",")).join("\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `polymarket-backtest-${strategy.key}.csv`;
document.body.appendChild(a); a.click();
setTimeout(() => { URL.revokeObjectURL(url); document.body.removeChild(a); }, 0);
}
function renderTradeRow(r) {
const row = document.createElement("div");
const didTrade = r.result.action === "trade";
const cls = didTrade
? (r.pnl > 0 ? "win" : r.pnl < 0 ? "loss" : "skip")
: "skip";
row.className = "trade-row " + cls;
// Scale by bankroll: if backtest cost was $0.40 for one share, and bankroll is
// $1000, the trader would buy $1000/$0.40 = 2500 units — scaled pnl = roi * bankroll.
const unitRoi = r.result.cost > 0 ? (r.pnl / r.result.cost) : 0;
const scaledCost = didTrade ? state.bankroll : 0;
const scaledPayout = didTrade ? state.bankroll * (1 + unitRoi) : 0;
const scaledPnl = scaledPayout - scaledCost;
const meta = didTrade
? `paid $${scaledCost.toLocaleString(undefined, {maximumFractionDigits:2})} → got back $${scaledPayout.toLocaleString(undefined, {maximumFractionDigits:2})}`
: (r.result.note || "Strategy did not trade this event.");
const resultCell = didTrade
? (scaledPnl > 0.005
? `<span class="trade-result pos">+$${scaledPnl.toLocaleString(undefined, {maximumFractionDigits:2})}</span>`
: scaledPnl < -0.005
? `<span class="trade-result neg">-$${Math.abs(scaledPnl).toLocaleString(undefined, {maximumFractionDigits:2})}</span>`
: `<span class="trade-result neutral">$0.00</span>`)
: `<span class="trade-result neutral">skipped</span>`;
row.innerHTML = `
<div class="trade-event">
<div class="trade-title">${escapeHtml(r.event.title)}</div>
<div class="trade-meta">${escapeHtml(meta)}</div>
</div>
<div class="trade-action">${escapeHtml(didTrade ? r.result.note : "")}</div>
${resultCell}
`;
return row;
}
// ========================== RENDER: STRATEGIES TAB ======================
function renderStrategyGrid() {
el.strategyGrid.innerHTML = "";
for (const s of STRATEGIES) {
const r = state.results[s.key];
const cls = verdictClass(r);
const card = document.createElement("div");
card.className = "strat-card" + (s.key === state.activeKey ? " active" : "");
const metric = r.trades > 0 ? formatSignedPct(r.roi) : "never fired";
const totalScaledPnl = r.roi * state.bankroll * r.trades;
const metricSub = r.trades > 0
? `${formatSignedDollar(totalScaledPnl)} total at $${state.bankroll.toLocaleString()}/trade · ${r.trades} trades`
: `skipped all ${r.eventCount} events`;
const verdictLabel = r.trades === 0 ? "INACTIVE"
: cls === "win" ? "PROFITABLE"
: cls === "loss" ? "LOSES MONEY"
: "BREAK-EVEN";
card.innerHTML = `
<div class="strat-card-head">
<div class="strat-card-name">${escapeHtml(s.name)}</div>
<div class="strat-card-badge ${cls}">${verdictLabel}</div>
</div>
<p class="strat-card-desc">${escapeHtml(s.oneLiner)}</p>
<div class="strat-card-metric ${cls}">${metric}</div>
<div class="strat-card-metric-sub">${escapeHtml(metricSub)}</div>
<div class="strat-card-stats">
<div class="strat-card-stat">trades: <strong>${r.trades}</strong></div>
<div class="strat-card-stat">wins: <strong>${r.wins}</strong></div>
<div class="strat-card-stat">losses: <strong>${r.losses}</strong></div>
</div>
<div class="strat-card-learn">Learn more & use this strategy →</div>
`;
card.addEventListener("click", () => openStrategyModal(s));
el.strategyGrid.appendChild(card);
}
}
function openStrategyModal(s) {
const r = state.results[s.key];
const cls = verdictClass(r);
const verdictLabel = r.trades === 0 ? "STRATEGY NEVER FIRED"
: cls === "win" ? "PROFITABLE ON THIS DATASET"
: cls === "loss" ? "LOSES MONEY ON THIS DATASET"
: "ROUGHLY BREAK-EVEN";
el.modalContent.innerHTML = `
<div class="strategy-detail">
<h2>${escapeHtml(s.name)}</h2>
<div class="detail-verdict ${cls}">${verdictLabel}</div>
<div class="detail-rule"><strong>The rule:</strong> ${s.rule}</div>
<div class="detail-section">
<h3>Why this strategy?</h3>
<p>${s.why}</p>
</div>
<div class="detail-section">
<h3>Results on ${r.eventCount} real resolved events</h3>
<div class="detail-stats">
<div class="dstat">
<div class="dstat-val ${r.pnlAbs > 0 ? 'pos' : r.pnlAbs < 0 ? 'neg' : ''}">${formatSignedDollar(r.pnlAbs)}</div>
<div class="dstat-lbl">total profit</div>
</div>
<div class="dstat">
<div class="dstat-val ${r.roi > 0 ? 'pos' : r.roi < 0 ? 'neg' : ''}">${r.trades > 0 ? formatSignedPct(r.roi) : '—'}</div>
<div class="dstat-lbl">ROI per dollar</div>
</div>
<div class="dstat">
<div class="dstat-val">${r.trades}</div>
<div class="dstat-lbl">trades taken</div>
</div>
<div class="dstat">
<div class="dstat-val">${r.trades > 0 ? (r.winRate * 100).toFixed(1) + '%' : '—'}</div>
<div class="dstat-lbl">win rate</div>
</div>
</div>
</div>
<div class="cta-row">
<button type="button" class="cta-primary" id="use-strategy">Run this strategy on Results tab</button>
<button type="button" class="cta-secondary" data-close>Close</button>
</div>
</div>
`;
el.modal.hidden = false;
document.getElementById("use-strategy").addEventListener("click", () => {
state.activeKey = s.key;
state.tradeFilter = "all";
document.querySelectorAll(".filter-btn").forEach(b => b.classList.toggle("active", b.dataset.filter === "all"));
renderActiveStrategy();
renderStrategyGrid();
el.modal.hidden = true;
switchTab("results");
});
}
// ========================== UTILS =======================================
function formatSignedDollar(x) {
const sign = x >= 0 ? "+" : "";
return sign + "$" + Math.abs(x).toFixed(2);
}
function formatSignedPct(x) {
const sign = x >= 0 ? "+" : "";
return sign + Math.abs(x * 100).toFixed(2) + "%";
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
+396
View File
@@ -0,0 +1,396 @@
/* Polymarket-inspired layout for a portfolio arb-scanner demo. */
:root {
--bg: #f7f8fa;
--surface: #ffffff;
--surface-2: #f1f3f6;
--border: #e5e7eb;
--border-2: #d1d5db;
--text: #0f172a;
--text-2: #4b5563;
--text-3: #6b7280;
--accent: #2d9cdb;
--accent-dark:#1e7fb8;
--pos: #16a34a;
--pos-soft: #dcfce7;
--neg: #dc2626;
--neg-soft: #fee2e2;
--warn: #d97706;
--warn-soft: #fef3c7;
--radius: 10px;
--radius-lg: 14px;
--shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 1px 3px rgba(15, 23, 42, 0.06);
--shadow-lg: 0 4px 10px rgba(15, 23, 42, 0.06), 0 2px 4px rgba(15, 23, 42, 0.04);
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
--sans: "Inter", -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--text);
font-family: var(--sans);
line-height: 1.5;
font-size: 15px;
-webkit-font-smoothing: antialiased;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
/* ---------- landing-page shared styles ---------- */
.container { max-width: 1120px; margin: 0 auto; padding: 2rem 1.5rem; }
header.hero { padding: 4rem 1.5rem 3rem; background: linear-gradient(180deg, #ffffff 0%, var(--bg) 100%); border-bottom: 1px solid var(--border); }
.hero-inner { max-width: 1120px; margin: 0 auto; padding: 0 1.5rem; }
.hero h1 { font-size: 2.5rem; font-weight: 700; letter-spacing: -0.02em; margin: 0 0 0.5rem; }
.hero p.tagline { font-size: 1.08rem; color: var(--text-2); max-width: 720px; margin: 0 0 1.5rem; }
.btn-row { display: flex; gap: 0.6rem; flex-wrap: wrap; margin-top: 1.25rem; }
.btn { display: inline-block; padding: 0.6rem 1.15rem; border-radius: 8px; background: var(--surface); border: 1px solid var(--border); color: var(--text); font-weight: 500; font-size: 0.95rem; transition: all 0.12s ease; }
.btn:hover { text-decoration: none; border-color: var(--border-2); transform: translateY(-1px); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: white; }
.btn.primary:hover { background: var(--accent-dark); border-color: var(--accent-dark); }
.badges { display: flex; gap: 0.4rem; margin-top: 0.8rem; flex-wrap: wrap; }
.badge { display: inline-block; padding: 3px 10px; border-radius: 4px; background: var(--surface); border: 1px solid var(--border); color: var(--text-2); font-size: 0.78rem; font-family: var(--mono); }
.badge.pos { color: var(--pos); border-color: rgba(22, 163, 74, 0.3); background: var(--pos-soft); }
section { padding: 3rem 1.5rem; border-bottom: 1px solid var(--border); background: var(--bg); }
section:nth-of-type(even) { background: var(--surface); }
section h2 { font-size: 1.4rem; font-weight: 600; margin: 0 0 1rem; letter-spacing: -0.01em; }
section h2 .rule { display: inline-block; width: 2rem; height: 2px; background: var(--accent); vertical-align: middle; margin-right: 0.6rem; }
.grid-2 { display: grid; grid-template-columns: 1fr; gap: 1.25rem; }
@media (min-width: 780px) { .grid-2 { grid-template-columns: 1fr 1fr; } }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.1rem 1.35rem; box-shadow: var(--shadow); }
.card h3 { margin: 0 0 0.5rem; font-size: 1rem; font-weight: 600; }
.card p { margin: 0; color: var(--text-2); font-size: 0.94rem; }
pre, code { font-family: var(--mono); }
code { background: var(--surface-2); padding: 2px 6px; border-radius: 4px; font-size: 0.88em; }
pre { background: var(--surface); border: 1px solid var(--border); padding: 0.95rem 1.1rem; border-radius: var(--radius); overflow-x: auto; font-size: 0.86rem; box-shadow: var(--shadow); }
pre code { background: transparent; padding: 0; }
.arch-diagram { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.25rem; font-family: var(--mono); font-size: 0.78rem; color: var(--text-2); white-space: pre; overflow-x: auto; box-shadow: var(--shadow); }
.metrics-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 0.9rem; margin-top: 1.25rem; }
.metric { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem; text-align: center; box-shadow: var(--shadow); }
.metric .val { font-size: 1.8rem; font-weight: 700; color: var(--accent); font-family: var(--mono); }
.metric .label { display: block; font-size: 0.78rem; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.04em; margin-top: 0.2rem; font-weight: 500; }
footer { padding: 2.5rem 1.5rem; text-align: center; color: var(--text-3); font-size: 0.85rem; background: var(--bg); }
footer a { color: var(--text-2); }
/* ==================== DEMO PAGE ==================== */
.demo-shell { min-height: 100vh; background: var(--bg); }
/* Top nav */
.demo-nav {
background: var(--surface); border-bottom: 1px solid var(--border);
padding: 0.9rem 1.5rem;
display: flex; align-items: center; justify-content: space-between;
flex-wrap: wrap; gap: 1rem;
}
.demo-nav .brand { display: flex; align-items: center; gap: 0.7rem; font-size: 0.95rem; }
.demo-nav .brand strong { font-weight: 600; }
.demo-nav .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--text-3); }
.demo-nav .dot.on { background: var(--pos); animation: pulse 2s infinite; }
.demo-nav .dot.pend { background: var(--warn); }
.demo-nav .dot.bad { background: var(--neg); }
@keyframes pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(22, 163, 74, 0.55); }
50% { box-shadow: 0 0 0 6px rgba(22, 163, 74, 0); }
}
.demo-nav .status-text { color: var(--text-2); font-size: 0.88rem; }
.demo-nav .nav-links { display: flex; gap: 1rem; align-items: center; }
.demo-nav .nav-links a { color: var(--text-2); font-size: 0.88rem; font-weight: 500; }
.demo-nav .nav-links a:hover { color: var(--accent); }
/* Hero question */
.hero-q {
background: linear-gradient(180deg, #ffffff 0%, var(--bg) 100%);
border-bottom: 1px solid var(--border);
padding: 2.5rem 1.5rem;
}
.hero-q-inner { max-width: 1120px; margin: 0 auto; text-align: center; }
.q-line {
font-size: 1.6rem; font-weight: 500; color: var(--text-2);
max-width: 800px; margin: 0 auto 0.9rem;
letter-spacing: -0.01em;
}
.a-line {
font-size: 2.4rem; font-weight: 700; letter-spacing: -0.02em;
line-height: 1.15; color: var(--text); margin-bottom: 0.6rem;
min-height: 3rem; display: flex; align-items: center; justify-content: center; gap: 0.6rem;
flex-wrap: wrap;
}
@media (min-width: 700px) { .a-line { font-size: 2.8rem; } }
.a-line .verdict { font-size: 1em; }
.a-line .verdict.no { color: var(--text-2); }
.a-line .verdict.yes { color: var(--pos); }
.a-line .verdict.near { color: var(--warn); }
.a-line .detail { font-weight: 600; font-size: 0.9em; color: var(--text-2); }
.a-line .cost-chip {
font-family: var(--mono); font-weight: 700;
padding: 0.15em 0.5em; border-radius: 8px; background: var(--surface);
border: 1px solid var(--border); font-size: 0.85em;
}
.a-line .cost-chip.no { color: var(--text); }
.a-line .cost-chip.yes { color: var(--pos); border-color: rgba(22,163,74,0.3); background: var(--pos-soft); }
.a-line .cost-chip.near{ color: var(--warn); border-color: rgba(217,119,6,0.3); background: var(--warn-soft); }
.a-sub {
color: var(--text-3); font-size: 0.9rem;
max-width: 600px; margin: 0 auto;
}
.a-sub strong { color: var(--text); }
.spinner {
width: 1em; height: 1em;
border: 2px solid var(--border); border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.9s linear infinite;
display: inline-block;
}
@keyframes spin { to { transform: rotate(360deg); } }
.muted { color: var(--text-3); }
/* Workbench layout */
.workbench {
max-width: 1120px; margin: 0 auto; padding: 1.5rem;
display: grid; gap: 1.25rem; grid-template-columns: 1fr;
}
@media (min-width: 960px) {
.workbench { grid-template-columns: 310px 1fr; }
}
/* Left column: event cards */
.column-head h3 {
font-size: 0.8rem; letter-spacing: 0.06em; text-transform: uppercase;
color: var(--text-3); margin: 0 0 0.3rem; font-weight: 600;
}
.column-head p { margin: 0 0 0.8rem; color: var(--text-3); font-size: 0.82rem; }
.event-cards { display: flex; flex-direction: column; gap: 0.55rem; }
.event-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 0.75rem 0.9rem;
cursor: pointer; transition: all 0.1s ease;
display: flex; flex-direction: column; gap: 0.3rem;
}
.event-card:hover { border-color: var(--accent); box-shadow: var(--shadow-lg); transform: translateY(-1px); }
.event-card.active { border-color: var(--accent); background: #f0f9ff; box-shadow: var(--shadow-lg); }
.event-card .ec-title { font-weight: 500; font-size: 0.92rem; color: var(--text); }
.event-card .ec-meta {
display: flex; justify-content: space-between; align-items: center;
font-size: 0.78rem;
}
.event-card .ec-count { color: var(--text-3); }
.event-card .ec-cost {
font-family: var(--mono); font-weight: 600;
padding: 2px 8px; border-radius: 4px;
background: var(--surface-2); color: var(--text-3);
}
.event-card .ec-cost.no { background: var(--surface-2); color: var(--text-3); }
.event-card .ec-cost.near { background: var(--warn-soft); color: var(--warn); }
.event-card .ec-cost.yes { background: var(--pos-soft); color: var(--pos); font-weight: 700; }
.event-card.skeleton { cursor: default; color: var(--text-3); font-size: 0.88rem; text-align: center; padding: 1.5rem 0.9rem; }
/* Right column: focused event + calculator */
.focused-event {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 1.5rem 1.6rem;
box-shadow: var(--shadow);
}
.focused-head { margin-bottom: 1.2rem; }
.focused-title-row { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
.focused-event h2 {
font-size: 1.35rem; font-weight: 700; margin: 0 0 0.25rem;
letter-spacing: -0.01em; color: var(--text);
}
.focused-event .subtitle { color: var(--text-3); font-size: 0.85rem; }
.why-btn {
background: var(--surface-2); border: 1px solid var(--border);
color: var(--text-2); font-size: 0.8rem; font-weight: 500;
padding: 0.3rem 0.7rem; border-radius: 999px; cursor: pointer;
transition: all 0.1s ease;
}
.why-btn:hover { background: var(--surface); border-color: var(--accent); color: var(--accent); }
/* The calculator card — the star */
.calc-card {
background: var(--surface-2); border: 1px solid var(--border);
border-radius: var(--radius); padding: 1.25rem;
margin-bottom: 1.5rem;
}
.calc-row.calc-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; gap: 1rem; }
.calc-label { font-size: 0.88rem; color: var(--text-2); font-weight: 500; }
.status {
font-size: 0.75rem; font-weight: 700; padding: 3px 10px; border-radius: 999px;
text-transform: uppercase; letter-spacing: 0.04em; flex-shrink: 0;
}
.status.arb { background: var(--pos-soft); color: var(--pos); }
.status.near { background: var(--warn-soft); color: var(--warn); }
.status.fair { background: var(--surface); color: var(--text-3); border: 1px solid var(--border); }
.calc-grid {
display: grid; grid-template-columns: 1fr; gap: 0.5rem;
margin-bottom: 1rem;
}
@media (min-width: 620px) { .calc-grid { grid-template-columns: 1fr 1fr 1fr; gap: 0.75rem; } }
.calc-cell {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 0.8rem 1rem;
}
.calc-cell.highlight { border-color: var(--border-2); }
.calc-cell .cell-label { font-size: 0.75rem; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.04em; font-weight: 500; }
.calc-cell .cell-val { font-family: var(--mono); font-size: 1.6rem; font-weight: 700; color: var(--text); letter-spacing: -0.01em; margin-top: 0.15rem; }
.calc-cell .cell-val.is-fixed { color: var(--text-2); }
.calc-cell .cell-val.pos { color: var(--pos); }
.calc-cell .cell-val.neg { color: var(--neg); }
.calc-cell .cell-val.near { color: var(--warn); }
.calc-cell .cell-sub { font-size: 0.78rem; color: var(--text-3); margin-top: 0.1rem; }
.scale-row { display: flex; align-items: center; gap: 0.8rem; margin-bottom: 0.5rem; flex-wrap: wrap; }
.scale-lbl { font-size: 0.85rem; color: var(--text-2); }
.scale-buttons { display: flex; gap: 0.3rem; flex-wrap: wrap; }
.scale-buttons button {
background: var(--surface); border: 1px solid var(--border);
color: var(--text-2); font-size: 0.82rem; font-weight: 500;
padding: 0.35rem 0.75rem; border-radius: 6px; cursor: pointer;
transition: all 0.1s ease;
}
.scale-buttons button:hover { border-color: var(--border-2); color: var(--text); }
.scale-buttons button.active { background: var(--text); border-color: var(--text); color: white; }
.scale-out {
font-size: 0.86rem; color: var(--text-2); padding: 0.6rem 0.9rem;
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
margin-bottom: 0.9rem; min-height: 1.5em;
line-height: 1.55;
}
.scale-out strong { color: var(--text); font-family: var(--mono); }
.scale-out .gain { color: var(--pos); font-weight: 600; font-family: var(--mono); }
.scale-out .loss { color: var(--neg); font-weight: 600; font-family: var(--mono); }
/* Threshold bar */
.threshold-bar {
position: relative; height: 10px;
background: var(--surface); border: 1px solid var(--border);
border-radius: 999px; overflow: hidden; margin-top: 0.3rem; margin-bottom: 0.4rem;
}
.threshold-bar .fill {
position: absolute; left: 0; top: 0; bottom: 0;
background: var(--accent); transition: width 0.35s ease, background 0.35s ease;
}
.threshold-bar .fill.arb { background: var(--pos); }
.threshold-bar .fill.near { background: var(--warn); }
.threshold-bar .marker-dollar {
position: absolute; top: -3px; bottom: -3px; width: 2px;
background: var(--text); left: 50%;
}
.threshold-bar-labels {
display: flex; justify-content: space-between; font-size: 0.72rem;
color: var(--text-3); position: relative;
}
.threshold-bar-labels .center {
position: absolute; left: 50%; transform: translateX(-50%); font-weight: 600; color: var(--text-2);
}
/* The outcomes list */
.section-head { margin: 0 0 0.8rem; }
.section-head h3 { font-size: 1rem; font-weight: 600; margin: 0 0 0.25rem; color: var(--text); }
.section-head p { color: var(--text-3); font-size: 0.86rem; margin: 0; max-width: 640px; }
.outcome-list { display: flex; flex-direction: column; gap: 0.45rem; }
.outcome-row {
display: grid; grid-template-columns: 1fr auto; gap: 0.75rem;
align-items: center; padding: 0.8rem 1rem;
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
}
.outcome-row.highlighted { background: var(--pos-soft); border-color: rgba(22,163,74,0.35); }
.outcome-row.empty, .outcome-row.skeleton { opacity: 0.7; color: var(--text-3); }
.outcome-row.skeleton { justify-content: center; text-align: center; }
.outcome-row .left { min-width: 0; }
.outcome-row .outcome-name {
font-size: 0.94rem; font-weight: 500; color: var(--text);
margin-bottom: 0.3rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.outcome-row .prob-bar {
height: 5px; background: var(--surface-2); border-radius: 999px; overflow: hidden; width: 100%;
}
.outcome-row .prob-bar .prob-fill {
height: 100%; background: var(--accent); transition: width 0.35s ease;
}
.outcome-row .right { display: flex; flex-direction: column; align-items: flex-end; min-width: 120px; }
.outcome-row .pct {
font-family: var(--mono); font-size: 1.2rem; font-weight: 700; color: var(--text);
letter-spacing: -0.01em;
}
.outcome-row .price-size { font-family: var(--mono); font-size: 0.76rem; color: var(--text-3); margin-top: 2px; }
/* FAQ */
.faq {
background: var(--surface); border-top: 1px solid var(--border);
padding: 3rem 1.5rem; margin-top: 1.5rem;
}
.faq-inner { max-width: 780px; margin: 0 auto; }
.faq-inner h2 { font-size: 1.3rem; font-weight: 700; margin: 0 0 1.2rem; letter-spacing: -0.01em; }
.faq-item {
background: var(--bg); border: 1px solid var(--border);
border-radius: var(--radius); padding: 0.25rem 1.1rem;
margin-bottom: 0.6rem; transition: border-color 0.12s ease;
}
.faq-item[open] { border-color: var(--accent); background: var(--surface); }
.faq-item summary {
cursor: pointer; padding: 0.85rem 0; font-weight: 600; font-size: 0.98rem;
list-style: none; position: relative; padding-left: 1.6rem;
color: var(--text);
}
.faq-item summary::-webkit-details-marker { display: none; }
.faq-item summary::before {
content: "+"; position: absolute; left: 0; top: 50%; transform: translateY(-50%);
font-size: 1.3rem; color: var(--text-3); font-weight: 400; width: 1.2rem; text-align: center;
}
.faq-item[open] summary::before { content: ""; color: var(--accent); }
.faq-item p {
color: var(--text-2); font-size: 0.93rem; line-height: 1.6;
margin: 0 0 0.85rem; padding-bottom: 0.2rem;
}
.faq-item p:last-child { margin-bottom: 0.6rem; }
.faq-item p strong { color: var(--text); }
/* Live stats footer */
.live-stats {
background: var(--surface); border-top: 1px solid var(--border);
padding: 1.5rem;
}
.live-inner {
max-width: 1120px; margin: 0 auto;
display: grid; gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
}
.stat { text-align: center; }
.stat-num { font-family: var(--mono); font-size: 1.5rem; font-weight: 700; color: var(--text); }
.stat-lbl { font-size: 0.75rem; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.04em; margin-top: 0.15rem; }
.live-caption {
max-width: 600px; margin: 1rem auto 0; text-align: center;
font-size: 0.82rem; color: var(--text-3);
}
.site-footer {
text-align: center; padding: 1.5rem; font-size: 0.85rem;
color: var(--text-3); background: var(--bg);
}
.site-footer a { color: var(--text-2); }
/* Modal */
.modal { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; padding: 1rem; }
.modal[hidden] { display: none; }
.modal-backdrop { position: absolute; inset: 0; background: rgba(15, 23, 42, 0.45); backdrop-filter: blur(2px); cursor: pointer; }
.modal-card {
position: relative; background: var(--surface); border-radius: var(--radius-lg);
max-width: 540px; width: 100%; padding: 1.8rem 2rem 1.6rem;
box-shadow: 0 20px 50px rgba(15,23,42,0.25);
max-height: 80vh; overflow-y: auto;
}
.modal-card h3 { margin: 0 0 0.5rem; font-size: 1.2rem; }
.modal-card p { color: var(--text-2); font-size: 0.95rem; line-height: 1.6; }
.modal-x {
position: absolute; top: 0.8rem; right: 0.8rem;
background: transparent; border: 0; font-size: 1.6rem; color: var(--text-3);
cursor: pointer; line-height: 1; padding: 0.2rem 0.5rem; border-radius: 6px;
}
.modal-x:hover { background: var(--surface-2); color: var(--text); }
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+277
View File
@@ -0,0 +1,277 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Live Polymarket Arbitrage Scanner</title>
<meta name="description" content="Can you make free money on Polymarket right now? This page watches Polymarket's live order book and shows you the answer in real time." />
<link rel="preconnect" href="https://rsms.me/" />
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
<link rel="stylesheet" href="assets/style.css?v=6" />
</head>
<body>
<div class="demo-shell">
<nav class="demo-nav">
<div class="brand">
<span id="conn-dot" class="dot pend"></span>
<strong>Polymarket Arbitrage Scanner</strong>
<span id="conn-label" class="status-text">connecting…</span>
</div>
<div class="nav-links">
<a href="index.html">About this project</a>
<a href="https://polymarket.com" target="_blank" rel="noopener">What is Polymarket?</a>
<a href="https://github.com/matthewnyc2/arbitrage" target="_blank" rel="noopener">Source</a>
</div>
</nav>
<!-- Giant hero: one question, one live answer -->
<section class="hero-q">
<div class="hero-q-inner">
<div class="q-line">Can you make free money on Polymarket right now?</div>
<div class="a-line" id="a-line">
<span class="spinner"></span><span class="muted">checking live prices…</span>
</div>
<div class="a-sub" id="a-sub">
Scanning <span id="watch-count">6</span> live events from Polymarket for an arbitrage window.
</div>
</div>
</section>
<!-- The main workbench: one event at a time, clickable -->
<div class="workbench">
<aside class="event-column">
<div class="column-head">
<h3>Live events from Polymarket</h3>
<p>Click any event to see the math for it.</p>
</div>
<div id="event-list" class="event-cards">
<div class="event-card skeleton">loading…</div>
</div>
</aside>
<main class="main-column">
<section class="focused-event">
<div class="focused-head">
<div class="focused-title-row">
<h2 id="event-title"></h2>
<button class="why-btn" type="button" id="why-this-event">What is this event?</button>
</div>
<div class="subtitle" id="event-subtitle">Pick an event on the left to see the arbitrage math.</div>
</div>
<!-- The big interactive calculator block -->
<div class="calc-card">
<div class="calc-row calc-head">
<div class="calc-label">If you bought one share of every possible answer right now</div>
<span class="status fair" id="basket-status">waiting</span>
</div>
<div class="calc-grid">
<div class="calc-cell">
<div class="cell-label">You would pay</div>
<div class="cell-val" id="basket-cost"></div>
<div class="cell-sub" id="basket-cost-sub">for one full set</div>
</div>
<div class="calc-cell">
<div class="cell-label">You would get back</div>
<div class="cell-val is-fixed">$1.00</div>
<div class="cell-sub">guaranteed — exactly one answer wins</div>
</div>
<div class="calc-cell highlight">
<div class="cell-label">Your profit</div>
<div class="cell-val" id="basket-pnl"></div>
<div class="cell-sub" id="basket-pnl-sub">per one-set bet</div>
</div>
</div>
<div class="scale-row">
<span class="scale-lbl">What if you bought more?</span>
<div class="scale-buttons" id="scale-buttons">
<button type="button" data-qty="1" class="active">1 set</button>
<button type="button" data-qty="10">10 sets</button>
<button type="button" data-qty="100">100 sets</button>
<button type="button" data-qty="1000">1,000 sets</button>
</div>
</div>
<div class="scale-out" id="scale-out"></div>
<div class="threshold-bar">
<div class="fill" id="basket-fill" style="width: 0%;"></div>
<div class="marker-dollar" title="$1.00"></div>
</div>
<div class="threshold-bar-labels">
<span class="left">$0.80 — you'd pocket 20¢ per set</span>
<span class="center">$1.00 — break-even</span>
<span class="right">$1.20 — overpriced</span>
</div>
</div>
<div class="section-head">
<h3>The possible answers and what the market thinks of each</h3>
<p>
Each row is one possible answer. The percentage is the market's
current estimate of that answer being correct — because a $0.72
share pays $1 when it wins, it must be priced at roughly a 72%
probability.
</p>
</div>
<div class="outcome-list" id="outcome-list">
<div class="outcome-row skeleton">loading…</div>
</div>
</section>
</main>
</div>
<!-- FAQ - collapsible plain-English Q&A -->
<section class="faq">
<div class="faq-inner">
<h2>Questions you probably have</h2>
<details class="faq-item" open>
<summary>What is Polymarket?</summary>
<p>
Polymarket is an online betting site for real-world questions —
elections, sports, Fed meetings, crypto prices, award shows.
Each question has a list of possible answers, and each answer is
a share you can buy. When the real-world event resolves, shares
of the winning answer pay exactly <strong>$1</strong>, and every
other share pays <strong>$0</strong>. The price of a share is
basically the market's guess at how likely that answer is.
</p>
</details>
<details class="faq-item">
<summary>Why does "buying one share of every answer" always pay $1?</summary>
<p>
Because exactly one answer can win. If the event has four possible
outcomes (say: "25 bps up", "no change", "25 bps down", "50+ bps
down") and you own one share of each, then <em>whatever</em>
happens, you hold the winning share — and that share pays $1.
The other three pay $0. You paid for all four, but only the
winner matters.
</p>
</details>
<details class="faq-item">
<summary>So where's the free money?</summary>
<p>
If the <em>total</em> cost of buying one of every answer drops
below $1, you're guaranteed to make the difference. Example: if
the four answers cost 60¢, 30¢, 7¢, and 2¢ — total 99¢ — you
pay 99¢ now and receive exactly $1 later, no matter which one
wins. That's a risk-free 1¢ profit on every $1 you bet.
</p>
</details>
<details class="faq-item">
<summary>Why am I seeing "no free money" every time I check?</summary>
<p>
Because the math is public and professional trading bots watch it
too. The moment the total dips below $1, they instantly buy every
outcome and push the price back up. Arbitrage on big, popular
Polymarket events is usually closed within milliseconds. A browser
page running on your laptop is not going to beat them.
</p>
<p>
This demo exists to show <em>how</em> the math works and
<em>how often</em> the opportunities actually appear. Watching it
produce nothing for 30 minutes is more informative than a fake
demo that flashes profits every second.
</p>
</details>
<details class="faq-item">
<summary>Is this actually live?</summary>
<p>
Yes. Your browser opens a WebSocket to Polymarket's public CLOB
(the same one their website uses) and receives real price updates
every few milliseconds. The number of price updates received is
in the footer below — it ticks up in real time. The event list
is refreshed hourly by a GitHub Action so you're always looking
at currently-open markets.
</p>
</details>
<details class="faq-item">
<summary>Does this page actually trade?</summary>
<p>
<strong>No.</strong> This page is read-only. It does not hold money,
sign transactions, or submit orders. It's a live view of the math.
The full Python bot in the GitHub repo can submit real orders, but
only with explicit operator configuration and hard risk caps —
and even then it defaults to a dry-run mode that logs orders
instead of broadcasting them.
</p>
</details>
<details class="faq-item">
<summary>Why would I hire the person who built this?</summary>
<p>
Because the same skills — reading real-time market data, writing
low-latency async pipelines, building production-grade risk
systems, and shipping interactive demos that explain themselves —
solve a lot of other, more lucrative problems than retail
arbitrage. If you have a problem that looks like "connect to a
streaming API, do math on every event, take action under hard
constraints," that's this whole project in one sentence.
</p>
</details>
</div>
</section>
<!-- Footer stats: show this is truly live -->
<footer class="live-stats">
<div class="live-inner">
<div class="stat">
<div class="stat-num" id="stat-events"></div>
<div class="stat-lbl">events watched</div>
</div>
<div class="stat">
<div class="stat-num" id="stat-tokens"></div>
<div class="stat-lbl">answers tracked</div>
</div>
<div class="stat">
<div class="stat-num" id="stat-books">0</div>
<div class="stat-lbl">order books active</div>
</div>
<div class="stat">
<div class="stat-num" id="stat-msgs">0</div>
<div class="stat-lbl">price updates received</div>
</div>
<div class="stat">
<div class="stat-num" id="stat-uptime">00:00</div>
<div class="stat-lbl">running for</div>
</div>
</div>
<p class="live-caption">
Every number above comes from Polymarket's servers. Nothing is mocked.
Close this tab and it all stops.
</p>
</footer>
<footer class="site-footer">
<p>
<a href="index.html">About this project</a> ·
<a href="https://github.com/matthewnyc2/arbitrage" target="_blank" rel="noopener">Source code</a>
</p>
</footer>
</div>
<!-- Modal for "what is this event" -->
<div class="modal" id="modal" hidden>
<div class="modal-backdrop" data-close></div>
<div class="modal-card">
<button type="button" class="modal-x" data-close aria-label="close">×</button>
<div id="modal-content"></div>
</div>
</div>
<script src="assets/demo.js?v=7"></script>
</body>
</html>
+227
View File
@@ -0,0 +1,227 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Polymarket Arbitrage Scanner — portfolio</title>
<meta name="description" content="Production-grade Python asyncio scanner for NegRisk multi-outcome arbitrage on Polymarket. Live book state, depth-clipped edge math, paper-trading mode." />
<link rel="stylesheet" href="assets/style.css" />
</head>
<body>
<header class="hero">
<div class="hero-inner">
<h1>Polymarket Arbitrage Scanner</h1>
<p class="tagline">
Production-grade Python asyncio system that detects NegRisk multi-outcome
arbitrage on Polymarket in real time, simulates fills against the live
order book with a realistic latency penalty, and can sign and submit live
orders behind hard risk caps.
</p>
<div class="badges">
<span class="badge pos">60 tests passing</span>
<span class="badge pos">94% core-math coverage</span>
<span class="badge">python 3.12</span>
<span class="badge">asyncio</span>
<span class="badge">fastapi</span>
<span class="badge">websockets</span>
<span class="badge">web3.py</span>
<span class="badge">sqlite (wal)</span>
<span class="badge">docker</span>
<span class="badge">MIT</span>
</div>
<div class="btn-row">
<a class="btn primary" href="demo.html">Live demo →</a>
<a class="btn" href="https://github.com/matthewnyc2/arbitrage" target="_blank" rel="noopener">GitHub</a>
<a class="btn" href="https://github.com/matthewnyc2/arbitrage#quickstart" target="_blank" rel="noopener">Quickstart</a>
</div>
</div>
</header>
<section>
<div class="container">
<h2><span class="rule"></span>The arbitrage, in one paragraph</h2>
<p>
Polymarket hosts <em>categorical</em> events — "Who wins the 2028 US Election?" —
where every outcome trades as its own YES token on a public CLOB. Because
exactly one outcome must win, the fair prices across all outcomes are bound
to sum to exactly <strong>$1</strong>. When the sum of best-asks drops below
$1, buying one share of every outcome locks in a guaranteed $1 payout on
resolution. The challenge isn't detecting the mispricing — it's racing
faster bots, walking order-book depth honestly so edge isn't a fantasy at
top-of-book, paying realistic Polygon gas, and surviving UMA resolution
disputes that can void a "risk-free" basket. This project builds the full
pipeline for that strategy, end to end, with paper mode as the primary
validation tool.
</p>
<div class="metrics-grid" style="margin-top: 2rem;">
<div class="metric"><span class="val">~2k</span><span class="label">lines of python</span></div>
<div class="metric"><span class="val">60</span><span class="label">unit tests</span></div>
<div class="metric"><span class="val">94%</span><span class="label">coverage (core)</span></div>
<div class="metric"><span class="val">~5s</span><span class="label">test suite time</span></div>
<div class="metric"><span class="val">2</span><span class="label">modes (paper / live)</span></div>
<div class="metric"><span class="val">7</span><span class="label">risk caps enforced</span></div>
</div>
</div>
</section>
<section>
<div class="container">
<h2><span class="rule"></span>What's in the box</h2>
<div class="grid-2">
<div class="card">
<h3>Gamma REST discovery</h3>
<p>Paginates Polymarket's <code>/events</code> endpoint, filters to active
NegRisk categoricals, normalizes into pydantic models, upserts idempotently
into SQLite, and marks dropped events inactive.</p>
</div>
<div class="card">
<h3>WebSocket L2 maintainer</h3>
<p>Subscribes to the CLOB <code>market</code> channel. Parses <code>book</code>
snapshots and <code>price_change</code> deltas against a per-token sorted
price ladder. Shards across sockets, keeps connections alive with PING/PONG,
reconnects with exponential backoff.</p>
</div>
<div class="card">
<h3>Opportunity engine</h3>
<p>On every book tick, walks depth across all outcomes of the affected
event, computes the basket size that maximizes net expected profit after
fees and amortised gas, emits a typed <code>Opportunity</code> record
only when the edge clears a configured threshold.</p>
</div>
<div class="card">
<h3>Paper executor</h3>
<p>Simulates IOC fills at <em>detection time + latency penalty</em> against
the live book, so levels that vanished during the simulated latency
window model "being beaten by a faster bot." Writes baskets + per-leg fills
to SQLite; closes out PnL when the underlying event resolves.</p>
</div>
<div class="card">
<h3>Live executor (gated)</h3>
<p>Signs EIP-712 orders through <code>py-clob-client</code>, submits FAK
across all legs in parallel, attempts to unwind any partial fill, calls
<code>NegRiskAdapter.redeemPositions</code> on the winning leg. Default
<code>dry_run=True</code>; real broadcast requires an explicit operator flip.</p>
</div>
<div class="card">
<h3>Risk gate</h3>
<p>Hard caps before any order touches the network: max basket USD, max
open baskets (global and per-event), daily loss stop, kill-switch file,
proximity-to-resolution skip. Every deny is logged with its reason.</p>
</div>
<div class="card">
<h3>FastAPI + HTMX dashboard</h3>
<p>Single-page viewer that polls SQLite every few seconds for live
opportunities, open and historical baskets, realized paper PnL, and mode
indicator. One-click kill-switch toggle. No SPA build step.</p>
</div>
<div class="card">
<h3>Docker + CLI</h3>
<p>Single-binary CLI (<code>arb init | discover | scan | web | resolve</code>)
plus Dockerfile + compose file for a one-command local run. Runs on a
$5/month GCP VM or on a laptop.</p>
</div>
</div>
</div>
</section>
<section>
<div class="container">
<h2><span class="rule"></span>Architecture</h2>
<pre class="arch-diagram">
Gamma REST CLOB WebSocket
│ │
▼ ▼
Event discovery L2 Book Maintainer
(active negRisk) (per token, in-memory)
│ │
└────────────┬───────────────┘
Opportunity Engine
(depth-walk, fee-net, gas-amortized threshold)
Risk Gate
(basket caps, daily loss, kill switch)
┌────────────┴───────────────┐
▼ ▼
Paper Executor Live Executor
(latency-penalized (sign + FAK + redeem)
sim fills + PnL) │
│ │
└────────────┬───────────────┘
SQLite (WAL)
FastAPI + HTMX dashboard</pre>
</div>
</section>
<section>
<div class="container">
<h2><span class="rule"></span>Run it yourself</h2>
<div class="grid-2">
<div class="card">
<h3>Local (Python 3.12+)</h3>
<pre><code>git clone https://github.com/matthewnyc2/arbitrage
cd arbitrage
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
cp .env.example .env
arb init
arb discover
arb scan &amp;
arb web
# http://127.0.0.1:8000</code></pre>
</div>
<div class="card">
<h3>Docker</h3>
<pre><code>docker compose up
# http://127.0.0.1:8000</code></pre>
<p style="margin-top: 1rem;">Stays in paper mode — no keys, no capital at
risk. Kill with <span class="kbd">Ctrl+C</span> or by touching
<code>./KILL</code>.</p>
</div>
</div>
</div>
</section>
<section>
<div class="container">
<h2><span class="rule"></span>What this is (and isn't)</h2>
<p>
This is a working engineering project — a reference for how to structure a
latency-sensitive async trading bot with disciplined risk gates, honest
simulated fills, and a testable core. It is <strong>not</strong> a
get-rich tool. The retail edge in public prediction-market arbitrage has
mostly been competed away by professional market makers with colocation,
custom hardware, and seven-figure working capital. Paper mode here exists
specifically to answer the question <em>is there any edge left for a solo
Python bot</em>, before a single dollar is risked.
</p>
<p>
The architecture transfers cleanly to any order-book venue (Kalshi,
Manifold, CEX spot markets) — swap the REST + WS adapters and the
engine keeps working.
</p>
</div>
</section>
<footer>
<p>
<a href="https://github.com/matthewnyc2/arbitrage" target="_blank" rel="noopener">GitHub</a> ·
<a href="demo.html">Live demo</a> ·
MIT licensed
</p>
<p class="small">Built with Python, asyncio, FastAPI, HTMX, web3.py, and a lot of Polymarket docs reading.</p>
</footer>
</body>
</html>
+211
View File
@@ -0,0 +1,211 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Polymarket Strategy Lab — honest backtests</title>
<meta name="description" content="See how five different arbitrage and betting strategies actually performed on 135 real resolved Polymarket events. No cherry-picked examples. No lies." />
<link rel="preconnect" href="https://rsms.me/" />
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
<link rel="stylesheet" href="assets/style.css?v=7" />
<link rel="stylesheet" href="assets/lab.css?v=3" />
</head>
<body>
<div class="lab-shell">
<nav class="demo-nav">
<div class="brand">
<span class="dot on"></span>
<strong>Polymarket Strategy Lab</strong>
<span class="status-text"><span id="event-count-inline"></span> real resolved events · backtested in your browser</span>
</div>
<div class="nav-links">
<a href="index.html">About the project</a>
<a href="demo.html">Live scanner</a>
<a href="https://github.com/matthewnyc2/arbitrage" target="_blank" rel="noopener">Source</a>
</div>
</nav>
<div class="tabs">
<div class="tabs-inner">
<button type="button" class="tab active" data-tab="results" id="tab-results">
Results
</button>
<button type="button" class="tab" data-tab="strategies" id="tab-strategies">
Strategies <span class="tab-count">5</span>
</button>
</div>
</div>
<!-- =================== RESULTS TAB =================== -->
<main class="panel active" id="panel-results">
<section class="lab-hero">
<div class="lab-hero-inner">
<div class="hero-strategy-row">
<div class="strategy-badge" id="active-strategy-label">Strategy</div>
<button type="button" class="switch-btn" id="switch-btn">
Change strategy
<span class="arrow"></span>
</button>
</div>
<h1 id="active-strategy-name"></h1>
<p id="active-strategy-desc" class="lead"></p>
<div class="bankroll-row">
<span class="bankroll-lbl">Bankroll per opportunity</span>
<div class="bankroll-choices" id="bankroll-choices">
<button type="button" data-bankroll="100">$100</button>
<button type="button" data-bankroll="1000" class="active">$1,000</button>
<button type="button" data-bankroll="10000">$10,000</button>
<button type="button" data-bankroll="100000">$100,000</button>
</div>
</div>
<div class="bankroll-row">
<span class="bankroll-lbl">Trade at prices from</span>
<div class="bankroll-choices" id="window-choices">
<button type="button" data-window="close">at close</button>
<button type="button" data-window="1h">1h before</button>
<button type="button" data-window="6h">6h before</button>
<button type="button" data-window="24h" class="active">24h before</button>
<button type="button" data-window="3d">3 days before</button>
<button type="button" data-window="7d">7 days before</button>
</div>
<span class="bankroll-note" id="window-note">
These are <strong>real historical Polymarket prices</strong>, pulled from
their public CLOB price-history endpoint. For each event, we look up the
actual price of every outcome at the chosen moment before the market closed.
</span>
</div>
</div>
</section>
<section class="verdict-section">
<div class="verdict-inner">
<div class="verdict-card" id="verdict-card">
<div class="verdict-icon" id="verdict-icon"></div>
<div class="verdict-body">
<div class="verdict-label" id="verdict-label">calculating…</div>
<div class="verdict-detail" id="verdict-detail">Running the strategy against every resolved event</div>
</div>
</div>
<div class="verdict-stats">
<div class="vstat">
<div class="vstat-val" id="vstat-pnl"></div>
<div class="vstat-lbl" id="vstat-pnl-lbl">Total profit across every trade</div>
</div>
<div class="vstat">
<div class="vstat-val" id="vstat-roi"></div>
<div class="vstat-lbl">Return on bankroll per trade</div>
</div>
<div class="vstat">
<div class="vstat-val" id="vstat-trades"></div>
<div class="vstat-lbl">Trades taken (out of 93 events)</div>
</div>
<div class="vstat">
<div class="vstat-val" id="vstat-winrate"></div>
<div class="vstat-lbl">Fraction of trades that won</div>
</div>
<div class="vstat">
<div class="vstat-val" id="vstat-annual"></div>
<div class="vstat-lbl" id="vstat-annual-lbl">Projected annual profit</div>
</div>
</div>
<p class="verdict-explainer" id="verdict-explainer"></p>
</div>
</section>
<section class="trades-section">
<div class="trades-inner">
<div class="section-header">
<h2>Every trade, one row each</h2>
<p>
Each row below is one resolved Polymarket event. The strategy either
placed a trade or skipped it. When it traded, you can see exactly
what it paid, what it got back, and whether it won money.
</p>
<div class="trade-filter">
<button type="button" class="filter-btn active" data-filter="all">All <span id="cnt-all">0</span></button>
<button type="button" class="filter-btn" data-filter="trades">Took trade <span id="cnt-trades">0</span></button>
<button type="button" class="filter-btn" data-filter="wins">Wins <span id="cnt-wins">0</span></button>
<button type="button" class="filter-btn" data-filter="losses">Losses <span id="cnt-losses">0</span></button>
<button type="button" class="filter-btn" data-filter="skipped">Skipped <span id="cnt-skipped">0</span></button>
</div>
</div>
<div id="trade-list" class="trade-list"></div>
</div>
</section>
<section class="data-section">
<div class="data-inner">
<h2>What you're looking at, in plain English</h2>
<p>
These events are real Polymarket markets that have already ended
in the last few weeks. For each one, we know who won and we have
the actual price of every outcome at every moment before the
market closed. The page runs a trading strategy on every event
and adds up how much money you would have made or lost.
</p>
<p>
The <strong>"trade at prices from"</strong> selector above is the
important knob. Prices right before a market closes tend to be
correct (because everyone already knows the answer). Prices a
day or more earlier are often noticeably off — and that's where
real arbitrage lives. Try clicking the different time windows
and watch the numbers change.
</p>
<p>
<strong>Every price on this page is a real price Polymarket
recorded.</strong> Pulled from their public CLOB price-history
API. No estimates, no math tricks. You can verify every trade
by looking up the event on polymarket.com.
</p>
</div>
</section>
</main>
<!-- =================== STRATEGIES TAB =================== -->
<main class="panel" id="panel-strategies">
<section class="strategies-hero">
<div class="strategies-hero-inner">
<h1>Pick a strategy to see how it actually performed</h1>
<p>
Each of these strategies has a clear rule. Each was run against the
same <span id="event-count-strat"></span> real resolved Polymarket
events. Results are plain: did it make money or lose money, and by
how much.
</p>
</div>
</section>
<section class="strategy-grid-section">
<div class="strategy-grid" id="strategy-grid"></div>
</section>
</main>
</div>
<!-- strategy detail modal -->
<div class="modal" id="strategy-modal" hidden>
<div class="modal-backdrop" data-close></div>
<div class="modal-card modal-wide">
<button type="button" class="modal-x" data-close aria-label="close">×</button>
<div id="strategy-modal-content"></div>
</div>
</div>
<footer class="site-footer">
<p>
Backtested on real, resolved Polymarket events —
<a href="https://github.com/matthewnyc2/arbitrage" target="_blank" rel="noopener">source on GitHub</a> ·
<a href="index.html">About this portfolio</a>
</p>
</footer>
<script src="assets/lab.js?v=8"></script>
</body>
</html>
+562
View File
@@ -0,0 +1,562 @@
# Polymarket 套利机器人 — 中文使用手册
> 适用版本:v0.1.0 · Python 3.12+ · Windows / Linux / macOS
>
> 本文从环境准备到日常运维,按真实使用顺序逐步讲解。
---
## 目录
1. [项目简介](#1-项目简介)
2. [系统要求](#2-系统要求)
3. [快速安装](#3-快速安装)
4. [首次配置](#4-首次配置)
5. [首次运行](#5-首次运行)
6. [日常使用(Paper 模式)](#6-日常使用paper-模式)
7. [Web 仪表板](#7-web-仪表板)
8. [切换到 Live 模式](#8-切换到-live-模式)
9. [监控与运维](#9-监控与运维)
10. [故障排查](#10-故障排查)
11. [安全建议](#11-安全建议)
12. [附录:环境变量参考](#附录环境变量参考)
---
## 1. 项目简介
**Polymarket NegRisk 多结果套利机器人**。Polymarket 的某些分类事件(例如"2028 大选谁赢")每个结果独立挂牌交易。当**所有结果的卖一价加总 < $1** 时,买入一整套结果,结算时一定收回 $1 → **无风险套利**
本机器人扫描所有活跃 negRisk 事件,实时计算套利空间,提供两种模式:
| 模式 | 资金风险 | 用途 |
|------|---------|------|
| **Paper** | $0 | 模拟成交,验证策略,观察 PnL |
| **Live** | 实盘资金 | 真实下单,需提供钱包私钥 |
---
## 2. 系统要求
| 项目 | 要求 |
|------|------|
| **Python** | 3.12 或更高 |
| **操作系统** | Windows 10+ / Linux / macOS |
| **内存** | ≥ 512 MB |
| **磁盘** | ≥ 1 GB(含 SQLite 与日志) |
| **网络** | **必须**能访问 `polymarket.com`(国内需配置代理) |
| **钱包**(仅 Live | Polygon 上的专用 EOA,持有 USDC.e |
**国内用户注意**:Polymarket 服务器在国内无法直连,必须配置 HTTP 代理(详见 [§4.2 网络代理](#42-网络代理))。
---
## 3. 快速安装
### 3.1 克隆项目
```bash
git clone https://github.com/matthewnyc2/arbitrage
cd arbitrage
```
### 3.2 创建虚拟环境
**Windows (PowerShell)**
```powershell
python -m venv .venv
.\.venv\Scripts\Activate.ps1
```
**Linux / macOS**
```bash
python3 -m venv .venv
source .venv/bin/activate
```
### 3.3 安装依赖
```bash
pip install -e ".[dev]"
```
> **⚠️ 重要**:默认会装 `websockets>=13.0`pip 可能会拉到 **15.x** 版本,但该版本与项目不兼容(参见 [§10.3 websockets 版本问题](#103-websockets-版本问题))。**显式锁版本**
> ```bash
> pip install "websockets==14.2"
> ```
### 3.4 验证安装
```bash
arb --help
```
应显示:`init / discover / scan / web / resolve` 五个子命令。
---
## 4. 首次配置
### 4.1 复制环境变量模板
```bash
cp .env.example .env
```
`.env` 关键字段(Paper 模式默认值即可运行):
```ini
ARB_MODE=paper # paper = 模拟盘;live = 实盘
ARB_CLOB_HOST=https://clob.polymarket.com
ARB_GAMMA_HOST=https://gamma-api.polymarket.com
ARB_PROXY= # 国内用户填代理,见下文
```
### 4.2 网络代理
**国内 / 防火墙环境下必须配置**,否则 `arb discover` 会报 `httpx.ConnectTimeout`
**PowerShell 临时设置**(仅当前会话):
```powershell
$env:HTTPS_PROXY="http://127.0.0.1:7890" # 改成你的代理地址
$env:HTTP_PROXY="http://127.0.0.1:7890"
```
**永久设置**(写入 `.env`):
```ini
ARB_PROXY=http://127.0.0.1:7890
```
> **常见代理端口**Clash 默认 7890V2Ray 默认 10809SSR 默认 1080。
>
> 配置完成后 httpxREST)和 websocketsWS)都会通过代理连接。
### 4.3 验证代理可用
```bash
arb discover
```
预期输出:
```
seen=2000 negRisk=928 upserted=894 malformed=34
```
如果仍超时,回到 [§10.1 网络连通性](#101-网络连通性)。
---
## 5. 首次运行
### 5.1 初始化数据库
```bash
arb init
```
创建 SQLite 表结构(含 events / outcomes / opportunities / baskets / fills / live_orders / resolutions / denylist / daily_pnl)。
### 5.2 发现事件
```bash
arb discover
```
从 Polymarket Gamma API 拉取所有活跃 negRisk 事件。需要约 10–20 秒。
**持续发现**(生产部署推荐,每 180 秒刷新):
```bash
arb discover --loop --interval 180
```
### 5.3 启动扫描
```bash
arb scan
```
开始监听 CLOB WebSocket,实时检测套利机会,写入 paper baskets。
预期日志:
```
scan loop started (885 events hydrated)
ws subscribed to N tokens
```
### 5.4 启动 Web 仪表板(另一终端)
```bash
arb web
```
打开浏览器访问 <http://127.0.0.1:8000>。
---
## 6. 日常使用(Paper 模式)
### 6.1 标准三进程部署
| 终端 | 命令 | 作用 |
|------|------|------|
| 终端 1 | `arb discover --loop --interval 180` | 每 3 分钟刷新事件列表 |
| 终端 2 | `arb scan` | 实时扫描 + 模拟成交 |
| 终端 3 | `arb web` | Web 仪表板 |
**Linux/macOS 后台运行**
```bash
nohup arb discover --loop --interval 180 > logs/discover.log 2>&1 &
nohup arb scan > logs/scan.log 2>&1 &
```
### 6.2 CLI 命令速查
| 命令 | 功能 |
|------|------|
| `arb init` | 创建 SQLite schema |
| `arb discover` | 单次拉取事件 |
| `arb discover --loop` | 持续拉取 |
| `arb scan` | 启动扫描 |
| `arb web` | 启动仪表板(默认 127.0.0.1:8000 |
| `arb resolve <event_id> --winner <token_id>` | 手动标记事件结算结果 |
| `arb resolve <event_id>` | 标记为 invalid |
### 6.3 停止服务
**正常停止**:在运行终端按 `Ctrl+C`
**强制清理残留进程**(Windows):
```powershell
Get-Process python | Where-Object { $_.StartTime -gt (Get-Date).AddHours(-1) } | Stop-Process -Force
```
---
## 7. Web 仪表板
仪表板每 2–3 秒自动刷新(HTMX 轮询),无需手动操作。
### 7.1 主要面板
| 面板 | 显示内容 |
|------|---------|
| **Paper PnL** | 已实现盈亏、各状态组合数、Kill Switch 按钮 |
| **Baskets** | 最近 25 个组合:ID、事件、份数、成本、状态、PnL |
| **Recent Opportunities** | 最近 25 个检测到的机会:Σ asks、净边际、最大组合、预期利润 |
### 7.2 关键指标解读
**Σ asks < $1** → 存在套利空间。典型值:
- `0.98` → 净边际 ~2%(扣费前)
- `0.95` → 净边际 ~5%(扣费前)
- `< 0.90` → 非常罕见的深度套利
**net edge bps**:扣除手续费 + 分摊 gas 后的净边际。低于 50 bps 会被 `ARB_MIN_NET_EDGE_BPS` 过滤。
**status**
| 状态 | 含义 |
|------|------|
| `pending_resolution` | 等待事件结算 |
| `redeemed` | 已结算,PnL 入账 |
| `failed` | 部分腿未成交(paper 模拟时深度消失) |
| `invalid` | 事件被标记为无效 |
### 7.3 Kill Switch
仪表板右下角红色 **kill** 按钮,点击后立即创建 `./KILL` 文件,执行器会拒绝所有新单。再次点击 **unkill** 删除文件即可恢复。
也可在终端手动:
```bash
# Windows
New-Item -Path .\KILL -ItemType File
# Linux/macOS
touch ./KILL
```
---
## 8. 切换到 Live 模式
### 8.1 准备工作
1. **专用钱包**:在 Polygon 上创建一个新的 EOA,**不要复用个人钱包**
2. **充值**:向钱包转入 USDC.e(建议 ≥ $200,含 gas
3. **批准授权**:按 `docs/api/order-signing.md` §8 的脚本,对 CTF Exchange + NegRisk Exchange + NegRisk Adapter 三个合约授权
### 8.2 派生 L2 API 凭证
`docs/api/order-signing.md` §2 的 `bootstrap_clob_creds.py` 脚本执行一次,生成:
- `CLOB_API_KEY`
- `CLOB_SECRET`
- `CLOB_PASSPHRASE`
**这三个凭证无法恢复,丢失需重新派生。**
### 8.3 修改 `.env`
```ini
ARB_MODE=live
ARB_PRIVATE_KEY=0x... # 钱包私钥(0x 前缀)
ARB_FUNDER_ADDRESS=0x... # 资金地址(EOA 模式 = 私钥对应地址)
ARB_SIGNATURE_TYPE=0 # 0=EOA, 1=Polymarket proxy, 2=Gnosis Safe
ARB_API_KEY=...
ARB_API_SECRET=...
ARB_API_PASSPHRASE=...
# 风险上限(强烈建议保持默认值)
ARB_MAX_BASKET_USD=50
ARB_MAX_OPEN_BASKETS=3
ARB_DAILY_LOSS_STOP_USD=100
```
### 8.4 首次实盘(强烈建议 dry_run 验证)
**第 1 步**:先用 dry_run 验证签名链路:
```python
# 在 Python REPL 中手动测试
from arbitrage.engine.live_executor import LiveExecutor, RiskLimits
from arbitrage.book.l2 import BookRegistry
books = BookRegistry()
ex = LiveExecutor(books=books, dry_run=True) # 只签名不提交
```
**第 2 步**:把 `cli.py:87``dry_run=False` 保持不变(默认就是 False),但**先把 `ARB_MAX_BASKET_USD` 设为 `5`**
```ini
ARB_MAX_BASKET_USD=5
ARB_MAX_OPEN_BASKETS=1
```
**第 3 步**:观察 1–2 天无异常后,再逐步放大到默认值。
---
## 9. 监控与运维
### 9.1 日志
日志写在 `logs/arbitrage.jsonl`(结构化 JSON,每天 0 点轮转,保留 7 天)。
**实时查看**
```bash
# Linux/macOS
tail -f logs/arbitrage.jsonl | jq .
# Windows PowerShell
Get-Content logs\arbitrage.jsonl -Wait
```
**按事件过滤**
```bash
grep "new_market" logs/arbitrage.jsonl | tail -20
```
### 9.2 数据库
位置:`./arbitrage.db`SQLite + WAL 模式)
**直接查询**
```bash
sqlite3 arbitrage.db "SELECT status, COUNT(*) FROM baskets GROUP BY status;"
sqlite3 arbitrage.db "SELECT * FROM opportunities ORDER BY detected_at DESC LIMIT 10;"
```
**备份**
```bash
cp arbitrage.db arbitrage.db.bak-$(date +%Y%m%d)
```
**重置**(清空所有 paper 数据):
```bash
rm arbitrage.db
arb init
arb discover
```
### 9.3 进程监控
**检查运行中的 arb 进程**
```powershell
# Windows
Get-Process | Where-Object { $_.Name -eq "python" -and $_.Path -like "*Python312*" }
# Linux
ps aux | grep arb
```
**查看资源占用**
```bash
# 内存占用(正常 100-300 MB
Get-Process python | Select-Object Id, @{n='Mem(MB)';e={[int]$_.WorkingSet64/1MB}}
```
### 9.4 升级
```bash
cd arbitrage
git pull
pip install -e ".[dev]"
# 重启 arb scan / arb web
```
---
## 10. 故障排查
### 10.1 网络连通性
**症状**`httpx.ConnectTimeout``arb discover` 卡住。
**排查**
```powershell
Test-NetConnection -ComputerName gamma-api.polymarket.com -Port 443
```
如果失败,确认 `ARB_PROXY` 设置正确,或切换代理节点。
### 10.2 WebSocket 反复重连
**症状**`ws disconnect (TimeoutError); reconnecting in 1.0s` 大量重复。
**根因**WebSocket 没有走代理。
**修复**:确认 `.env` 中设置了 `ARB_PROXY=http://...`,代码会自动通过 HTTP CONNECT 隧道建立 WS 连接。
### 10.3 websockets 版本问题
**症状**`AttributeError: 'ClientConnection' object has no attribute 'recv_messages'`,或 `got an unexpected keyword argument 'proxy'`
**根因**`websockets 15.0` 与项目不兼容。
**修复**
```bash
pip install "websockets==14.2"
```
### 10.4 日志 PermissionError
**症状**`PermissionError: [WinError 32] 另一个程序正在使用此文件`
**根因**loguru 在 Windows 上的 50MB 轮转触发 `close() → os.rename()` 竞态。
**修复**(已默认配置):`rotation="1 day"` + `retention="7 days"`,避免高并发期轮转。
如果仍出现:
```powershell
# 清理残留进程
Get-Process python | Where-Object { $_.Path -like "*Python312*" } | Stop-Process -Force
```
### 10.5 残留进程占用文件
**症状**:杀掉 `arb scan` 后,新进程仍报 PermissionError。
**排查**
```powershell
Get-Process | Where-Object { $_.Name -eq "python" }
```
**清理**
```powershell
Get-Process python -ErrorAction SilentlyContinue | Stop-Process -Force
```
### 10.6 仪表板 404 / 端口冲突
**症状**:浏览器访问 `http://127.0.0.1:8000` 返回 404 或连接拒绝。
**排查**
```powershell
Test-NetConnection -ComputerName 127.0.0.1 -Port 8000
Get-NetTCPConnection -LocalPort 8000 -State Listen
```
**修复**:更换端口启动:
```bash
arb web --port 8888
```
### 10.7 没有任何套利机会
**症状**:仪表板显示 `recent opportunities` 为空或 net edge bps 全部 < 50。
**原因**
1. 当前 Polymarket 没有负空间(正常 — 套利机会稀少)
2. WS 数据未刷新(检查 `ws subscribed` 日志)
3. `ARB_MIN_NET_EDGE_BPS` 设得太高(默认值 50 合理)
---
## 11. 安全建议
### 11.1 私钥保护
- **永远不要把 `.env` 提交到 Git**(已在 `.gitignore` 中)
- 实盘私钥使用**专用钱包**,与其他资产隔离
- 服务器上 `.env` 文件权限设为 `chmod 600`Linux
- 考虑使用硬件钱包或多签
### 11.2 Web 仪表板
- 默认绑定 `127.0.0.1`**不要**改为 `0.0.0.0` 后暴露到公网
- `kill` / `unkill` 端点**无身份认证**,暴露即等于把钱包控制权交给攻击者
- 如果需要远程访问,使用 SSH 隧道:
```bash
ssh -L 8000:127.0.0.1:8000 user@server
```
### 11.3 依赖管理
- 锁定关键依赖版本,避免供应链风险:
```bash
pip install "websockets==14.2" "py-clob-client==0.34.6"
```
- 定期升级时检查 changelog
### 11.4 Live 模式额外建议
- 先用 ≥ $10 的小资金跑 1 周
- 每日检查 realized PnL,确认与 paper 结果趋势一致
- 设置价格告警(Discord webhook / Telegram bot 等)
- 监控 `daily_pnl` 表,日亏损接近 `ARB_DAILY_LOSS_STOP_USD` 时手动 kill
---
## 附录:环境变量参考
完整 `.env` 配置项:
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `ARB_MODE` | `paper` | `paper` / `live` |
| `ARB_CLOB_HOST` | `https://clob.polymarket.com` | CLOB API |
| `ARB_GAMMA_HOST` | `https://gamma-api.polymarket.com` | Gamma REST |
| `ARB_DATA_HOST` | `https://data-api.polymarket.com` | Data API |
| `ARB_WS_URL` | `wss://ws-subscriptions-clob.polymarket.com/ws/market` | WS 端点 |
| `ARB_POLYGON_RPC` | `https://polygon-rpc.com` | Polygon RPC |
| `ARB_PROXY` | __ | HTTP 代理(REST + WS 共享) |
| `ARB_PRIVATE_KEY` | __ | 钱包私钥(Live 必填) |
| `ARB_FUNDER_ADDRESS` | __ | 资金地址(Live 必填) |
| `ARB_SIGNATURE_TYPE` | `0` | 0=EOA / 1=proxy / 2=Safe |
| `ARB_API_KEY` | __ | CLOB L2 API key |
| `ARB_API_SECRET` | __ | CLOB L2 API secret |
| `ARB_API_PASSPHRASE` | __ | CLOB L2 API passphrase |
| `ARB_MIN_NET_EDGE_BPS` | `50` | 最低净边际(基点) |
| `ARB_MAX_BASKET_USD` | `50` | 单笔组合上限 USD |
| `ARB_MAX_OPEN_BASKETS` | `3` | 全局未结算组合上限 |
| `ARB_DAILY_LOSS_STOP_USD` | `100` | 日亏损止损 USD |
| `ARB_KILL_SWITCH_FILE` | `./KILL` | Kill switch 文件路径 |
| `ARB_PAPER_LATENCY_MS` | `250` | Paper 模拟延迟(毫秒) |
| `ARB_DB_PATH` | `./arbitrage.db` | SQLite 路径 |
| `ARB_WEB_HOST` | `127.0.0.1` | Web 监听地址 |
| `ARB_WEB_PORT` | `8000` | Web 端口 |
| `ARB_LOG_LEVEL` | `INFO` | 控制台日志级别 |
---
## 联系与反馈
- 项目主页:<https://matthewnyc2.github.io/arbitrage/>
- GitHub Issues<https://github.com/matthewnyc2/arbitrage/issues>
- API 参考:`docs/api/order-signing.md``docs/api/negrisk.md`
- 设计文档:`DESIGN.md`