| import { Pool } from 'pg'; |
|
|
| function mapBet(row) { |
| if (!row) { |
| return null; |
| } |
|
|
| return { |
| id: Number(row.id), |
| discordUserId: row.discord_user_id, |
| betNumber: Number(row.bet_number), |
| book: row.book, |
| sport: row.sport, |
| oddsInput: row.odds_input, |
| normalizedDecimalOdds: Number(row.normalized_decimal_odds), |
| prop: row.prop, |
| stake: Number(row.stake), |
| unitsValue: row.units_value === null ? null : Number(row.units_value), |
| status: row.status, |
| profitLoss: row.profit_loss === null ? null : Number(row.profit_loss), |
| rawInput: row.raw_input, |
| createdAt: row.created_at, |
| resolvedAt: row.resolved_at, |
| deletedAt: row.deleted_at, |
| deletedReason: row.deleted_reason, |
| }; |
| } |
|
|
| function numberOrNull(value) { |
| return value === null || value === undefined ? null : Number(value); |
| } |
|
|
| function buildWhereClause(filters = {}, options = {}) { |
| const clauses = []; |
| const values = []; |
| let index = 1; |
|
|
| clauses.push(`discord_user_id = $${index++}`); |
| values.push(filters.userId); |
|
|
| if (!options.includeDeleted) { |
| clauses.push('deleted_at IS NULL'); |
| } |
|
|
| if (filters.book) { |
| clauses.push(`book = $${index++}`); |
| values.push(filters.book); |
| } |
|
|
| if (filters.sport) { |
| clauses.push(`sport = $${index++}`); |
| values.push(filters.sport); |
| } |
|
|
| if (filters.status) { |
| if (filters.status === 'deleted') { |
| clauses.push('deleted_at IS NOT NULL'); |
| } else { |
| clauses.push(`status = $${index++}`); |
| values.push(filters.status); |
| } |
| } |
|
|
| if (filters.dateWindow && filters.dateWindow !== 'all') { |
| if (filters.dateWindow === 'today') { |
| clauses.push('created_at >= DATE_TRUNC(\'day\', NOW())'); |
| } else if (filters.dateWindow === '7d') { |
| clauses.push('created_at >= NOW() - INTERVAL \'7 days\''); |
| } else if (filters.dateWindow === '30d') { |
| clauses.push('created_at >= NOW() - INTERVAL \'30 days\''); |
| } |
| } |
|
|
| return { |
| clause: clauses.join(' AND '), |
| values, |
| }; |
| } |
|
|
| function computeAnalytics(rows, profile) { |
| const activeRows = rows.filter((row) => row.deletedAt === null); |
| const wins = activeRows.filter((row) => row.status === 'win').length; |
| const losses = activeRows.filter((row) => row.status === 'loss').length; |
| const voids = activeRows.filter((row) => row.status === 'void').length; |
| const openBets = activeRows.filter((row) => row.status === 'open').length; |
| const settledRows = activeRows.filter((row) => row.status !== 'open'); |
| const gradedRows = activeRows.filter((row) => row.status === 'win' || row.status === 'loss'); |
| const settledStake = gradedRows.reduce((total, row) => total + row.stake, 0); |
| const netProfit = settledRows.reduce((total, row) => total + (row.profitLoss ?? 0), 0); |
| const totalUnits = settledRows.reduce( |
| (total, row) => total + (row.unitsValue ?? deriveUnitsValue(row.stake, profile.unitSize)), |
| 0 |
| ); |
| const bankrollNow = profile.startingBankroll !== null ? profile.startingBankroll + netProfit : null; |
|
|
| return { |
| totalBets: activeRows.length, |
| openBets, |
| wins, |
| losses, |
| voids, |
| settledStake, |
| netProfit, |
| roiPercent: settledStake > 0 ? (netProfit / settledStake) * 100 : 0, |
| totalUnits, |
| unitSize: profile.unitSize, |
| startingBankroll: profile.startingBankroll, |
| bankrollNow, |
| winRatePercent: gradedRows.length > 0 ? (wins / gradedRows.length) * 100 : 0, |
| }; |
| } |
|
|
| function deriveUnitsValue(stake, unitSize) { |
| if (!unitSize || unitSize <= 0) { |
| return 0; |
| } |
|
|
| return Number((stake / unitSize).toFixed(4)); |
| } |
|
|
| function computeStreak(rows) { |
| const settled = rows |
| .filter((row) => row.deletedAt === null && (row.status === 'win' || row.status === 'loss')) |
| .sort((left, right) => left.betNumber - right.betNumber); |
|
|
| if (settled.length === 0) { |
| return { type: 'none', count: 0 }; |
| } |
|
|
| const last = settled[settled.length - 1].status; |
| let count = 0; |
| for (let index = settled.length - 1; index >= 0; index -= 1) { |
| if (settled[index].status !== last) { |
| break; |
| } |
| count += 1; |
| } |
|
|
| return { type: last, count }; |
| } |
|
|
| function computeBestWorst(rows) { |
| const settled = rows.filter((row) => row.deletedAt === null && row.profitLoss !== null); |
| if (settled.length === 0) { |
| return { best: null, worst: null }; |
| } |
|
|
| const best = settled.reduce((top, row) => (row.profitLoss > top.profitLoss ? row : top), settled[0]); |
| const worst = settled.reduce((low, row) => (row.profitLoss < low.profitLoss ? row : low), settled[0]); |
|
|
| return { best, worst }; |
| } |
|
|
| export class BetStore { |
| constructor(databaseUrl, options = {}) { |
| const usesSsl = /sslmode=(require|verify-ca|verify-full)/i.test(databaseUrl); |
| this.pool = options.pool ?? new Pool({ |
| connectionString: databaseUrl, |
| ssl: usesSsl ? { rejectUnauthorized: false } : undefined, |
| }); |
| } |
|
|
| async initialize() { |
| await this.pool.query(` |
| CREATE TABLE IF NOT EXISTS users ( |
| discord_user_id TEXT PRIMARY KEY, |
| username TEXT NOT NULL, |
| display_name TEXT NOT NULL, |
| next_bet_number INT NOT NULL DEFAULT 1, |
| starting_bankroll DOUBLE PRECISION, |
| unit_size DOUBLE PRECISION, |
| created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), |
| updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() |
| ); |
| `); |
|
|
| await this.pool.query(` |
| ALTER TABLE users ADD COLUMN IF NOT EXISTS username TEXT NOT NULL DEFAULT ''; |
| ALTER TABLE users ADD COLUMN IF NOT EXISTS display_name TEXT NOT NULL DEFAULT ''; |
| ALTER TABLE users ADD COLUMN IF NOT EXISTS next_bet_number INT NOT NULL DEFAULT 1; |
| ALTER TABLE users ADD COLUMN IF NOT EXISTS starting_bankroll DOUBLE PRECISION; |
| ALTER TABLE users ADD COLUMN IF NOT EXISTS unit_size DOUBLE PRECISION; |
| ALTER TABLE users ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); |
| ALTER TABLE users ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); |
| `); |
|
|
| await this.pool.query(` |
| CREATE TABLE IF NOT EXISTS bets ( |
| id INT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, |
| discord_user_id TEXT NOT NULL REFERENCES users(discord_user_id), |
| bet_number INT NOT NULL, |
| book TEXT NOT NULL, |
| sport TEXT NOT NULL DEFAULT 'Other', |
| odds_input TEXT NOT NULL, |
| normalized_decimal_odds DOUBLE PRECISION NOT NULL, |
| prop TEXT NOT NULL, |
| stake DOUBLE PRECISION NOT NULL, |
| units_value DOUBLE PRECISION, |
| status TEXT NOT NULL DEFAULT 'open', |
| profit_loss DOUBLE PRECISION, |
| raw_input TEXT NOT NULL, |
| created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), |
| resolved_at TIMESTAMPTZ, |
| deleted_at TIMESTAMPTZ, |
| deleted_reason TEXT, |
| CONSTRAINT bets_user_bet_number_unique UNIQUE (discord_user_id, bet_number) |
| ); |
| `); |
|
|
| await this.pool.query(` |
| ALTER TABLE bets ADD COLUMN IF NOT EXISTS discord_user_id TEXT; |
| ALTER TABLE bets ADD COLUMN IF NOT EXISTS bet_number INT; |
| ALTER TABLE bets ADD COLUMN IF NOT EXISTS book TEXT; |
| ALTER TABLE bets ADD COLUMN IF NOT EXISTS sport TEXT NOT NULL DEFAULT 'Other'; |
| ALTER TABLE bets ADD COLUMN IF NOT EXISTS odds_input TEXT; |
| ALTER TABLE bets ADD COLUMN IF NOT EXISTS normalized_decimal_odds DOUBLE PRECISION; |
| ALTER TABLE bets ADD COLUMN IF NOT EXISTS prop TEXT; |
| ALTER TABLE bets ADD COLUMN IF NOT EXISTS stake DOUBLE PRECISION; |
| ALTER TABLE bets ADD COLUMN IF NOT EXISTS units_value DOUBLE PRECISION; |
| ALTER TABLE bets ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open'; |
| ALTER TABLE bets ADD COLUMN IF NOT EXISTS profit_loss DOUBLE PRECISION; |
| ALTER TABLE bets ADD COLUMN IF NOT EXISTS raw_input TEXT; |
| ALTER TABLE bets ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); |
| ALTER TABLE bets ADD COLUMN IF NOT EXISTS resolved_at TIMESTAMPTZ; |
| ALTER TABLE bets ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; |
| ALTER TABLE bets ADD COLUMN IF NOT EXISTS deleted_reason TEXT; |
| `); |
|
|
| await this.pool.query(` |
| CREATE UNIQUE INDEX IF NOT EXISTS bets_user_bet_number_unique |
| ON bets (discord_user_id, bet_number); |
| `); |
|
|
| await this.pool.query(` |
| CREATE TABLE IF NOT EXISTS scan_runs ( |
| id INT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, |
| scan_type TEXT NOT NULL, |
| status TEXT NOT NULL DEFAULT 'completed', |
| captured_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), |
| entry_count INT NOT NULL DEFAULT 0, |
| error_text TEXT |
| ); |
| `); |
|
|
| await this.pool.query(` |
| CREATE TABLE IF NOT EXISTS scan_markets ( |
| id INT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, |
| scan_run_id INT NOT NULL REFERENCES scan_runs(id) ON DELETE CASCADE, |
| market_key TEXT NOT NULL, |
| source TEXT NOT NULL, |
| book TEXT NOT NULL, |
| event_name TEXT, |
| event_commence_time TIMESTAMPTZ, |
| player_name TEXT NOT NULL, |
| market_type TEXT NOT NULL, |
| market_label TEXT NOT NULL, |
| side TEXT NOT NULL, |
| line_value DOUBLE PRECISION, |
| odds_input TEXT NOT NULL, |
| implied_probability DOUBLE PRECISION NOT NULL, |
| raw_label TEXT, |
| captured_at TIMESTAMPTZ NOT NULL DEFAULT NOW() |
| ); |
| `); |
|
|
| await this.pool.query(` |
| ALTER TABLE scan_markets ADD COLUMN IF NOT EXISTS event_commence_time TIMESTAMPTZ; |
| `); |
|
|
| await this.pool.query(` |
| CREATE TABLE IF NOT EXISTS scan_reports ( |
| id INT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, |
| report_date DATE NOT NULL, |
| report_type TEXT NOT NULL, |
| channel_id TEXT NOT NULL, |
| primary_message_id TEXT, |
| secondary_message_id TEXT, |
| created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), |
| CONSTRAINT scan_reports_date_type_unique UNIQUE (report_date, report_type) |
| ); |
| `); |
|
|
| await this.pool.query(` |
| CREATE TABLE IF NOT EXISTS scan_alerts ( |
| alert_key TEXT PRIMARY KEY, |
| last_sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW() |
| ); |
| `); |
|
|
| await this.pool.query(` |
| CREATE TABLE IF NOT EXISTS circa_snapshots ( |
| id INT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, |
| file_name TEXT NOT NULL, |
| file_source TEXT, |
| fingerprint TEXT NOT NULL UNIQUE, |
| file_date DATE, |
| seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), |
| entry_count INT NOT NULL DEFAULT 0 |
| ); |
| `); |
|
|
| await this.pool.query(` |
| CREATE TABLE IF NOT EXISTS circa_snapshot_entries ( |
| id INT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, |
| snapshot_id INT NOT NULL REFERENCES circa_snapshots(id) ON DELETE CASCADE, |
| market_key TEXT NOT NULL, |
| player_name TEXT NOT NULL, |
| team TEXT, |
| market_type TEXT NOT NULL, |
| market_label TEXT NOT NULL, |
| side TEXT NOT NULL, |
| line_value DOUBLE PRECISION, |
| odds_input TEXT NOT NULL, |
| implied_probability DOUBLE PRECISION NOT NULL, |
| raw_label TEXT |
| ); |
| `); |
|
|
| await this.pool.query(` |
| CREATE TABLE IF NOT EXISTS circa_daily_posts ( |
| post_date DATE PRIMARY KEY, |
| snapshot_id INT NOT NULL REFERENCES circa_snapshots(id), |
| channel_id TEXT NOT NULL, |
| created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() |
| ); |
| `); |
|
|
| await this.pool.query(` |
| CREATE TABLE IF NOT EXISTS circa_movement_posts ( |
| movement_key TEXT PRIMARY KEY, |
| last_snapshot_fingerprint TEXT NOT NULL, |
| last_sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW() |
| ); |
| `); |
| } |
|
|
| async upsertUser(user) { |
| await this.pool.query( |
| ` |
| INSERT INTO users (discord_user_id, username, display_name) |
| VALUES ($1, $2, $3) |
| ON CONFLICT (discord_user_id) DO UPDATE SET |
| username = EXCLUDED.username, |
| display_name = EXCLUDED.display_name, |
| updated_at = NOW() |
| `, |
| [user.id, user.username, user.displayName ?? user.username] |
| ); |
| } |
|
|
| async getUserProfile(userId) { |
| const { rows } = await this.pool.query( |
| ` |
| SELECT * |
| FROM users |
| WHERE discord_user_id = $1 |
| `, |
| [userId] |
| ); |
|
|
| if (!rows[0]) { |
| return { |
| startingBankroll: null, |
| unitSize: null, |
| }; |
| } |
|
|
| return { |
| startingBankroll: numberOrNull(rows[0].starting_bankroll), |
| unitSize: numberOrNull(rows[0].unit_size), |
| }; |
| } |
|
|
| async updateUserPerformanceConfig(user, settings) { |
| await this.upsertUser(user); |
| const current = await this.getUserProfile(user.id); |
| const startingBankroll = settings.startingBankroll ?? current.startingBankroll; |
| const unitSize = settings.unitSize ?? current.unitSize; |
|
|
| await this.pool.query( |
| ` |
| UPDATE users |
| SET starting_bankroll = $2, |
| unit_size = $3, |
| username = $4, |
| display_name = $5, |
| updated_at = NOW() |
| WHERE discord_user_id = $1 |
| `, |
| [user.id, startingBankroll, unitSize, user.username, user.displayName ?? user.username] |
| ); |
|
|
| if (unitSize !== null && unitSize > 0) { |
| await this.pool.query( |
| ` |
| UPDATE bets |
| SET units_value = stake / $2 |
| WHERE discord_user_id = $1 |
| `, |
| [user.id, unitSize] |
| ); |
| } |
|
|
| return this.getUserProfile(user.id); |
| } |
|
|
| async createBet(user, bet) { |
| const client = await this.pool.connect(); |
|
|
| try { |
| await client.query('BEGIN'); |
| await client.query( |
| ` |
| INSERT INTO users (discord_user_id, username, display_name) |
| VALUES ($1, $2, $3) |
| ON CONFLICT (discord_user_id) DO UPDATE SET |
| username = EXCLUDED.username, |
| display_name = EXCLUDED.display_name, |
| updated_at = NOW() |
| `, |
| [user.id, user.username, user.displayName ?? user.username] |
| ); |
|
|
| const { rows: userRows } = await client.query( |
| ` |
| SELECT unit_size |
| FROM users |
| WHERE discord_user_id = $1 |
| `, |
| [user.id] |
| ); |
| const unitSize = numberOrNull(userRows[0]?.unit_size); |
| const unitsValue = unitSize && unitSize > 0 ? deriveUnitsValue(bet.stake, unitSize) : 0; |
|
|
| const { rows: counterRows } = await client.query( |
| ` |
| UPDATE users |
| SET next_bet_number = next_bet_number + 1, |
| username = $2, |
| display_name = $3, |
| updated_at = NOW() |
| WHERE discord_user_id = $1 |
| RETURNING next_bet_number - 1 AS bet_number |
| `, |
| [user.id, user.username, user.displayName ?? user.username] |
| ); |
| const betNumber = Number(counterRows[0].bet_number); |
|
|
| await client.query( |
| ` |
| INSERT INTO bets ( |
| discord_user_id, |
| bet_number, |
| book, |
| sport, |
| odds_input, |
| normalized_decimal_odds, |
| prop, |
| stake, |
| units_value, |
| raw_input |
| ) |
| VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) |
| `, |
| [ |
| user.id, |
| betNumber, |
| bet.book, |
| bet.sport, |
| bet.oddsInput, |
| bet.normalizedDecimalOdds, |
| bet.prop, |
| bet.stake, |
| unitsValue, |
| bet.rawInput, |
| ] |
| ); |
|
|
| await client.query('COMMIT'); |
| return this.findBet(user.id, betNumber); |
| } catch (error) { |
| await client.query('ROLLBACK'); |
| throw error; |
| } finally { |
| client.release(); |
| } |
| } |
|
|
| async findBet(userId, betNumber, options = {}) { |
| const includeDeleted = options.includeDeleted ?? true; |
| const { rows } = await this.pool.query( |
| ` |
| SELECT * |
| FROM bets |
| WHERE discord_user_id = $1 AND bet_number = $2 |
| ${includeDeleted ? '' : 'AND deleted_at IS NULL'} |
| `, |
| [userId, betNumber] |
| ); |
|
|
| return mapBet(rows[0]); |
| } |
|
|
| async updateBet(userId, betNumber, patch) { |
| const existingBet = await this.findBet(userId, betNumber); |
| if (!existingBet) { |
| return { type: 'missing' }; |
| } |
|
|
| if (existingBet.deletedAt) { |
| return { type: 'deleted', bet: existingBet }; |
| } |
|
|
| const attemptsFinancialEdit = [ |
| patch.oddsInput, |
| patch.normalizedDecimalOdds, |
| patch.stake, |
| patch.unitsValue, |
| ].some((value) => value !== undefined); |
|
|
| if (existingBet.status !== 'open' && attemptsFinancialEdit) { |
| return { type: 'financial_locked', bet: existingBet }; |
| } |
|
|
| const updates = []; |
| const values = []; |
| let index = 1; |
|
|
| for (const [column, value] of Object.entries({ |
| book: patch.book, |
| sport: patch.sport, |
| prop: patch.prop, |
| odds_input: patch.oddsInput, |
| normalized_decimal_odds: patch.normalizedDecimalOdds, |
| stake: patch.stake, |
| units_value: patch.unitsValue, |
| raw_input: patch.rawInput, |
| })) { |
| if (value !== undefined) { |
| updates.push(`${column} = $${index++}`); |
| values.push(value); |
| } |
| } |
|
|
| if (updates.length === 0) { |
| return { type: 'no_changes', bet: existingBet }; |
| } |
|
|
| values.push(userId, betNumber); |
| await this.pool.query( |
| ` |
| UPDATE bets |
| SET ${updates.join(', ')} |
| WHERE discord_user_id = $${index++} AND bet_number = $${index} AND deleted_at IS NULL |
| `, |
| values |
| ); |
|
|
| return { type: 'updated', bet: await this.findBet(userId, betNumber) }; |
| } |
|
|
| async softDeleteBet(userId, betNumber, reason = null) { |
| const existingBet = await this.findBet(userId, betNumber); |
| if (!existingBet) { |
| return { type: 'missing' }; |
| } |
|
|
| if (existingBet.deletedAt) { |
| return { type: 'already_deleted', bet: existingBet }; |
| } |
|
|
| await this.pool.query( |
| ` |
| UPDATE bets |
| SET deleted_at = NOW(), |
| deleted_reason = $3 |
| WHERE discord_user_id = $1 AND bet_number = $2 |
| `, |
| [userId, betNumber, reason] |
| ); |
|
|
| return { type: 'deleted', bet: await this.findBet(userId, betNumber) }; |
| } |
|
|
| async resolveBet(userId, betNumber, result) { |
| const existingBet = await this.findBet(userId, betNumber); |
| if (!existingBet) { |
| return { type: 'missing' }; |
| } |
|
|
| if (existingBet.deletedAt) { |
| return { type: 'deleted', bet: existingBet }; |
| } |
|
|
| if (existingBet.status !== 'open') { |
| return { type: 'already_resolved', bet: existingBet }; |
| } |
|
|
| const profitLoss = calculateProfitLoss(existingBet.stake, existingBet.normalizedDecimalOdds, result); |
| await this.pool.query( |
| ` |
| UPDATE bets |
| SET status = $1, profit_loss = $2, resolved_at = NOW() |
| WHERE discord_user_id = $3 AND bet_number = $4 AND status = 'open' AND deleted_at IS NULL |
| `, |
| [result, profitLoss, userId, betNumber] |
| ); |
|
|
| return { type: 'resolved', bet: await this.findBet(userId, betNumber) }; |
| } |
|
|
| async getBetRows(userId, filters = {}, options = {}) { |
| const { clause, values } = buildWhereClause({ ...filters, userId }, options); |
| const limitClause = options.limit ? ` LIMIT ${Number(options.limit)}` : ''; |
| const offsetClause = options.offset ? ` OFFSET ${Number(options.offset)}` : ''; |
| const orderClause = options.orderBy ?? 'bet_number DESC'; |
|
|
| const { rows } = await this.pool.query( |
| ` |
| SELECT * |
| FROM bets |
| WHERE ${clause} |
| ORDER BY ${orderClause} |
| ${limitClause} |
| ${offsetClause} |
| `, |
| values |
| ); |
|
|
| return rows.map(mapBet); |
| } |
|
|
| async getFilteredCount(userId, filters = {}, options = {}) { |
| const { clause, values } = buildWhereClause({ ...filters, userId }, options); |
| const { rows } = await this.pool.query( |
| ` |
| SELECT COUNT(*) AS count |
| FROM bets |
| WHERE ${clause} |
| `, |
| values |
| ); |
|
|
| return Number(rows[0].count ?? 0); |
| } |
|
|
| async getUserSummary(userId, filters = {}) { |
| const rows = await this.getBetRows(userId, filters, { orderBy: 'bet_number ASC' }); |
| const profile = await this.getUserProfile(userId); |
| const analytics = computeAnalytics(rows, profile); |
| const streak = computeStreak(rows); |
| const { best, worst } = computeBestWorst(rows); |
|
|
| return { |
| ...analytics, |
| streak, |
| bestBet: best, |
| worstBet: worst, |
| }; |
| } |
|
|
| async getRecentBets(userId, filters = {}, page = 1, limit = 5) { |
| const offset = Math.max(0, page - 1) * limit; |
| const bets = await this.getBetRows(userId, filters, { |
| limit, |
| offset, |
| orderBy: 'bet_number DESC', |
| includeDeleted: filters.status === 'deleted', |
| }); |
| const totalCount = await this.getFilteredCount(userId, filters, { |
| includeDeleted: filters.status === 'deleted', |
| }); |
|
|
| return { |
| bets, |
| totalCount, |
| currentPage: page, |
| totalPages: Math.max(1, Math.ceil(totalCount / limit)), |
| }; |
| } |
|
|
| async getChartPoints(userId, filters = {}) { |
| const rows = await this.getBetRows(userId, filters, { orderBy: 'bet_number ASC' }); |
| let runningProfit = 0; |
|
|
| return rows |
| .filter((row) => row.deletedAt === null && row.status !== 'open') |
| .map((row) => { |
| runningProfit += Number(row.profitLoss ?? 0); |
| return { |
| betNumber: row.betNumber, |
| status: row.status, |
| profitLoss: Number(row.profitLoss ?? 0), |
| runningProfit, |
| label: `#${row.betNumber}`, |
| }; |
| }); |
| } |
|
|
| async getBookBreakdown(userId, filters = {}) { |
| const rows = await this.getBetRows(userId, filters, { orderBy: 'bet_number ASC' }); |
| return computeGroupedBreakdown(rows, 'book'); |
| } |
|
|
| async getSportBreakdown(userId, filters = {}) { |
| const rows = await this.getBetRows(userId, filters, { orderBy: 'bet_number ASC' }); |
| return computeGroupedBreakdown(rows, 'sport'); |
| } |
|
|
| async exportBets(userId, filters = {}) { |
| return this.getBetRows(userId, filters, { |
| orderBy: 'bet_number ASC', |
| includeDeleted: filters.status === 'deleted', |
| }); |
| } |
|
|
| async recordMarketSnapshot(scanType, entries) { |
| const client = await this.pool.connect(); |
|
|
| try { |
| await client.query('BEGIN'); |
| const { rows } = await client.query( |
| ` |
| INSERT INTO scan_runs (scan_type, status, entry_count) |
| VALUES ($1, 'completed', $2) |
| RETURNING id |
| `, |
| [scanType, entries.length] |
| ); |
| const scanRunId = Number(rows[0].id); |
|
|
| for (const entry of entries) { |
| await client.query( |
| ` |
| INSERT INTO scan_markets ( |
| scan_run_id, |
| market_key, |
| source, |
| book, |
| event_name, |
| event_commence_time, |
| player_name, |
| market_type, |
| market_label, |
| side, |
| line_value, |
| odds_input, |
| implied_probability, |
| raw_label |
| ) |
| VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) |
| `, |
| [ |
| scanRunId, |
| entry.marketKey, |
| entry.source, |
| entry.book, |
| entry.eventName, |
| entry.eventCommenceTime ?? null, |
| entry.playerName, |
| entry.marketType, |
| entry.marketLabel, |
| entry.side, |
| entry.lineValue, |
| entry.oddsInput, |
| entry.impliedProbability, |
| entry.rawLabel, |
| ] |
| ); |
| } |
|
|
| await client.query('COMMIT'); |
| return scanRunId; |
| } catch (error) { |
| await client.query('ROLLBACK'); |
| throw error; |
| } finally { |
| client.release(); |
| } |
| } |
|
|
| async getScanRunById(scanRunId) { |
| const { rows } = await this.pool.query( |
| ` |
| SELECT * |
| FROM scan_runs |
| WHERE id = $1 |
| LIMIT 1 |
| `, |
| [scanRunId] |
| ); |
|
|
| if (!rows[0]) { |
| return null; |
| } |
|
|
| const run = rows[0]; |
| const entriesResult = await this.pool.query( |
| ` |
| SELECT * |
| FROM scan_markets |
| WHERE scan_run_id = $1 |
| ORDER BY player_name, market_type, side, line_value NULLS FIRST, book |
| `, |
| [scanRunId] |
| ); |
|
|
| return { |
| id: Number(run.id), |
| scanType: run.scan_type, |
| status: run.status, |
| entryCount: Number(run.entry_count ?? 0), |
| createdAt: run.created_at?.toISOString?.() ?? String(run.created_at), |
| errorText: run.error_text ?? null, |
| entries: entriesResult.rows.map((entry) => ({ |
| marketKey: entry.market_key, |
| source: entry.source, |
| book: entry.book, |
| eventName: entry.event_name, |
| eventCommenceTime: entry.event_commence_time?.toISOString?.() ?? (entry.event_commence_time ? String(entry.event_commence_time) : null), |
| playerName: entry.player_name, |
| marketType: entry.market_type, |
| marketLabel: entry.market_label, |
| side: entry.side, |
| lineValue: numberOrNull(entry.line_value), |
| oddsInput: entry.odds_input, |
| impliedProbability: Number(entry.implied_probability), |
| rawLabel: entry.raw_label ?? null, |
| })), |
| }; |
| } |
|
|
| async getLatestScanRun(scanType = null) { |
| const { rows } = await this.pool.query( |
| ` |
| SELECT id |
| FROM scan_runs |
| ${scanType ? 'WHERE scan_type = $1' : ''} |
| ORDER BY created_at DESC, id DESC |
| LIMIT 1 |
| `, |
| scanType ? [scanType] : [] |
| ); |
|
|
| if (!rows[0]) { |
| return null; |
| } |
|
|
| return this.getScanRunById(Number(rows[0].id)); |
| } |
|
|
| async getPreviousScanRun(scanRunId, scanType = null) { |
| const { rows } = await this.pool.query( |
| ` |
| SELECT id |
| FROM scan_runs |
| WHERE id < $1 |
| ${scanType ? 'AND scan_type = $2' : ''} |
| ORDER BY id DESC |
| LIMIT 1 |
| `, |
| scanType ? [scanRunId, scanType] : [scanRunId] |
| ); |
|
|
| if (!rows[0]) { |
| return null; |
| } |
|
|
| return this.getScanRunById(Number(rows[0].id)); |
| } |
|
|
| async hasScanReport(reportDate, reportType) { |
| const { rows } = await this.pool.query( |
| ` |
| SELECT 1 |
| FROM scan_reports |
| WHERE report_date = $1 AND report_type = $2 |
| `, |
| [reportDate, reportType] |
| ); |
|
|
| return Boolean(rows[0]); |
| } |
|
|
| async recordScanReport(reportDate, reportType, channelId, primaryMessageId, secondaryMessageId = null) { |
| await this.pool.query( |
| ` |
| INSERT INTO scan_reports (report_date, report_type, channel_id, primary_message_id, secondary_message_id) |
| VALUES ($1, $2, $3, $4, $5) |
| ON CONFLICT (report_date, report_type) DO UPDATE SET |
| channel_id = EXCLUDED.channel_id, |
| primary_message_id = EXCLUDED.primary_message_id, |
| secondary_message_id = EXCLUDED.secondary_message_id, |
| created_at = NOW() |
| `, |
| [reportDate, reportType, channelId, primaryMessageId, secondaryMessageId] |
| ); |
| } |
|
|
| async canSendScanAlert(alertKey, cooldownMinutes) { |
| const { rows } = await this.pool.query( |
| ` |
| SELECT last_sent_at |
| FROM scan_alerts |
| WHERE alert_key = $1 |
| `, |
| [alertKey] |
| ); |
|
|
| if (!rows[0]) { |
| return true; |
| } |
|
|
| const lastSentAt = new Date(rows[0].last_sent_at); |
| return Date.now() - lastSentAt.getTime() >= cooldownMinutes * 60 * 1000; |
| } |
|
|
| async recordScanAlert(alertKey) { |
| await this.pool.query( |
| ` |
| INSERT INTO scan_alerts (alert_key, last_sent_at) |
| VALUES ($1, NOW()) |
| ON CONFLICT (alert_key) DO UPDATE SET |
| last_sent_at = NOW() |
| `, |
| [alertKey] |
| ); |
| } |
|
|
| async getLatestCircaSnapshot() { |
| const { rows } = await this.pool.query( |
| ` |
| SELECT * |
| FROM circa_snapshots |
| ORDER BY seen_at DESC |
| LIMIT 1 |
| ` |
| ); |
|
|
| if (!rows[0]) { |
| return null; |
| } |
|
|
| return this.getCircaSnapshotById(Number(rows[0].id)); |
| } |
|
|
| async getPreviousCircaSnapshot(snapshotId) { |
| const { rows } = await this.pool.query( |
| ` |
| SELECT * |
| FROM circa_snapshots |
| WHERE id < $1 |
| ORDER BY id DESC |
| LIMIT 1 |
| `, |
| [snapshotId] |
| ); |
|
|
| if (!rows[0]) { |
| return null; |
| } |
|
|
| return this.getCircaSnapshotById(Number(rows[0].id)); |
| } |
|
|
| async getCircaSnapshotByFingerprint(fingerprint) { |
| const { rows } = await this.pool.query( |
| ` |
| SELECT * |
| FROM circa_snapshots |
| WHERE fingerprint = $1 |
| LIMIT 1 |
| `, |
| [fingerprint] |
| ); |
|
|
| if (!rows[0]) { |
| return null; |
| } |
|
|
| return this.getCircaSnapshotById(Number(rows[0].id)); |
| } |
|
|
| async getCircaSnapshotById(snapshotId) { |
| const { rows } = await this.pool.query( |
| ` |
| SELECT * |
| FROM circa_snapshots |
| WHERE id = $1 |
| LIMIT 1 |
| `, |
| [snapshotId] |
| ); |
|
|
| if (!rows[0]) { |
| return null; |
| } |
|
|
| const snapshotRow = rows[0]; |
| const entryResult = await this.pool.query( |
| ` |
| SELECT * |
| FROM circa_snapshot_entries |
| WHERE snapshot_id = $1 |
| ORDER BY market_label, player_name, side, line_value NULLS FIRST |
| `, |
| [snapshotId] |
| ); |
|
|
| return { |
| id: Number(snapshotRow.id), |
| fileName: snapshotRow.file_name, |
| fileSource: snapshotRow.file_source, |
| fingerprint: snapshotRow.fingerprint, |
| fileDate: snapshotRow.file_date ? new Date(snapshotRow.file_date).toISOString().slice(0, 10) : null, |
| seenAt: snapshotRow.seen_at?.toISOString?.() ?? String(snapshotRow.seen_at), |
| entryCount: Number(snapshotRow.entry_count), |
| entries: entryResult.rows.map((entry) => ({ |
| marketKey: entry.market_key, |
| source: 'circa', |
| book: 'Circa', |
| eventName: 'Circa MLB', |
| eventId: null, |
| playerName: entry.player_name, |
| team: entry.team, |
| marketType: entry.market_type, |
| marketLabel: entry.market_label, |
| side: entry.side, |
| lineValue: numberOrNull(entry.line_value), |
| oddsInput: entry.odds_input, |
| impliedProbability: Number(entry.implied_probability), |
| rawLabel: entry.raw_label, |
| })), |
| }; |
| } |
|
|
| async recordCircaSnapshot(snapshot) { |
| const existing = await this.getCircaSnapshotByFingerprint(snapshot.fingerprint); |
| if (existing) { |
| return existing; |
| } |
|
|
| const client = await this.pool.connect(); |
|
|
| try { |
| await client.query('BEGIN'); |
| const { rows } = await client.query( |
| ` |
| INSERT INTO circa_snapshots (file_name, file_source, fingerprint, file_date, entry_count) |
| VALUES ($1, $2, $3, $4, $5) |
| ON CONFLICT (fingerprint) DO NOTHING |
| RETURNING id |
| `, |
| [ |
| snapshot.fileName, |
| snapshot.fileSource ?? null, |
| snapshot.fingerprint, |
| snapshot.fileDate ?? null, |
| snapshot.entries.length, |
| ] |
| ); |
| if (!rows[0]) { |
| await client.query('ROLLBACK'); |
| return this.getCircaSnapshotByFingerprint(snapshot.fingerprint); |
| } |
|
|
| const snapshotId = Number(rows[0].id); |
|
|
| for (const entry of snapshot.entries) { |
| await client.query( |
| ` |
| INSERT INTO circa_snapshot_entries ( |
| snapshot_id, |
| market_key, |
| player_name, |
| team, |
| market_type, |
| market_label, |
| side, |
| line_value, |
| odds_input, |
| implied_probability, |
| raw_label |
| ) |
| VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) |
| `, |
| [ |
| snapshotId, |
| entry.marketKey, |
| entry.playerName, |
| entry.team ?? null, |
| entry.marketType, |
| entry.marketLabel, |
| entry.side, |
| entry.lineValue ?? null, |
| entry.oddsInput, |
| entry.impliedProbability, |
| entry.rawLabel ?? null, |
| ] |
| ); |
| } |
|
|
| await client.query('COMMIT'); |
| return this.getCircaSnapshotById(snapshotId); |
| } catch (error) { |
| await client.query('ROLLBACK'); |
| if (error?.code === '23505') { |
| return this.getCircaSnapshotByFingerprint(snapshot.fingerprint); |
| } |
| throw error; |
| } finally { |
| client.release(); |
| } |
| } |
|
|
| async getCircaDailyPost(postDate) { |
| const { rows } = await this.pool.query( |
| ` |
| SELECT post_date, snapshot_id, channel_id, created_at |
| FROM circa_daily_posts |
| WHERE post_date = $1 |
| `, |
| [postDate] |
| ); |
|
|
| if (!rows[0]) { |
| return null; |
| } |
|
|
| return { |
| postDate: rows[0].post_date?.toISOString?.().slice(0, 10) ?? String(rows[0].post_date), |
| snapshotId: Number(rows[0].snapshot_id), |
| channelId: rows[0].channel_id, |
| createdAt: rows[0].created_at?.toISOString?.() ?? String(rows[0].created_at), |
| }; |
| } |
|
|
| async recordCircaDailyPost(postDate, snapshotId, channelId) { |
| await this.pool.query( |
| ` |
| INSERT INTO circa_daily_posts (post_date, snapshot_id, channel_id, created_at) |
| VALUES ($1, $2, $3, NOW()) |
| ON CONFLICT (post_date) DO UPDATE SET |
| snapshot_id = EXCLUDED.snapshot_id, |
| channel_id = EXCLUDED.channel_id, |
| created_at = NOW() |
| `, |
| [postDate, snapshotId, channelId] |
| ); |
| } |
|
|
| async getLatestCircaDailyPost() { |
| const { rows } = await this.pool.query( |
| ` |
| SELECT post_date, snapshot_id, channel_id, created_at |
| FROM circa_daily_posts |
| ORDER BY post_date DESC |
| LIMIT 1 |
| ` |
| ); |
|
|
| if (!rows[0]) { |
| return null; |
| } |
|
|
| return { |
| postDate: rows[0].post_date?.toISOString?.().slice(0, 10) ?? String(rows[0].post_date), |
| snapshotId: Number(rows[0].snapshot_id), |
| channelId: rows[0].channel_id, |
| createdAt: rows[0].created_at?.toISOString?.() ?? String(rows[0].created_at), |
| }; |
| } |
|
|
| async canSendCircaMovement(movementKey, fingerprint) { |
| const { rows } = await this.pool.query( |
| ` |
| SELECT last_snapshot_fingerprint |
| FROM circa_movement_posts |
| WHERE movement_key = $1 |
| `, |
| [movementKey] |
| ); |
|
|
| if (!rows[0]) { |
| return true; |
| } |
|
|
| return rows[0].last_snapshot_fingerprint !== fingerprint; |
| } |
|
|
| async recordCircaMovement(movementKey, fingerprint) { |
| await this.pool.query( |
| ` |
| INSERT INTO circa_movement_posts (movement_key, last_snapshot_fingerprint, last_sent_at) |
| VALUES ($1, $2, NOW()) |
| ON CONFLICT (movement_key) DO UPDATE SET |
| last_snapshot_fingerprint = EXCLUDED.last_snapshot_fingerprint, |
| last_sent_at = NOW() |
| `, |
| [movementKey, fingerprint] |
| ); |
| } |
|
|
| async close() { |
| await this.pool.end(); |
| } |
| } |
|
|
| function computeGroupedBreakdown(rows, key) { |
| const groups = new Map(); |
|
|
| for (const row of rows) { |
| if (row.deletedAt) { |
| continue; |
| } |
|
|
| const groupKey = row[key]; |
| if (!groups.has(groupKey)) { |
| groups.set(groupKey, []); |
| } |
| groups.get(groupKey).push(row); |
| } |
|
|
| return [...groups.entries()].map(([label, groupRows]) => { |
| const wins = groupRows.filter((row) => row.status === 'win').length; |
| const losses = groupRows.filter((row) => row.status === 'loss').length; |
| const voids = groupRows.filter((row) => row.status === 'void').length; |
| const openBets = groupRows.filter((row) => row.status === 'open').length; |
| const settledStake = groupRows |
| .filter((row) => row.status === 'win' || row.status === 'loss') |
| .reduce((total, row) => total + row.stake, 0); |
| const netProfit = groupRows |
| .filter((row) => row.status !== 'open') |
| .reduce((total, row) => total + (row.profitLoss ?? 0), 0); |
| const gradedBets = wins + losses; |
|
|
| return { |
| [key]: label, |
| label, |
| totalBets: groupRows.length, |
| openBets, |
| wins, |
| losses, |
| voids, |
| settledStake, |
| netProfit, |
| roiPercent: settledStake > 0 ? (netProfit / settledStake) * 100 : 0, |
| winRatePercent: gradedBets > 0 ? (wins / gradedBets) * 100 : 0, |
| }; |
| }); |
| } |
|
|
| export function calculateProfitLoss(stake, normalizedDecimalOdds, result) { |
| if (result === 'win') { |
| return Number((stake * (normalizedDecimalOdds - 1)).toFixed(2)); |
| } |
|
|
| if (result === 'loss') { |
| return Number((-stake).toFixed(2)); |
| } |
|
|
| return 0; |
| } |
|
|