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.
|
|
4
|
-
"description": "
|
|
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
|
+
});
|