skills-ws 1.5.4 → 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.4",
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
  },
@@ -0,0 +1,244 @@
1
+ ---
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".
9
+ ---
10
+
11
+ # Polymarket Sports Betting
12
+
13
+ ## ⚠️ CRITICAL RULES (Non-Negotiable)
14
+
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.
22
+
23
+ ## Wallet & Keys
24
+
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)
31
+
32
+ ## Supported Sports
33
+
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 |
43
+
44
+ ## Scripts
45
+
46
+ All scripts are in the skill directory: `~/.agents/skills/polymarket/`
47
+
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
+
55
+ ---
56
+
57
+ ## Workflow
58
+
59
+ ### Step 1: SCAN — Fetch Bookmaker Odds
60
+
61
+ Run the scan script to find qualifying bets:
62
+
63
+ ```bash
64
+ # Scan all sports
65
+ node ~/.agents/skills/polymarket/scripts/scan.mjs --all-sports
66
+
67
+ # Single sport
68
+ node ~/.agents/skills/polymarket/scripts/scan.mjs --sport=basketball_nba
69
+
70
+ # Custom probability threshold
71
+ node ~/.agents/skills/polymarket/scripts/scan.mjs --all-sports --min-prob=0.75
72
+ ```
73
+
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
82
+
83
+ **Validation gate:** If the script returns no picks, stop. Tell the user "No qualifying bets today." Do not lower the threshold.
84
+
85
+ ### Step 2: REVIEW — Validate Picks
86
+
87
+ Before presenting to the user, validate each pick:
88
+
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
94
+
95
+ **Validation gate:** Remove any picks where token ID is `N/A` or the game has already started.
96
+
97
+ ### Step 3: PRESENT — Show Picks to User
98
+
99
+ Present qualifying picks in a clean format:
100
+
101
+ ```
102
+ 🏀 NBA Picks — March 8, 2026
103
+
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 |
108
+
109
+ Token IDs:
110
+ - Boston Celtics: 123456789...
111
+ - LA Lakers: 987654321...
112
+ ```
113
+
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**
120
+
121
+ ### Step 4: EXECUTE — Place Trades
122
+
123
+ After user approval, execute trades using `trade.mjs`:
124
+
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
+ ```
129
+
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
134
+
135
+ **Validation gate:** Always confirm with the user before executing. Show the exact command that will run.
136
+
137
+ **Post-execution:** Show the order response. If the order is rejected, explain why (insufficient balance, price moved, etc.).
138
+
139
+ ### Step 5: TRACK — Monitor Positions
140
+
141
+ Check current positions:
142
+
143
+ ```bash
144
+ # Via Data API
145
+ curl "https://data-api.polymarket.com/positions?user=0xCa5e2a326DE9544EAe2810E3f0E4e1d4Cef1847b"
146
+
147
+ # Or via trade.mjs
148
+ node ~/.agents/skills/polymarket/trade.mjs balances
149
+ ```
150
+
151
+ Check open orders:
152
+
153
+ ```bash
154
+ node ~/.agents/skills/polymarket/trade.mjs orders
155
+ ```
156
+
157
+ ### Step 6: REDEEM — Collect Winnings
158
+
159
+ After markets resolve, redeem winning positions:
160
+
161
+ ```bash
162
+ node ~/.agents/skills/polymarket/redeem.mjs
163
+ ```
164
+
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
170
+
171
+ ---
172
+
173
+ ## Error Handling
174
+
175
+ ### Common Issues
176
+
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 |
187
+
188
+ ### The Odds API Quota
189
+
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
194
+
195
+ ---
196
+
197
+ ## Examples
198
+
199
+ ### "Scan for bets today"
200
+
201
+ ```
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
206
+ ```
207
+
208
+ ### "Bet $100 on the Celtics"
209
+
210
+ ```
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
217
+ ```
218
+
219
+ ### "Check my positions"
220
+
221
+ ```
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
225
+ ```
226
+
227
+ ### "What are the odds on Real Madrid?"
228
+
229
+ ```
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
234
+ ```
235
+
236
+ ---
237
+
238
+ ## Key Concepts
239
+
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
+ });