Codex commited on
Commit ·
11e6e87
1
Parent(s): a0f98a7
Add Circa daily board and movement feed
Browse files- .env.example +5 -0
- deploy/oracle/roibot.env.example +5 -0
- src/commands.js +39 -0
- src/config.js +19 -0
- src/db.js +307 -0
- src/embeds.js +86 -0
- src/index.js +77 -0
- src/market-scanner.js +427 -14
.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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1501 |
this.runDisagreementScan().catch((error) => {
|
| 1502 |
this.status.lastScanError = error.message;
|
| 1503 |
-
this.logger.error('
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1504 |
});
|
| 1505 |
-
}, this.config.scanFrequencyMinutes * 60 * 1000);
|
| 1506 |
|
| 1507 |
-
|
| 1508 |
-
this.maybeRunMorningReport().catch((error) => {
|
| 1509 |
this.status.lastScanError = error.message;
|
| 1510 |
-
this.logger.error('
|
| 1511 |
});
|
| 1512 |
-
}, 60 * 1000);
|
| 1513 |
|
| 1514 |
-
|
| 1515 |
-
|
| 1516 |
-
|
| 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
|
| 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 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
}
|