first commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# Prevent Jekyll from trying to process the site; we ship plain HTML/CSS/JS.
|
||||
theme: null
|
||||
plugins: []
|
||||
include:
|
||||
- assets
|
||||
@@ -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` ~ 200–250 k gas
|
||||
- `mergePositions` ~ 200–250 k gas
|
||||
- `redeemPositions(N=2)` ~ 150–220 k gas (single binary)
|
||||
- `convertPositions` ~ 250–400 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)
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,484 @@
|
||||
/*
|
||||
* Live Polymarket arbitrage scanner — portfolio demo.
|
||||
*
|
||||
* What this does, in plain English:
|
||||
* - Loads a list of 6–8 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 => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
}
|
||||
@@ -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); }
|
||||
@@ -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 “vig” — 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 => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 &
|
||||
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
@@ -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
@@ -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 默认 7890,V2Ray 默认 10809,SSR 默认 1080。
|
||||
>
|
||||
> 配置完成后 httpx(REST)和 websockets(WS)都会通过代理连接。
|
||||
|
||||
### 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`
|
||||
Reference in New Issue
Block a user