skills-ws 1.5.5 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "skills-ws",
3
- "version": "1.5.5",
4
- "description": "83 agent skills for AI coding assistants \u2014 marketing, growth, web3, dev, design & operations. Built for OpenClaw, Claude Code, Cursor, and Codex.",
3
+ "version": "1.6.0",
4
+ "description": "84 agent skills for AI coding assistants \u2014 marketing, growth, web3, dev, design & operations. Built for OpenClaw, Claude Code, Cursor, and Codex.",
5
5
  "scripts": {
6
6
  "test": "node test/cli.test.mjs"
7
7
  },
@@ -1,411 +1,244 @@
1
1
  ---
2
- name: polymarket-trading
3
- description: Polymarket prediction market trading — market analysis, edge calculation, bookmaker cross-referencing, order placement via CLOB API, position management, and redemption. Covers sports betting strategy, risk management, and the full Polymarket SDK workflow.
4
- version: 1.0.0
2
+ name: polymarket
3
+ description: >
4
+ Sports betting on Polymarket prediction markets. Scan bookmaker odds via The Odds API,
5
+ find high-conviction favorites (>70% implied probability), match to Polymarket markets,
6
+ and execute trades for better payouts than traditional bookmakers. Use when asked about
7
+ "polymarket", "sports betting", "place a bet", "scan for bets", "check my positions",
8
+ "redeem bets", "NBA odds", "football odds", "betting picks", or "what should I bet on".
5
9
  ---
6
10
 
7
- # Polymarket Trading
11
+ # Polymarket Sports Betting
8
12
 
9
- Complete framework for analyzing, trading, and managing positions on Polymarket — the world's largest prediction market.
13
+ ## ⚠️ CRITICAL RULES (Non-Negotiable)
10
14
 
11
- ## Table of Contents
15
+ 1. **SPORTS ONLY** — Never bet on crypto, politics, or geopolitics. Crypto markets are manipulated by insiders.
16
+ 2. **Favorites only** — Minimum **70% implied probability on bookmakers** (averaged across 20+ books via The Odds API).
17
+ 3. **No long shots** — Ever. Long shots = money pit.
18
+ 4. **The best trade is sometimes no trade** — If nothing qualifies, say so.
19
+ 5. **Every bet must be high conviction** — Target 70%+ win rate across all bets.
20
+ 6. **Verify Polymarket availability** — Many matches aren't listed on PM. Never recommend a bet without confirming the PM market exists and resolving the token ID.
21
+ 7. **Football 3-way markets are harder** — PM splits win/draw/lose probability differently than bookmakers. Flag this to the user.
12
22
 
13
- 1. [Market Analysis Framework](#market-analysis-framework)
14
- 2. [Edge Calculation](#edge-calculation)
15
- 3. [Bookmaker Cross-Referencing](#bookmaker-cross-referencing)
16
- 4. [Risk Management Rules](#risk-management-rules)
17
- 5. [APIs & Data Sources](#apis--data-sources)
18
- 6. [Trading via CLOB](#trading-via-clob)
19
- 7. [Position Management & Redemption](#position-management--redemption)
20
- 8. [Understanding Polymarket Mechanics](#understanding-polymarket-mechanics)
21
- 9. [Common Pitfalls](#common-pitfalls)
23
+ ## Wallet & Keys
22
24
 
23
- ---
24
-
25
- ## Market Analysis Framework
25
+ - **Address:** `0xCa5e2a326DE9544EAe2810E3f0E4e1d4Cef1847b`
26
+ - **Chain:** Polygon (USDC.e)
27
+ - **Positions API:** `https://data-api.polymarket.com/positions?user=0xCa5e2a326DE9544EAe2810E3f0E4e1d4Cef1847b`
28
+ - **Keys:** macOS Keychain, account `stuart`:
29
+ - `polymarket-wallet-key`, `polymarket-api-key`, `polymarket-api-secret`, `polymarket-api-passphrase`
30
+ - `odds-api-key` (The Odds API)
26
31
 
27
- ### The Scan Pipeline
32
+ ## Supported Sports
28
33
 
29
- For every potential bet, follow this pipeline in order:
30
-
31
- ```
32
- 1. Polymarket prices identify markets with volume > $10K
33
- 2. Filter bookmaker favorites > 65% implied probability
34
- 3. Injury/news check any material changes not priced in?
35
- 4. Form & H2H analysis → recent performance, matchup history
36
- 5. Cross-reference 3+ bookmaker sources → calculate true probability
37
- 6. Calculate edge only bet if edge > 10% vs Polymarket price
38
- 7. Size the bet → based on edge magnitude and bankroll
39
- ```
34
+ | The Odds API Key | Sport |
35
+ |---|---|
36
+ | `basketball_nba` | NBA |
37
+ | `soccer_epl` | English Premier League |
38
+ | `soccer_spain_la_liga` | La Liga |
39
+ | `soccer_italy_serie_a` | Serie A |
40
+ | `soccer_germany_bundesliga` | Bundesliga |
41
+ | `soccer_france_ligue_one` | Ligue 1 |
42
+ | `soccer_efl_champ` | EFL Championship |
40
43
 
41
- ### Market Selection Criteria
44
+ ## Scripts
42
45
 
43
- **Good markets:**
44
- - High volume (> $10K) — ensures liquidity for entry and exit
45
- - Near-term resolution (days, not months) — capital efficiency
46
- - Binary outcomes with clear resolution criteria
47
- - Markets where bookmaker odds exist for cross-referencing
46
+ All scripts are in the skill directory: `~/.agents/skills/polymarket/`
48
47
 
49
- **Bad markets:**
50
- - Low liquidity (< $5K volume) — wide spreads eat your edge
51
- - Subjective resolution criteria dispute risk
52
- - Markets with insider information advantage (crypto governance, company decisions)
53
- - Long-dated futures that tie up capital for months
48
+ | Script | Purpose | Auth Required |
49
+ |---|---|---|
50
+ | `scripts/scan.mjs` | Scan odds filter → match PM → present picks | No (read-only) |
51
+ | `polymarket.mjs` | Query PM markets (search, price, book, spread) | No |
52
+ | `trade.mjs` | Place buy/sell orders on PM | Yes |
53
+ | `redeem.mjs` | Redeem resolved winning positions | Yes |
54
54
 
55
55
  ---
56
56
 
57
- ## Edge Calculation
57
+ ## Workflow
58
58
 
59
- ### The Math
59
+ ### Step 1: SCAN — Fetch Bookmaker Odds
60
60
 
61
- ```
62
- Bookmaker Implied Probability = 1 / Decimal Odds
63
- Edge = (True Probability - Polymarket Price) / Polymarket Price × 100
61
+ Run the scan script to find qualifying bets:
64
62
 
65
- Example:
66
- Bookmaker odds: -225 (decimal 1.44) → Implied probability: 69.4%
67
- Polymarket price: $0.645 (64.5%)
68
- Edge = (69.4 - 64.5) / 64.5 × 100 = 7.6%
69
- ```
63
+ ```bash
64
+ # Scan all sports
65
+ node ~/.agents/skills/polymarket/scripts/scan.mjs --all-sports
70
66
 
71
- ### American Odds to Probability
67
+ # Single sport
68
+ node ~/.agents/skills/polymarket/scripts/scan.mjs --sport=basketball_nba
72
69
 
70
+ # Custom probability threshold
71
+ node ~/.agents/skills/polymarket/scripts/scan.mjs --all-sports --min-prob=0.75
73
72
  ```
74
- Negative odds (favorites): Probability = |odds| / (|odds| + 100)
75
- -225 → 225 / 325 = 69.2%
76
73
 
77
- Positive odds (underdogs): Probability = 100 / (odds + 100)
78
- +150 100 / 250 = 40.0%
79
- ```
74
+ The script:
75
+ 1. Fetches odds from The Odds API (h2h market, EU region, decimal format)
76
+ 2. Calculates implied probability per team (average of `1/decimal_odds` across all bookmakers)
77
+ 3. Filters to favorites above the threshold (default 70%)
78
+ 4. Searches Polymarket for matching moneyline markets
79
+ 5. Resolves the correct token ID for each favorite outcome
80
+ 6. Fetches current PM price
81
+ 7. Outputs a table with: Game, Favorite, Book Prob, PM Price, Edge, Token ID, Kickoff
80
82
 
81
- ### Removing the Vig
83
+ **Validation gate:** If the script returns no picks, stop. Tell the user "No qualifying bets today." Do not lower the threshold.
82
84
 
83
- Bookmaker odds include a margin (vig). To get true probabilities:
84
-
85
- ```
86
- 1. Convert both sides to implied probabilities
87
- 2. Sum them (will be > 100%, e.g., 105%)
88
- 3. Divide each by the sum to normalize to 100%
89
-
90
- Example:
91
- Team A: -200 → 66.7% Team B: +170 → 37.0%
92
- Sum: 103.7%
93
- True A: 66.7 / 103.7 = 64.3%
94
- True B: 37.0 / 103.7 = 35.7%
95
- ```
96
-
97
- ### Edge Thresholds
98
-
99
- | Edge | Action |
100
- |------|--------|
101
- | < 5% | Skip — too thin, transaction costs eat it |
102
- | 5-10% | Marginal — only if very high conviction + multiple sources agree |
103
- | **> 10%** | **Target zone — place the bet** |
104
- | > 20% | Strong edge — size up, but verify it's not a trap (news you missed?) |
105
-
106
- ---
85
+ ### Step 2: REVIEW Validate Picks
107
86
 
108
- ## Bookmaker Cross-Referencing
87
+ Before presenting to the user, validate each pick:
109
88
 
110
- ### Why Cross-Reference?
89
+ 1. **Book probability ≥ 70%** — Reconfirm the threshold is met
90
+ 2. **PM market exists** — Token ID was resolved (not `N/A`)
91
+ 3. **Edge is reasonable** — `Edge = Book Probability - PM Price`. Positive edge means PM is cheaper than books. Near-zero or negative edge is still fine (PM has better payouts than bookmakers regardless).
92
+ 4. **Game hasn't started** — Check kickoff time vs current time
93
+ 5. **Football 3-way caveat** — If it's a football match, note that draw probability affects the moneyline odds
111
94
 
112
- Polymarket prices are set by traders, not oddsmakers. They systematically:
113
- - **Overvalue favorites** by 3-7% in major sports markets
114
- - **Undervalue underdogs/draws** in 3-way football markets
115
- - **Lag behind** sharp bookmaker lines by hours
95
+ **Validation gate:** Remove any picks where token ID is `N/A` or the game has already started.
116
96
 
117
- ### Sources to Cross-Reference
97
+ ### Step 3: PRESENT — Show Picks to User
118
98
 
119
- | Source | Use | Notes |
120
- |--------|-----|-------|
121
- | **Pinnacle** | Sharpest lines globally | Gold standard, lowest vig |
122
- | **Bet365** | Popular, liquid markets | Good for mainstream sports |
123
- | **DraftKings/FanDuel** | US sports | NFL, NBA, MLB, NHL |
124
- | **Betfair Exchange** | True market prices | No vig, just commission |
125
- | **OddsPortal/OddsChecker** | Aggregators | Compare across 20+ books |
126
- | **Action Network** | Analysis + odds | Good injury/form context |
99
+ Present qualifying picks in a clean format:
127
100
 
128
- ### The 3-Source Rule
129
-
130
- Never bet based on a single bookmaker. Always confirm with **3+ independent sources**:
131
-
132
- ```
133
- ✅ Good: Pinnacle -220, Bet365 -225, DraftKings -215 → consensus ~69%
134
- Polymarket at 60¢ → 15% edge → BET
135
-
136
- ❌ Bad: Only one bookmaker has odds, others don't list the market
137
- → Information asymmetry, you might be wrong
138
101
  ```
102
+ 🏀 NBA Picks — March 8, 2026
139
103
 
140
- ---
141
-
142
- ## Risk Management Rules
143
-
144
- ### Bankroll Management
104
+ | Game | Pick | Book Prob | PM Price | Edge | Kickoff |
105
+ |------|------|-----------|----------|------|---------|
106
+ | BOS vs WAS | Boston Celtics | 88.2% | 0.87 | +1.2% | 19:00 ET |
107
+ | LAL vs DET | LA Lakers | 74.5% | 0.73 | +1.5% | 21:30 ET |
145
108
 
146
- ```
147
- Max single bet: 10% of bankroll
148
- Typical bet size: 2-5% of bankroll
149
- Max daily exposure: 25% of bankroll
109
+ Token IDs:
110
+ - Boston Celtics: 123456789...
111
+ - LA Lakers: 987654321...
150
112
  ```
151
113
 
152
- ### Hard Rules
114
+ Include:
115
+ - The sport and date
116
+ - Clear table of picks
117
+ - Token IDs for execution
118
+ - Any caveats (football 3-way, low liquidity)
119
+ - **Ask for explicit approval before executing any trade**
153
120
 
154
- 1. **Sport only**No crypto, politics, or geopolitics bets. Crypto markets are manipulated by insiders.
155
- 2. **Only heavy favorites** — Bookmaker implied probability > 65%
156
- 3. **Edge > 10%** — No exceptions for "gut feelings"
157
- 4. **3+ sources minimum** — Cross-reference before every bet
158
- 5. **No long shots** — Underdogs and parlays are money pits
159
- 6. **The best trade is sometimes no trade** — Don't force action
121
+ ### Step 4: EXECUTE Place Trades
160
122
 
161
- ### When NOT to Bet
123
+ After user approval, execute trades using `trade.mjs`:
162
124
 
163
- - Market is illiquid (< $5K volume, wide spreads)
164
- - News is breaking and odds haven't settled
165
- - You can't find 3 bookmakers listing the event
166
- - The edge comes from a single outlier source
167
- - You're chasing losses from a previous bet
168
- - Resolution criteria are ambiguous
125
+ ```bash
126
+ # Buy $50 on Boston Celtics at 0.87
127
+ node ~/.agents/skills/polymarket/trade.mjs buy <token_id> 0.87 50
128
+ ```
169
129
 
170
- ### Track Record Requirements
130
+ **Parameters:**
131
+ - `token_id` — From the scan output
132
+ - `price` — The PM price (or slightly above for guaranteed fill)
133
+ - `size` — Dollar amount to bet
171
134
 
172
- - Target **80%+ win rate** on individual bets
173
- - If below 60% over 10+ bets, stop and re-evaluate strategy
174
- - Log every bet: market, entry price, bookmaker consensus, edge, result
135
+ **Validation gate:** Always confirm with the user before executing. Show the exact command that will run.
175
136
 
176
- ---
137
+ **Post-execution:** Show the order response. If the order is rejected, explain why (insufficient balance, price moved, etc.).
177
138
 
178
- ## APIs & Data Sources
139
+ ### Step 5: TRACK — Monitor Positions
179
140
 
180
- ### Gamma API (Public, No Auth)
141
+ Check current positions:
181
142
 
182
- Market discovery and search. Base: `https://gamma-api.polymarket.com`
143
+ ```bash
144
+ # Via Data API
145
+ curl "https://data-api.polymarket.com/positions?user=0xCa5e2a326DE9544EAe2810E3f0E4e1d4Cef1847b"
183
146
 
184
- ```
185
- GET /public-search?q=<query> — Search markets/events
186
- GET /events?active=true&closed=false — List active events
187
- GET /events?tag_slug=<slug> — Events by category (sports, politics, crypto)
188
- GET /markets?slug=<slug> — Market details by slug
189
- GET /tags — All available categories
147
+ # Or via trade.mjs
148
+ node ~/.agents/skills/polymarket/trade.mjs balances
190
149
  ```
191
150
 
192
- **Key response fields:**
193
- - `outcomePrices` — Current Yes/No prices (JSON string, parse it)
194
- - `clobTokenIds` — Token IDs needed for CLOB trading (JSON string)
195
- - `volume` — Total dollar volume traded
196
- - `negRisk` — If true, uses negRisk contract (multi-outcome markets)
197
- - `groupItemTitle` — The outcome name in grouped markets
151
+ Check open orders:
198
152
 
199
- ### CLOB API (Public reads, Auth for trading)
200
-
201
- Order book and trading. Base: `https://clob.polymarket.com`
202
-
203
- ```
204
- # Public (no auth)
205
- GET /midpoint?token_id=<id> — Midpoint price
206
- GET /book?token_id=<id> — Full order book
207
- GET /spread?token_id=<id> — Bid-ask spread
208
- GET /price?token_id=<id>&side=buy|sell — Best available price
209
- GET /tick-size?token_id=<id> — Min price increment
210
-
211
- # Authenticated (requires L2 API key)
212
- POST /order — Place order
213
- DELETE /order/<id> — Cancel order
214
- GET /orders — Open orders
215
- GET /balances — CLOB balances
153
+ ```bash
154
+ node ~/.agents/skills/polymarket/trade.mjs orders
216
155
  ```
217
156
 
218
- ### Data API (Public, No Auth)
157
+ ### Step 6: REDEEM Collect Winnings
219
158
 
220
- Positions and history. Base: `https://data-api.polymarket.com`
159
+ After markets resolve, redeem winning positions:
221
160
 
161
+ ```bash
162
+ node ~/.agents/skills/polymarket/redeem.mjs
222
163
  ```
223
- GET /positions?user=<wallet_address> — All positions for a wallet
224
- GET /trades?user=<wallet_address> — Trade history
225
- ```
226
-
227
- ### Authentication Model
228
164
 
229
- Two-layer auth system:
230
- - **L1 (Wallet Signature)**: EIP-712 signature from your Polygon wallet — used to derive API credentials
231
- - **L2 (API Key)**: HMAC-SHA256 headers for all trading operations
232
-
233
- **Headers for authenticated requests:**
234
- ```
235
- POLY_ADDRESS — Wallet address
236
- POLY_SIGNATURE — HMAC signature of request
237
- POLY_TIMESTAMP — Unix timestamp
238
- POLY_NONCE — Request nonce
239
- POLY_API_KEY — Your API key
240
- POLY_PASSPHRASE — Your passphrase
241
- POLY_SECRET — Your API secret (used for HMAC)
242
- ```
165
+ The script automatically:
166
+ - Finds all redeemable positions
167
+ - Handles both standard and negativeRisk markets
168
+ - Redeems via CTF contract on Polygon
169
+ - Reports the new USDC.e balance
243
170
 
244
171
  ---
245
172
 
246
- ## Trading via CLOB
247
-
248
- ### Using the TypeScript SDK
173
+ ## Error Handling
249
174
 
250
- ```javascript
251
- const { ClobClient, Side } = require('@polymarket/clob-client');
252
- const { Wallet } = require('ethers');
175
+ ### Common Issues
253
176
 
254
- // Initialize client
255
- const wallet = new Wallet(PRIVATE_KEY);
256
- const client = new ClobClient(
257
- 'https://clob.polymarket.com',
258
- 137, // Polygon chainId
259
- wallet,
260
- creds // { apiKey, secret, passphrase }
261
- );
177
+ | Error | Cause | Fix |
178
+ |---|---|---|
179
+ | `security: SecItemCopyMatching` | Keychain access denied | Run in terminal with Keychain access, or unlock Keychain |
180
+ | `HTTP 401` from Odds API | Invalid/expired API key | Check `security find-generic-password -s odds-api-key -a stuart -w` |
181
+ | `HTTP 429` from Odds API | Rate limited (500 req/month free tier) | Wait, or check remaining quota in response headers |
182
+ | Token ID `N/A` | PM doesn't have this market | Skip this pick — common for smaller football matches |
183
+ | `No results` from PM search | Team name mismatch | Try alternate team names or search manually via `polymarket.mjs search` |
184
+ | Order rejected | Price moved or insufficient USDC.e | Check balance, adjust price, retry |
185
+ | `NONCE_TOO_LOW` | Transaction nonce conflict | Wait 30s, retry |
186
+ | Redeem fails | Gas price spike on Polygon | Retry with higher gas — script uses 500 gwei max fee |
262
187
 
263
- // Derive API credentials (first time)
264
- const creds = await client.createOrDeriveApiKey();
188
+ ### The Odds API Quota
265
189
 
266
- // Place a limit buy order
267
- const order = await client.createAndPostOrder({
268
- tokenID: '<token_id>', // From Gamma API clobTokenIds
269
- price: 0.65, // Max price willing to pay
270
- size: 10, // Dollar amount
271
- side: Side.BUY,
272
- }, { tickSize: '0.01' }); // Check tick-size endpoint first
190
+ The free tier has 500 requests/month. Each scan of one sport = 1 request. A full `--all-sports` scan = 7 requests. Be mindful:
191
+ - Don't scan repeatedly in the same hour
192
+ - Check `x-requests-remaining` header in responses
193
+ - If close to limit, scan only the sport the user asks about
273
194
 
274
- // Cancel an order
275
- await client.cancelOrder(orderId);
195
+ ---
276
196
 
277
- // Get open orders
278
- const orders = await client.getOpenOrders();
279
- ```
197
+ ## Examples
280
198
 
281
- ### Order Flow
199
+ ### "Scan for bets today"
282
200
 
283
201
  ```
284
- 1. Search market on Gamma → get slug
285
- 2. Get market details extract clobTokenIds and outcomePrices
286
- 3. Identify the outcome you want (Yes token ID vs No token ID)
287
- 4. Check tick-size for that token
288
- 5. Check current best price: GET /price?token_id=X&side=buy
289
- 6. Place limit order at your target price
290
- 7. Monitor: GET /orders to check if filled
202
+ 1. Run: node ~/.agents/skills/polymarket/scripts/scan.mjs --all-sports
203
+ 2. Review output for qualifying picks
204
+ 3. Present picks table to user
205
+ 4. Wait for approval before trading
291
206
  ```
292
207
 
293
- ### Token ID Selection
294
-
295
- Grouped markets (like "Who wins UFC 326?") have multiple outcomes. Each outcome has a Yes and No token:
208
+ ### "Bet $100 on the Celtics"
296
209
 
297
210
  ```
298
- Market: "UFC 326 Main Event"
299
- Outcome: "Max Holloway"
300
- Yes Token ID: 7068099725... (buy this if you think Holloway wins)
301
- No Token ID: 1293847561... (buy this if you think Holloway loses)
302
- Outcome: "Charles Oliveira"
303
- Yes Token ID: 8843920183...
304
- → No Token ID: 5567382910...
211
+ 1. Search PM for the Celtics game: node ~/.agents/skills/polymarket/polymarket.mjs search "Celtics"
212
+ 2. Find the moneyline market and resolve the token ID for the Celtics outcome
213
+ 3. Get current price: node ~/.agents/skills/polymarket/polymarket.mjs price <token_id>
214
+ 4. Cross-check bookmaker odds to confirm >70% implied probability
215
+ 5. Present the trade: "Buy $100 on Celtics at 0.XX (PM), books have them at YY%"
216
+ 6. After approval: node ~/.agents/skills/polymarket/trade.mjs buy <token_id> <price> 100
305
217
  ```
306
218
 
307
- The Gamma API returns `clobTokenIds` as a JSON string with `[NoTokenId, YesTokenId]` — **index 1 is Yes**.
308
-
309
- ---
310
-
311
- ## Position Management & Redemption
312
-
313
- ### Checking Positions
219
+ ### "Check my positions"
314
220
 
315
221
  ```
316
- GET https://data-api.polymarket.com/positions?user=<wallet_address>
222
+ 1. Fetch: curl "https://data-api.polymarket.com/positions?user=0xCa5e2a326DE9544EAe2810E3f0E4e1d4Cef1847b"
223
+ 2. Show open positions with current value
224
+ 3. If any are redeemable: node ~/.agents/skills/polymarket/redeem.mjs
317
225
  ```
318
226
 
319
- Returns all current positions with:
320
- - `asset` — Token ID
321
- - `size` — Number of shares
322
- - `avgPrice` — Average entry price
323
- - `currentPrice` — Current market price
324
- - `pnl` — Unrealized P&L
325
-
326
- ### Redeeming Resolved Positions
227
+ ### "What are the odds on Real Madrid?"
327
228
 
328
- When a market resolves, winning shares are worth $1.00. You need to call the contract to redeem:
329
-
330
- **Standard markets** (2-outcome, `negRisk: false`):
331
- ```javascript
332
- // Call ConditionalTokens contract: redeemPositions()
333
- const CTF_ADDRESS = '0x4D97DCd97eC945f40cF65F87097ACe5EA0476045';
334
229
  ```
335
-
336
- **NegRisk markets** (multi-outcome, `negRisk: true`):
337
- ```javascript
338
- // Call NegRiskAdapter: redeemPositions()
339
- const NEG_RISK_ADAPTER = '0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296';
340
- // Also call NegRiskCTFExchange for conversion
341
- const NEG_RISK_EXCHANGE = '0xC5d563A36AE78145C45a50134d48A1215220f80a';
230
+ 1. Scan La Liga: node ~/.agents/skills/polymarket/scripts/scan.mjs --sport=soccer_spain_la_liga
231
+ 2. Find Real Madrid in results
232
+ 3. If they qualify (>70%), present as a pick
233
+ 4. If they don't qualify, show the odds but warn it's below threshold
342
234
  ```
343
235
 
344
- ### Exit Strategies
345
-
346
- - **Winner**: Hold until resolution → redeem at $1.00
347
- - **Cut losses**: Sell on the CLOB if the market moves against you
348
- - **Take profit**: If price moved significantly in your favor before resolution, consider selling early to lock in gains and free capital
349
-
350
236
  ---
351
237
 
352
- ## Understanding Polymarket Mechanics
353
-
354
- ### How Prices Work
355
-
356
- - Prices = probabilities ($0.65 = market says 65% chance of Yes)
357
- - Markets resolve to $1.00 (correct outcome) or $0.00 (incorrect)
358
- - Your profit = $1.00 - entry price (per share, if you win)
359
- - Your loss = entry price (per share, if you lose)
360
-
361
- ### Where Polymarket Misprices
362
-
363
- | Pattern | Why | How to Exploit |
364
- |---------|-----|----------------|
365
- | Favorites overvalued by 3-7% | Retail bias toward "safe" bets | Compare vs sharp bookmaker lines |
366
- | Underdogs/draws undervalued | People avoid complexity | 3-way football markets (win/draw/lose) |
367
- | Slow to react to news | Traders aren't 24/7 | Fast reaction to injury reports, lineups |
368
- | Low-volume markets inefficient | Not enough informed traders | Small edges in niche markets |
369
-
370
- ### Polymarket vs Bookmakers
371
-
372
- | Feature | Polymarket | Traditional Bookmaker |
373
- |---------|-----------|----------------------|
374
- | Vig/margin | 0% (peer-to-peer) | 3-10% |
375
- | Liquidity | Variable | Guaranteed |
376
- | Resolution | Smart contract | Bookmaker decides |
377
- | Settlement | USDC on Polygon | Fiat |
378
- | Edge | Retail-driven inefficiencies | Sharp lines, hard to beat |
379
-
380
- ### Chain Details
381
-
382
- - **Chain**: Polygon (MATIC for gas, USDC.e for trading)
383
- - **USDC.e contract**: `0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174`
384
- - **Note**: Polymarket uses USDC.e (bridged), NOT native USDC
385
- - **Geo**: Restricted in some countries (US blocked, most of EU is fine)
386
-
387
- ---
238
+ ## Key Concepts
388
239
 
389
- ## Common Pitfalls
390
-
391
- ### Mistakes to Avoid
392
-
393
- 1. **Betting on crypto/politics markets** Insider manipulation is rampant (project teams, whale wallets, political operatives)
394
- 2. **Chasing long shots** — A 5¢ token that could pay $1 sounds amazing; it almost never hits
395
- 3. **Ignoring liquidity** — A "great price" means nothing if you can't exit
396
- 4. **Single-source analysis** — One bookmaker can be wrong; always cross-reference
397
- 5. **Overexposure** — Never have > 25% of bankroll in active bets
398
- 6. **Ignoring the vig** — Bookmaker odds include margin; remove it before comparing
399
- 7. **Trading illiquid markets** — Wide spreads (> 5¢) silently destroy your edge
400
- 8. **Holding long-dated positions** — Capital is locked; shorter resolution = better capital efficiency
401
- 9. **Not tracking results** — Without a log, you can't evaluate if your strategy works
402
- 10. **Emotional trading** — If you just lost, don't immediately place another bet
403
-
404
- ### ✅ Habits of Profitable Traders
405
-
406
- 1. Systematic scan pipeline for every bet (not ad-hoc)
407
- 2. Spreadsheet tracking all bets with entry, target, result, edge
408
- 3. Walk away when there's no edge — most days have no good bets
409
- 4. Focus on 1-2 sports you know deeply rather than spreading thin
410
- 5. Check injury reports, team news, and lineup confirmations before betting
411
- 6. Review win/loss ratio monthly and adjust thresholds if needed
240
+ - **Implied probability:** `1 / decimal_odds` — averaged across all bookmakers for consensus
241
+ - **Edge:** `Book Probability - PM Price` — positive means PM is cheaper than fair value
242
+ - **Why PM over bookmakers:** Better payouts (no vig baked in the same way), no KYC, instant settlement on Polygon
243
+ - **This isn't arbitrage:** PM tracks fair value closely. The strategy is picking safe winners and benefiting from PM's superior payout structure
244
+ - **Volume:** Bet size is unrestricted, but every bet must meet the conviction threshold
@@ -0,0 +1,494 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Polymarket Sports Betting Scanner
4
+ *
5
+ * Fetches bookmaker odds from The Odds API, filters to high-probability favorites,
6
+ * matches them to Polymarket markets, and outputs actionable picks.
7
+ *
8
+ * Usage:
9
+ * node scripts/scan.mjs --sport=basketball_nba
10
+ * node scripts/scan.mjs --all-sports
11
+ * node scripts/scan.mjs --all-sports --min-prob=0.75
12
+ */
13
+
14
+ import { execSync } from 'child_process';
15
+
16
+ // ── Config ──────────────────────────────────────────────────────────────────
17
+
18
+ const ODDS_API = 'https://api.the-odds-api.com/v4/sports';
19
+ const GAMMA = 'https://gamma-api.polymarket.com';
20
+ const CLOB = 'https://clob.polymarket.com';
21
+
22
+ const SPORTS = [
23
+ 'basketball_nba',
24
+ 'soccer_epl',
25
+ 'soccer_spain_la_liga',
26
+ 'soccer_italy_serie_a',
27
+ 'soccer_germany_bundesliga',
28
+ 'soccer_france_ligue_one',
29
+ 'soccer_efl_champ',
30
+ ];
31
+
32
+ // Map Odds API sport keys to PM search tags / keywords
33
+ const SPORT_META = {
34
+ basketball_nba: { pmTag: 'nba', label: 'NBA', type: 'basketball' },
35
+ soccer_epl: { pmTag: 'epl', label: 'EPL', type: 'soccer' },
36
+ soccer_spain_la_liga: { pmTag: 'la-liga', label: 'La Liga', type: 'soccer' },
37
+ soccer_italy_serie_a: { pmTag: 'serie-a', label: 'Serie A', type: 'soccer' },
38
+ soccer_germany_bundesliga:{ pmTag: 'bundesliga', label: 'Bundesliga', type: 'soccer' },
39
+ soccer_france_ligue_one: { pmTag: 'ligue-1', label: 'Ligue 1', type: 'soccer' },
40
+ soccer_efl_champ: { pmTag: 'efl-championship', label: 'EFL Champ', type: 'soccer' },
41
+ };
42
+
43
+ // ── Helpers ─────────────────────────────────────────────────────────────────
44
+
45
+ function getOddsApiKey() {
46
+ // Preferred: explicit env var (portable for Linux/CI/containers)
47
+ if (process.env.ODDS_API_KEY && process.env.ODDS_API_KEY.trim()) {
48
+ return process.env.ODDS_API_KEY.trim();
49
+ }
50
+
51
+ // Fallback: macOS Keychain for local developer setup
52
+ if (process.platform === 'darwin') {
53
+ try {
54
+ return execSync('security find-generic-password -s odds-api-key -a stuart -w', {
55
+ encoding: 'utf8',
56
+ }).trim();
57
+ } catch {}
58
+ }
59
+
60
+ throw new Error('Missing ODDS_API_KEY. Set environment variable ODDS_API_KEY (or use macOS keychain fallback).');
61
+ }
62
+
63
+ async function fetchJSON(url, { timeoutMs = 12000, retries = 2 } = {}) {
64
+ let lastErr;
65
+
66
+ for (let attempt = 0; attempt <= retries; attempt++) {
67
+ const controller = new AbortController();
68
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
69
+
70
+ try {
71
+ const res = await fetch(url, {
72
+ headers: { Accept: 'application/json' },
73
+ signal: controller.signal,
74
+ });
75
+ if (!res.ok) {
76
+ const body = await res.text().catch(() => '');
77
+ throw new Error(`HTTP ${res.status} from ${url}: ${body.slice(0, 200)}`);
78
+ }
79
+ return await res.json();
80
+ } catch (e) {
81
+ lastErr = e;
82
+ if (attempt < retries) {
83
+ await new Promise(r => setTimeout(r, 400 * (attempt + 1)));
84
+ }
85
+ } finally {
86
+ clearTimeout(timeout);
87
+ }
88
+ }
89
+
90
+ throw lastErr;
91
+ }
92
+
93
+ function parseArgs() {
94
+ const opts = { sports: [], minProb: 0.70, allSports: false };
95
+ for (const a of process.argv.slice(2)) {
96
+ if (a === '--all-sports') opts.allSports = true;
97
+ else if (a.startsWith('--sport=')) opts.sports.push(a.split('=')[1]);
98
+ else if (a.startsWith('--min-prob=')) opts.minProb = parseFloat(a.split('=')[1]);
99
+ else if (a === '--help' || a === '-h') {
100
+ console.log(`Usage: scan.mjs [--sport=basketball_nba] [--all-sports] [--min-prob=0.70]`);
101
+ process.exit(0);
102
+ }
103
+ }
104
+ if (opts.allSports) opts.sports = [...SPORTS];
105
+ if (opts.sports.length === 0) opts.sports = [...SPORTS]; // default to all
106
+ return opts;
107
+ }
108
+
109
+ // ── Odds API ────────────────────────────────────────────────────────────────
110
+
111
+ async function fetchOdds(sport, apiKey) {
112
+ // Filter to games starting from now until end of tomorrow (48h window)
113
+ const now = new Date();
114
+ const tomorrow = new Date(now);
115
+ tomorrow.setDate(tomorrow.getDate() + 2);
116
+ tomorrow.setHours(23, 59, 59, 999);
117
+
118
+ const params = new URLSearchParams({
119
+ apiKey,
120
+ regions: 'eu',
121
+ markets: 'h2h',
122
+ oddsFormat: 'decimal',
123
+ commenceTimeFrom: now.toISOString().replace(/\.\d{3}Z$/, 'Z'),
124
+ commenceTimeTo: tomorrow.toISOString().replace(/\.\d{3}Z$/, 'Z'),
125
+ });
126
+
127
+ const url = `${ODDS_API}/${sport}/odds?${params}`;
128
+ try {
129
+ return await fetchJSON(url);
130
+ } catch (e) {
131
+ if (e.message.includes('429')) {
132
+ console.error(`⚠️ Rate limited on Odds API. Check your quota.`);
133
+ return [];
134
+ }
135
+ if (e.message.includes('404')) {
136
+ // Sport may have no upcoming events
137
+ return [];
138
+ }
139
+ throw e;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Calculate implied probability for each outcome by averaging 1/odds across bookmakers.
145
+ * Returns array of { team, impliedProb } sorted by probability descending.
146
+ */
147
+ function calcImpliedProbabilities(game) {
148
+ const bookmakers = game.bookmakers || [];
149
+ if (bookmakers.length === 0) return [];
150
+
151
+ // Collect all h2h odds per outcome
152
+ const oddsMap = {}; // team -> [decimal_odds, ...]
153
+ for (const bm of bookmakers) {
154
+ const h2h = bm.markets?.find(m => m.key === 'h2h');
155
+ if (!h2h) continue;
156
+ for (const outcome of h2h.outcomes) {
157
+ if (!oddsMap[outcome.name]) oddsMap[outcome.name] = [];
158
+ oddsMap[outcome.name].push(outcome.price);
159
+ }
160
+ }
161
+
162
+ // Average implied probability per team
163
+ const results = [];
164
+ for (const [team, oddsList] of Object.entries(oddsMap)) {
165
+ const avgProb = oddsList.reduce((sum, o) => sum + 1 / o, 0) / oddsList.length;
166
+ results.push({ team, impliedProb: avgProb, numBooks: oddsList.length });
167
+ }
168
+
169
+ return results.sort((a, b) => b.impliedProb - a.impliedProb);
170
+ }
171
+
172
+ // ── Polymarket Matching ─────────────────────────────────────────────────────
173
+
174
+ /**
175
+ * Normalize a team name for fuzzy matching.
176
+ */
177
+ function normalize(name) {
178
+ return name
179
+ .toLowerCase()
180
+ .replace(/\bfc\b/g, '')
181
+ .replace(/\bsc\b/g, '')
182
+ .replace(/\bac\b/g, '')
183
+ .replace(/\brc\b/g, '')
184
+ .replace(/\bssc\b/g, '')
185
+ .replace(/\bas\b/g, '')
186
+ .replace(/\bcf\b/g, '')
187
+ .replace(/\bud\b/g, '')
188
+ .replace(/\brcd\b/g, '')
189
+ .replace(/\breal\b/g, 'real')
190
+ .replace(/\bunited\b/g, 'utd')
191
+ .replace(/\bcity\b/g, 'city')
192
+ .replace(/[^a-z0-9 ]/g, '')
193
+ .replace(/\s+/g, ' ')
194
+ .trim();
195
+ }
196
+
197
+ /**
198
+ * Check if two team names are a fuzzy match.
199
+ */
200
+ function fuzzyMatch(a, b) {
201
+ const na = normalize(a);
202
+ const nb = normalize(b);
203
+ // Exact
204
+ if (na === nb) return true;
205
+ // One contains the other
206
+ if (na.includes(nb) || nb.includes(na)) return true;
207
+ // Last word match (e.g. "Lens" in "Racing Club de Lens")
208
+ const wordsA = na.split(' ');
209
+ const wordsB = nb.split(' ');
210
+ const lastA = wordsA[wordsA.length - 1];
211
+ const lastB = wordsB[wordsB.length - 1];
212
+ if (lastA.length >= 3 && lastA === lastB) return true;
213
+ // Check if any significant word (4+ chars) matches
214
+ for (const w of wordsA) {
215
+ if (w.length >= 4 && wordsB.includes(w)) return true;
216
+ }
217
+ return false;
218
+ }
219
+
220
+ /**
221
+ * Search Polymarket for a game and try to find a moneyline market.
222
+ * Returns { found, tokenId, pmPrice, marketQuestion, slug } or { found: false }
223
+ */
224
+ async function findPMMarket(homeTeam, awayTeam, favoriteTeam, sportMeta) {
225
+ // Extract short team names (last word = mascot) for better PM search
226
+ const shortHome = homeTeam.split(' ').pop();
227
+ const shortAway = awayTeam.split(' ').pop();
228
+ const shortFav = favoriteTeam.split(' ').pop();
229
+
230
+ // Try multiple search strategies — short names first (avoid season-long market noise)
231
+ const searches = [
232
+ `${shortAway} ${shortHome}`,
233
+ `${shortHome} ${shortAway}`,
234
+ `${homeTeam} ${awayTeam}`,
235
+ shortFav,
236
+ ];
237
+
238
+ for (const query of searches) {
239
+ try {
240
+ const params = new URLSearchParams({ q: query, limit_per_type: '10' });
241
+ const data = await fetchJSON(`${GAMMA}/public-search?${params}`);
242
+ const events = data.events || [];
243
+
244
+ for (const ev of events) {
245
+ // Check if event title contains both teams or the favorite
246
+ const title = (ev.title || '').toLowerCase();
247
+ const hasHome = fuzzyMatch(ev.title || '', homeTeam);
248
+ const hasAway = fuzzyMatch(ev.title || '', awayTeam);
249
+ const hasFav = fuzzyMatch(ev.title || '', favoriteTeam);
250
+
251
+ if (!hasFav && !(hasHome && hasAway)) continue;
252
+
253
+ // Look through markets for a moneyline / winner market
254
+ const markets = (ev.markets || []).filter(m => !m.closed);
255
+ for (const m of markets) {
256
+ const q = (m.question || '').toLowerCase();
257
+ // Skip spread, total, player props, 1st half
258
+ if (/spread|total|over|under|points|1st half|first half|player|assists|rebounds|goals scored/i.test(q)) continue;
259
+
260
+ // Check if this is a "Will X win?" or "X vs Y" moneyline
261
+ if (fuzzyMatch(m.question || '', favoriteTeam) || q.includes('win') || q.includes('winner')) {
262
+ // Parse outcomes and find the favorite
263
+ const outcomes = typeof m.outcomes === 'string' ? JSON.parse(m.outcomes) : (m.outcomes || []);
264
+ const prices = m.outcomePrices ? (typeof m.outcomePrices === 'string' ? JSON.parse(m.outcomePrices) : m.outcomePrices) : [];
265
+ const clobIds = m.clobTokenIds ? (typeof m.clobTokenIds === 'string' ? JSON.parse(m.clobTokenIds) : m.clobTokenIds) : [];
266
+
267
+ // Find the outcome matching the favorite
268
+ for (let i = 0; i < outcomes.length; i++) {
269
+ const outcomeName = outcomes[i];
270
+ if (fuzzyMatch(outcomeName, favoriteTeam) || outcomeName.toLowerCase() === 'yes') {
271
+ const tokenId = clobIds[i] || null;
272
+ const price = prices[i] ? parseFloat(prices[i]) : null;
273
+
274
+ if (tokenId && price) {
275
+ return {
276
+ found: true,
277
+ tokenId,
278
+ pmPrice: price,
279
+ marketQuestion: m.question,
280
+ slug: m.slug || ev.slug,
281
+ outcomeName,
282
+ };
283
+ }
284
+ }
285
+ }
286
+ }
287
+ }
288
+ }
289
+ } catch (e) {
290
+ // Search failed, try next strategy
291
+ continue;
292
+ }
293
+
294
+ // Small delay between searches to be nice to the API
295
+ await new Promise(r => setTimeout(r, 300));
296
+ }
297
+
298
+ return { found: false };
299
+ }
300
+
301
+ /**
302
+ * Get the live midpoint price from CLOB for a token.
303
+ */
304
+ async function getLivePrice(tokenId) {
305
+ try {
306
+ const data = await fetchJSON(`${CLOB}/midpoint?token_id=${tokenId}`);
307
+ return parseFloat(data.mid);
308
+ } catch {
309
+ return null;
310
+ }
311
+ }
312
+
313
+ // ── Main ────────────────────────────────────────────────────────────────────
314
+
315
+ async function main() {
316
+ const opts = parseArgs();
317
+
318
+ // 1. Get API key
319
+ let apiKey;
320
+ try {
321
+ apiKey = getOddsApiKey();
322
+ } catch (e) {
323
+ console.error('❌ Failed to get Odds API key.');
324
+ console.error(' Set ODDS_API_KEY env var (or use macOS keychain fallback).');
325
+ process.exit(1);
326
+ }
327
+
328
+ console.log(`\n🔍 Scanning ${opts.sports.length} sport(s) | Min probability: ${(opts.minProb * 100).toFixed(0)}%\n`);
329
+
330
+ const picks = [];
331
+
332
+ for (const sport of opts.sports) {
333
+ const meta = SPORT_META[sport];
334
+ if (!meta) {
335
+ console.error(`⚠️ Unknown sport: ${sport}`);
336
+ continue;
337
+ }
338
+
339
+ process.stdout.write(` 📡 ${meta.label}... `);
340
+
341
+ // 2. Fetch odds
342
+ let games;
343
+ try {
344
+ games = await fetchOdds(sport, apiKey);
345
+ } catch (e) {
346
+ console.log(`❌ ${e.message}`);
347
+ continue;
348
+ }
349
+
350
+ if (!games || games.length === 0) {
351
+ console.log('no upcoming games');
352
+ continue;
353
+ }
354
+
355
+ console.log(`${games.length} game(s)`);
356
+
357
+ // 3. Calculate implied probabilities and filter
358
+ for (const game of games) {
359
+ const probs = calcImpliedProbabilities(game);
360
+ if (probs.length === 0) continue;
361
+
362
+ const favorite = probs[0];
363
+ if (favorite.impliedProb < opts.minProb) continue;
364
+
365
+ // This is a qualifying favorite
366
+ const homeTeam = game.home_team;
367
+ const awayTeam = game.away_team;
368
+ const kickoff = new Date(game.commence_time);
369
+
370
+ // Skip games that have already started
371
+ if (kickoff < new Date()) continue;
372
+
373
+ // 4. Search Polymarket
374
+ process.stdout.write(` 🔎 ${favorite.team} (${(favorite.impliedProb * 100).toFixed(1)}%)... `);
375
+
376
+ const pm = await findPMMarket(homeTeam, awayTeam, favorite.team, meta);
377
+
378
+ if (!pm.found) {
379
+ console.log('not on PM');
380
+ picks.push({
381
+ sport: meta.label,
382
+ game: `${homeTeam} vs ${awayTeam}`,
383
+ favorite: favorite.team,
384
+ bookProb: favorite.impliedProb,
385
+ numBooks: favorite.numBooks,
386
+ pmPrice: null,
387
+ edge: null,
388
+ tokenId: null,
389
+ kickoff,
390
+ isSoccer: meta.type === 'soccer',
391
+ });
392
+ continue;
393
+ }
394
+
395
+ // 5. Get live CLOB price (more accurate than Gamma cache)
396
+ const livePrice = await getLivePrice(pm.tokenId);
397
+ const pmPrice = livePrice || pm.pmPrice;
398
+
399
+ const edge = favorite.impliedProb - pmPrice;
400
+
401
+ console.log(`✅ PM: ${(pmPrice * 100).toFixed(1)}¢ | Edge: ${edge >= 0 ? '+' : ''}${(edge * 100).toFixed(1)}%`);
402
+
403
+ picks.push({
404
+ sport: meta.label,
405
+ game: `${homeTeam} vs ${awayTeam}`,
406
+ favorite: favorite.team,
407
+ bookProb: favorite.impliedProb,
408
+ numBooks: favorite.numBooks,
409
+ pmPrice,
410
+ edge,
411
+ tokenId: pm.tokenId,
412
+ kickoff,
413
+ marketQuestion: pm.marketQuestion,
414
+ isSoccer: meta.type === 'soccer',
415
+ });
416
+
417
+ // Rate limit protection
418
+ await new Promise(r => setTimeout(r, 200));
419
+ }
420
+ }
421
+
422
+ // ── Output ──────────────────────────────────────────────────────────────
423
+
424
+ console.log('\n' + '═'.repeat(120));
425
+
426
+ const matched = picks.filter(p => p.tokenId);
427
+ const unmatched = picks.filter(p => !p.tokenId);
428
+
429
+ if (matched.length === 0 && unmatched.length === 0) {
430
+ console.log('\n😐 No favorites above threshold found across all sports.');
431
+ console.log(' The best trade is sometimes no trade.\n');
432
+ process.exit(0);
433
+ }
434
+
435
+ if (matched.length > 0) {
436
+ console.log(`\n✅ ACTIONABLE PICKS (${matched.length})\n`);
437
+ console.log(
438
+ padR('Game', 40) +
439
+ padR('Pick', 24) +
440
+ padR('Books', 10) +
441
+ padR('PM', 8) +
442
+ padR('Edge', 8) +
443
+ padR('Kickoff', 18) +
444
+ 'Token ID'
445
+ );
446
+ console.log('─'.repeat(120));
447
+
448
+ for (const p of matched) {
449
+ const kickStr = p.kickoff.toLocaleString('en-GB', {
450
+ month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
451
+ timeZone: 'Europe/Luxembourg',
452
+ });
453
+ const soccer3way = p.isSoccer ? ' ⚽' : '';
454
+ console.log(
455
+ padR(p.game, 40) +
456
+ padR(p.favorite + soccer3way, 24) +
457
+ padR(`${(p.bookProb * 100).toFixed(1)}%`, 10) +
458
+ padR(`${(p.pmPrice * 100).toFixed(1)}¢`, 8) +
459
+ padR(`${p.edge >= 0 ? '+' : ''}${(p.edge * 100).toFixed(1)}%`, 8) +
460
+ padR(kickStr, 18) +
461
+ p.tokenId
462
+ );
463
+ }
464
+
465
+ if (matched.some(p => p.isSoccer)) {
466
+ console.log('\n⚽ = Football 3-way market (win/draw/lose) — PM splits probability differently than bookmakers');
467
+ }
468
+
469
+ console.log('\n📋 To execute a trade:');
470
+ console.log(' node ~/.agents/skills/polymarket/trade.mjs buy <token_id> <price> <size>\n');
471
+ }
472
+
473
+ if (unmatched.length > 0) {
474
+ console.log(`\n⚠️ NOT ON POLYMARKET (${unmatched.length})\n`);
475
+ for (const p of unmatched) {
476
+ const kickStr = p.kickoff.toLocaleString('en-GB', {
477
+ month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
478
+ timeZone: 'Europe/Luxembourg',
479
+ });
480
+ console.log(` ${padR(p.game, 40)} ${padR(p.favorite, 20)} ${(p.bookProb * 100).toFixed(1)}% ${kickStr}`);
481
+ }
482
+ console.log('');
483
+ }
484
+ }
485
+
486
+ function padR(str, len) {
487
+ if (str.length >= len) return str.slice(0, len - 1) + ' ';
488
+ return str + ' '.repeat(len - str.length);
489
+ }
490
+
491
+ main().catch(e => {
492
+ console.error(`\n❌ Fatal: ${e.message}`);
493
+ process.exit(1);
494
+ });