Codex commited on
Commit
11e6e87
·
1 Parent(s): a0f98a7

Add Circa daily board and movement feed

Browse files
.env.example CHANGED
@@ -9,6 +9,11 @@ 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
 
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
+ CIRCA_CHANNEL_ID=
13
+ CIRCA_DAILY_TIME=09:30
14
+ CIRCA_TIMEZONE=America/Chicago
15
+ CIRCA_RETRY_MINUTES=30
16
+ CIRCA_MOVEMENT_FREQUENCY_MINUTES=5
17
  SCAN_REPORT_CHANNEL_ID=
18
  SCAN_ALERT_CHANNEL_ID=
19
  SCAN_MORNING_TIME=08:00
deploy/oracle/roibot.env.example CHANGED
@@ -9,6 +9,11 @@ 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
 
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
+ CIRCA_CHANNEL_ID=
13
+ CIRCA_DAILY_TIME=09:30
14
+ CIRCA_TIMEZONE=America/Chicago
15
+ CIRCA_RETRY_MINUTES=30
16
+ CIRCA_MOVEMENT_FREQUENCY_MINUTES=5
17
  SCAN_REPORT_CHANNEL_ID=
18
  SCAN_ALERT_CHANNEL_ID=
19
  SCAN_MORNING_TIME=08:00
src/commands.js CHANGED
@@ -211,6 +211,45 @@ export const commands = [
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.'),
 
211
  new SlashCommandBuilder()
212
  .setName('circatest')
213
  .setDescription('Run a Circa OCR diagnostic and preview parsed entries.'),
214
+ new SlashCommandBuilder()
215
+ .setName('circamarket')
216
+ .setDescription('Show the latest Circa market in the current channel.')
217
+ .addStringOption((option) =>
218
+ option
219
+ .setName('market')
220
+ .setDescription('Choose which Circa market to show.')
221
+ .setRequired(true)
222
+ .addChoices(
223
+ { name: 'Home Runs', value: 'home_runs' },
224
+ { name: 'Hits', value: 'hits' },
225
+ { name: 'Total Bases', value: 'total_bases' },
226
+ { name: 'RBIs', value: 'rbis' },
227
+ { name: 'Runs', value: 'runs' },
228
+ { name: 'Hits + Runs + RBIs', value: 'hits_runs_rbis' },
229
+ { name: 'Pitcher Strikeouts', value: 'pitcher_strikeouts_generic' }
230
+ )
231
+ ),
232
+ new SlashCommandBuilder()
233
+ .setName('circahr')
234
+ .setDescription('Show the latest Circa Home Runs market in this channel.'),
235
+ new SlashCommandBuilder()
236
+ .setName('circahits')
237
+ .setDescription('Show the latest Circa Hits market in this channel.'),
238
+ new SlashCommandBuilder()
239
+ .setName('circatb')
240
+ .setDescription('Show the latest Circa Total Bases market in this channel.'),
241
+ new SlashCommandBuilder()
242
+ .setName('circarbis')
243
+ .setDescription('Show the latest Circa RBIs market in this channel.'),
244
+ new SlashCommandBuilder()
245
+ .setName('circaruns')
246
+ .setDescription('Show the latest Circa Runs market in this channel.'),
247
+ new SlashCommandBuilder()
248
+ .setName('circahrri')
249
+ .setDescription('Show the latest Circa Hits + Runs + RBIs market in this channel.'),
250
+ new SlashCommandBuilder()
251
+ .setName('circak')
252
+ .setDescription('Show the latest Circa Pitcher Strikeouts market in this channel.'),
253
  new SlashCommandBuilder()
254
  .setName('alerts')
255
  .setDescription('Post the analyst alert-role embed to the welcome channel.'),
src/config.js CHANGED
@@ -23,6 +23,11 @@ export function getConfig() {
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.');
@@ -40,11 +45,20 @@ export function getConfig() {
40
  adminRoleName,
41
  scanner: {
42
  enabled: Boolean(
 
 
 
 
 
 
 
 
43
  oddsApiKey
44
  && scanReportChannelId
45
  && scanAlertChannelId
46
  && circaDropboxUrl
47
  ),
 
48
  oddsApiKey,
49
  oddsApiBaseUrl,
50
  oddsApiSportKey,
@@ -59,6 +73,11 @@ export function getConfig() {
59
  scanDisagreementThreshold,
60
  scanAlertCooldownMinutes,
61
  scanFrequencyMinutes,
 
 
 
 
 
62
  },
63
  };
64
  }
 
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
+ const circaChannelId = process.env.CIRCA_CHANNEL_ID?.trim() || null;
27
+ const circaDailyTime = process.env.CIRCA_DAILY_TIME?.trim() || '09:30';
28
+ const circaTimeZone = process.env.CIRCA_TIMEZONE?.trim() || 'America/Chicago';
29
+ const circaRetryMinutes = Number(process.env.CIRCA_RETRY_MINUTES || 30);
30
+ const circaMovementFrequencyMinutes = Number(process.env.CIRCA_MOVEMENT_FREQUENCY_MINUTES || 5);
31
 
32
  if (!token) {
33
  throw new Error('Missing DISCORD_TOKEN in environment.');
 
45
  adminRoleName,
46
  scanner: {
47
  enabled: Boolean(
48
+ circaDropboxUrl
49
+ && circaChannelId
50
+ || oddsApiKey
51
+ && scanReportChannelId
52
+ && scanAlertChannelId
53
+ && circaDropboxUrl
54
+ ),
55
+ oddsWorkflowEnabled: Boolean(
56
  oddsApiKey
57
  && scanReportChannelId
58
  && scanAlertChannelId
59
  && circaDropboxUrl
60
  ),
61
+ circaWorkflowEnabled: Boolean(circaDropboxUrl && circaChannelId),
62
  oddsApiKey,
63
  oddsApiBaseUrl,
64
  oddsApiSportKey,
 
73
  scanDisagreementThreshold,
74
  scanAlertCooldownMinutes,
75
  scanFrequencyMinutes,
76
+ circaChannelId,
77
+ circaDailyTime,
78
+ circaTimeZone,
79
+ circaRetryMinutes,
80
+ circaMovementFrequencyMinutes,
81
  },
82
  };
83
  }
src/db.js CHANGED
@@ -281,6 +281,52 @@ export class BetStore {
281
  last_sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
282
  );
283
  `);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
  }
285
 
286
  async upsertUser(user) {
@@ -784,6 +830,267 @@ export class BetStore {
784
  );
785
  }
786
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
787
  async close() {
788
  await this.pool.end();
789
  }
 
281
  last_sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
282
  );
283
  `);
284
+
285
+ await this.pool.query(`
286
+ CREATE TABLE IF NOT EXISTS circa_snapshots (
287
+ id INT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
288
+ file_name TEXT NOT NULL,
289
+ file_source TEXT,
290
+ fingerprint TEXT NOT NULL UNIQUE,
291
+ file_date DATE,
292
+ seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
293
+ entry_count INT NOT NULL DEFAULT 0
294
+ );
295
+ `);
296
+
297
+ await this.pool.query(`
298
+ CREATE TABLE IF NOT EXISTS circa_snapshot_entries (
299
+ id INT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
300
+ snapshot_id INT NOT NULL REFERENCES circa_snapshots(id) ON DELETE CASCADE,
301
+ market_key TEXT NOT NULL,
302
+ player_name TEXT NOT NULL,
303
+ team TEXT,
304
+ market_type TEXT NOT NULL,
305
+ market_label TEXT NOT NULL,
306
+ side TEXT NOT NULL,
307
+ line_value DOUBLE PRECISION,
308
+ odds_input TEXT NOT NULL,
309
+ implied_probability DOUBLE PRECISION NOT NULL,
310
+ raw_label TEXT
311
+ );
312
+ `);
313
+
314
+ await this.pool.query(`
315
+ CREATE TABLE IF NOT EXISTS circa_daily_posts (
316
+ post_date DATE PRIMARY KEY,
317
+ snapshot_id INT NOT NULL REFERENCES circa_snapshots(id),
318
+ channel_id TEXT NOT NULL,
319
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
320
+ );
321
+ `);
322
+
323
+ await this.pool.query(`
324
+ CREATE TABLE IF NOT EXISTS circa_movement_posts (
325
+ movement_key TEXT PRIMARY KEY,
326
+ last_snapshot_fingerprint TEXT NOT NULL,
327
+ last_sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
328
+ );
329
+ `);
330
  }
331
 
332
  async upsertUser(user) {
 
830
  );
831
  }
832
 
833
+ async getLatestCircaSnapshot() {
834
+ const { rows } = await this.pool.query(
835
+ `
836
+ SELECT *
837
+ FROM circa_snapshots
838
+ ORDER BY seen_at DESC
839
+ LIMIT 1
840
+ `
841
+ );
842
+
843
+ if (!rows[0]) {
844
+ return null;
845
+ }
846
+
847
+ return this.getCircaSnapshotById(Number(rows[0].id));
848
+ }
849
+
850
+ async getPreviousCircaSnapshot(snapshotId) {
851
+ const { rows } = await this.pool.query(
852
+ `
853
+ SELECT *
854
+ FROM circa_snapshots
855
+ WHERE id < $1
856
+ ORDER BY id DESC
857
+ LIMIT 1
858
+ `,
859
+ [snapshotId]
860
+ );
861
+
862
+ if (!rows[0]) {
863
+ return null;
864
+ }
865
+
866
+ return this.getCircaSnapshotById(Number(rows[0].id));
867
+ }
868
+
869
+ async getCircaSnapshotByFingerprint(fingerprint) {
870
+ const { rows } = await this.pool.query(
871
+ `
872
+ SELECT *
873
+ FROM circa_snapshots
874
+ WHERE fingerprint = $1
875
+ LIMIT 1
876
+ `,
877
+ [fingerprint]
878
+ );
879
+
880
+ if (!rows[0]) {
881
+ return null;
882
+ }
883
+
884
+ return this.getCircaSnapshotById(Number(rows[0].id));
885
+ }
886
+
887
+ async getCircaSnapshotById(snapshotId) {
888
+ const { rows } = await this.pool.query(
889
+ `
890
+ SELECT *
891
+ FROM circa_snapshots
892
+ WHERE id = $1
893
+ LIMIT 1
894
+ `,
895
+ [snapshotId]
896
+ );
897
+
898
+ if (!rows[0]) {
899
+ return null;
900
+ }
901
+
902
+ const snapshotRow = rows[0];
903
+ const entryResult = await this.pool.query(
904
+ `
905
+ SELECT *
906
+ FROM circa_snapshot_entries
907
+ WHERE snapshot_id = $1
908
+ ORDER BY market_label, player_name, side, line_value NULLS FIRST
909
+ `,
910
+ [snapshotId]
911
+ );
912
+
913
+ return {
914
+ id: Number(snapshotRow.id),
915
+ fileName: snapshotRow.file_name,
916
+ fileSource: snapshotRow.file_source,
917
+ fingerprint: snapshotRow.fingerprint,
918
+ fileDate: snapshotRow.file_date ? new Date(snapshotRow.file_date).toISOString().slice(0, 10) : null,
919
+ seenAt: snapshotRow.seen_at?.toISOString?.() ?? String(snapshotRow.seen_at),
920
+ entryCount: Number(snapshotRow.entry_count),
921
+ entries: entryResult.rows.map((entry) => ({
922
+ marketKey: entry.market_key,
923
+ playerName: entry.player_name,
924
+ team: entry.team,
925
+ marketType: entry.market_type,
926
+ marketLabel: entry.market_label,
927
+ side: entry.side,
928
+ lineValue: numberOrNull(entry.line_value),
929
+ oddsInput: entry.odds_input,
930
+ impliedProbability: Number(entry.implied_probability),
931
+ rawLabel: entry.raw_label,
932
+ })),
933
+ };
934
+ }
935
+
936
+ async recordCircaSnapshot(snapshot) {
937
+ const existing = await this.getCircaSnapshotByFingerprint(snapshot.fingerprint);
938
+ if (existing) {
939
+ return existing;
940
+ }
941
+
942
+ const client = await this.pool.connect();
943
+
944
+ try {
945
+ await client.query('BEGIN');
946
+ const { rows } = await client.query(
947
+ `
948
+ INSERT INTO circa_snapshots (file_name, file_source, fingerprint, file_date, entry_count)
949
+ VALUES ($1, $2, $3, $4, $5)
950
+ RETURNING id
951
+ `,
952
+ [
953
+ snapshot.fileName,
954
+ snapshot.fileSource ?? null,
955
+ snapshot.fingerprint,
956
+ snapshot.fileDate ?? null,
957
+ snapshot.entries.length,
958
+ ]
959
+ );
960
+ const snapshotId = Number(rows[0].id);
961
+
962
+ for (const entry of snapshot.entries) {
963
+ await client.query(
964
+ `
965
+ INSERT INTO circa_snapshot_entries (
966
+ snapshot_id,
967
+ market_key,
968
+ player_name,
969
+ team,
970
+ market_type,
971
+ market_label,
972
+ side,
973
+ line_value,
974
+ odds_input,
975
+ implied_probability,
976
+ raw_label
977
+ )
978
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
979
+ `,
980
+ [
981
+ snapshotId,
982
+ entry.marketKey,
983
+ entry.playerName,
984
+ entry.team ?? null,
985
+ entry.marketType,
986
+ entry.marketLabel,
987
+ entry.side,
988
+ entry.lineValue ?? null,
989
+ entry.oddsInput,
990
+ entry.impliedProbability,
991
+ entry.rawLabel ?? null,
992
+ ]
993
+ );
994
+ }
995
+
996
+ await client.query('COMMIT');
997
+ return this.getCircaSnapshotById(snapshotId);
998
+ } catch (error) {
999
+ await client.query('ROLLBACK');
1000
+ throw error;
1001
+ } finally {
1002
+ client.release();
1003
+ }
1004
+ }
1005
+
1006
+ async getCircaDailyPost(postDate) {
1007
+ const { rows } = await this.pool.query(
1008
+ `
1009
+ SELECT post_date, snapshot_id, channel_id, created_at
1010
+ FROM circa_daily_posts
1011
+ WHERE post_date = $1
1012
+ `,
1013
+ [postDate]
1014
+ );
1015
+
1016
+ if (!rows[0]) {
1017
+ return null;
1018
+ }
1019
+
1020
+ return {
1021
+ postDate: rows[0].post_date?.toISOString?.().slice(0, 10) ?? String(rows[0].post_date),
1022
+ snapshotId: Number(rows[0].snapshot_id),
1023
+ channelId: rows[0].channel_id,
1024
+ createdAt: rows[0].created_at?.toISOString?.() ?? String(rows[0].created_at),
1025
+ };
1026
+ }
1027
+
1028
+ async recordCircaDailyPost(postDate, snapshotId, channelId) {
1029
+ await this.pool.query(
1030
+ `
1031
+ INSERT INTO circa_daily_posts (post_date, snapshot_id, channel_id, created_at)
1032
+ VALUES ($1, $2, $3, NOW())
1033
+ ON CONFLICT (post_date) DO UPDATE SET
1034
+ snapshot_id = EXCLUDED.snapshot_id,
1035
+ channel_id = EXCLUDED.channel_id,
1036
+ created_at = NOW()
1037
+ `,
1038
+ [postDate, snapshotId, channelId]
1039
+ );
1040
+ }
1041
+
1042
+ async getLatestCircaDailyPost() {
1043
+ const { rows } = await this.pool.query(
1044
+ `
1045
+ SELECT post_date, snapshot_id, channel_id, created_at
1046
+ FROM circa_daily_posts
1047
+ ORDER BY post_date DESC
1048
+ LIMIT 1
1049
+ `
1050
+ );
1051
+
1052
+ if (!rows[0]) {
1053
+ return null;
1054
+ }
1055
+
1056
+ return {
1057
+ postDate: rows[0].post_date?.toISOString?.().slice(0, 10) ?? String(rows[0].post_date),
1058
+ snapshotId: Number(rows[0].snapshot_id),
1059
+ channelId: rows[0].channel_id,
1060
+ createdAt: rows[0].created_at?.toISOString?.() ?? String(rows[0].created_at),
1061
+ };
1062
+ }
1063
+
1064
+ async canSendCircaMovement(movementKey, fingerprint) {
1065
+ const { rows } = await this.pool.query(
1066
+ `
1067
+ SELECT last_snapshot_fingerprint
1068
+ FROM circa_movement_posts
1069
+ WHERE movement_key = $1
1070
+ `,
1071
+ [movementKey]
1072
+ );
1073
+
1074
+ if (!rows[0]) {
1075
+ return true;
1076
+ }
1077
+
1078
+ return rows[0].last_snapshot_fingerprint !== fingerprint;
1079
+ }
1080
+
1081
+ async recordCircaMovement(movementKey, fingerprint) {
1082
+ await this.pool.query(
1083
+ `
1084
+ INSERT INTO circa_movement_posts (movement_key, last_snapshot_fingerprint, last_sent_at)
1085
+ VALUES ($1, $2, NOW())
1086
+ ON CONFLICT (movement_key) DO UPDATE SET
1087
+ last_snapshot_fingerprint = EXCLUDED.last_snapshot_fingerprint,
1088
+ last_sent_at = NOW()
1089
+ `,
1090
+ [movementKey, fingerprint]
1091
+ );
1092
+ }
1093
+
1094
  async close() {
1095
  await this.pool.end();
1096
  }
src/embeds.js CHANGED
@@ -293,6 +293,7 @@ export function buildCommandsEmbed() {
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
  );
@@ -395,6 +396,7 @@ export function buildScanStatusEmbed(status) {
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 },
@@ -404,6 +406,13 @@ export function buildScanStatusEmbed(status) {
404
  { name: 'Last Report', value: status.lastReportAt ?? 'Never', inline: true },
405
  { name: 'Last Circa File', value: status.lastCircaFileName ?? 'None', inline: false },
406
  { name: 'Last Counts', value: `API: ${status.lastApiEntries ?? 0} | Circa: ${status.lastCircaEntries ?? 0} | Alerts: ${status.lastAlertCount ?? 0}`, inline: false },
 
 
 
 
 
 
 
407
  { name: 'Last Error', value: status.lastScanError ?? 'None', inline: false },
408
  );
409
  }
@@ -482,6 +491,83 @@ export function buildCircaFailureEmbed(details) {
482
  );
483
  }
484
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
485
  function escapeCsv(value) {
486
  const stringValue = String(value);
487
  if (stringValue.includes(',') || stringValue.includes('"') || stringValue.includes('\n')) {
 
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: 'Circa Market Commands', value: '`/circamarket`, `/circahr`, `/circahits`, `/circatb`, `/circarbis`, `/circaruns`, `/circahrri`, `/circak` post the latest parsed Circa markets in the channel where you run them. Admin only.' },
297
  { name: '/alerts', value: 'Post the public analyst alert-role panel to the welcome channel. Only for jew_olympics.' },
298
  { name: '/welcome', value: 'Post the public welcome embed and tag everyone. Only for Kenny F\'n Powers.' }
299
  );
 
396
  : 'Scanner is disabled because one or more scan environment variables are missing.')
397
  .addFields(
398
  { name: 'Enabled', value: status.enabled ? 'Yes' : 'No', inline: true },
399
+ { name: 'Odds / Circa', value: `${status.oddsWorkflowEnabled ? 'On' : 'Off'} / ${status.circaWorkflowEnabled ? 'On' : 'Off'}`, inline: true },
400
  { name: 'Frequency', value: `${status.frequencyMinutes} minutes`, inline: true },
401
  { name: 'Morning Time', value: `${status.morningTime} (${status.timeZone})`, inline: true },
402
  { name: 'Report Channel', value: status.reportChannelId ? `<#${status.reportChannelId}>` : 'Not set', inline: true },
 
406
  { name: 'Last Report', value: status.lastReportAt ?? 'Never', inline: true },
407
  { name: 'Last Circa File', value: status.lastCircaFileName ?? 'None', inline: false },
408
  { name: 'Last Counts', value: `API: ${status.lastApiEntries ?? 0} | Circa: ${status.lastCircaEntries ?? 0} | Alerts: ${status.lastAlertCount ?? 0}`, inline: false },
409
+ { name: 'Circa Channel', value: status.circaChannelId ? `<#${status.circaChannelId}>` : 'Not set', inline: true },
410
+ { name: 'Circa Daily / Retry', value: `${status.circaDailyTime ?? '09:30'} (${status.circaTimeZone ?? 'America/Chicago'}) / ${status.circaRetryMinutes ?? 30}m`, inline: true },
411
+ { name: 'Circa Move Frequency', value: `${status.circaMovementFrequencyMinutes ?? 5} minutes`, inline: true },
412
+ { name: 'Last Seen Fingerprint', value: status.lastCircaFingerprintAt ?? 'Never', inline: true },
413
+ { name: 'Last Daily Board', value: status.lastCircaBoardAt ?? 'Never', inline: true },
414
+ { name: 'Last Move Scan', value: status.lastCircaMovementScanAt ?? 'Never', inline: true },
415
+ { name: 'Board Retry State', value: status.circaRetryState ?? 'Idle', inline: false },
416
  { name: 'Last Error', value: status.lastScanError ?? 'None', inline: false },
417
  );
418
  }
 
491
  );
492
  }
493
 
494
+ function chunkLines(lines, maxLength = 1000) {
495
+ const chunks = [];
496
+ let current = [];
497
+ let currentLength = 0;
498
+
499
+ for (const line of lines) {
500
+ const lineLength = line.length + (current.length > 0 ? 1 : 0);
501
+ if (current.length > 0 && currentLength + lineLength > maxLength) {
502
+ chunks.push(current.join('\n'));
503
+ current = [line];
504
+ currentLength = line.length;
505
+ } else {
506
+ current.push(line);
507
+ currentLength += lineLength;
508
+ }
509
+ }
510
+
511
+ if (current.length > 0) {
512
+ chunks.push(current.join('\n'));
513
+ }
514
+
515
+ return chunks;
516
+ }
517
+
518
+ export function buildCircaMarketEmbed(snapshot, marketLabel, entries, options = {}) {
519
+ const embed = new EmbedBuilder()
520
+ .setColor(PALETTE.gold)
521
+ .setTitle(`Circa ${marketLabel}`)
522
+ .setDescription(`Source file: **${snapshot.fileName ?? 'Unknown'}**`)
523
+ .setFooter({ text: options.footerText ?? 'Latest parsed Circa market snapshot' });
524
+
525
+ if (entries.length === 0) {
526
+ return embed.addFields({ name: 'No props', value: 'No parsed props were available for this market.' });
527
+ }
528
+
529
+ const lines = entries.map((entry) => {
530
+ const team = entry.team ? ` (${entry.team})` : '';
531
+ const side = `${String(entry.side ?? '').toUpperCase()}${entry.lineValue !== null && entry.lineValue !== undefined ? ` ${entry.lineValue}` : ''}`;
532
+ return `${entry.playerName}${team} | ${side} | ${entry.oddsInput}`;
533
+ });
534
+
535
+ const chunks = chunkLines(lines).slice(0, 25);
536
+ embed.addFields(
537
+ chunks.map((value, index) => ({
538
+ name: index === 0 ? 'Props' : `Props ${index + 1}`,
539
+ value,
540
+ inline: false,
541
+ }))
542
+ );
543
+
544
+ if (chunks.length < Math.ceil(lines.length / 1000)) {
545
+ embed.addFields({
546
+ name: 'Trimmed',
547
+ value: `Displayed the first ${chunks.length} embed sections for this market.`,
548
+ inline: false,
549
+ });
550
+ }
551
+
552
+ return embed;
553
+ }
554
+
555
+ export function buildCircaMovementEmbed(movement, snapshot) {
556
+ return new EmbedBuilder()
557
+ .setColor(PALETTE.blue)
558
+ .setTitle('Circa Movement')
559
+ .setDescription(`${movement.playerName} - ${movement.marketLabel}`)
560
+ .addFields(
561
+ { name: 'Side', value: `${String(movement.side).toUpperCase()}${movement.lineValue !== null && movement.lineValue !== undefined ? ` ${movement.lineValue}` : ''}`, inline: true },
562
+ { name: 'Old Odds', value: movement.oldOddsInput, inline: true },
563
+ { name: 'New Odds', value: movement.newOddsInput, inline: true },
564
+ { name: 'Implied Change', value: `${movement.impliedChangePercent >= 0 ? '+' : ''}${movement.impliedChangePercent.toFixed(2)}%`, inline: true },
565
+ { name: 'Percent Change', value: `${movement.relativePercentChange >= 0 ? '+' : ''}${movement.relativePercentChange.toFixed(2)}%`, inline: true },
566
+ { name: 'Source File', value: snapshot.fileName ?? 'Unknown', inline: true },
567
+ )
568
+ .setFooter({ text: 'Posted only because this Circa prop moved from the prior seen snapshot.' });
569
+ }
570
+
571
  function escapeCsv(value) {
572
  const stringValue = String(value);
573
  if (stringValue.includes(',') || stringValue.includes('"') || stringValue.includes('\n')) {
src/index.js CHANGED
@@ -35,6 +35,7 @@ import {
35
  buildCircaDiagnosticEmbed,
36
  buildCircaAlertEmbed,
37
  buildCircaFailureEmbed,
 
38
  buildDeleteBetEmbed,
39
  buildEditBetEmbed,
40
  buildErrorEmbed,
@@ -278,6 +279,46 @@ async function handleChatInput(interaction, store, config) {
278
  return;
279
  }
280
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
281
  if (commandName === 'alerts') {
282
  await handleAlerts(interaction);
283
  return;
@@ -933,6 +974,42 @@ async function handleCircaTest(interaction, config) {
933
  }
934
  }
935
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
936
  async function handleButton(interaction, store) {
937
  const alertRoleName = parseAlertRoleButtonId(interaction.customId);
938
  if (alertRoleName) {
 
35
  buildCircaDiagnosticEmbed,
36
  buildCircaAlertEmbed,
37
  buildCircaFailureEmbed,
38
+ buildCircaMarketEmbed,
39
  buildDeleteBetEmbed,
40
  buildEditBetEmbed,
41
  buildErrorEmbed,
 
279
  return;
280
  }
281
 
282
+ if (commandName === 'circamarket') {
283
+ await handleCircaMarket(interaction, config);
284
+ return;
285
+ }
286
+
287
+ if (commandName === 'circahr') {
288
+ await handleCircaShortcut(interaction, config, 'home_runs');
289
+ return;
290
+ }
291
+
292
+ if (commandName === 'circahits') {
293
+ await handleCircaShortcut(interaction, config, 'hits');
294
+ return;
295
+ }
296
+
297
+ if (commandName === 'circatb') {
298
+ await handleCircaShortcut(interaction, config, 'total_bases');
299
+ return;
300
+ }
301
+
302
+ if (commandName === 'circarbis') {
303
+ await handleCircaShortcut(interaction, config, 'rbis');
304
+ return;
305
+ }
306
+
307
+ if (commandName === 'circaruns') {
308
+ await handleCircaShortcut(interaction, config, 'runs');
309
+ return;
310
+ }
311
+
312
+ if (commandName === 'circahrri') {
313
+ await handleCircaShortcut(interaction, config, 'hits_runs_rbis');
314
+ return;
315
+ }
316
+
317
+ if (commandName === 'circak') {
318
+ await handleCircaShortcut(interaction, config, 'pitcher_strikeouts_generic');
319
+ return;
320
+ }
321
+
322
  if (commandName === 'alerts') {
323
  await handleAlerts(interaction);
324
  return;
 
974
  }
975
  }
976
 
977
+ async function handleCircaMarket(interaction, config) {
978
+ const marketType = interaction.options.getString('market', true);
979
+ await handleCircaShortcut(interaction, config, marketType);
980
+ }
981
+
982
+ async function handleCircaShortcut(interaction, config, marketType) {
983
+ const isAdmin = await memberHasRoleName(interaction, config.adminRoleName);
984
+ if (!isAdmin) {
985
+ await denyAdminOnly(interaction, config.adminRoleName);
986
+ return;
987
+ }
988
+
989
+ const scanner = interaction.client.__marketScanner;
990
+ if (!scanner?.getStatus().circaWorkflowEnabled) {
991
+ await interaction.reply({
992
+ embeds: [buildErrorEmbed('Circa disabled', 'Set the Circa environment variables before using Circa commands.')],
993
+ flags: MessageFlags.Ephemeral,
994
+ });
995
+ return;
996
+ }
997
+
998
+ await interaction.deferReply();
999
+ try {
1000
+ const market = await scanner.getLatestCircaMarket(marketType);
1001
+ await interaction.editReply({
1002
+ embeds: [buildCircaMarketEmbed(market.snapshot, market.marketLabel, market.entries, {
1003
+ footerText: `Manual Circa view requested by ${interaction.user.displayName ?? interaction.user.username}`,
1004
+ })],
1005
+ });
1006
+ } catch (error) {
1007
+ await interaction.editReply({
1008
+ embeds: [buildErrorEmbed('Circa market unavailable', error.message || 'The Circa market lookup hit an unexpected error.')],
1009
+ });
1010
+ }
1011
+ }
1012
+
1013
  async function handleButton(interaction, store) {
1014
  const alertRoleName = parseAlertRoleButtonId(interaction.customId);
1015
  if (alertRoleName) {
src/market-scanner.js CHANGED
@@ -6,6 +6,7 @@ import os from 'node:os';
6
  import path from 'node:path';
7
  import { execFile } from 'node:child_process';
8
  import { promisify } from 'node:util';
 
9
 
10
  if (!globalThis.DOMMatrix) {
11
  globalThis.DOMMatrix = DOMMatrix;
@@ -39,6 +40,26 @@ const MARKET_TYPE_LABELS = {
39
  pitcher_strikeouts_generic: 'Pitcher Strikeouts',
40
  };
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  const OCR_MARKET_PATTERNS = [
43
  { type: 'home_runs', label: 'Home Runs', pattern: /(home\s*runs?|hr\b)/i },
44
  { type: 'total_bases', label: 'Total Bases', pattern: /(total\s*bases?|tb\b)/i },
@@ -1461,6 +1482,147 @@ function getZonedTime(date, timeZone) {
1461
  return `${parts.hour}:${parts.minute}`;
1462
  }
1463
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1464
  export class MarketScanner {
1465
  constructor({ client, store, config, embeds, logger = console }) {
1466
  this.client = client;
@@ -1470,9 +1632,16 @@ export class MarketScanner {
1470
  this.logger = logger;
1471
  this.scanTimer = null;
1472
  this.reportTimer = null;
 
 
1473
  this.running = false;
 
 
 
1474
  this.status = {
1475
  enabled: config.enabled,
 
 
1476
  lastScanAt: null,
1477
  lastReportAt: null,
1478
  lastScanError: null,
@@ -1480,6 +1649,10 @@ export class MarketScanner {
1480
  lastApiEntries: 0,
1481
  lastAlertCount: 0,
1482
  lastCircaFileName: null,
 
 
 
 
1483
  };
1484
  }
1485
 
@@ -1495,26 +1668,62 @@ export class MarketScanner {
1495
  morningTime: this.config.scanMorningTime,
1496
  timeZone: this.config.scanTimeZone,
1497
  frequencyMinutes: this.config.scanFrequencyMinutes,
 
 
 
1498
  });
1499
 
1500
- this.scanTimer = setInterval(() => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1501
  this.runDisagreementScan().catch((error) => {
1502
  this.status.lastScanError = error.message;
1503
- this.logger.error('Scanner interval failed', error);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1504
  });
1505
- }, this.config.scanFrequencyMinutes * 60 * 1000);
1506
 
1507
- this.reportTimer = setInterval(() => {
1508
- this.maybeRunMorningReport().catch((error) => {
1509
  this.status.lastScanError = error.message;
1510
- this.logger.error('Morning report interval failed', error);
1511
  });
1512
- }, 60 * 1000);
1513
 
1514
- this.runDisagreementScan().catch((error) => {
1515
- this.status.lastScanError = error.message;
1516
- this.logger.error('Initial scanner run failed', error);
1517
- });
 
1518
  }
1519
 
1520
  async stop() {
@@ -1524,6 +1733,12 @@ export class MarketScanner {
1524
  if (this.reportTimer) {
1525
  clearInterval(this.reportTimer);
1526
  }
 
 
 
 
 
 
1527
  if (ocrWorkerPromise) {
1528
  const worker = await ocrWorkerPromise;
1529
  await worker.terminate();
@@ -1541,6 +1756,11 @@ export class MarketScanner {
1541
  frequencyMinutes: this.config.scanFrequencyMinutes,
1542
  minBooks: this.config.scanMinBooks,
1543
  disagreementThreshold: this.config.scanDisagreementThreshold,
 
 
 
 
 
1544
  };
1545
  }
1546
 
@@ -1563,6 +1783,10 @@ export class MarketScanner {
1563
  }
1564
 
1565
  async runMorningReport(dateKey = getZonedDateKey(new Date(), this.config.scanTimeZone)) {
 
 
 
 
1566
  const analysis = await this.collectMarketAnalysis('morning');
1567
  const channel = await this.client.channels.fetch(this.config.scanReportChannelId);
1568
  if (!channel?.isTextBased()) {
@@ -1589,6 +1813,10 @@ export class MarketScanner {
1589
  }
1590
 
1591
  async runDisagreementScan() {
 
 
 
 
1592
  const analysis = await this.collectMarketAnalysis('scan');
1593
  const channel = await this.client.channels.fetch(this.config.scanAlertChannelId);
1594
  if (!channel?.isTextBased()) {
@@ -1617,7 +1845,7 @@ export class MarketScanner {
1617
  }
1618
 
1619
  async runCircaDiagnostic() {
1620
- const result = await fetchCircaEntries(this.config);
1621
  return {
1622
  fileName: result.fileName,
1623
  rawTextSample: result.text.slice(0, 1000),
@@ -1626,6 +1854,185 @@ export class MarketScanner {
1626
  };
1627
  }
1628
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1629
  async collectMarketAnalysis(scanType) {
1630
  if (this.running) {
1631
  throw new Error('Market scanner is already running');
@@ -1635,7 +2042,7 @@ export class MarketScanner {
1635
  try {
1636
  const [oddsEntries, circaResult] = await Promise.all([
1637
  fetchOddsApiEntries(this.config),
1638
- fetchCircaEntries(this.config),
1639
  ]);
1640
  const allEntries = [...oddsEntries, ...circaResult.entries];
1641
  const analysis = analyzeMarkets(allEntries, {
@@ -1649,6 +2056,7 @@ export class MarketScanner {
1649
  this.status.lastApiEntries = oddsEntries.length;
1650
  this.status.lastCircaEntries = circaResult.entries.length;
1651
  this.status.lastCircaFileName = circaResult.fileName ?? null;
 
1652
 
1653
  return analysis;
1654
  } catch (error) {
@@ -1663,7 +2071,12 @@ export class MarketScanner {
1663
  }
1664
 
1665
  async sendCircaFailureAlert(error) {
1666
- const channel = await this.client.channels.fetch(this.config.scanAlertChannelId).catch(() => null);
 
 
 
 
 
1667
  if (!channel?.isTextBased()) {
1668
  return;
1669
  }
 
6
  import path from 'node:path';
7
  import { execFile } from 'node:child_process';
8
  import { promisify } from 'node:util';
9
+ import { createHash } from 'node:crypto';
10
 
11
  if (!globalThis.DOMMatrix) {
12
  globalThis.DOMMatrix = DOMMatrix;
 
40
  pitcher_strikeouts_generic: 'Pitcher Strikeouts',
41
  };
42
 
43
+ export const CIRCA_MARKET_DISPLAY_ORDER = [
44
+ 'home_runs',
45
+ 'hits',
46
+ 'total_bases',
47
+ 'rbis',
48
+ 'runs',
49
+ 'hits_runs_rbis',
50
+ 'pitcher_strikeouts_generic',
51
+ ];
52
+
53
+ const CIRCA_MARKET_ALIASES = {
54
+ batter_home_runs: 'home_runs',
55
+ batter_hits: 'hits',
56
+ batter_total_bases: 'total_bases',
57
+ batter_rbis: 'rbis',
58
+ batter_runs_scored: 'runs',
59
+ batter_hits_runs_rbis: 'hits_runs_rbis',
60
+ pitcher_strikeouts: 'pitcher_strikeouts_generic',
61
+ };
62
+
63
  const OCR_MARKET_PATTERNS = [
64
  { type: 'home_runs', label: 'Home Runs', pattern: /(home\s*runs?|hr\b)/i },
65
  { type: 'total_bases', label: 'Total Bases', pattern: /(total\s*bases?|tb\b)/i },
 
1482
  return `${parts.hour}:${parts.minute}`;
1483
  }
1484
 
1485
+ function compareCircaMarketOrder(left, right) {
1486
+ const leftIndex = CIRCA_MARKET_DISPLAY_ORDER.indexOf(left);
1487
+ const rightIndex = CIRCA_MARKET_DISPLAY_ORDER.indexOf(right);
1488
+ const normalizedLeft = leftIndex === -1 ? Number.MAX_SAFE_INTEGER : leftIndex;
1489
+ const normalizedRight = rightIndex === -1 ? Number.MAX_SAFE_INTEGER : rightIndex;
1490
+ return normalizedLeft - normalizedRight || left.localeCompare(right);
1491
+ }
1492
+
1493
+ function normalizeCircaMarketType(marketType) {
1494
+ return CIRCA_MARKET_ALIASES[marketType] ?? marketType;
1495
+ }
1496
+
1497
+ function groupCircaMarkets(entries = []) {
1498
+ const grouped = new Map();
1499
+
1500
+ for (const entry of entries) {
1501
+ const normalizedMarketType = normalizeCircaMarketType(entry.marketType);
1502
+ if (!grouped.has(normalizedMarketType)) {
1503
+ grouped.set(normalizedMarketType, {
1504
+ marketType: normalizedMarketType,
1505
+ marketLabel: MARKET_TYPE_LABELS[normalizedMarketType] ?? entry.marketLabel ?? normalizedMarketType,
1506
+ entries: [],
1507
+ });
1508
+ }
1509
+
1510
+ grouped.get(normalizedMarketType).entries.push({
1511
+ ...entry,
1512
+ marketType: normalizedMarketType,
1513
+ marketLabel: MARKET_TYPE_LABELS[normalizedMarketType] ?? entry.marketLabel ?? normalizedMarketType,
1514
+ });
1515
+ }
1516
+
1517
+ return [...grouped.values()]
1518
+ .map((group) => ({
1519
+ ...group,
1520
+ entries: [...group.entries].sort((left, right) =>
1521
+ left.playerName.localeCompare(right.playerName)
1522
+ || String(left.side).localeCompare(String(right.side))
1523
+ || (left.lineValue ?? 0) - (right.lineValue ?? 0)
1524
+ ),
1525
+ }))
1526
+ .sort((left, right) => compareCircaMarketOrder(left.marketType, right.marketType));
1527
+ }
1528
+
1529
+ function serializeCircaEntries(entries = []) {
1530
+ return [...entries]
1531
+ .map((entry) => ({
1532
+ marketKey: entry.marketKey,
1533
+ playerName: entry.playerName,
1534
+ team: entry.team ?? null,
1535
+ marketType: normalizeCircaMarketType(entry.marketType),
1536
+ marketLabel: MARKET_TYPE_LABELS[normalizeCircaMarketType(entry.marketType)] ?? entry.marketLabel,
1537
+ side: entry.side,
1538
+ lineValue: entry.lineValue ?? null,
1539
+ oddsInput: entry.oddsInput,
1540
+ impliedProbability: entry.impliedProbability,
1541
+ }))
1542
+ .sort((left, right) =>
1543
+ left.marketKey.localeCompare(right.marketKey)
1544
+ || left.oddsInput.localeCompare(right.oddsInput)
1545
+ );
1546
+ }
1547
+
1548
+ function buildCircaFingerprint(fileName, entries = []) {
1549
+ return createHash('sha256')
1550
+ .update(JSON.stringify({ fileName, entries: serializeCircaEntries(entries) }))
1551
+ .digest('hex');
1552
+ }
1553
+
1554
+ function parseFileDateFromName(fileName) {
1555
+ const timestamp = parseDateFromCircaFilename(fileName);
1556
+ if (timestamp === null) {
1557
+ return null;
1558
+ }
1559
+
1560
+ return new Date(timestamp).toISOString().slice(0, 10);
1561
+ }
1562
+
1563
+ function buildMovementKey(entry) {
1564
+ return [
1565
+ normalizePlayerName(entry.playerName),
1566
+ normalizeCircaMarketType(entry.marketType),
1567
+ entry.side,
1568
+ entry.lineValue ?? 'na',
1569
+ ].join('|');
1570
+ }
1571
+
1572
+ function americanOddsToNumber(oddsInput) {
1573
+ const value = Number(String(oddsInput ?? '').trim());
1574
+ return Number.isFinite(value) ? value : null;
1575
+ }
1576
+
1577
+ function buildCircaMovements(previousSnapshot, currentSnapshot) {
1578
+ if (!previousSnapshot || !currentSnapshot) {
1579
+ return [];
1580
+ }
1581
+
1582
+ const previousEntries = new Map(previousSnapshot.entries.map((entry) => [buildMovementKey(entry), entry]));
1583
+ const movements = [];
1584
+
1585
+ for (const currentEntry of currentSnapshot.entries) {
1586
+ const key = buildMovementKey(currentEntry);
1587
+ const previousEntry = previousEntries.get(key);
1588
+ if (!previousEntry || previousEntry.oddsInput === currentEntry.oddsInput) {
1589
+ continue;
1590
+ }
1591
+
1592
+ const oldImplied = previousEntry.impliedProbability ?? americanToImpliedProbability(previousEntry.oddsInput);
1593
+ const newImplied = currentEntry.impliedProbability ?? americanToImpliedProbability(currentEntry.oddsInput);
1594
+ const oldOddsNumber = americanOddsToNumber(previousEntry.oddsInput);
1595
+ const newOddsNumber = americanOddsToNumber(currentEntry.oddsInput);
1596
+ const impliedChange = (newImplied ?? 0) - (oldImplied ?? 0);
1597
+ const relativePercentChange = oldImplied && Number.isFinite(oldImplied)
1598
+ ? (impliedChange / oldImplied) * 100
1599
+ : 0;
1600
+
1601
+ movements.push({
1602
+ movementKey: key,
1603
+ playerName: currentEntry.playerName,
1604
+ marketType: normalizeCircaMarketType(currentEntry.marketType),
1605
+ marketLabel: MARKET_TYPE_LABELS[normalizeCircaMarketType(currentEntry.marketType)] ?? currentEntry.marketLabel,
1606
+ side: currentEntry.side,
1607
+ lineValue: currentEntry.lineValue ?? null,
1608
+ oldOddsInput: previousEntry.oddsInput,
1609
+ newOddsInput: currentEntry.oddsInput,
1610
+ oldOddsNumber,
1611
+ newOddsNumber,
1612
+ impliedChangePercent: impliedChange * 100,
1613
+ relativePercentChange,
1614
+ });
1615
+ }
1616
+
1617
+ return movements.sort((left, right) =>
1618
+ Math.abs(right.impliedChangePercent) - Math.abs(left.impliedChangePercent)
1619
+ );
1620
+ }
1621
+
1622
+ function hasReachedZonedTime(now, timeZone, targetTime) {
1623
+ return getZonedTime(now, timeZone) >= targetTime;
1624
+ }
1625
+
1626
  export class MarketScanner {
1627
  constructor({ client, store, config, embeds, logger = console }) {
1628
  this.client = client;
 
1632
  this.logger = logger;
1633
  this.scanTimer = null;
1634
  this.reportTimer = null;
1635
+ this.circaRetryTimer = null;
1636
+ this.circaMovementTimer = null;
1637
  this.running = false;
1638
+ this.circaRunning = false;
1639
+ this.circaMovementRunning = false;
1640
+ this.circaBoardRunning = false;
1641
  this.status = {
1642
  enabled: config.enabled,
1643
+ oddsWorkflowEnabled: config.oddsWorkflowEnabled,
1644
+ circaWorkflowEnabled: config.circaWorkflowEnabled,
1645
  lastScanAt: null,
1646
  lastReportAt: null,
1647
  lastScanError: null,
 
1649
  lastApiEntries: 0,
1650
  lastAlertCount: 0,
1651
  lastCircaFileName: null,
1652
+ lastCircaFingerprintAt: null,
1653
+ lastCircaBoardAt: null,
1654
+ lastCircaMovementScanAt: null,
1655
+ circaRetryState: 'Idle',
1656
  };
1657
  }
1658
 
 
1668
  morningTime: this.config.scanMorningTime,
1669
  timeZone: this.config.scanTimeZone,
1670
  frequencyMinutes: this.config.scanFrequencyMinutes,
1671
+ circaChannelId: this.config.circaChannelId,
1672
+ circaDailyTime: this.config.circaDailyTime,
1673
+ circaMovementFrequencyMinutes: this.config.circaMovementFrequencyMinutes,
1674
  });
1675
 
1676
+ if (this.config.oddsWorkflowEnabled) {
1677
+ this.scanTimer = setInterval(() => {
1678
+ this.runDisagreementScan().catch((error) => {
1679
+ this.status.lastScanError = error.message;
1680
+ this.logger.error('Scanner interval failed', error);
1681
+ });
1682
+ }, this.config.scanFrequencyMinutes * 60 * 1000);
1683
+
1684
+ this.reportTimer = setInterval(() => {
1685
+ this.maybeRunMorningReport().catch((error) => {
1686
+ this.status.lastScanError = error.message;
1687
+ this.logger.error('Morning report interval failed', error);
1688
+ });
1689
+ }, 60 * 1000);
1690
+
1691
  this.runDisagreementScan().catch((error) => {
1692
  this.status.lastScanError = error.message;
1693
+ this.logger.error('Initial scanner run failed', error);
1694
+ });
1695
+ }
1696
+
1697
+ if (this.config.circaWorkflowEnabled) {
1698
+ this.circaRetryTimer = setInterval(() => {
1699
+ this.maybeRunCircaDailyBoard().catch((error) => {
1700
+ this.status.lastScanError = error.message;
1701
+ this.logger.error('Circa daily board interval failed', error);
1702
+ });
1703
+ }, Math.max(1, this.config.circaRetryMinutes) * 60 * 1000);
1704
+
1705
+ this.circaMovementTimer = setInterval(() => {
1706
+ this.runCircaMovementScan().catch((error) => {
1707
+ this.status.lastScanError = error.message;
1708
+ this.logger.error('Circa movement interval failed', error);
1709
+ });
1710
+ }, Math.max(1, this.config.circaMovementFrequencyMinutes) * 60 * 1000);
1711
+
1712
+ this.refreshCircaSnapshot().catch((error) => {
1713
+ this.status.lastScanError = error.message;
1714
+ this.logger.error('Initial Circa snapshot refresh failed', error);
1715
  });
 
1716
 
1717
+ this.maybeRunCircaDailyBoard().catch((error) => {
 
1718
  this.status.lastScanError = error.message;
1719
+ this.logger.error('Initial Circa board check failed', error);
1720
  });
 
1721
 
1722
+ this.runCircaMovementScan().catch((error) => {
1723
+ this.status.lastScanError = error.message;
1724
+ this.logger.error('Initial Circa movement scan failed', error);
1725
+ });
1726
+ }
1727
  }
1728
 
1729
  async stop() {
 
1733
  if (this.reportTimer) {
1734
  clearInterval(this.reportTimer);
1735
  }
1736
+ if (this.circaRetryTimer) {
1737
+ clearInterval(this.circaRetryTimer);
1738
+ }
1739
+ if (this.circaMovementTimer) {
1740
+ clearInterval(this.circaMovementTimer);
1741
+ }
1742
  if (ocrWorkerPromise) {
1743
  const worker = await ocrWorkerPromise;
1744
  await worker.terminate();
 
1756
  frequencyMinutes: this.config.scanFrequencyMinutes,
1757
  minBooks: this.config.scanMinBooks,
1758
  disagreementThreshold: this.config.scanDisagreementThreshold,
1759
+ circaChannelId: this.config.circaChannelId,
1760
+ circaDailyTime: this.config.circaDailyTime,
1761
+ circaTimeZone: this.config.circaTimeZone,
1762
+ circaRetryMinutes: this.config.circaRetryMinutes,
1763
+ circaMovementFrequencyMinutes: this.config.circaMovementFrequencyMinutes,
1764
  };
1765
  }
1766
 
 
1783
  }
1784
 
1785
  async runMorningReport(dateKey = getZonedDateKey(new Date(), this.config.scanTimeZone)) {
1786
+ if (!this.config.oddsWorkflowEnabled) {
1787
+ throw new Error('Odds workflow is disabled.');
1788
+ }
1789
+
1790
  const analysis = await this.collectMarketAnalysis('morning');
1791
  const channel = await this.client.channels.fetch(this.config.scanReportChannelId);
1792
  if (!channel?.isTextBased()) {
 
1813
  }
1814
 
1815
  async runDisagreementScan() {
1816
+ if (!this.config.oddsWorkflowEnabled) {
1817
+ throw new Error('Odds workflow is disabled.');
1818
+ }
1819
+
1820
  const analysis = await this.collectMarketAnalysis('scan');
1821
  const channel = await this.client.channels.fetch(this.config.scanAlertChannelId);
1822
  if (!channel?.isTextBased()) {
 
1845
  }
1846
 
1847
  async runCircaDiagnostic() {
1848
+ const result = await this.fetchLatestCircaSnapshot({ persist: true });
1849
  return {
1850
  fileName: result.fileName,
1851
  rawTextSample: result.text.slice(0, 1000),
 
1854
  };
1855
  }
1856
 
1857
+ async fetchLatestCircaSnapshot(options = {}) {
1858
+ if (!this.config.circaDropboxUrl) {
1859
+ throw new Error('No Circa source configured.');
1860
+ }
1861
+
1862
+ const result = await fetchCircaEntries(this.config);
1863
+ const normalizedEntries = serializeCircaEntries(result.entries);
1864
+ const snapshotPayload = {
1865
+ fileName: result.fileName ?? 'Unknown',
1866
+ fileSource: result.source ?? 'Public Dropbox folder',
1867
+ fileDate: parseFileDateFromName(result.fileName ?? ''),
1868
+ fingerprint: buildCircaFingerprint(result.fileName ?? 'Unknown', normalizedEntries),
1869
+ entries: normalizedEntries,
1870
+ };
1871
+
1872
+ const snapshot = options.persist === false
1873
+ ? { ...snapshotPayload, id: null, seenAt: new Date().toISOString(), entryCount: normalizedEntries.length }
1874
+ : await this.store.recordCircaSnapshot(snapshotPayload);
1875
+
1876
+ this.status.lastCircaEntries = normalizedEntries.length;
1877
+ this.status.lastCircaFileName = snapshot.fileName;
1878
+ this.status.lastCircaFingerprintAt = snapshot.seenAt;
1879
+ this.status.lastScanError = null;
1880
+
1881
+ return {
1882
+ ...snapshot,
1883
+ rawTextSample: result.text.slice(0, 1000),
1884
+ text: result.text,
1885
+ source: result.source,
1886
+ };
1887
+ }
1888
+
1889
+ async refreshCircaSnapshot() {
1890
+ if (this.circaRunning) {
1891
+ return this.store.getLatestCircaSnapshot();
1892
+ }
1893
+
1894
+ this.circaRunning = true;
1895
+ try {
1896
+ return await this.fetchLatestCircaSnapshot({ persist: true });
1897
+ } catch (error) {
1898
+ this.status.lastScanError = error.message;
1899
+ await this.sendCircaFailureAlert(error);
1900
+ throw error;
1901
+ } finally {
1902
+ this.circaRunning = false;
1903
+ }
1904
+ }
1905
+
1906
+ async maybeRunCircaDailyBoard(now = new Date()) {
1907
+ if (!this.config.circaWorkflowEnabled) {
1908
+ return null;
1909
+ }
1910
+
1911
+ const dateKey = getZonedDateKey(now, this.config.circaTimeZone);
1912
+ const alreadyPosted = await this.store.getCircaDailyPost(dateKey);
1913
+ if (alreadyPosted) {
1914
+ this.status.circaRetryState = 'Posted for today';
1915
+ return null;
1916
+ }
1917
+
1918
+ if (!hasReachedZonedTime(now, this.config.circaTimeZone, this.config.circaDailyTime)) {
1919
+ this.status.circaRetryState = `Waiting for ${this.config.circaDailyTime} ${this.config.circaTimeZone}`;
1920
+ return null;
1921
+ }
1922
+
1923
+ const snapshot = await this.refreshCircaSnapshot();
1924
+ if (snapshot.fileDate !== dateKey) {
1925
+ this.status.circaRetryState = `Waiting for new ${dateKey} Circa file`;
1926
+ return null;
1927
+ }
1928
+
1929
+ await this.postCircaDailyBoard(snapshot, dateKey);
1930
+ this.status.circaRetryState = 'Posted for today';
1931
+ return snapshot;
1932
+ }
1933
+
1934
+ async postCircaDailyBoard(snapshot, dateKey = getZonedDateKey(new Date(), this.config.circaTimeZone)) {
1935
+ if (this.circaBoardRunning) {
1936
+ throw new Error('Circa board is already running');
1937
+ }
1938
+
1939
+ this.circaBoardRunning = true;
1940
+ try {
1941
+ const channel = await this.client.channels.fetch(this.config.circaChannelId);
1942
+ if (!channel?.isTextBased()) {
1943
+ throw new Error(`Circa channel ${this.config.circaChannelId} is not text-based`);
1944
+ }
1945
+
1946
+ const groupedMarkets = groupCircaMarkets(snapshot.entries);
1947
+ for (const group of groupedMarkets) {
1948
+ await channel.send({
1949
+ embeds: [this.embeds.buildCircaMarketEmbed(snapshot, group.marketLabel, group.entries, {
1950
+ footerText: `Daily Circa board for ${dateKey}`,
1951
+ })],
1952
+ });
1953
+ }
1954
+
1955
+ await this.store.recordCircaDailyPost(dateKey, snapshot.id, this.config.circaChannelId);
1956
+ this.status.lastCircaBoardAt = new Date().toISOString();
1957
+ return groupedMarkets;
1958
+ } finally {
1959
+ this.circaBoardRunning = false;
1960
+ }
1961
+ }
1962
+
1963
+ async runCircaMovementScan() {
1964
+ if (!this.config.circaWorkflowEnabled) {
1965
+ throw new Error('Circa workflow is disabled.');
1966
+ }
1967
+
1968
+ if (this.circaMovementRunning) {
1969
+ return [];
1970
+ }
1971
+
1972
+ this.circaMovementRunning = true;
1973
+ try {
1974
+ const currentSnapshot = await this.refreshCircaSnapshot();
1975
+ const previousSnapshot = currentSnapshot?.id
1976
+ ? await this.store.getPreviousCircaSnapshot(currentSnapshot.id)
1977
+ : null;
1978
+
1979
+ this.status.lastCircaMovementScanAt = new Date().toISOString();
1980
+ if (!previousSnapshot) {
1981
+ return [];
1982
+ }
1983
+
1984
+ const movements = buildCircaMovements(previousSnapshot, currentSnapshot);
1985
+ if (movements.length === 0) {
1986
+ return [];
1987
+ }
1988
+
1989
+ const channel = await this.client.channels.fetch(this.config.circaChannelId);
1990
+ if (!channel?.isTextBased()) {
1991
+ throw new Error(`Circa channel ${this.config.circaChannelId} is not text-based`);
1992
+ }
1993
+
1994
+ const posted = [];
1995
+ for (const movement of movements) {
1996
+ const canPost = await this.store.canSendCircaMovement(movement.movementKey, currentSnapshot.fingerprint);
1997
+ if (!canPost) {
1998
+ continue;
1999
+ }
2000
+
2001
+ await channel.send({
2002
+ embeds: [this.embeds.buildCircaMovementEmbed(movement, currentSnapshot)],
2003
+ });
2004
+ await this.store.recordCircaMovement(movement.movementKey, currentSnapshot.fingerprint);
2005
+ posted.push(movement);
2006
+ }
2007
+
2008
+ return posted;
2009
+ } finally {
2010
+ this.circaMovementRunning = false;
2011
+ }
2012
+ }
2013
+
2014
+ async getLatestCircaMarket(marketType) {
2015
+ const snapshot = await this.store.getLatestCircaSnapshot();
2016
+ if (!snapshot) {
2017
+ throw new Error('No stored Circa snapshot is available yet.');
2018
+ }
2019
+
2020
+ const normalizedMarketType = normalizeCircaMarketType(marketType);
2021
+ const groupedMarkets = groupCircaMarkets(snapshot.entries);
2022
+ const group = groupedMarkets.find((entry) => entry.marketType === normalizedMarketType);
2023
+
2024
+ if (!group) {
2025
+ throw new Error(`No parsed Circa market was found for ${MARKET_TYPE_LABELS[normalizedMarketType] ?? normalizedMarketType}.`);
2026
+ }
2027
+
2028
+ return {
2029
+ snapshot,
2030
+ marketType: normalizedMarketType,
2031
+ marketLabel: group.marketLabel,
2032
+ entries: group.entries,
2033
+ };
2034
+ }
2035
+
2036
  async collectMarketAnalysis(scanType) {
2037
  if (this.running) {
2038
  throw new Error('Market scanner is already running');
 
2042
  try {
2043
  const [oddsEntries, circaResult] = await Promise.all([
2044
  fetchOddsApiEntries(this.config),
2045
+ this.fetchLatestCircaSnapshot({ persist: true }),
2046
  ]);
2047
  const allEntries = [...oddsEntries, ...circaResult.entries];
2048
  const analysis = analyzeMarkets(allEntries, {
 
2056
  this.status.lastApiEntries = oddsEntries.length;
2057
  this.status.lastCircaEntries = circaResult.entries.length;
2058
  this.status.lastCircaFileName = circaResult.fileName ?? null;
2059
+ this.status.lastCircaFingerprintAt = circaResult.seenAt ?? new Date().toISOString();
2060
 
2061
  return analysis;
2062
  } catch (error) {
 
2071
  }
2072
 
2073
  async sendCircaFailureAlert(error) {
2074
+ const fallbackChannelId = this.config.scanAlertChannelId ?? this.config.circaChannelId;
2075
+ if (!fallbackChannelId) {
2076
+ return;
2077
+ }
2078
+
2079
+ const channel = await this.client.channels.fetch(fallbackChannelId).catch(() => null);
2080
  if (!channel?.isTextBased()) {
2081
  return;
2082
  }