Codex commited on
Commit
8128600
·
1 Parent(s): 15a348c

Add market scanner and Circa OCR support

Browse files
.env.example CHANGED
@@ -3,3 +3,17 @@ GUILD_ID=optional_guild_id_for_faster_command_registration
3
  DATABASE_URL=postgresql://username:password@host:26257/database?sslmode=require
4
  PORT=7860
5
  ADMIN_ROLE_NAME=Admin
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  DATABASE_URL=postgresql://username:password@host:26257/database?sslmode=require
4
  PORT=7860
5
  ADMIN_ROLE_NAME=Admin
6
+ ODDS_API_KEY=
7
+ ODDS_API_BASE_URL=https://api.the-odds-api.com/v4
8
+ ODDS_API_SPORT_KEY=baseball_mlb
9
+ ODDS_API_REGIONS=us
10
+ ODDS_API_MARKETS=batter_home_runs,batter_hits,batter_total_bases,batter_rbis,batter_runs_scored,batter_hits_runs_rbis,pitcher_strikeouts
11
+ CIRCA_DROPBOX_URL=
12
+ SCAN_REPORT_CHANNEL_ID=
13
+ SCAN_ALERT_CHANNEL_ID=
14
+ SCAN_MORNING_TIME=08:00
15
+ SCAN_TIMEZONE=America/Chicago
16
+ SCAN_MIN_BOOKS=3
17
+ SCAN_DISAGREEMENT_THRESHOLD=0.08
18
+ SCAN_ALERT_COOLDOWN_MINUTES=180
19
+ SCAN_FREQUENCY_MINUTES=15
README.md CHANGED
@@ -114,3 +114,29 @@ Deployment assets included in this repo:
114
  - `docs/oracle-always-free.md`
115
  - `deploy/oracle/roibot.service`
116
  - `deploy/oracle/roibot.env.example`
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  - `docs/oracle-always-free.md`
115
  - `deploy/oracle/roibot.service`
116
  - `deploy/oracle/roibot.env.example`
117
+
118
+ ## Market Scanner
119
+
120
+ Market scanning is admin-controlled and built into the same long-running bot process.
121
+
122
+ - `/scanstatus` shows scanner health and last-run information
123
+ - `/scanrun` runs the Circa disagreement scanner manually
124
+ - `/scanreport` posts the morning discrepancy and width reports on demand
125
+ - `/circatest` previews Circa OCR parsing
126
+
127
+ Environment variables for scanner features:
128
+
129
+ - `ODDS_API_KEY`
130
+ - `ODDS_API_BASE_URL`
131
+ - `ODDS_API_SPORT_KEY`
132
+ - `ODDS_API_REGIONS`
133
+ - `ODDS_API_MARKETS`
134
+ - `CIRCA_DROPBOX_URL`
135
+ - `SCAN_REPORT_CHANNEL_ID`
136
+ - `SCAN_ALERT_CHANNEL_ID`
137
+ - `SCAN_MORNING_TIME`
138
+ - `SCAN_TIMEZONE`
139
+ - `SCAN_MIN_BOOKS`
140
+ - `SCAN_DISAGREEMENT_THRESHOLD`
141
+ - `SCAN_ALERT_COOLDOWN_MINUTES`
142
+ - `SCAN_FREQUENCY_MINUTES`
deploy/oracle/roibot.env.example CHANGED
@@ -3,3 +3,17 @@ DATABASE_URL=postgresql://username:password@host:26257/database?sslmode=require
3
  GUILD_ID=optional_guild_id_for_faster_command_registration
4
  ADMIN_ROLE_NAME=Admin
5
  PORT=7860
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  GUILD_ID=optional_guild_id_for_faster_command_registration
4
  ADMIN_ROLE_NAME=Admin
5
  PORT=7860
6
+ ODDS_API_KEY=
7
+ ODDS_API_BASE_URL=https://api.the-odds-api.com/v4
8
+ ODDS_API_SPORT_KEY=baseball_mlb
9
+ ODDS_API_REGIONS=us
10
+ ODDS_API_MARKETS=batter_home_runs,batter_hits,batter_total_bases,batter_rbis,batter_runs_scored,batter_hits_runs_rbis,pitcher_strikeouts
11
+ CIRCA_DROPBOX_URL=
12
+ SCAN_REPORT_CHANNEL_ID=
13
+ SCAN_ALERT_CHANNEL_ID=
14
+ SCAN_MORNING_TIME=08:00
15
+ SCAN_TIMEZONE=America/Chicago
16
+ SCAN_MIN_BOOKS=3
17
+ SCAN_DISAGREEMENT_THRESHOLD=0.08
18
+ SCAN_ALERT_COOLDOWN_MINUTES=180
19
+ SCAN_FREQUENCY_MINUTES=15
package-lock.json CHANGED
@@ -11,8 +11,10 @@
11
  "chart.js": "^4.5.0",
12
  "discord.js": "^14.25.1",
13
  "dotenv": "^17.2.3",
 
14
  "pg": "^8.16.3",
15
- "skia-canvas": "^3.0.8"
 
16
  },
17
  "devDependencies": {
18
  "pg-mem": "^3.0.5"
@@ -176,6 +178,256 @@
176
  "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
177
  "license": "MIT"
178
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  "node_modules/@sapphire/async-queue": {
180
  "version": "1.5.5",
181
  "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz",
@@ -246,6 +498,12 @@
246
  "node": ">= 14"
247
  }
248
  },
 
 
 
 
 
 
249
  "node_modules/call-bind": {
250
  "version": "1.0.8",
251
  "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
@@ -609,6 +867,12 @@
609
  "node": ">= 14"
610
  }
611
  },
 
 
 
 
 
 
612
  "node_modules/immutable": {
613
  "version": "4.3.8",
614
  "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.8.tgz",
@@ -616,6 +880,12 @@
616
  "dev": true,
617
  "license": "MIT"
618
  },
 
 
 
 
 
 
619
  "node_modules/isarray": {
620
  "version": "2.0.5",
621
  "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
@@ -740,6 +1010,33 @@
740
  "url": "https://nearley.js.org/#give-to-nearley"
741
  }
742
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
743
  "node_modules/object-hash": {
744
  "version": "2.2.0",
745
  "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
@@ -760,12 +1057,34 @@
760
  "node": ">= 0.4"
761
  }
762
  },
 
 
 
 
 
 
 
 
 
763
  "node_modules/parenthesis": {
764
  "version": "3.1.8",
765
  "resolved": "https://registry.npmjs.org/parenthesis/-/parenthesis-3.1.8.tgz",
766
  "integrity": "sha512-KF/U8tk54BgQewkJPvB4s/US3VQY68BRDpH638+7O/n58TpnwiwnOtGIOsT2/i+M78s61BBpeC83STB88d8sqw==",
767
  "license": "MIT"
768
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
769
  "node_modules/pg": {
770
  "version": "8.20.0",
771
  "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
@@ -985,6 +1304,12 @@
985
  "node": ">=0.12"
986
  }
987
  },
 
 
 
 
 
 
988
  "node_modules/ret": {
989
  "version": "0.1.15",
990
  "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz",
@@ -1044,6 +1369,36 @@
1044
  "parenthesis": "^3.1.5"
1045
  }
1046
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1047
  "node_modules/ts-mixer": {
1048
  "version": "6.0.4",
1049
  "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz",
@@ -1071,6 +1426,28 @@
1071
  "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
1072
  "license": "MIT"
1073
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1074
  "node_modules/ws": {
1075
  "version": "8.20.0",
1076
  "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
@@ -1107,6 +1484,15 @@
1107
  "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
1108
  "dev": true,
1109
  "license": "ISC"
 
 
 
 
 
 
 
 
 
1110
  }
1111
  }
1112
  }
 
11
  "chart.js": "^4.5.0",
12
  "discord.js": "^14.25.1",
13
  "dotenv": "^17.2.3",
14
+ "pdfjs-dist": "^5.6.205",
15
  "pg": "^8.16.3",
16
+ "skia-canvas": "^3.0.8",
17
+ "tesseract.js": "^7.0.0"
18
  },
19
  "devDependencies": {
20
  "pg-mem": "^3.0.5"
 
178
  "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
179
  "license": "MIT"
180
  },
181
+ "node_modules/@napi-rs/canvas": {
182
+ "version": "0.1.97",
183
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz",
184
+ "integrity": "sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==",
185
+ "license": "MIT",
186
+ "optional": true,
187
+ "workspaces": [
188
+ "e2e/*"
189
+ ],
190
+ "engines": {
191
+ "node": ">= 10"
192
+ },
193
+ "funding": {
194
+ "type": "github",
195
+ "url": "https://github.com/sponsors/Brooooooklyn"
196
+ },
197
+ "optionalDependencies": {
198
+ "@napi-rs/canvas-android-arm64": "0.1.97",
199
+ "@napi-rs/canvas-darwin-arm64": "0.1.97",
200
+ "@napi-rs/canvas-darwin-x64": "0.1.97",
201
+ "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.97",
202
+ "@napi-rs/canvas-linux-arm64-gnu": "0.1.97",
203
+ "@napi-rs/canvas-linux-arm64-musl": "0.1.97",
204
+ "@napi-rs/canvas-linux-riscv64-gnu": "0.1.97",
205
+ "@napi-rs/canvas-linux-x64-gnu": "0.1.97",
206
+ "@napi-rs/canvas-linux-x64-musl": "0.1.97",
207
+ "@napi-rs/canvas-win32-arm64-msvc": "0.1.97",
208
+ "@napi-rs/canvas-win32-x64-msvc": "0.1.97"
209
+ }
210
+ },
211
+ "node_modules/@napi-rs/canvas-android-arm64": {
212
+ "version": "0.1.97",
213
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.97.tgz",
214
+ "integrity": "sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ==",
215
+ "cpu": [
216
+ "arm64"
217
+ ],
218
+ "license": "MIT",
219
+ "optional": true,
220
+ "os": [
221
+ "android"
222
+ ],
223
+ "engines": {
224
+ "node": ">= 10"
225
+ },
226
+ "funding": {
227
+ "type": "github",
228
+ "url": "https://github.com/sponsors/Brooooooklyn"
229
+ }
230
+ },
231
+ "node_modules/@napi-rs/canvas-darwin-arm64": {
232
+ "version": "0.1.97",
233
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.97.tgz",
234
+ "integrity": "sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw==",
235
+ "cpu": [
236
+ "arm64"
237
+ ],
238
+ "license": "MIT",
239
+ "optional": true,
240
+ "os": [
241
+ "darwin"
242
+ ],
243
+ "engines": {
244
+ "node": ">= 10"
245
+ },
246
+ "funding": {
247
+ "type": "github",
248
+ "url": "https://github.com/sponsors/Brooooooklyn"
249
+ }
250
+ },
251
+ "node_modules/@napi-rs/canvas-darwin-x64": {
252
+ "version": "0.1.97",
253
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.97.tgz",
254
+ "integrity": "sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA==",
255
+ "cpu": [
256
+ "x64"
257
+ ],
258
+ "license": "MIT",
259
+ "optional": true,
260
+ "os": [
261
+ "darwin"
262
+ ],
263
+ "engines": {
264
+ "node": ">= 10"
265
+ },
266
+ "funding": {
267
+ "type": "github",
268
+ "url": "https://github.com/sponsors/Brooooooklyn"
269
+ }
270
+ },
271
+ "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
272
+ "version": "0.1.97",
273
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.97.tgz",
274
+ "integrity": "sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw==",
275
+ "cpu": [
276
+ "arm"
277
+ ],
278
+ "license": "MIT",
279
+ "optional": true,
280
+ "os": [
281
+ "linux"
282
+ ],
283
+ "engines": {
284
+ "node": ">= 10"
285
+ },
286
+ "funding": {
287
+ "type": "github",
288
+ "url": "https://github.com/sponsors/Brooooooklyn"
289
+ }
290
+ },
291
+ "node_modules/@napi-rs/canvas-linux-arm64-gnu": {
292
+ "version": "0.1.97",
293
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.97.tgz",
294
+ "integrity": "sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==",
295
+ "cpu": [
296
+ "arm64"
297
+ ],
298
+ "license": "MIT",
299
+ "optional": true,
300
+ "os": [
301
+ "linux"
302
+ ],
303
+ "engines": {
304
+ "node": ">= 10"
305
+ },
306
+ "funding": {
307
+ "type": "github",
308
+ "url": "https://github.com/sponsors/Brooooooklyn"
309
+ }
310
+ },
311
+ "node_modules/@napi-rs/canvas-linux-arm64-musl": {
312
+ "version": "0.1.97",
313
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.97.tgz",
314
+ "integrity": "sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==",
315
+ "cpu": [
316
+ "arm64"
317
+ ],
318
+ "license": "MIT",
319
+ "optional": true,
320
+ "os": [
321
+ "linux"
322
+ ],
323
+ "engines": {
324
+ "node": ">= 10"
325
+ },
326
+ "funding": {
327
+ "type": "github",
328
+ "url": "https://github.com/sponsors/Brooooooklyn"
329
+ }
330
+ },
331
+ "node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
332
+ "version": "0.1.97",
333
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.97.tgz",
334
+ "integrity": "sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==",
335
+ "cpu": [
336
+ "riscv64"
337
+ ],
338
+ "license": "MIT",
339
+ "optional": true,
340
+ "os": [
341
+ "linux"
342
+ ],
343
+ "engines": {
344
+ "node": ">= 10"
345
+ },
346
+ "funding": {
347
+ "type": "github",
348
+ "url": "https://github.com/sponsors/Brooooooklyn"
349
+ }
350
+ },
351
+ "node_modules/@napi-rs/canvas-linux-x64-gnu": {
352
+ "version": "0.1.97",
353
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.97.tgz",
354
+ "integrity": "sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==",
355
+ "cpu": [
356
+ "x64"
357
+ ],
358
+ "license": "MIT",
359
+ "optional": true,
360
+ "os": [
361
+ "linux"
362
+ ],
363
+ "engines": {
364
+ "node": ">= 10"
365
+ },
366
+ "funding": {
367
+ "type": "github",
368
+ "url": "https://github.com/sponsors/Brooooooklyn"
369
+ }
370
+ },
371
+ "node_modules/@napi-rs/canvas-linux-x64-musl": {
372
+ "version": "0.1.97",
373
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.97.tgz",
374
+ "integrity": "sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==",
375
+ "cpu": [
376
+ "x64"
377
+ ],
378
+ "license": "MIT",
379
+ "optional": true,
380
+ "os": [
381
+ "linux"
382
+ ],
383
+ "engines": {
384
+ "node": ">= 10"
385
+ },
386
+ "funding": {
387
+ "type": "github",
388
+ "url": "https://github.com/sponsors/Brooooooklyn"
389
+ }
390
+ },
391
+ "node_modules/@napi-rs/canvas-win32-arm64-msvc": {
392
+ "version": "0.1.97",
393
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.97.tgz",
394
+ "integrity": "sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==",
395
+ "cpu": [
396
+ "arm64"
397
+ ],
398
+ "license": "MIT",
399
+ "optional": true,
400
+ "os": [
401
+ "win32"
402
+ ],
403
+ "engines": {
404
+ "node": ">= 10"
405
+ },
406
+ "funding": {
407
+ "type": "github",
408
+ "url": "https://github.com/sponsors/Brooooooklyn"
409
+ }
410
+ },
411
+ "node_modules/@napi-rs/canvas-win32-x64-msvc": {
412
+ "version": "0.1.97",
413
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.97.tgz",
414
+ "integrity": "sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ==",
415
+ "cpu": [
416
+ "x64"
417
+ ],
418
+ "license": "MIT",
419
+ "optional": true,
420
+ "os": [
421
+ "win32"
422
+ ],
423
+ "engines": {
424
+ "node": ">= 10"
425
+ },
426
+ "funding": {
427
+ "type": "github",
428
+ "url": "https://github.com/sponsors/Brooooooklyn"
429
+ }
430
+ },
431
  "node_modules/@sapphire/async-queue": {
432
  "version": "1.5.5",
433
  "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz",
 
498
  "node": ">= 14"
499
  }
500
  },
501
+ "node_modules/bmp-js": {
502
+ "version": "0.1.0",
503
+ "resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz",
504
+ "integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==",
505
+ "license": "MIT"
506
+ },
507
  "node_modules/call-bind": {
508
  "version": "1.0.8",
509
  "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
 
867
  "node": ">= 14"
868
  }
869
  },
870
+ "node_modules/idb-keyval": {
871
+ "version": "6.2.2",
872
+ "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz",
873
+ "integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==",
874
+ "license": "Apache-2.0"
875
+ },
876
  "node_modules/immutable": {
877
  "version": "4.3.8",
878
  "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.8.tgz",
 
880
  "dev": true,
881
  "license": "MIT"
882
  },
883
+ "node_modules/is-url": {
884
+ "version": "1.2.4",
885
+ "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
886
+ "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
887
+ "license": "MIT"
888
+ },
889
  "node_modules/isarray": {
890
  "version": "2.0.5",
891
  "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
 
1010
  "url": "https://nearley.js.org/#give-to-nearley"
1011
  }
1012
  },
1013
+ "node_modules/node-fetch": {
1014
+ "version": "2.7.0",
1015
+ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
1016
+ "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
1017
+ "license": "MIT",
1018
+ "dependencies": {
1019
+ "whatwg-url": "^5.0.0"
1020
+ },
1021
+ "engines": {
1022
+ "node": "4.x || >=6.0.0"
1023
+ },
1024
+ "peerDependencies": {
1025
+ "encoding": "^0.1.0"
1026
+ },
1027
+ "peerDependenciesMeta": {
1028
+ "encoding": {
1029
+ "optional": true
1030
+ }
1031
+ }
1032
+ },
1033
+ "node_modules/node-readable-to-web-readable-stream": {
1034
+ "version": "0.4.2",
1035
+ "resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz",
1036
+ "integrity": "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==",
1037
+ "license": "MIT",
1038
+ "optional": true
1039
+ },
1040
  "node_modules/object-hash": {
1041
  "version": "2.2.0",
1042
  "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
 
1057
  "node": ">= 0.4"
1058
  }
1059
  },
1060
+ "node_modules/opencollective-postinstall": {
1061
+ "version": "2.0.3",
1062
+ "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz",
1063
+ "integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==",
1064
+ "license": "MIT",
1065
+ "bin": {
1066
+ "opencollective-postinstall": "index.js"
1067
+ }
1068
+ },
1069
  "node_modules/parenthesis": {
1070
  "version": "3.1.8",
1071
  "resolved": "https://registry.npmjs.org/parenthesis/-/parenthesis-3.1.8.tgz",
1072
  "integrity": "sha512-KF/U8tk54BgQewkJPvB4s/US3VQY68BRDpH638+7O/n58TpnwiwnOtGIOsT2/i+M78s61BBpeC83STB88d8sqw==",
1073
  "license": "MIT"
1074
  },
1075
+ "node_modules/pdfjs-dist": {
1076
+ "version": "5.6.205",
1077
+ "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.6.205.tgz",
1078
+ "integrity": "sha512-tlUj+2IDa7G1SbvBNN74UHRLJybZDWYom+k6p5KIZl7huBvsA4APi6mKL+zCxd3tLjN5hOOEE9Tv7VdzO88pfg==",
1079
+ "license": "Apache-2.0",
1080
+ "engines": {
1081
+ "node": ">=20.19.0 || >=22.13.0 || >=24"
1082
+ },
1083
+ "optionalDependencies": {
1084
+ "@napi-rs/canvas": "^0.1.96",
1085
+ "node-readable-to-web-readable-stream": "^0.4.2"
1086
+ }
1087
+ },
1088
  "node_modules/pg": {
1089
  "version": "8.20.0",
1090
  "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
 
1304
  "node": ">=0.12"
1305
  }
1306
  },
1307
+ "node_modules/regenerator-runtime": {
1308
+ "version": "0.13.11",
1309
+ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
1310
+ "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
1311
+ "license": "MIT"
1312
+ },
1313
  "node_modules/ret": {
1314
  "version": "0.1.15",
1315
  "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz",
 
1369
  "parenthesis": "^3.1.5"
1370
  }
1371
  },
1372
+ "node_modules/tesseract.js": {
1373
+ "version": "7.0.0",
1374
+ "resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-7.0.0.tgz",
1375
+ "integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==",
1376
+ "hasInstallScript": true,
1377
+ "license": "Apache-2.0",
1378
+ "dependencies": {
1379
+ "bmp-js": "^0.1.0",
1380
+ "idb-keyval": "^6.2.0",
1381
+ "is-url": "^1.2.4",
1382
+ "node-fetch": "^2.6.9",
1383
+ "opencollective-postinstall": "^2.0.3",
1384
+ "regenerator-runtime": "^0.13.3",
1385
+ "tesseract.js-core": "^7.0.0",
1386
+ "wasm-feature-detect": "^1.8.0",
1387
+ "zlibjs": "^0.3.1"
1388
+ }
1389
+ },
1390
+ "node_modules/tesseract.js-core": {
1391
+ "version": "7.0.0",
1392
+ "resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz",
1393
+ "integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==",
1394
+ "license": "Apache-2.0"
1395
+ },
1396
+ "node_modules/tr46": {
1397
+ "version": "0.0.3",
1398
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
1399
+ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
1400
+ "license": "MIT"
1401
+ },
1402
  "node_modules/ts-mixer": {
1403
  "version": "6.0.4",
1404
  "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz",
 
1426
  "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
1427
  "license": "MIT"
1428
  },
1429
+ "node_modules/wasm-feature-detect": {
1430
+ "version": "1.8.0",
1431
+ "resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz",
1432
+ "integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==",
1433
+ "license": "Apache-2.0"
1434
+ },
1435
+ "node_modules/webidl-conversions": {
1436
+ "version": "3.0.1",
1437
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
1438
+ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
1439
+ "license": "BSD-2-Clause"
1440
+ },
1441
+ "node_modules/whatwg-url": {
1442
+ "version": "5.0.0",
1443
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
1444
+ "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
1445
+ "license": "MIT",
1446
+ "dependencies": {
1447
+ "tr46": "~0.0.3",
1448
+ "webidl-conversions": "^3.0.0"
1449
+ }
1450
+ },
1451
  "node_modules/ws": {
1452
  "version": "8.20.0",
1453
  "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
 
1484
  "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
1485
  "dev": true,
1486
  "license": "ISC"
1487
+ },
1488
+ "node_modules/zlibjs": {
1489
+ "version": "0.3.1",
1490
+ "resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz",
1491
+ "integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==",
1492
+ "license": "MIT",
1493
+ "engines": {
1494
+ "node": "*"
1495
+ }
1496
  }
1497
  }
1498
  }
package.json CHANGED
@@ -15,8 +15,10 @@
15
  "chart.js": "^4.5.0",
16
  "discord.js": "^14.25.1",
17
  "dotenv": "^17.2.3",
 
18
  "pg": "^8.16.3",
19
- "skia-canvas": "^3.0.8"
 
20
  },
21
  "devDependencies": {
22
  "pg-mem": "^3.0.5"
 
15
  "chart.js": "^4.5.0",
16
  "discord.js": "^14.25.1",
17
  "dotenv": "^17.2.3",
18
+ "pdfjs-dist": "^5.6.205",
19
  "pg": "^8.16.3",
20
+ "skia-canvas": "^3.0.8",
21
+ "tesseract.js": "^7.0.0"
22
  },
23
  "devDependencies": {
24
  "pg-mem": "^3.0.5"
src/commands.js CHANGED
@@ -199,6 +199,18 @@ export const commands = [
199
  new SlashCommandBuilder()
200
  .setName('commands')
201
  .setDescription('Post a public command reference embed.'),
 
 
 
 
 
 
 
 
 
 
 
 
202
  new SlashCommandBuilder()
203
  .setName('alerts')
204
  .setDescription('Post the analyst alert-role embed to the welcome channel.'),
 
199
  new SlashCommandBuilder()
200
  .setName('commands')
201
  .setDescription('Post a public command reference embed.'),
202
+ new SlashCommandBuilder()
203
+ .setName('scanstatus')
204
+ .setDescription('Show the market scanner status and latest run info.'),
205
+ new SlashCommandBuilder()
206
+ .setName('scanrun')
207
+ .setDescription('Run the Circa market scanner manually.'),
208
+ new SlashCommandBuilder()
209
+ .setName('scanreport')
210
+ .setDescription('Post the daily scan reports immediately.'),
211
+ new SlashCommandBuilder()
212
+ .setName('circatest')
213
+ .setDescription('Run a Circa OCR diagnostic and preview parsed entries.'),
214
  new SlashCommandBuilder()
215
  .setName('alerts')
216
  .setDescription('Post the analyst alert-role embed to the welcome channel.'),
src/config.js CHANGED
@@ -5,6 +5,24 @@ export function getConfig() {
5
  const databaseUrl = process.env.DATABASE_URL?.trim();
6
  const port = Number(process.env.PORT || 7860);
7
  const adminRoleName = process.env.ADMIN_ROLE_NAME?.trim() || 'Admin';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
  if (!token) {
10
  throw new Error('Missing DISCORD_TOKEN in environment.');
@@ -20,5 +38,22 @@ export function getConfig() {
20
  databaseUrl,
21
  port,
22
  adminRoleName,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  };
24
  }
 
5
  const databaseUrl = process.env.DATABASE_URL?.trim();
6
  const port = Number(process.env.PORT || 7860);
7
  const adminRoleName = process.env.ADMIN_ROLE_NAME?.trim() || 'Admin';
8
+ const oddsApiKey = process.env.ODDS_API_KEY?.trim() || null;
9
+ const oddsApiBaseUrl = process.env.ODDS_API_BASE_URL?.trim() || 'https://api.the-odds-api.com/v4';
10
+ const oddsApiSportKey = process.env.ODDS_API_SPORT_KEY?.trim() || 'baseball_mlb';
11
+ const oddsApiRegions = process.env.ODDS_API_REGIONS?.trim() || 'us';
12
+ const oddsApiMarkets = (process.env.ODDS_API_MARKETS?.trim()
13
+ || 'batter_home_runs,batter_hits,batter_total_bases,batter_rbis,batter_runs_scored,batter_hits_runs_rbis,pitcher_strikeouts')
14
+ .split(',')
15
+ .map((value) => value.trim())
16
+ .filter(Boolean);
17
+ const circaDropboxUrl = process.env.CIRCA_DROPBOX_URL?.trim() || null;
18
+ const scanReportChannelId = process.env.SCAN_REPORT_CHANNEL_ID?.trim() || null;
19
+ const scanAlertChannelId = process.env.SCAN_ALERT_CHANNEL_ID?.trim() || null;
20
+ const scanMorningTime = process.env.SCAN_MORNING_TIME?.trim() || '08:00';
21
+ const scanTimeZone = process.env.SCAN_TIMEZONE?.trim() || 'America/Chicago';
22
+ const scanMinBooks = Number(process.env.SCAN_MIN_BOOKS || 3);
23
+ const scanDisagreementThreshold = Number(process.env.SCAN_DISAGREEMENT_THRESHOLD || 0.08);
24
+ const scanAlertCooldownMinutes = Number(process.env.SCAN_ALERT_COOLDOWN_MINUTES || 180);
25
+ const scanFrequencyMinutes = Number(process.env.SCAN_FREQUENCY_MINUTES || 15);
26
 
27
  if (!token) {
28
  throw new Error('Missing DISCORD_TOKEN in environment.');
 
38
  databaseUrl,
39
  port,
40
  adminRoleName,
41
+ scanner: {
42
+ enabled: Boolean(oddsApiKey && circaDropboxUrl && scanReportChannelId && scanAlertChannelId),
43
+ oddsApiKey,
44
+ oddsApiBaseUrl,
45
+ oddsApiSportKey,
46
+ oddsApiRegions,
47
+ oddsApiMarkets,
48
+ circaDropboxUrl,
49
+ scanReportChannelId,
50
+ scanAlertChannelId,
51
+ scanMorningTime,
52
+ scanTimeZone,
53
+ scanMinBooks,
54
+ scanDisagreementThreshold,
55
+ scanAlertCooldownMinutes,
56
+ scanFrequencyMinutes,
57
+ },
58
  };
59
  }
src/db.js CHANGED
@@ -230,6 +230,57 @@ export class BetStore {
230
  CREATE UNIQUE INDEX IF NOT EXISTS bets_user_bet_number_unique
231
  ON bets (discord_user_id, bet_number);
232
  `);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  }
234
 
235
  async upsertUser(user) {
@@ -612,6 +663,127 @@ export class BetStore {
612
  });
613
  }
614
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
615
  async close() {
616
  await this.pool.end();
617
  }
 
230
  CREATE UNIQUE INDEX IF NOT EXISTS bets_user_bet_number_unique
231
  ON bets (discord_user_id, bet_number);
232
  `);
233
+
234
+ await this.pool.query(`
235
+ CREATE TABLE IF NOT EXISTS scan_runs (
236
+ id INT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
237
+ scan_type TEXT NOT NULL,
238
+ status TEXT NOT NULL DEFAULT 'completed',
239
+ captured_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
240
+ entry_count INT NOT NULL DEFAULT 0,
241
+ error_text TEXT
242
+ );
243
+ `);
244
+
245
+ await this.pool.query(`
246
+ CREATE TABLE IF NOT EXISTS scan_markets (
247
+ id INT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
248
+ scan_run_id INT NOT NULL REFERENCES scan_runs(id) ON DELETE CASCADE,
249
+ market_key TEXT NOT NULL,
250
+ source TEXT NOT NULL,
251
+ book TEXT NOT NULL,
252
+ event_name TEXT,
253
+ player_name TEXT NOT NULL,
254
+ market_type TEXT NOT NULL,
255
+ market_label TEXT NOT NULL,
256
+ side TEXT NOT NULL,
257
+ line_value DOUBLE PRECISION,
258
+ odds_input TEXT NOT NULL,
259
+ implied_probability DOUBLE PRECISION NOT NULL,
260
+ raw_label TEXT,
261
+ captured_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
262
+ );
263
+ `);
264
+
265
+ await this.pool.query(`
266
+ CREATE TABLE IF NOT EXISTS scan_reports (
267
+ id INT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
268
+ report_date DATE NOT NULL,
269
+ report_type TEXT NOT NULL,
270
+ channel_id TEXT NOT NULL,
271
+ primary_message_id TEXT,
272
+ secondary_message_id TEXT,
273
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
274
+ CONSTRAINT scan_reports_date_type_unique UNIQUE (report_date, report_type)
275
+ );
276
+ `);
277
+
278
+ await this.pool.query(`
279
+ CREATE TABLE IF NOT EXISTS scan_alerts (
280
+ alert_key TEXT PRIMARY KEY,
281
+ last_sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
282
+ );
283
+ `);
284
  }
285
 
286
  async upsertUser(user) {
 
663
  });
664
  }
665
 
666
+ async recordMarketSnapshot(scanType, entries) {
667
+ const client = await this.pool.connect();
668
+
669
+ try {
670
+ await client.query('BEGIN');
671
+ const { rows } = await client.query(
672
+ `
673
+ INSERT INTO scan_runs (scan_type, status, entry_count)
674
+ VALUES ($1, 'completed', $2)
675
+ RETURNING id
676
+ `,
677
+ [scanType, entries.length]
678
+ );
679
+ const scanRunId = Number(rows[0].id);
680
+
681
+ for (const entry of entries) {
682
+ await client.query(
683
+ `
684
+ INSERT INTO scan_markets (
685
+ scan_run_id,
686
+ market_key,
687
+ source,
688
+ book,
689
+ event_name,
690
+ player_name,
691
+ market_type,
692
+ market_label,
693
+ side,
694
+ line_value,
695
+ odds_input,
696
+ implied_probability,
697
+ raw_label
698
+ )
699
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
700
+ `,
701
+ [
702
+ scanRunId,
703
+ entry.marketKey,
704
+ entry.source,
705
+ entry.book,
706
+ entry.eventName,
707
+ entry.playerName,
708
+ entry.marketType,
709
+ entry.marketLabel,
710
+ entry.side,
711
+ entry.lineValue,
712
+ entry.oddsInput,
713
+ entry.impliedProbability,
714
+ entry.rawLabel,
715
+ ]
716
+ );
717
+ }
718
+
719
+ await client.query('COMMIT');
720
+ return scanRunId;
721
+ } catch (error) {
722
+ await client.query('ROLLBACK');
723
+ throw error;
724
+ } finally {
725
+ client.release();
726
+ }
727
+ }
728
+
729
+ async hasScanReport(reportDate, reportType) {
730
+ const { rows } = await this.pool.query(
731
+ `
732
+ SELECT 1
733
+ FROM scan_reports
734
+ WHERE report_date = $1 AND report_type = $2
735
+ `,
736
+ [reportDate, reportType]
737
+ );
738
+
739
+ return Boolean(rows[0]);
740
+ }
741
+
742
+ async recordScanReport(reportDate, reportType, channelId, primaryMessageId, secondaryMessageId = null) {
743
+ await this.pool.query(
744
+ `
745
+ INSERT INTO scan_reports (report_date, report_type, channel_id, primary_message_id, secondary_message_id)
746
+ VALUES ($1, $2, $3, $4, $5)
747
+ ON CONFLICT (report_date, report_type) DO UPDATE SET
748
+ channel_id = EXCLUDED.channel_id,
749
+ primary_message_id = EXCLUDED.primary_message_id,
750
+ secondary_message_id = EXCLUDED.secondary_message_id,
751
+ created_at = NOW()
752
+ `,
753
+ [reportDate, reportType, channelId, primaryMessageId, secondaryMessageId]
754
+ );
755
+ }
756
+
757
+ async canSendScanAlert(alertKey, cooldownMinutes) {
758
+ const { rows } = await this.pool.query(
759
+ `
760
+ SELECT last_sent_at
761
+ FROM scan_alerts
762
+ WHERE alert_key = $1
763
+ `,
764
+ [alertKey]
765
+ );
766
+
767
+ if (!rows[0]) {
768
+ return true;
769
+ }
770
+
771
+ const lastSentAt = new Date(rows[0].last_sent_at);
772
+ return Date.now() - lastSentAt.getTime() >= cooldownMinutes * 60 * 1000;
773
+ }
774
+
775
+ async recordScanAlert(alertKey) {
776
+ await this.pool.query(
777
+ `
778
+ INSERT INTO scan_alerts (alert_key, last_sent_at)
779
+ VALUES ($1, NOW())
780
+ ON CONFLICT (alert_key) DO UPDATE SET
781
+ last_sent_at = NOW()
782
+ `,
783
+ [alertKey]
784
+ );
785
+ }
786
+
787
  async close() {
788
  await this.pool.end();
789
  }
src/embeds.js CHANGED
@@ -289,6 +289,10 @@ export function buildCommandsEmbed() {
289
  { name: '/sports', value: 'Post public ROI and win-rate breakdowns by sport.' },
290
  { name: '/export', value: 'Export your filtered bets to CSV privately.' },
291
  { name: '/commands', value: 'Post this public command reference embed.' },
 
 
 
 
292
  { name: '/alerts', value: 'Post the public analyst alert-role panel to the welcome channel. Only for jew_olympics.' },
293
  { name: '/welcome', value: 'Post the public welcome embed and tag everyone. Only for Kenny F\'n Powers.' }
294
  );
@@ -382,6 +386,89 @@ export function buildExportAttachment(rows) {
382
  });
383
  }
384
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
385
  function escapeCsv(value) {
386
  const stringValue = String(value);
387
  if (stringValue.includes(',') || stringValue.includes('"') || stringValue.includes('\n')) {
 
289
  { name: '/sports', value: 'Post public ROI and win-rate breakdowns by sport.' },
290
  { name: '/export', value: 'Export your filtered bets to CSV privately.' },
291
  { name: '/commands', value: 'Post this public command reference embed.' },
292
+ { name: '/scanstatus', value: 'Show the market scanner status and latest run info. Admin only.' },
293
+ { name: '/scanrun', value: 'Run the market scanner manually. Admin only.' },
294
+ { name: '/scanreport', value: 'Post the morning scan reports immediately. Admin only.' },
295
+ { name: '/circatest', value: 'Run a Circa OCR diagnostic preview. Admin only.' },
296
  { name: '/alerts', value: 'Post the public analyst alert-role panel to the welcome channel. Only for jew_olympics.' },
297
  { name: '/welcome', value: 'Post the public welcome embed and tag everyone. Only for Kenny F\'n Powers.' }
298
  );
 
386
  });
387
  }
388
 
389
+ export function buildScanStatusEmbed(status) {
390
+ return new EmbedBuilder()
391
+ .setColor(status.enabled ? PALETTE.amber : PALETTE.gray)
392
+ .setTitle('Market Scanner Status')
393
+ .setDescription(status.enabled
394
+ ? 'Scanner is configured and ready to run MLB Circa comparisons.'
395
+ : 'Scanner is disabled because one or more scan environment variables are missing.')
396
+ .addFields(
397
+ { name: 'Enabled', value: status.enabled ? 'Yes' : 'No', inline: true },
398
+ { name: 'Frequency', value: `${status.frequencyMinutes} minutes`, inline: true },
399
+ { name: 'Morning Time', value: `${status.morningTime} (${status.timeZone})`, inline: true },
400
+ { name: 'Report Channel', value: status.reportChannelId ? `<#${status.reportChannelId}>` : 'Not set', inline: true },
401
+ { name: 'Alert Channel', value: status.alertChannelId ? `<#${status.alertChannelId}>` : 'Not set', inline: true },
402
+ { name: 'Min Books / Threshold', value: `${status.minBooks} / ${(status.disagreementThreshold * 100).toFixed(2)}%`, inline: true },
403
+ { name: 'Last Scan', value: status.lastScanAt ?? 'Never', inline: true },
404
+ { name: 'Last Report', value: status.lastReportAt ?? 'Never', inline: true },
405
+ { name: 'Last Counts', value: `API: ${status.lastApiEntries ?? 0} | Circa: ${status.lastCircaEntries ?? 0} | Alerts: ${status.lastAlertCount ?? 0}`, inline: false },
406
+ { name: 'Last Error', value: status.lastScanError ?? 'None', inline: false },
407
+ );
408
+ }
409
+
410
+ export function buildMarketTopEmbed(title, rows, options = {}) {
411
+ const metricLabel = options.metricLabel ?? 'Edge';
412
+ const metricFormatter = options.metricFormatter ?? ((row) => String(row.valueEdge ?? '0'));
413
+ const secondaryValue = options.secondaryValue ?? ((row) => row.oddsInput);
414
+ const embed = new EmbedBuilder()
415
+ .setColor(PALETTE.amber)
416
+ .setTitle(title)
417
+ .setDescription('MLB player props ranked from the latest market scan.');
418
+
419
+ if (rows.length === 0) {
420
+ return embed.addFields({ name: 'No rows', value: 'No matching markets were found in the latest scan.' });
421
+ }
422
+
423
+ embed.addFields(
424
+ rows.map((row, index) => ({
425
+ name: `${index + 1}. ${row.playerName} - ${row.marketLabel}`,
426
+ value: [
427
+ `Side: ${row.side.toUpperCase()}${row.lineValue !== null && row.lineValue !== undefined ? ` ${row.lineValue}` : ''}`,
428
+ `Book: ${row.book} | Odds: ${row.oddsInput}`,
429
+ `${metricLabel}: ${metricFormatter(row)} | Books: ${row.books}`,
430
+ `Detail: ${secondaryValue(row)}`,
431
+ ].join('\n'),
432
+ inline: false,
433
+ }))
434
+ );
435
+
436
+ return embed;
437
+ }
438
+
439
+ export function buildCircaAlertEmbed(alert) {
440
+ return new EmbedBuilder()
441
+ .setColor(PALETTE.red)
442
+ .setTitle('Circa Disagreement Alert')
443
+ .setDescription(`${alert.playerName} - ${alert.marketLabel}`)
444
+ .addFields(
445
+ { name: 'Circa', value: `${alert.side.toUpperCase()}${alert.lineValue !== null && alert.lineValue !== undefined ? ` ${alert.lineValue}` : ''} @ ${alert.oddsInput}`, inline: true },
446
+ { name: 'Furthest Book', value: `${alert.furthestBookName} @ ${alert.furthestBookOddsInput}`, inline: true },
447
+ { name: 'Books Compared', value: String(alert.books), inline: true },
448
+ { name: 'Max Deviation', value: `${(alert.maxDeviation * 100).toFixed(2)}%`, inline: true },
449
+ { name: 'Market Avg', value: `${((alert.marketAverageImpliedProbability ?? 0) * 100).toFixed(2)}% implied`, inline: true },
450
+ { name: 'Market Key', value: alert.marketKey, inline: false },
451
+ )
452
+ .setFooter({ text: 'Triggered because market books materially disagree with Circa.' });
453
+ }
454
+
455
+ export function buildCircaDiagnosticEmbed(result) {
456
+ const preview = result.parsedEntries.length > 0
457
+ ? result.parsedEntries.map((entry) =>
458
+ `${entry.playerName} | ${entry.marketLabel} | ${entry.side} ${entry.lineValue ?? ''} | ${entry.oddsInput}`.trim()
459
+ ).join('\n')
460
+ : 'No Circa entries parsed.';
461
+
462
+ return new EmbedBuilder()
463
+ .setColor(PALETTE.gold)
464
+ .setTitle('Circa OCR Diagnostic')
465
+ .setDescription(`Parsed **${result.totalEntries}** Circa entries from the latest download.`)
466
+ .addFields(
467
+ { name: 'Parsed Preview', value: truncate(preview, 1000), inline: false },
468
+ { name: 'OCR Text Sample', value: truncate(result.rawTextSample || 'No OCR text extracted.', 1000), inline: false },
469
+ );
470
+ }
471
+
472
  function escapeCsv(value) {
473
  const stringValue = String(value);
474
  if (stringValue.includes(',') || stringValue.includes('"') || stringValue.includes('\n')) {
src/index.js CHANGED
@@ -32,13 +32,17 @@ import {
32
  buildBulkAddEmbed,
33
  buildChartAttachment,
34
  buildCommandsEmbed,
 
 
35
  buildDeleteBetEmbed,
36
  buildEditBetEmbed,
37
  buildErrorEmbed,
38
  buildExportAttachment,
 
39
  buildResolveAllEmbed,
40
  buildResolveEmbed,
41
  buildRoiEmbed,
 
42
  buildSportsEmbed,
43
  buildSummaryEmbed,
44
  buildWelcomeEmbed,
@@ -48,6 +52,7 @@ import {
48
  } from './embeds.js';
49
  import { parseBulkAddInput } from './bulk-add.js';
50
  import { parseBetIdList } from './resolve-bulk.js';
 
51
 
52
  const BET_MODAL_PREFIX = 'bet-entry-modal';
53
  const BULK_ADD_MODAL_PREFIX = 'bulk-add-modal';
@@ -72,6 +77,17 @@ async function main() {
72
  const client = new Client({
73
  intents: [GatewayIntentBits.Guilds],
74
  });
 
 
 
 
 
 
 
 
 
 
 
75
 
76
  const healthServer = http.createServer((request, response) => {
77
  response.writeHead(200, { 'Content-Type': 'application/json' });
@@ -95,6 +111,8 @@ async function main() {
95
  await readyClient.application.commands.set(commands);
96
  console.log('Registered global slash commands');
97
  }
 
 
98
  });
99
 
100
  client.on(Events.InteractionCreate, async (interaction) => {
@@ -151,6 +169,7 @@ async function main() {
151
  const shutdown = async () => {
152
  console.log('Shutting down...');
153
  healthServer.close();
 
154
  client.destroy();
155
  await store.close();
156
  process.exit(0);
@@ -237,6 +256,26 @@ async function handleChatInput(interaction, store, config) {
237
  return;
238
  }
239
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  if (commandName === 'alerts') {
241
  await handleAlerts(interaction);
242
  return;
@@ -785,6 +824,95 @@ async function handleAlerts(interaction) {
785
  });
786
  }
787
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
788
  async function handleButton(interaction, store) {
789
  const alertRoleName = parseAlertRoleButtonId(interaction.customId);
790
  if (alertRoleName) {
@@ -870,6 +998,13 @@ async function memberHasRoleName(interaction, roleName) {
870
  return member.roles.cache.some((role) => role.name === roleName);
871
  }
872
 
 
 
 
 
 
 
 
873
  function auditLog(event, payload) {
874
  console.log(`[audit] ${event}`, payload);
875
  }
 
32
  buildBulkAddEmbed,
33
  buildChartAttachment,
34
  buildCommandsEmbed,
35
+ buildCircaDiagnosticEmbed,
36
+ buildCircaAlertEmbed,
37
  buildDeleteBetEmbed,
38
  buildEditBetEmbed,
39
  buildErrorEmbed,
40
  buildExportAttachment,
41
+ buildMarketTopEmbed,
42
  buildResolveAllEmbed,
43
  buildResolveEmbed,
44
  buildRoiEmbed,
45
+ buildScanStatusEmbed,
46
  buildSportsEmbed,
47
  buildSummaryEmbed,
48
  buildWelcomeEmbed,
 
52
  } from './embeds.js';
53
  import { parseBulkAddInput } from './bulk-add.js';
54
  import { parseBetIdList } from './resolve-bulk.js';
55
+ import { MarketScanner } from './market-scanner.js';
56
 
57
  const BET_MODAL_PREFIX = 'bet-entry-modal';
58
  const BULK_ADD_MODAL_PREFIX = 'bulk-add-modal';
 
77
  const client = new Client({
78
  intents: [GatewayIntentBits.Guilds],
79
  });
80
+ const scanner = new MarketScanner({
81
+ client,
82
+ store,
83
+ config: config.scanner,
84
+ embeds: {
85
+ buildMarketTopEmbed,
86
+ buildCircaAlertEmbed,
87
+ },
88
+ logger: console,
89
+ });
90
+ client.__marketScanner = scanner;
91
 
92
  const healthServer = http.createServer((request, response) => {
93
  response.writeHead(200, { 'Content-Type': 'application/json' });
 
111
  await readyClient.application.commands.set(commands);
112
  console.log('Registered global slash commands');
113
  }
114
+
115
+ scanner.start();
116
  });
117
 
118
  client.on(Events.InteractionCreate, async (interaction) => {
 
169
  const shutdown = async () => {
170
  console.log('Shutting down...');
171
  healthServer.close();
172
+ await scanner.stop();
173
  client.destroy();
174
  await store.close();
175
  process.exit(0);
 
256
  return;
257
  }
258
 
259
+ if (commandName === 'scanstatus') {
260
+ await handleScanStatus(interaction, config);
261
+ return;
262
+ }
263
+
264
+ if (commandName === 'scanrun') {
265
+ await handleScanRun(interaction, config);
266
+ return;
267
+ }
268
+
269
+ if (commandName === 'scanreport') {
270
+ await handleScanReport(interaction, config);
271
+ return;
272
+ }
273
+
274
+ if (commandName === 'circatest') {
275
+ await handleCircaTest(interaction, config);
276
+ return;
277
+ }
278
+
279
  if (commandName === 'alerts') {
280
  await handleAlerts(interaction);
281
  return;
 
824
  });
825
  }
826
 
827
+ async function handleScanStatus(interaction, config) {
828
+ const isAdmin = await memberHasRoleName(interaction, config.adminRoleName);
829
+ if (!isAdmin) {
830
+ await denyAdminOnly(interaction, config.adminRoleName);
831
+ return;
832
+ }
833
+
834
+ const scanner = interaction.client.__marketScanner;
835
+ await interaction.reply({
836
+ embeds: [buildScanStatusEmbed(scanner?.getStatus?.() ?? { enabled: false, frequencyMinutes: 0, morningTime: 'N/A', timeZone: 'N/A', minBooks: 0, disagreementThreshold: 0 })],
837
+ flags: MessageFlags.Ephemeral,
838
+ });
839
+ }
840
+
841
+ async function handleScanRun(interaction, config) {
842
+ const isAdmin = await memberHasRoleName(interaction, config.adminRoleName);
843
+ if (!isAdmin) {
844
+ await denyAdminOnly(interaction, config.adminRoleName);
845
+ return;
846
+ }
847
+
848
+ const scanner = interaction.client.__marketScanner;
849
+ if (!scanner?.getStatus().enabled) {
850
+ await interaction.reply({
851
+ embeds: [buildErrorEmbed('Scanner disabled', 'Set the market scan environment variables before running scanner commands.')],
852
+ flags: MessageFlags.Ephemeral,
853
+ });
854
+ return;
855
+ }
856
+
857
+ await interaction.deferReply({ flags: MessageFlags.Ephemeral });
858
+ const analysis = await scanner.runDisagreementScan();
859
+ await interaction.editReply({
860
+ embeds: [
861
+ buildMarketTopEmbed('Manual Scan Complete', analysis.circaAlerts.slice(0, 10), {
862
+ metricLabel: 'Circa Deviation',
863
+ metricFormatter: (row) => `${(row.maxDeviation * 100).toFixed(2)}%`,
864
+ secondaryValue: (row) => `${row.furthestBookName} @ ${row.furthestBookOddsInput}`,
865
+ }),
866
+ ],
867
+ });
868
+ }
869
+
870
+ async function handleScanReport(interaction, config) {
871
+ const isAdmin = await memberHasRoleName(interaction, config.adminRoleName);
872
+ if (!isAdmin) {
873
+ await denyAdminOnly(interaction, config.adminRoleName);
874
+ return;
875
+ }
876
+
877
+ const scanner = interaction.client.__marketScanner;
878
+ if (!scanner?.getStatus().enabled) {
879
+ await interaction.reply({
880
+ embeds: [buildErrorEmbed('Scanner disabled', 'Set the market scan environment variables before running scanner commands.')],
881
+ flags: MessageFlags.Ephemeral,
882
+ });
883
+ return;
884
+ }
885
+
886
+ await interaction.deferReply({ flags: MessageFlags.Ephemeral });
887
+ await scanner.runMorningReport();
888
+ await interaction.editReply({
889
+ embeds: [buildErrorEmbed('Morning report posted', `Sent the discrepancy and width reports to <#${config.scanner.scanReportChannelId}>.`).setColor(0x2563eb)],
890
+ });
891
+ }
892
+
893
+ async function handleCircaTest(interaction, config) {
894
+ const isAdmin = await memberHasRoleName(interaction, config.adminRoleName);
895
+ if (!isAdmin) {
896
+ await denyAdminOnly(interaction, config.adminRoleName);
897
+ return;
898
+ }
899
+
900
+ const scanner = interaction.client.__marketScanner;
901
+ if (!scanner?.getStatus().enabled) {
902
+ await interaction.reply({
903
+ embeds: [buildErrorEmbed('Scanner disabled', 'Set the market scan environment variables before running scanner commands.')],
904
+ flags: MessageFlags.Ephemeral,
905
+ });
906
+ return;
907
+ }
908
+
909
+ await interaction.deferReply({ flags: MessageFlags.Ephemeral });
910
+ const diagnostic = await scanner.runCircaDiagnostic();
911
+ await interaction.editReply({
912
+ embeds: [buildCircaDiagnosticEmbed(diagnostic)],
913
+ });
914
+ }
915
+
916
  async function handleButton(interaction, store) {
917
  const alertRoleName = parseAlertRoleButtonId(interaction.customId);
918
  if (alertRoleName) {
 
998
  return member.roles.cache.some((role) => role.name === roleName);
999
  }
1000
 
1001
+ async function denyAdminOnly(interaction, roleName) {
1002
+ await interaction.reply({
1003
+ embeds: [buildErrorEmbed('Not allowed', `Only members with the **${roleName}** role can use this command.`)],
1004
+ flags: MessageFlags.Ephemeral,
1005
+ });
1006
+ }
1007
+
1008
  function auditLog(event, payload) {
1009
  console.log(`[audit] ${event}`, payload);
1010
  }
src/market-scanner.js ADDED
@@ -0,0 +1,670 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Canvas } from 'skia-canvas';
2
+ import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
3
+ import { createWorker } from 'tesseract.js';
4
+
5
+ const MARKET_TYPE_LABELS = {
6
+ batter_home_runs: 'Home Runs',
7
+ batter_hits: 'Hits',
8
+ batter_total_bases: 'Total Bases',
9
+ batter_rbis: 'RBIs',
10
+ batter_runs_scored: 'Runs',
11
+ batter_hits_runs_rbis: 'Hits + Runs + RBIs',
12
+ pitcher_strikeouts: 'Pitcher Strikeouts',
13
+ home_runs: 'Home Runs',
14
+ hits: 'Hits',
15
+ total_bases: 'Total Bases',
16
+ rbis: 'RBIs',
17
+ runs: 'Runs',
18
+ hits_runs_rbis: 'Hits + Runs + RBIs',
19
+ pitcher_strikeouts_generic: 'Pitcher Strikeouts',
20
+ };
21
+
22
+ const OCR_MARKET_PATTERNS = [
23
+ { type: 'home_runs', label: 'Home Runs', pattern: /(home\s*runs?|hr\b)/i },
24
+ { type: 'total_bases', label: 'Total Bases', pattern: /(total\s*bases?|tb\b)/i },
25
+ { type: 'hits_runs_rbis', label: 'Hits \+ Runs \+ RBIs', pattern: /(hits?\s*\+\s*runs?\s*\+\s*rbis?|hrr?i?)/i },
26
+ { type: 'rbis', label: 'RBIs', pattern: /\brbis?\b/i },
27
+ { type: 'runs', label: 'Runs', pattern: /\bruns?\b/i },
28
+ { type: 'hits', label: 'Hits', pattern: /\bhits?\b/i },
29
+ { type: 'pitcher_strikeouts_generic', label: 'Pitcher Strikeouts', pattern: /(strikeouts?|k['’]?s?\b)/i },
30
+ ];
31
+
32
+ function normalizeWhitespace(value) {
33
+ return String(value ?? '')
34
+ .replace(/\u00a0/g, ' ')
35
+ .replace(/[|]/g, ' ')
36
+ .replace(/\s+/g, ' ')
37
+ .trim();
38
+ }
39
+
40
+ export function normalizePlayerName(name) {
41
+ return normalizeWhitespace(name)
42
+ .replace(/[^a-zA-Z0-9.\-'\s]/g, '')
43
+ .toLowerCase();
44
+ }
45
+
46
+ export function slugify(value) {
47
+ return normalizeWhitespace(value)
48
+ .toLowerCase()
49
+ .replace(/[^a-z0-9]+/g, '-')
50
+ .replace(/^-+|-+$/g, '');
51
+ }
52
+
53
+ export function americanToImpliedProbability(oddsInput) {
54
+ if (!oddsInput) {
55
+ return null;
56
+ }
57
+
58
+ const odds = Number(String(oddsInput).trim());
59
+ if (!Number.isFinite(odds) || odds === 0) {
60
+ return null;
61
+ }
62
+
63
+ if (odds > 0) {
64
+ return Number((100 / (odds + 100)).toFixed(6));
65
+ }
66
+
67
+ return Number((Math.abs(odds) / (Math.abs(odds) + 100)).toFixed(6));
68
+ }
69
+
70
+ function normalizeSide({ marketKey, outcomeName, description, point }) {
71
+ const name = normalizeWhitespace(outcomeName).toLowerCase();
72
+ const desc = normalizeWhitespace(description).toLowerCase();
73
+
74
+ if (desc === 'over' || /^over\b/.test(name)) {
75
+ return 'over';
76
+ }
77
+
78
+ if (desc === 'under' || /^under\b/.test(name)) {
79
+ return 'under';
80
+ }
81
+
82
+ if (marketKey === 'batter_home_runs') {
83
+ return 'yes';
84
+ }
85
+
86
+ return 'yes';
87
+ }
88
+
89
+ function normalizeLine(point, side, marketKey) {
90
+ if (point !== null && point !== undefined && point !== '') {
91
+ const parsed = Number(point);
92
+ return Number.isFinite(parsed) ? parsed : null;
93
+ }
94
+
95
+ if (marketKey === 'batter_home_runs' || side === 'yes') {
96
+ return 0.5;
97
+ }
98
+
99
+ return null;
100
+ }
101
+
102
+ function normalizeMarketLabel(marketKey) {
103
+ return MARKET_TYPE_LABELS[marketKey] ?? marketKey;
104
+ }
105
+
106
+ export function buildMarketKey(entry) {
107
+ return [
108
+ slugify(entry.playerName),
109
+ slugify(entry.marketType),
110
+ entry.side || 'yes',
111
+ entry.lineValue ?? 'na',
112
+ ].join('|');
113
+ }
114
+
115
+ export function normalizeOddsApiEntries(payload = []) {
116
+ const entries = [];
117
+
118
+ for (const event of payload) {
119
+ for (const bookmaker of event.bookmakers ?? []) {
120
+ for (const market of bookmaker.markets ?? []) {
121
+ for (const outcome of market.outcomes ?? []) {
122
+ const impliedProbability = americanToImpliedProbability(outcome.price);
123
+ if (impliedProbability === null) {
124
+ continue;
125
+ }
126
+
127
+ const playerName = normalizeWhitespace(outcome.name);
128
+ if (!playerName) {
129
+ continue;
130
+ }
131
+
132
+ const side = normalizeSide({
133
+ marketKey: market.key,
134
+ outcomeName: outcome.name,
135
+ description: outcome.description,
136
+ point: outcome.point,
137
+ });
138
+ const lineValue = normalizeLine(outcome.point, side, market.key);
139
+ const entry = {
140
+ source: bookmaker.title === 'Circa' ? 'circa' : 'odds_api',
141
+ book: bookmaker.title,
142
+ eventName: `${event.away_team} @ ${event.home_team}`,
143
+ eventId: event.id,
144
+ team: null,
145
+ playerName,
146
+ playerKey: normalizePlayerName(playerName),
147
+ marketType: market.key,
148
+ marketLabel: normalizeMarketLabel(market.key),
149
+ side,
150
+ lineValue,
151
+ oddsInput: String(outcome.price),
152
+ impliedProbability,
153
+ rawLabel: [playerName, outcome.description, outcome.point, outcome.price].filter(Boolean).join(' '),
154
+ };
155
+ entry.marketKey = buildMarketKey(entry);
156
+ entries.push(entry);
157
+ }
158
+ }
159
+ }
160
+ }
161
+
162
+ return entries;
163
+ }
164
+
165
+ function detectMarketType(line) {
166
+ const normalizedLine = normalizeWhitespace(line);
167
+ return OCR_MARKET_PATTERNS.find((candidate) => candidate.pattern.test(normalizedLine)) ?? null;
168
+ }
169
+
170
+ function extractSide(line) {
171
+ const normalizedLine = normalizeWhitespace(line).toLowerCase();
172
+ if (/\bover\b/.test(normalizedLine) || /\bo\s*\d/.test(normalizedLine)) {
173
+ return 'over';
174
+ }
175
+ if (/\bunder\b/.test(normalizedLine) || /\bu\s*\d/.test(normalizedLine)) {
176
+ return 'under';
177
+ }
178
+ if (/\b1\+\b/.test(normalizedLine)) {
179
+ return 'yes';
180
+ }
181
+ return 'yes';
182
+ }
183
+
184
+ function extractLineValue(line, side) {
185
+ const withoutOdds = normalizeWhitespace(line).replace(/([+-]\d{2,6}).*$/, '').trim();
186
+ const matches = [...withoutOdds.matchAll(/(\d+(?:\.\d+)?)/g)];
187
+ if (matches.length === 0) {
188
+ return side === 'yes' ? 0.5 : null;
189
+ }
190
+
191
+ const parsed = Number(matches[matches.length - 1][1]);
192
+ return Number.isFinite(parsed) ? parsed : null;
193
+ }
194
+
195
+ function extractOdds(line) {
196
+ const match = normalizeWhitespace(line).match(/([+-]\d{2,6})/);
197
+ return match ? match[1] : null;
198
+ }
199
+
200
+ function extractPlayerName(line, marketPattern) {
201
+ let cleaned = normalizeWhitespace(line);
202
+ cleaned = cleaned.replace(/([+-]\d{2,6}).*$/, '').trim();
203
+ cleaned = cleaned.replace(/\b(over|under)\b.*$/i, '').trim();
204
+ cleaned = cleaned.replace(/\bo\s*\d.*$/i, '').trim();
205
+ cleaned = cleaned.replace(/\bu\s*\d.*$/i, '').trim();
206
+ if (marketPattern) {
207
+ cleaned = cleaned.replace(marketPattern.pattern, '').trim();
208
+ }
209
+ cleaned = cleaned.replace(/\b\d+(?:\.\d+)?\+?\b/g, '').trim();
210
+ return cleaned;
211
+ }
212
+
213
+ export function parseCircaOcrText(text) {
214
+ const entries = [];
215
+ const lines = String(text ?? '')
216
+ .split(/\n+/)
217
+ .map((line) => normalizeWhitespace(line))
218
+ .filter(Boolean);
219
+
220
+ for (const rawLine of lines) {
221
+ const line = normalizeWhitespace(rawLine);
222
+ const oddsInput = extractOdds(line);
223
+ if (!oddsInput) {
224
+ continue;
225
+ }
226
+
227
+ const marketPattern = detectMarketType(line);
228
+ if (!marketPattern) {
229
+ continue;
230
+ }
231
+
232
+ const playerName = extractPlayerName(line, marketPattern);
233
+ if (!playerName || playerName.length < 3) {
234
+ continue;
235
+ }
236
+
237
+ const side = extractSide(line);
238
+ const lineValue = extractLineValue(line, side);
239
+ const impliedProbability = americanToImpliedProbability(oddsInput);
240
+ if (impliedProbability === null) {
241
+ continue;
242
+ }
243
+
244
+ const entry = {
245
+ source: 'circa',
246
+ book: 'Circa',
247
+ eventName: 'Circa MLB',
248
+ eventId: null,
249
+ team: null,
250
+ playerName,
251
+ playerKey: normalizePlayerName(playerName),
252
+ marketType: marketPattern.type,
253
+ marketLabel: marketPattern.label,
254
+ side,
255
+ lineValue,
256
+ oddsInput,
257
+ impliedProbability,
258
+ rawLabel: line,
259
+ };
260
+ entry.marketKey = buildMarketKey(entry);
261
+ entries.push(entry);
262
+ }
263
+
264
+ return dedupeBy(entries, (entry) => `${entry.marketKey}|${entry.book}`);
265
+ }
266
+
267
+ function dedupeBy(values, getKey) {
268
+ const seen = new Set();
269
+ const output = [];
270
+
271
+ for (const value of values) {
272
+ const key = getKey(value);
273
+ if (seen.has(key)) {
274
+ continue;
275
+ }
276
+ seen.add(key);
277
+ output.push(value);
278
+ }
279
+
280
+ return output;
281
+ }
282
+
283
+ function trimmedAverage(values) {
284
+ if (values.length === 0) {
285
+ return null;
286
+ }
287
+
288
+ const sorted = [...values].sort((left, right) => left - right);
289
+ const trimmed = sorted.length >= 5 ? sorted.slice(1, -1) : sorted;
290
+ const total = trimmed.reduce((sum, value) => sum + value, 0);
291
+ return total / trimmed.length;
292
+ }
293
+
294
+ export function analyzeMarkets(entries, config = {}) {
295
+ const grouped = new Map();
296
+
297
+ for (const entry of entries) {
298
+ if (!grouped.has(entry.marketKey)) {
299
+ grouped.set(entry.marketKey, []);
300
+ }
301
+ grouped.get(entry.marketKey).push(entry);
302
+ }
303
+
304
+ const discrepancies = [];
305
+ const widths = [];
306
+ const circaAlerts = [];
307
+
308
+ for (const groupEntries of grouped.values()) {
309
+ const marketBooks = groupEntries.filter((entry) => entry.book !== 'Circa');
310
+ if (marketBooks.length < 2) {
311
+ continue;
312
+ }
313
+
314
+ const impliedProbabilities = marketBooks.map((entry) => entry.impliedProbability).filter((value) => value !== null);
315
+ if (impliedProbabilities.length < 2) {
316
+ continue;
317
+ }
318
+
319
+ const consensusImplied = trimmedAverage(impliedProbabilities);
320
+ const bestBook = marketBooks.reduce((best, entry) =>
321
+ entry.impliedProbability < best.impliedProbability ? entry : best, marketBooks[0]);
322
+ const width = Math.max(...impliedProbabilities) - Math.min(...impliedProbabilities);
323
+
324
+ discrepancies.push({
325
+ ...bestBook,
326
+ consensusImpliedProbability: consensusImplied,
327
+ valueEdge: consensusImplied !== null ? Number((consensusImplied - bestBook.impliedProbability).toFixed(6)) : 0,
328
+ books: marketBooks.length,
329
+ });
330
+
331
+ widths.push({
332
+ ...bestBook,
333
+ width,
334
+ books: marketBooks.length,
335
+ bestOddsInput: marketBooks.reduce((best, entry) => entry.impliedProbability < best.impliedProbability ? entry : best, marketBooks[0]).oddsInput,
336
+ worstOddsInput: marketBooks.reduce((worst, entry) => entry.impliedProbability > worst.impliedProbability ? entry : worst, marketBooks[0]).oddsInput,
337
+ });
338
+
339
+ const circaEntry = groupEntries.find((entry) => entry.book === 'Circa');
340
+ if (!circaEntry) {
341
+ continue;
342
+ }
343
+
344
+ if (marketBooks.length < (config.minBooks ?? 3)) {
345
+ continue;
346
+ }
347
+
348
+ const maxDeviation = Math.max(
349
+ ...marketBooks.map((entry) => Math.abs(entry.impliedProbability - circaEntry.impliedProbability))
350
+ );
351
+
352
+ if (maxDeviation >= (config.disagreementThreshold ?? 0.08)) {
353
+ const furthestBook = marketBooks.reduce((furthest, entry) =>
354
+ Math.abs(entry.impliedProbability - circaEntry.impliedProbability) > Math.abs(furthest.impliedProbability - circaEntry.impliedProbability)
355
+ ? entry
356
+ : furthest,
357
+ marketBooks[0]);
358
+ circaAlerts.push({
359
+ ...circaEntry,
360
+ books: marketBooks.length,
361
+ marketAverageImpliedProbability: trimmedAverage(impliedProbabilities),
362
+ maxDeviation,
363
+ furthestBookName: furthestBook.book,
364
+ furthestBookOddsInput: furthestBook.oddsInput,
365
+ marketKey: circaEntry.marketKey,
366
+ });
367
+ }
368
+ }
369
+
370
+ return {
371
+ discrepancies: discrepancies.sort((left, right) => right.valueEdge - left.valueEdge),
372
+ widths: widths.sort((left, right) => right.width - left.width),
373
+ circaAlerts: circaAlerts.sort((left, right) => right.maxDeviation - left.maxDeviation),
374
+ };
375
+ }
376
+
377
+ async function fetchArrayBuffer(url) {
378
+ const response = await fetch(url);
379
+ if (!response.ok) {
380
+ throw new Error(`Request failed with ${response.status} for ${url}`);
381
+ }
382
+ return response.arrayBuffer();
383
+ }
384
+
385
+ async function extractPdfText(buffer) {
386
+ const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(buffer), useWorkerFetch: false, isEvalSupported: false });
387
+ const pdf = await loadingTask.promise;
388
+ let text = '';
389
+
390
+ for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) {
391
+ const page = await pdf.getPage(pageNumber);
392
+ const textContent = await page.getTextContent();
393
+ const pageText = textContent.items.map((item) => item.str).join(' ').trim();
394
+ if (pageText.length > 20) {
395
+ text += `${pageText}\n`;
396
+ continue;
397
+ }
398
+
399
+ const viewport = page.getViewport({ scale: 2 });
400
+ const canvas = new Canvas(Math.ceil(viewport.width), Math.ceil(viewport.height));
401
+ const context = canvas.getContext('2d');
402
+ await page.render({ canvasContext: context, viewport }).promise;
403
+ const pngBuffer = await canvas.toBuffer('png');
404
+ text += `${await recognizeTextFromImage(pngBuffer)}\n`;
405
+ }
406
+
407
+ return text;
408
+ }
409
+
410
+ let ocrWorkerPromise = null;
411
+
412
+ async function getOcrWorker() {
413
+ if (!ocrWorkerPromise) {
414
+ ocrWorkerPromise = createWorker('eng');
415
+ }
416
+
417
+ return ocrWorkerPromise;
418
+ }
419
+
420
+ async function recognizeTextFromImage(imageBuffer) {
421
+ const worker = await getOcrWorker();
422
+ const result = await worker.recognize(imageBuffer);
423
+ return result.data.text ?? '';
424
+ }
425
+
426
+ export async function fetchCircaEntries(config) {
427
+ const buffer = await fetchArrayBuffer(config.circaDropboxUrl);
428
+ const text = await extractPdfText(buffer);
429
+ return {
430
+ text,
431
+ entries: parseCircaOcrText(text),
432
+ };
433
+ }
434
+
435
+ export async function fetchOddsApiEntries(config) {
436
+ const url = new URL(`${config.oddsApiBaseUrl.replace(/\/$/, '')}/sports/${config.oddsApiSportKey}/odds/`);
437
+ url.searchParams.set('apiKey', config.oddsApiKey);
438
+ url.searchParams.set('regions', config.oddsApiRegions);
439
+ url.searchParams.set('markets', config.oddsApiMarkets.join(','));
440
+ url.searchParams.set('oddsFormat', 'american');
441
+ url.searchParams.set('dateFormat', 'iso');
442
+
443
+ const response = await fetch(url);
444
+ if (!response.ok) {
445
+ throw new Error(`Odds API request failed with ${response.status}`);
446
+ }
447
+
448
+ const payload = await response.json();
449
+ return normalizeOddsApiEntries(payload);
450
+ }
451
+
452
+ function formatZonedParts(date, timeZone) {
453
+ const parts = new Intl.DateTimeFormat('en-US', {
454
+ timeZone,
455
+ hour12: false,
456
+ year: 'numeric',
457
+ month: '2-digit',
458
+ day: '2-digit',
459
+ hour: '2-digit',
460
+ minute: '2-digit',
461
+ }).formatToParts(date);
462
+
463
+ return Object.fromEntries(parts.filter((part) => part.type !== 'literal').map((part) => [part.type, part.value]));
464
+ }
465
+
466
+ export function getZonedDateKey(date, timeZone) {
467
+ const parts = formatZonedParts(date, timeZone);
468
+ return `${parts.year}-${parts.month}-${parts.day}`;
469
+ }
470
+
471
+ function getZonedTime(date, timeZone) {
472
+ const parts = formatZonedParts(date, timeZone);
473
+ return `${parts.hour}:${parts.minute}`;
474
+ }
475
+
476
+ export class MarketScanner {
477
+ constructor({ client, store, config, embeds, logger = console }) {
478
+ this.client = client;
479
+ this.store = store;
480
+ this.config = config;
481
+ this.embeds = embeds;
482
+ this.logger = logger;
483
+ this.scanTimer = null;
484
+ this.reportTimer = null;
485
+ this.running = false;
486
+ this.status = {
487
+ enabled: config.enabled,
488
+ lastScanAt: null,
489
+ lastReportAt: null,
490
+ lastScanError: null,
491
+ lastCircaEntries: 0,
492
+ lastApiEntries: 0,
493
+ lastAlertCount: 0,
494
+ };
495
+ }
496
+
497
+ start() {
498
+ if (!this.config.enabled) {
499
+ this.logger.log('Market scanner disabled: missing scan configuration');
500
+ return;
501
+ }
502
+
503
+ this.logger.log('Market scanner enabled', {
504
+ reportChannelId: this.config.scanReportChannelId,
505
+ alertChannelId: this.config.scanAlertChannelId,
506
+ morningTime: this.config.scanMorningTime,
507
+ timeZone: this.config.scanTimeZone,
508
+ frequencyMinutes: this.config.scanFrequencyMinutes,
509
+ });
510
+
511
+ this.scanTimer = setInterval(() => {
512
+ this.runDisagreementScan().catch((error) => {
513
+ this.status.lastScanError = error.message;
514
+ this.logger.error('Scanner interval failed', error);
515
+ });
516
+ }, this.config.scanFrequencyMinutes * 60 * 1000);
517
+
518
+ this.reportTimer = setInterval(() => {
519
+ this.maybeRunMorningReport().catch((error) => {
520
+ this.status.lastScanError = error.message;
521
+ this.logger.error('Morning report interval failed', error);
522
+ });
523
+ }, 60 * 1000);
524
+
525
+ this.runDisagreementScan().catch((error) => {
526
+ this.status.lastScanError = error.message;
527
+ this.logger.error('Initial scanner run failed', error);
528
+ });
529
+ }
530
+
531
+ async stop() {
532
+ if (this.scanTimer) {
533
+ clearInterval(this.scanTimer);
534
+ }
535
+ if (this.reportTimer) {
536
+ clearInterval(this.reportTimer);
537
+ }
538
+ if (ocrWorkerPromise) {
539
+ const worker = await ocrWorkerPromise;
540
+ await worker.terminate();
541
+ ocrWorkerPromise = null;
542
+ }
543
+ }
544
+
545
+ getStatus() {
546
+ return {
547
+ ...this.status,
548
+ reportChannelId: this.config.scanReportChannelId,
549
+ alertChannelId: this.config.scanAlertChannelId,
550
+ morningTime: this.config.scanMorningTime,
551
+ timeZone: this.config.scanTimeZone,
552
+ frequencyMinutes: this.config.scanFrequencyMinutes,
553
+ minBooks: this.config.scanMinBooks,
554
+ disagreementThreshold: this.config.scanDisagreementThreshold,
555
+ };
556
+ }
557
+
558
+ async maybeRunMorningReport(now = new Date()) {
559
+ if (!this.config.enabled) {
560
+ return null;
561
+ }
562
+
563
+ const dateKey = getZonedDateKey(now, this.config.scanTimeZone);
564
+ if (getZonedTime(now, this.config.scanTimeZone) !== this.config.scanMorningTime) {
565
+ return null;
566
+ }
567
+
568
+ const hasReport = await this.store.hasScanReport(dateKey, 'morning');
569
+ if (hasReport) {
570
+ return null;
571
+ }
572
+
573
+ return this.runMorningReport(dateKey);
574
+ }
575
+
576
+ async runMorningReport(dateKey = getZonedDateKey(new Date(), this.config.scanTimeZone)) {
577
+ const analysis = await this.collectMarketAnalysis('morning');
578
+ const channel = await this.client.channels.fetch(this.config.scanReportChannelId);
579
+ if (!channel?.isTextBased()) {
580
+ throw new Error(`Report channel ${this.config.scanReportChannelId} is not text-based`);
581
+ }
582
+
583
+ const discrepancyMessage = await channel.send({
584
+ embeds: [this.embeds.buildMarketTopEmbed('Top 10 Discrepancies', analysis.discrepancies.slice(0, 10), {
585
+ metricLabel: 'Consensus Edge',
586
+ metricFormatter: (row) => `${(row.valueEdge * 100).toFixed(2)}%`,
587
+ })],
588
+ });
589
+ const widthMessage = await channel.send({
590
+ embeds: [this.embeds.buildMarketTopEmbed('Top 10 Market Width', analysis.widths.slice(0, 10), {
591
+ metricLabel: 'Implied Width',
592
+ metricFormatter: (row) => `${(row.width * 100).toFixed(2)}%`,
593
+ secondaryValue: (row) => `${row.bestOddsInput} -> ${row.worstOddsInput}`,
594
+ })],
595
+ });
596
+
597
+ await this.store.recordScanReport(dateKey, 'morning', this.config.scanReportChannelId, discrepancyMessage.id, widthMessage.id);
598
+ this.status.lastReportAt = new Date().toISOString();
599
+ return analysis;
600
+ }
601
+
602
+ async runDisagreementScan() {
603
+ const analysis = await this.collectMarketAnalysis('scan');
604
+ const channel = await this.client.channels.fetch(this.config.scanAlertChannelId);
605
+ if (!channel?.isTextBased()) {
606
+ throw new Error(`Alert channel ${this.config.scanAlertChannelId} is not text-based`);
607
+ }
608
+
609
+ let sentCount = 0;
610
+ for (const alert of analysis.circaAlerts) {
611
+ const cooldownOk = await this.store.canSendScanAlert(
612
+ alert.marketKey,
613
+ this.config.scanAlertCooldownMinutes,
614
+ );
615
+ if (!cooldownOk) {
616
+ continue;
617
+ }
618
+
619
+ await channel.send({
620
+ embeds: [this.embeds.buildCircaAlertEmbed(alert)],
621
+ });
622
+ await this.store.recordScanAlert(alert.marketKey);
623
+ sentCount += 1;
624
+ }
625
+
626
+ this.status.lastAlertCount = sentCount;
627
+ return analysis;
628
+ }
629
+
630
+ async runCircaDiagnostic() {
631
+ const result = await fetchCircaEntries(this.config);
632
+ return {
633
+ rawTextSample: result.text.slice(0, 1000),
634
+ parsedEntries: result.entries.slice(0, 10),
635
+ totalEntries: result.entries.length,
636
+ };
637
+ }
638
+
639
+ async collectMarketAnalysis(scanType) {
640
+ if (this.running) {
641
+ throw new Error('Market scanner is already running');
642
+ }
643
+
644
+ this.running = true;
645
+ try {
646
+ const [oddsEntries, circaResult] = await Promise.all([
647
+ fetchOddsApiEntries(this.config),
648
+ fetchCircaEntries(this.config),
649
+ ]);
650
+ const allEntries = [...oddsEntries, ...circaResult.entries];
651
+ const analysis = analyzeMarkets(allEntries, {
652
+ minBooks: this.config.scanMinBooks,
653
+ disagreementThreshold: this.config.scanDisagreementThreshold,
654
+ });
655
+
656
+ await this.store.recordMarketSnapshot(scanType, allEntries);
657
+ this.status.lastScanAt = new Date().toISOString();
658
+ this.status.lastScanError = null;
659
+ this.status.lastApiEntries = oddsEntries.length;
660
+ this.status.lastCircaEntries = circaResult.entries.length;
661
+
662
+ return analysis;
663
+ } catch (error) {
664
+ this.status.lastScanError = error.message;
665
+ throw error;
666
+ } finally {
667
+ this.running = false;
668
+ }
669
+ }
670
+ }
test/market-scanner.test.js ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {
4
+ americanToImpliedProbability,
5
+ analyzeMarkets,
6
+ buildMarketKey,
7
+ normalizeOddsApiEntries,
8
+ parseCircaOcrText,
9
+ } from '../src/market-scanner.js';
10
+
11
+ test('converts american odds to implied probability', () => {
12
+ assert.equal(americanToImpliedProbability('+100'), 0.5);
13
+ assert.equal(americanToImpliedProbability('-150'), 0.6);
14
+ });
15
+
16
+ test('normalizes odds api entries into shared market keys', () => {
17
+ const entries = normalizeOddsApiEntries([
18
+ {
19
+ id: 'event-1',
20
+ away_team: 'Yankees',
21
+ home_team: 'Red Sox',
22
+ bookmakers: [
23
+ {
24
+ title: 'FanDuel',
25
+ markets: [
26
+ {
27
+ key: 'batter_total_bases',
28
+ outcomes: [
29
+ { name: 'Aaron Judge', description: 'Over', point: 1.5, price: -110 },
30
+ ],
31
+ },
32
+ ],
33
+ },
34
+ ],
35
+ },
36
+ ]);
37
+
38
+ assert.equal(entries.length, 1);
39
+ assert.equal(entries[0].marketKey, buildMarketKey(entries[0]));
40
+ assert.equal(entries[0].side, 'over');
41
+ });
42
+
43
+ test('parses circa OCR lines into normalized entries', () => {
44
+ const entries = parseCircaOcrText('Aaron Judge Over 1.5 Total Bases -110\nJuan Soto Over 0.5 Home Runs +250');
45
+
46
+ assert.equal(entries.length, 2);
47
+ assert.equal(entries[0].book, 'Circa');
48
+ assert.equal(entries[0].marketLabel, 'Total Bases');
49
+ });
50
+
51
+ test('ranks discrepancy, width, and circa alerts', () => {
52
+ const entries = [
53
+ {
54
+ marketKey: 'judge|total-bases|over|1.5',
55
+ playerName: 'Aaron Judge',
56
+ marketLabel: 'Total Bases',
57
+ marketType: 'batter_total_bases',
58
+ side: 'over',
59
+ lineValue: 1.5,
60
+ book: 'FanDuel',
61
+ oddsInput: '+110',
62
+ impliedProbability: americanToImpliedProbability('+110'),
63
+ },
64
+ {
65
+ marketKey: 'judge|total-bases|over|1.5',
66
+ playerName: 'Aaron Judge',
67
+ marketLabel: 'Total Bases',
68
+ marketType: 'batter_total_bases',
69
+ side: 'over',
70
+ lineValue: 1.5,
71
+ book: 'DraftKings',
72
+ oddsInput: '-120',
73
+ impliedProbability: americanToImpliedProbability('-120'),
74
+ },
75
+ {
76
+ marketKey: 'judge|total-bases|over|1.5',
77
+ playerName: 'Aaron Judge',
78
+ marketLabel: 'Total Bases',
79
+ marketType: 'batter_total_bases',
80
+ side: 'over',
81
+ lineValue: 1.5,
82
+ book: 'BetMGM',
83
+ oddsInput: '-125',
84
+ impliedProbability: americanToImpliedProbability('-125'),
85
+ },
86
+ {
87
+ marketKey: 'judge|total-bases|over|1.5',
88
+ playerName: 'Aaron Judge',
89
+ marketLabel: 'Total Bases',
90
+ marketType: 'batter_total_bases',
91
+ side: 'over',
92
+ lineValue: 1.5,
93
+ book: 'Circa',
94
+ oddsInput: '-150',
95
+ impliedProbability: americanToImpliedProbability('-150'),
96
+ },
97
+ ];
98
+
99
+ const analysis = analyzeMarkets(entries, { minBooks: 3, disagreementThreshold: 0.05 });
100
+
101
+ assert.equal(analysis.discrepancies.length, 1);
102
+ assert.equal(analysis.widths.length, 1);
103
+ assert.equal(analysis.circaAlerts.length, 1);
104
+ assert.equal(analysis.circaAlerts[0].furthestBookName, 'FanDuel');
105
+ });