forsyt.io Fraud Detection System — Unified Event Log Architecture
Design & Implementation Blueprint Built on Existing Winston + TimescaleDB Infrastructure Internal - Product & Engineering - March 2026
Table of Contents
Part I — Architecture
- Design Philosophy
- The Unified Event Log
- Price Tick Architecture: Dual-Source Logging
- System Architecture
- Data Sources & Platform Instrumentation
- Identity Data: Separate from the Fraud Log
- How Actions Reach the Platform
Part II — Detection Pipeline
- Stage 1: Batch Ingestion & Evaluation
- Stage 2: Bet Evaluation (One Query for Context)
- Stage 3: Five Dimension Extractors
- Stage 4: Scoring, Flagging & Rule Registry
- Stage 5: Case Management & Feedback
Part III — Fraud Logic
Part IV — Interfaces & Workflows
- Agent Interface
- Risk Team Dashboard & Investigation View
- Sevens Integration
- VIP Protocol
- Fraud Response SOP
Part V — Implementation
- Fraud Worker Service
- Database Architecture
- Database Schema
- API Routes
- Background Jobs
- Frontend Components
- Build Phases
- Key Principles
Part I — Architecture
1. Design Philosophy
1.1 One Log to Rule Them All
The fraud detection system is built on a single architectural idea: every event that matters — price ticks, bets placed, bets settled, cricket balls, goals, market suspensions, score updates, user logins, agent config changes, cashouts, cancellations — flows into one unified, append-only, time-series event log. The fraud system is a consumer of this log. It never modifies the bet placement pipeline.
When a bet needs to be evaluated, the fraud system asks one question: "What happened around this bet?" A single query against the unified log returns the complete context — prices before and after, whether the market suspended, what events occurred, what other bets were placed. No joins across multiple tables. One timeline.
1.2 Built on Existing Infrastructure — Not a Parallel System
The platform already has a proven event logging pipeline:
Platform services → logger.info({ tsEvent: '...' }) → Winston → TimescaleTransport
→ batched inserts (100 events or 5s) → TimescaleDB hypertables (hannibal_analytics)
Six hypertables already capture bet lifecycle, financial events, sessions, exchange events, agent operations, and B-book routing — with compression after 7 days, retention policies, and Grafana dashboards.
The fraud system extends this existing pipeline. It does not build a parallel one. Specifically:
- A 7th hypertable (
fraud_events) is added tohannibal_analyticsas the unified event log - The existing
TimescaleTransportis extended with newtsEventtypes for missing events (price ticks, cricket events, config changes, cashout, void) - Platform services log missing events using the same
logger.info({ tsEvent: '...' })pattern already used across the codebase - The fraud worker is a scheduled batch job that queries
fraud_events— no real-time processing needed since payments are not immediate - Existing hypertables continue serving their analytics/dashboard purposes unchanged
No new infrastructure. No Redis Streams. No consumer groups. No separate ingestion service. The fraud system is just another consumer of TimescaleDB data, exactly like the Grafana dashboards.
1.3 Log Everything Now, Decide What to Use Later
A fraud signal you didn't think of today might need the toss result, the innings break timing, or the agent's config change from 5 minutes before a bet. If the data is in the log, you can build the rule retroactively. If it's not, you're stuck.
The platform already logs ~25 event types across the 6 existing hypertables. The fraud system adds ~10 new event types for events not currently captured (price ticks, cricket events, cashout, void, config changes). All ~30+ event types flow into the fraud_events unified hypertable. The cost is storage — and at <200 MB/day, storage is cheap. The cost of not having the data is irreversible.
1.4 Core Design Decisions
| Decision | Rationale |
|---|---|
| Unified event log (7th hypertable) | One table, one index pattern (fixtureId + timestamp), one query for all context. Prices, events, bets, suspensions — all interleaved chronologically. |
| Extend existing TimescaleDB pipeline | Winston → TimescaleTransport already batches events (100/5s) into hypertables. Add new tsEvent types + a unified fraud_events table. Zero new infrastructure. |
| Batch evaluation, not real-time | Payments are not immediate. Fraud worker runs on schedule (every few minutes), evaluates unscored bets. Simpler, more reliable, no message loss risk. |
| Same logging pattern everywhere | logger.info('...', { tsEvent: 'event_type', ...fields }) — identical to how bet_placed, user_login, and agent_created are already logged across the codebase. |
| Dual-source price logging | BetfairAdapter logs exchange prices, BifrostAdapter logs bookmaker prices. Both go to fraud_events. Fraud worker correlates at query time — no in-memory state map needed. |
| Nullable fixtureId for user-centric events | LOGIN, DEPOSIT, WITHDRAWAL, AGENT_CONFIG_CHANGE are not fixture-scoped. fixtureId is nullable so the same log captures both market events and account lifecycle events. |
| Independent dimension monitoring at bet level | When fraud is committed, one or two dimensions spike hard. Composite scoring averages the spike away. Monitor each dimension independently. |
| Composite risk score at user level only | Pattern consistency surfaces over time. The rolling user risk score aggregates bet-level signals across 30 days. |
| Event-relative pricing (T-1 / T+1) | Natural event markers (one ball in cricket, next significant event in football). Not arbitrary time windows. |
| Rule registry pattern | Dimensions and rules register declaratively. Adding 20 new rules = 20 new files, no changes to the pipeline. |
| Deterministic rules day 0, baselines from day 1 | Common sense rules at launch. Baseline collection starts immediately. Migration to baseline-relative as data accumulates. |
| Human judgment first, automation later | Strong signals flag for human review. Automation increases as baselines firm up and confirmed cases provide calibration data. |
| Flagged users never know | No notifications, no friction during investigation. If false positive, user never knew. |
| Agents are weighted sensors | Referral button stays. Scores and detection logic never exposed. Reports weighted by reliability. |
| Identity data stays in dedicated tables | User IP, device fingerprint, payment methods are NOT in the fraud log. Fraud log is market-centric. Identity queries hit existing V3DeviceFingerprint / V3IpAccessLog tables. |
| Existing hypertables for analytics, fraud_events for detection | The 6 existing hypertables (bet_events, financial_events, etc.) continue serving dashboards and analytics unchanged. fraud_events is the unified timeline for fraud detection and investigation. |
2. The Unified Event Log
2.1 Concept
Every event the fraud system cares about is written to one append-only hypertable: fraud_events in the existing hannibal_analytics TimescaleDB. Each row is a timestamped event with a type, an optional fixture context, and a JSON payload. The table is indexed on (fixtureId, timestamp), (userId, timestamp), and (agentId, timestamp).
Events reach this table through the same Winston → TimescaleTransport pipeline already used by the platform. The TimescaleTransport is extended to route fraud-relevant events to fraud_events in addition to their existing destination table.
2.2 Complete Event Type Inventory
Market Events (Fixture-Scoped)
| eventType | Source (tsEvent) | Payload Fields | Tick Rate |
|---|---|---|---|
| EXCHANGE_TICK | NEW: exchange_tick — BetfairAdapter after stream delta | exchangeBack, exchangeLay, exchangeMidpoint, totalMarketVolume (tv), availableVolume, marketStatus | ~1/sec per live match |
| BOOKMAKER_TICK | NEW: bookmaker_tick — BifrostAdapter after cache update | bookmakerPrice, source (bifrost), marketStatus | Throttled: 100ms in-play, 500ms pre-play |
| SUSPENSION | Derived by fraud worker from tick data (status OPEN→SUSPENDED) | previousStatus, newStatus, reason, lastPrice | Per event |
| PRICE_SPIKE | Derived by fraud worker from tick data (>5% single-tick change) | previousPrice, newPrice, changePercent | Per event |
| FEED_SUSPENSION | NEW: feed_suspension — dataFeedMonitor.ts | eventId, marketId, reason (stale/spike), lastUpdateTime, threshold | Per event |
| MATCH_STATUS | NEW: match_status — from match status change handlers | previousStatus, newStatus, statusReason | Per status change |
Cricket Events (Fixture-Scoped)
| eventType | Source (tsEvent) | Payload Fields | Tick Rate |
|---|---|---|---|
| BALL | NEW: cricket_ball — ballByBallService.ts | ballId, overBall, template (outcome type), runs, extras | ~300/match |
| WICKET | NEW: cricket_wicket — ballByBallService.ts | batter, bowler, dismissalType, fielder | Per wicket |
| OVER_COMPLETE | NEW: cricket_over — ballByBallService.ts | overNumber, runsConceded, wicketsInOver, bowler | ~40/innings |
| MILESTONE | NEW: cricket_milestone — ballByBallService.ts | player, milestoneType (50/100/5-wicket), team | Per milestone |
| TOSS | NEW: cricket_toss — tossAnalysisService.ts | tossWinner, tossDecision (bat/bowl), venueConditions | 1/match |
| MATCH_CONTEXT | NEW: cricket_context — ballByBallService.ts | innings, runRate, requiredRate, overs, partnerships | Periodic |
| SESSION_UPDATE | NEW: cricket_session — sessionBettingService.ts | sessionType (innings break/drinks/lunch/tea/rain), duration | Per break |
Bet Lifecycle Events (Fixture-Scoped, User-Linked)
| eventType | Source (tsEvent) | Payload Fields | Tick Rate |
|---|---|---|---|
| BET_PLACED | EXISTING: bet_placed — already logged in orders.ts | userId, agentId, orderId, stake, odds, side, selection, marketId, betType | Per bet |
| BET_SETTLED | EXISTING: bet_settled — already logged in settlement.ts | userId, orderId, stake, odds, outcome, profitLoss | Per settlement |
| BET_CANCELLED | EXISTING: bet_cancelled — already logged in orders.ts | userId, orderId, cancellationReason, initiator | Per cancellation |
| BET_VOIDED | NEW: bet_voided — voidCancellation.ts | userId, orderId, voidReason, voidedBy, originalStake, originalOdds | Per void |
| CASHOUT | NEW: cashout — cashOut.ts | userId, orderId, cashoutPercentage, originalStake, returnAmount, currentOdds | Per cashout |
| ORDER_STATUS | EXISTING: bet_accepted/bet_declined — already logged | userId, orderId, status, reason | Per status change |
Score Events (Fixture-Scoped)
| eventType | Source (tsEvent) | Payload Fields | Tick Rate |
|---|---|---|---|
| SCORE_UPDATE | NEW: score_update — from scores:updated handler | period, team1Score, team2Score | Per score change |
| GOAL | Derived by fraud worker from score diffs | minute, scoringTeam, newScore | Per goal |
| CARD | Derived by fraud worker from score data | minute, cardType, player, team | Per card |
User Account Events (NOT Fixture-Scoped — fixtureId = null)
| eventType | Source (tsEvent) | Payload Fields | Tick Rate |
|---|---|---|---|
| USER_LOGIN | EXISTING: user_login — already logged in auth.ts | userId, authMethod (web3/credentials), ipAddress, userAgent | Per login |
| USER_SIGNUP | EXISTING: user_signup — already logged in auth.ts | userId, walletAddress, chainType, agentId | Per signup |
| LOGIN_FAILED | NEW: login_failed — auth.ts | attemptedUsername, failureReason, ipAddress, attemptCount | Per failure |
| BALANCE_CHANGE | NEW: balance_change — balance update handlers | userId, changeType (deposit/withdrawal/credit), amount, newBalance | Per balance change |
Agent Events (NOT Fixture-Scoped — fixtureId = null)
| eventType | Source (tsEvent) | Payload Fields | Tick Rate |
|---|---|---|---|
| AGENT_CONFIG_CHANGED | NEW: agent_config_changed — bbook-v3-admin.ts | agentId, field (booking_points/forward_pct/markup_pct), oldValue, newValue, changedBy | Per change |
| AGENT_CLASSIFICATION_CHANGED | NEW: agent_classification_changed — collusionDetection.ts | userId, agentId, oldClassification, newClassification (NORMAL/SHARP), reason | Per change |
| AGENT_CREATED | EXISTING: agent_created — already logged in agents.ts | agentId, agentCode | Per creation |
| AGENT_STATUS | EXISTING: agent_status_change — already logged in agents.ts | agentId, previousStatus, newStatus | Per change |
Total: ~30 event types across 6 categories. ~15 already logged via existing tsEvent calls, ~15 new tsEvent calls needed.
2.3 Schema (TimescaleDB Hypertable)
-- Added to hannibal_analytics alongside the existing 6 hypertables
CREATE TABLE IF NOT EXISTS fraud_events (
time TIMESTAMPTZ NOT NULL,
event_type TEXT NOT NULL, -- ~30 types (see inventory above)
fixture_id TEXT, -- nullable for user/agent events
sport_id TEXT,
market_id TEXT,
selection_id TEXT,
user_id TEXT,
agent_id TEXT,
-- Denormalised price fields (null for non-price events)
exchange_midpoint DOUBLE PRECISION,
exchange_back DOUBLE PRECISION,
exchange_lay DOUBLE PRECISION,
bookmaker_price DOUBLE PRECISION,
total_market_volume DOUBLE PRECISION, -- Betfair tv (traded volume)
available_volume DOUBLE PRECISION, -- sum of back+lay ladder depth
market_status TEXT,
-- Bet fields (null for non-bet events)
order_id TEXT,
stake DOUBLE PRECISION,
odds DOUBLE PRECISION,
-- Full event payload
payload JSONB,
-- Source tracking
source TEXT -- which adapter/service produced this
);
-- 1-day chunks: most queries hit recent data (last 24h of live matches)
SELECT create_hypertable('fraud_events', 'time',
chunk_time_interval => INTERVAL '1 day',
if_not_exists => TRUE);
-- Primary fraud query: "what happened around this bet on this fixture?"
CREATE INDEX IF NOT EXISTS idx_fraud_events_fixture ON fraud_events (fixture_id, time DESC);
CREATE INDEX IF NOT EXISTS idx_fraud_events_fixture_type ON fraud_events (fixture_id, event_type, time DESC);
-- Price correlation: fraud worker looks up exchange/bookmaker ticks by fixture + selection
CREATE INDEX IF NOT EXISTS idx_fraud_events_price_lookup ON fraud_events (fixture_id, selection_id, event_type, time DESC)
WHERE event_type IN ('EXCHANGE_TICK', 'BOOKMAKER_TICK');
-- User-centric query: "what has this user done in the last 30 days?"
CREATE INDEX IF NOT EXISTS idx_fraud_events_user ON fraud_events (user_id, time DESC) WHERE user_id IS NOT NULL;
-- Agent-centric query: "what has this agent's activity looked like?"
CREATE INDEX IF NOT EXISTS idx_fraud_events_agent ON fraud_events (agent_id, time DESC) WHERE agent_id IS NOT NULL;
-- Unprocessed bets: fraud worker picks up bets not yet scored (see fraud_evaluation_cursor)
CREATE INDEX IF NOT EXISTS idx_fraud_events_unscored ON fraud_events (time ASC)
WHERE event_type = 'BET_PLACED';
-- Compression: segmentby fixture_id only (event_type has ~30 values — too many segments)
-- event_type goes in orderby so queries on specific types within a fixture still perform well
ALTER TABLE fraud_events SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'fixture_id',
timescaledb.compress_orderby = 'event_type, time DESC'
);
SELECT add_compression_policy('fraud_events', INTERVAL '7 days', if_not_exists => TRUE);
-- Retention: 2 years (ML training needs historical data, baseline calibration needs seasonal patterns)
SELECT add_retention_policy('fraud_events', INTERVAL '2 years', if_not_exists => TRUE);
Cursor-Based Tracking (Replaces Cross-Database JOIN)
The fraud worker tracks which bets have been evaluated using a lightweight cursor table in TimescaleDB — not a cross-database JOIN to main PostgreSQL:
-- Tracks the fraud worker's evaluation progress
-- Avoids cross-database JOINs between TimescaleDB and main PostgreSQL
CREATE TABLE IF NOT EXISTS fraud_evaluation_cursor (
id SERIAL PRIMARY KEY,
cursor_name TEXT NOT NULL UNIQUE, -- e.g. 'bet_evaluation', 't1_completion'
last_evaluated TIMESTAMPTZ NOT NULL, -- timestamp of last processed event
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Seed with epoch so first run picks up everything
INSERT INTO fraud_evaluation_cursor (cursor_name, last_evaluated)
VALUES ('bet_evaluation', '1970-01-01'::timestamptz)
ON CONFLICT (cursor_name) DO NOTHING;
Continuous Aggregates
-- Hourly fraud event volume by type — for operational monitoring dashboards
CREATE MATERIALIZED VIEW IF NOT EXISTS fraud_event_stats_hourly
WITH (timescaledb.continuous) AS
SELECT
time_bucket('1 hour', time) AS bucket,
event_type,
count(*) AS event_count,
count(DISTINCT fixture_id) AS fixture_count,
count(DISTINCT user_id) FILTER (WHERE user_id IS NOT NULL) AS unique_users
FROM fraud_events
GROUP BY bucket, event_type
WITH NO DATA;
SELECT add_continuous_aggregate_policy('fraud_event_stats_hourly',
start_offset => INTERVAL '3 hours',
end_offset => INTERVAL '1 hour',
schedule_interval => INTERVAL '1 hour',
if_not_exists => TRUE);
Key design notes:
fixture_idis nullable — user-centric events (LOGIN, DEPOSIT, AGENT_CONFIG_CHANGE) have no fixture- Partial indexes on
user_idandagent_id(WHERE NOT NULL) — most rows are price ticks with null user/agent - Price fields denormalized to top-level columns — avoids JSON parsing on the hot path when reconstructing price context for a bet
sourcefield tracks which adapter/service produced the event (useful for debugging)- Compression uses
segmentby='fixture_id'only — with ~30 event types,segmentby='fixture_id, event_type'would create far too many segments per chunk. Instead,event_typegoes inorderbyso type-filtered queries still scan efficiently within a fixture's segment. - Chunk interval is 1 day (matching live-match query patterns where most reads hit the last 24 hours)
- Retention is 2 years (not 1) — ML training needs seasonal patterns, and fraud investigations may reference events from prior season
2.4 Why Denormalise Prices?
The most frequent query is: "What was the exchange midpoint at time T for fixture X?" If prices are buried in the JSON payload column, every lookup requires JSON parsing. By promoting exchange_midpoint, bookmaker_price, total_market_volume, available_volume, and market_status to top-level columns, the query becomes a simple index scan with no JSON overhead. For non-price events these columns are null.
2.5 Relationship to Existing Hypertables
| Hypertable | Purpose | Modified? |
|---|---|---|
bet_events | Revenue Command Center, Betting Activity dashboards | No — continues as-is |
financial_events | Revenue, Financial Integrity dashboards | No — continues as-is |
session_events | Player Health & Retention dashboards | No — continues as-is |
exchange_events | Exchange & Infrastructure dashboards | No — continues as-is |
agent_events | Agent Network Performance dashboards | No — continues as-is |
bbook_events | Risk & Exposure dashboards | No — continues as-is |
fraud_events (NEW) | Unified fraud detection timeline | New table |
Events that are already logged (bet_placed, user_login, agent_created, etc.) are routed to BOTH their existing table AND fraud_events. New events (price ticks, cricket events) are routed only to fraud_events. The existing continuous aggregates (bet_stats_hourly, daily_active_users, financial_stats_hourly) are unaffected.
2.6 Volume & Storage
| Scenario | Events/Day | Storage/Day |
|---|---|---|
| 5 live cricket matches (~1 tick/sec each, exchange + bookmaker) | ~864,000 ticks | ~100 MB |
| 5 cricket matches ball-by-ball + overs + milestones + context | ~2,500 events | <2 MB |
| 20 live football matches (~0.5 tick/sec) | ~864,000 ticks | ~100 MB |
| Football events (goals, cards, scores) | ~2,000 events | <1 MB |
| Bets placed + settled + cancelled + voided + cashout | ~12,000 events | <6 MB |
| Suspensions + price spikes + feed issues | ~5,000 events | <2 MB |
| User logins + logouts + failed attempts | ~5,000 events | <2 MB |
| Agent events | ~500 events | <1 MB |
| Balance changes | ~3,000 events | <1 MB |
| Match status + toss + session updates | ~200 events | <1 MB |
| Total | ~1.76M rows/day | ~216 MB/day |
2-year retention = ~158 GB uncompressed. TimescaleDB compression (after 7 days) typically achieves 5-10x reduction → ~16-32 GB actual storage.
2.7 What the Unified Log Eliminates
| Previous Approach | Unified Log Replaces With |
|---|---|
| Separate FraudEventLog Prisma model in main PostgreSQL | fraud_events hypertable in existing TimescaleDB |
| Redis Streams / XADD / consumer groups for event delivery | Existing Winston → TimescaleTransport pipeline |
| Separate real-time ingestion service | Batch evaluation by fraud worker (scheduled job) |
| PriceCorrelator in-memory state map | Query-time price correlation from logged ticks |
| publishToFraudStream() wrapper | Standard logger.info({ tsEvent: '...' }) |
| Betfair stream consumer in fraud worker | BetfairAdapter logs exchange ticks via tsEvent |
| Startup seed / state recovery logic | No state to recover — everything is in the hypertable |
3. Price Tick Architecture: Dual-Source Logging
The fraud system needs both the exchange price (what the market says) and the bookmaker price (what the user was offered). These come from two independent data sources that log to fraud_events separately. The fraud worker correlates them at query time.
3.1 Source 1: Betfair Exchange Stream (Push)
forsyt uses the Betfair Streaming API (Exchange Stream), NOT polling. The stream pushes MarketChangeMessage deltas — only what changed since the last message. The platform already maintains this connection via BetfairAdapter.ts.
| Field | Stream Path | What It Contains |
|---|---|---|
| Exchange prices | mc[].rc[].atb[] / mc[].rc[].atl[] | Price + size at each level of the back/lay ladder |
| Traded volume (per runner) | mc[].rc[].tv | Total matched volume on this runner — this is totalMarketVolume for Dimension 3 |
| Market status | mc[].marketDefinition.status | OPEN, SUSPENDED, CLOSED |
New instrumentation: Add logger.info({ tsEvent: 'exchange_tick', ... }) in BetfairAdapter after processing each stream delta. The existing redis.publish('odds:updated', ...) call is NOT modified.
3.2 Source 2: Bifrost (Bookmaker Prices)
Bifrost is forsyt's bookmaker pricing adapter for cricket (SportId 27). Its data flow:
Bifrost upstream → RabbitMQ (market-book.queue)
→ BifrostQueueManager (protobuf decode)
→ BifrostCache (in-memory + Redis persistence)
→ BifrostAdapter publishes to Redis "odds:updated" with source: 3
→ clientWs.ts broadcasts to frontend
Key details:
- Cricket only (SportId 27 in Hannibal, SportId "2" in Bifrost)
- Covers sportsbook markets (14.* prefix) + fancy/session markets (9.* prefix). Exchange markets (1.* prefix) served directly by Betfair.
- Position Take (PT) = 0% — no margin applied on prices.
- Throttled: pre-play 500ms, in-play 100ms per market. Status changes (BALL_RUNNING ↔ OPEN) bypass throttle immediately.
New instrumentation: Add logger.info({ tsEvent: 'bookmaker_tick', ... }) in BifrostAdapter after each price update. The existing redis.publish('odds:updated', ...) call is NOT modified.
3.3 How the Fraud Worker Correlates Prices
Unlike a real-time correlator, the fraud worker correlates exchange and bookmaker prices at query time when evaluating a bet:
-- For a bet placed at :betTime on :fixtureId/:selectionId:
-- Closest exchange tick before/at bet time
SELECT exchange_midpoint, exchange_back, exchange_lay,
total_market_volume, available_volume, market_status, time
FROM fraud_events
WHERE fixture_id = :fixtureId
AND selection_id = :selectionId
AND event_type = 'EXCHANGE_TICK'
AND time <= :betTime
ORDER BY time DESC LIMIT 1;
-- Closest bookmaker tick before/at bet time
SELECT bookmaker_price, market_status, time
FROM fraud_events
WHERE fixture_id = :fixtureId
AND selection_id = :selectionId
AND event_type = 'BOOKMAKER_TICK'
AND time <= :betTime
ORDER BY time DESC LIMIT 1;
-- Bookmaker price staleness = :betTime - bookmaker_tick.time
-- If staleness > 5 seconds, reduce confidence in Dimension 1
Advantages over real-time correlation:
- No in-memory state map to manage
- No startup seed / state recovery needed
- No staleness tracking as a runtime concern — it's a computed value at query time
- No out-of-order handling needed — TimescaleDB handles insertion order
- Crash recovery is trivial — all data is already in the hypertable
3.4 Volume Data Availability by Sport
| Sport | Exchange Source | Volume Available? | Dimension 3 Impact |
|---|---|---|---|
| Cricket | Betfair Exchange Stream | YES — tv (total traded volume per runner) on every tick | Full Dimension 3 scoring: stake / totalMarketVolume |
| Football, Tennis, etc. | Pinnacle (via other adapters) | NO — Pinnacle does not expose market volume | Dimension 3 falls back to internal platform volume (count of BET_PLACED events in the log for same market) |
Dimension 3 (Liquidity Exploitation) is cricket-first by design. Cricket is where most fraud risk concentrates due to the ball-by-ball betting model.
3.5 Why Two Prices Matter for Fraud
Dimension 1 (Exchange vs Bookmaker at T) compares these two prices. The gap between exchange midpoint and bookmaker price reveals whether a user is capturing value that the multiplier model creates. Logging both separately means the fraud worker can see exactly when pricing gaps opened and whether bets clustered in those windows.
4. System Architecture
4.1 Architecture Diagram
EXISTING PLATFORM (unchanged + targeted logger.info additions)
┌───────────────────────────────────────────────────────────┐
│ orders.ts settlement.ts orderService.ts│
│ ballByBallService.ts dataFeedMonitor.ts │
│ auth.ts voidCancellation.ts [+tsEvent] │
│ cashOut.ts [+tsEvent] bbook-v3-admin.ts [+tsEvent] │
│ collusionDetection.ts [+tsEvent] │
│ BetfairAdapter.ts [+tsEvent] BifrostAdapter.ts [+tsEvent]│
└────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬───┘
│ │ │ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │ │ │
═════╪════╪════╪════╪════╪════╪════╪════╪════╪════╪════╪═══
EXISTING: Redis Pub/Sub (18+ channels — COMPLETELY UNCHANGED)
ClientWs → Socket.IO → Frontend (13 channels)
AgentMonitoringService (odds:updated)
SoccerMonitoringService (scores:updated, odds:updated)
════════════════════════════════════════════════════════════
│ │ │ │ │ │ │ │ │ │ │
─────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴───
EXISTING: Winston logger → TimescaleTransport
────────────────────────────────────────────────────────────
│ Batches events (100 events or 5 seconds)
│ Routes by tsEvent → hypertable
│
┌────┴──────────────────────────────────────────────────────┐
│ TIMESCALEDB (hannibal_analytics) │
│ │
│ EXISTING HYPERTABLES (unchanged): │
│ ┌──────────────┬──────────────┬──────────────┐ │
│ │ bet_events │ financial_ │ session_ │ │
│ │ │ events │ events │ │
│ ├──────────────┼──────────────┼──────────────┤ │
│ │ exchange_ │ agent_ │ bbook_ │ │
│ │ events │ events │ events │ │
│ └──────────────┴──────────────┴──────────────┘ │
│ │
│ NEW: UNIFIED FRAUD EVENT LOG │
│ ┌────────────────────────────────────────────────────┐ │
│ │ fraud_events │ │
│ │ One table. ~30 event types. Every event. │ │
│ │ │ │
│ │ EXCHANGE_TICK | BOOKMAKER_TICK | SUSPENSION │ │
│ │ BALL | WICKET | TOSS | MILESTONE | SESSION_UPDATE │ │
│ │ BET_PLACED | BET_CANCELLED | CASHOUT | VOIDED │ │
│ │ USER_LOGIN | BALANCE_CHANGE | AGENT_CONFIG │ │
│ │ MATCH_STATUS | FEED_SUSPENSION | SCORE_UPDATE │ │
│ │ │ │
│ │ Indexes: (fixture_id, time) (user_id, time) │ │
│ │ (agent_id, time) │ │
│ │ Compression after 7 days. 2-year retention. │ │
│ └────────────────────────────────────────────────────┘ │
└──────────────────────────┬─────────────────────────────────┘
│
┌──────────────────────────┴─────────────────────────────────┐
│ │
│ FRAUD WORKER (scheduled batch job) │
│ │
│ Runs every N minutes. Queries fraud_events for │
│ unscored BET_PLACED events. For each bet: │
│ │
│ 1. Query timeline: fraud_events WHERE fixture_id = X │
│ AND time BETWEEN (betTime - 60s) AND (betTime + 5min) │
│ 2. Correlate exchange + bookmaker prices at query time │
│ 3. Extract 5 dimension scores │
│ 4. Run deterministic rules │
│ 5. Write FraudBetScore (main PostgreSQL via Prisma) │
│ 6. Flag if threshold exceeded │
│ │
│ ┌────────┬────────┬────────┬────────┬────────┐ │
│ │Exch vs │ Price │Liquid- │Repeti- │Identity│ │
│ │Bookie │Movement│ity │tion │Linkage │ │
│ └────┬───┴────┬───┴────┬───┴────┬───┴────┬───┘ │
│ │ │ │ │ │ │
│ ┌────┴────────┴────────┴────────┴────────┴───┐ │
│ │ RULE REGISTRY │ │
│ │ 5 dimensions + N deterministic rules │ │
│ │ + M fraud signatures │ │
│ │ Declarative. Add rules without pipeline │ │
│ │ changes. │ │
│ └──────────────────┬─────────────────────────┘ │
│ │ │
│ Scoring → Flagging → Case Management │
│ │ │
└───────────────────────┴────────────────────────────────────┘
│
Redis: v3:fraud:user_risk:{userId}
Redis: v3:fraud:user_block:{userId}
Redis: fraud:alert (pub/sub → dashboards only)
│
EXISTING PLATFORM READS FLAGS:
• stakeReduction.ts reads Redis keys on next bet
• clientWs.ts forwards fraud:alert to dashboards
4.2 The Core Query
When the fraud worker picks up an unscored BET_PLACED event, it reconstructs full context with one query:
SELECT * FROM fraud_events
WHERE fixture_id = :fixtureId
AND time BETWEEN (:betTime - interval '60 seconds')
AND (:betTime + interval '5 minutes')
ORDER BY time ASC;
This single result set contains: every exchange and bookmaker price tick before and after the bet, whether the market suspended, what cricket balls or goals occurred, what other bets were placed, whether any bets were cancelled, what the toss result was, whether there was a session break. The dimension extractors parse this timeline to compute scores. No joins. No separate tables. One query.
The initial query uses a 60-second lookback to find T and T-1. The T+1 data may not be available yet (the next event hasn't happened). In that case, the bet is queued and T+1 scoring completes on the next evaluation run when the event has arrived.
4.3 User-Centric Query
For cross-fixture analysis (Dimension 4: Repetition, agent-level investigation):
SELECT * FROM fraud_events
WHERE user_id = :userId
AND time > NOW() - INTERVAL '30 days'
AND event_type IN ('BET_PLACED', 'BET_SETTLED', 'BET_CANCELLED', 'CASHOUT',
'USER_LOGIN', 'BALANCE_CHANGE')
ORDER BY time ASC;
Uses the (user_id, time) partial index. Returns the user's complete activity history.
4.4 Agent-Centric Query
For agent-level fraud detection:
SELECT * FROM fraud_events
WHERE agent_id = :agentId
AND time > NOW() - INTERVAL '30 days'
AND event_type IN ('BET_PLACED', 'AGENT_CONFIG_CHANGED',
'AGENT_CLASSIFICATION_CHANGED')
ORDER BY time ASC;
Uses the (agent_id, time) partial index.
5. Data Sources & Platform Instrumentation
5.1 Already Logged (Existing tsEvent Calls — No Changes Needed)
These events are already flowing into TimescaleDB hypertables via existing logger.info({ tsEvent: '...' }) calls. The only change is extending the TimescaleTransport to ALSO route them to fraud_events.
| tsEvent | File | Existing Hypertable | fraud_events Type |
|---|---|---|---|
| bet_placed | orders.ts:118 | bet_events | BET_PLACED |
| bet_accepted | orderService.ts | bet_events | ORDER_STATUS |
| bet_settled | settlement.ts:821 | bet_events | BET_SETTLED |
| bet_cancelled | orders.ts:501 | bet_events | BET_CANCELLED |
| bet_declined | orderService.ts | bet_events | ORDER_STATUS |
| user_login | auth.ts:572 | session_events | USER_LOGIN |
| user_signup | auth.ts:499 | session_events | USER_SIGNUP |
| agent_created | agents.ts:235 | agent_events | AGENT_CREATED |
| agent_status_change | agents.ts:978+ | agent_events | AGENT_STATUS |
| agent_credit_change | agents.ts:363 | agent_events | AGENT_CREDIT |
| agent_settlement | agentSettlementService.ts:212 | agent_events | AGENT_SETTLEMENT |
| bbook_routing | filterEngine.ts:72 | bbook_events | BBOOK_ROUTING |
| bbook_fill | bbookFillService.ts:128 | bbook_events | BBOOK_FILL |
| bbook_settlement | bbookSettlementService.ts:149 | bbook_events | BBOOK_SETTLEMENT |
| exchange_connect | BetfairStreamClient.ts:212 | exchange_events | EXCHANGE_CONNECT |
| exchange_disconnect | BetfairStreamClient.ts:251 | exchange_events | EXCHANGE_DISCONNECT |
| refund_processed | BifrostBetConsumer.ts:418 | financial_events | REFUND |
17 event types already flowing. Zero code changes to these files.
5.2 New tsEvent Calls (Additions)
These events are not currently logged. Each requires a logger.info({ tsEvent: '...' }) call in the relevant service. Same pattern used everywhere else in the codebase.
| tsEvent | File to Modify | Example Call | Lines |
|---|---|---|---|
| exchange_tick | BetfairAdapter.ts (after stream delta processing) | logger.info('Exchange tick', { tsEvent: 'exchange_tick', fixtureId, marketId, selectionId, exchangeBack, exchangeLay, exchangeMidpoint, totalMarketVolume, availableVolume, marketStatus }) | ~5 |
| bookmaker_tick | BifrostAdapter.ts (after cache update, ~line 447) | logger.info('Bookmaker tick', { tsEvent: 'bookmaker_tick', fixtureId, marketId, selectionId, bookmakerPrice, marketStatus, source: 'bifrost' }) | ~5 |
| cricket_ball | ballByBallService.ts (after ball publish, ~line 1446) | logger.info('Cricket ball', { tsEvent: 'cricket_ball', fixtureId, ballId, overBall, template, runs, extras }) | ~3 |
| cricket_wicket | ballByBallService.ts (after wicket publish) | logger.info('Cricket wicket', { tsEvent: 'cricket_wicket', fixtureId, batter, bowler, dismissalType }) | ~3 |
| cricket_toss | tossAnalysisService.ts:573 | logger.info('Toss result', { tsEvent: 'cricket_toss', fixtureId, tossWinner, tossDecision }) | ~3 |
| cricket_milestone | ballByBallService.ts:1883 | logger.info('Milestone', { tsEvent: 'cricket_milestone', fixtureId, player, milestoneType }) | ~3 |
| cricket_context | ballByBallService.ts:1902 | logger.info('Match context', { tsEvent: 'cricket_context', fixtureId, innings, runRate, requiredRate }) | ~3 |
| cricket_session | sessionBettingService.ts:645 | logger.info('Session update', { tsEvent: 'cricket_session', fixtureId, sessionType, duration }) | ~3 |
| cricket_over | ballByBallService.ts | logger.info('Over complete', { tsEvent: 'cricket_over', fixtureId, overNumber, runsConceded }) | ~3 |
| bet_voided | voidCancellation.ts (after void) | logger.info('Bet voided', { tsEvent: 'bet_voided', userId, orderId, voidReason, voidedBy }) | ~3 |
| cashout | cashOut.ts (after execution) | logger.info('Cashout', { tsEvent: 'cashout', userId, orderId, cashoutPercentage, returnAmount }) | ~3 |
| login_failed | auth.ts (after failed auth, ~line 745) | logger.info('Login failed', { tsEvent: 'login_failed', attemptedUsername, failureReason, ipAddress }) | ~3 |
| balance_change | balance update handlers | logger.info('Balance change', { tsEvent: 'balance_change', userId, changeType, amount, newBalance }) | ~3 |
| agent_config_changed | bbook-v3-admin.ts (~line 1270) | logger.info('Agent config changed', { tsEvent: 'agent_config_changed', agentId, field, oldValue, newValue }) | ~3 |
| agent_classification_changed | collusionDetection.ts | logger.info('Classification changed', { tsEvent: 'agent_classification_changed', userId, agentId, oldClassification, newClassification }) | ~3 |
| feed_suspension | dataFeedMonitor.ts:271 | logger.info('Feed suspension', { tsEvent: 'feed_suspension', fixtureId, marketId, reason }) | ~3 |
| match_status | match status handlers | logger.info('Match status', { tsEvent: 'match_status', fixtureId, previousStatus, newStatus }) | ~3 |
| score_update | score update handlers | logger.info('Score update', { tsEvent: 'score_update', fixtureId, team1Score, team2Score }) | ~3 |
~18 new tsEvent calls, ~55 lines of logger.info() additions across ~10 files. Each is a single logger.info() call placed alongside existing code — the same pattern already used in 16+ files across the codebase.
5.3 TimescaleTransport Extension
The EVENT_TABLE_MAP in timescaleTransport.ts needs these additions:
// New entries in EVENT_TABLE_MAP
const EVENT_TABLE_MAP: Record<string, string> = {
// ... existing mappings (unchanged) ...
// Price events → fraud_events only
exchange_tick: 'fraud_events',
bookmaker_tick: 'fraud_events',
// Cricket events → fraud_events only
cricket_ball: 'fraud_events',
cricket_wicket: 'fraud_events',
cricket_over: 'fraud_events',
cricket_milestone: 'fraud_events',
cricket_toss: 'fraud_events',
cricket_context: 'fraud_events',
cricket_session: 'fraud_events',
// Missing lifecycle events → fraud_events only
bet_voided: 'fraud_events',
cashout: 'fraud_events',
login_failed: 'fraud_events',
balance_change: 'fraud_events',
agent_config_changed: 'fraud_events',
agent_classification_changed: 'fraud_events',
feed_suspension: 'fraud_events',
match_status: 'fraud_events',
score_update: 'fraud_events',
};
Additionally, the log() method is extended to dual-route: events that already go to an existing table (bet_placed, user_login, etc.) are ALSO written to fraud_events:
// In TimescaleTransport.log():
const table = EVENT_TABLE_MAP[tsEvent];
const values = this.extractValues(table, tsEvent, info);
this.buffer.push({ table, values });
// Dual-route: if this event also belongs in fraud_events, add a second entry
if (table !== 'fraud_events' && FRAUD_RELEVANT_EVENTS.has(tsEvent)) {
const fraudValues = this.extractFraudValues(tsEvent, info);
this.buffer.push({ table: 'fraud_events', values: fraudValues });
}
A new extractFraudValues() method maps any tsEvent to the fraud_events schema (time, event_type, fixture_id, user_id, agent_id, price fields, payload).
Buffer & Pool Sizing for Fraud Event Volume
With dual-routing and high-frequency price ticks, the current TimescaleTransport settings need adjustment:
| Setting | Current | Recommended | Rationale |
|---|---|---|---|
MAX_BUFFER_SIZE | 10,000 | 50,000 | Price ticks at ~2/sec across 25 live matches = ~180K/hour. 10K buffer exhausts in <4 minutes if TimescaleDB is briefly unreachable. |
batchSize | 100 | 500 | Larger batch inserts are more efficient. With fraud dual-routing, a single bet generates 2 rows. Fewer, larger batch INSERTs reduce round trips. |
pool.max | 3 | 5 | Dual-routing roughly doubles write volume. 3 connections may bottleneck during flush if multiple tables batch-insert concurrently. |
These are configuration changes to timescaleTransport.ts constructor defaults — not structural changes.
5.4 Database Tables (Enrichment Queries)
| Table | When Queried | Purpose |
|---|---|---|
| v3_bets | On BET_PLACED evaluation | Full bet context (eventPhase, sportType, liquidityBand, acceptedStake) |
| v3_audit_trail | On BET_PLACED evaluation | Cascade routing, win cap checks, stake reduction details from payload JSON |
5.5 What Stays Unchanged
| Component | Modified? |
|---|---|
| Redis Pub/Sub (all 18+ channels) | No |
| ClientWs → Socket.IO → Frontend | No |
| AgentMonitoringService | No |
| SoccerMonitoringService | No |
| Existing 6 hypertables | No |
| Existing continuous aggregates | No |
| Grafana dashboards | No |
| Loki + Promtail log aggregation | No |
6. Identity Data: Separate from the Fraud Log
User identity data (IP addresses, device fingerprints, payment methods) is NOT stored in fraud_events. The fraud log is a market-centric event stream — it records what happened at time T. User identity is a fundamentally different concern with different cardinality, retention, and privacy requirements.
Exception: USER_LOGIN events capture IP and device at login time. This is event data ("user logged in from this device at this time"), not reference data ("this is the user's device"). The identity tables store the reference/linkage data.
6.1 Why Keep Identity Separate
| Reason | Detail |
|---|---|
| Cardinality mismatch | One user has thousands of price tick events but one device fingerprint. Denormalising identity into every row bloats storage for zero benefit. |
| Privacy isolation | PII (IP addresses, device identifiers) stays in dedicated tables with their own retention and GDPR/compliance policies. |
| Tables already exist | V3DeviceFingerprint and V3IpAccessLog are defined in the Prisma schema. No new models needed for identity. |
| Query pattern differs | Identity queries are user-centric ("find all devices for user X"). Fraud log queries are fixture-centric ("what happened around this bet"). Different indexes, different access patterns. |
6.2 Identity Tables Used by Fraud Worker
| Table | Queried By | Purpose |
|---|---|---|
| V3DeviceFingerprint | identityLinkage.ts → sharpDetection.findDeviceMatches(userId) | Device matching: exact device ID, canvas hash, screen fingerprint. Score: +40 exact device, +35 canvas match. |
| V3IpAccessLog | identityLinkage.ts → sharpDetection.findIPCorrelations(userId) | IP correlation: shared IPs, geo proximity. Score: +25 * confidence. |
| V3ClvEntry | clvIntegration.ts → getPersistentCLVProfile(userId) | CLV (Closing Line Value) history per user. Fields: openingOdds, closingOdds, clvPercent, stake. Persistent positive CLV is the single strongest predictor of sharp/informed betting. See Section 10.2. |
Note: V3PaymentTransaction does NOT exist in the current Prisma schema. Payment method overlap detection (originally planned for Dimension 5) requires either creating this model or integrating with the external payment provider's API. This is deferred to Phase 2. Identity linkage currently relies on device fingerprinting and IP correlation only.
6.3 Important: Tables Exist but Not Yet Populated
V3DeviceFingerprint and V3IpAccessLog exist in the Prisma schema but are NOT currently being written to by existing application code. A request middleware must populate these tables from HTTP request context (headers, user-agent, IP) on user login and bet placement. This is a platform change — and it's necessary for Dimension 5 to function.
6.4 How Dimension 5 Works End-to-End
- BET_PLACED event found in fraud_events with userId and agentId.
- Fraud worker triggers Dimension 5 (Identity Linkage) evaluation.
- identityLinkage.ts calls existing sharpDetection.ts functions:
findDeviceMatches(userId),findIPCorrelations(userId). - These functions query V3DeviceFingerprint, V3IpAccessLog — NOT the fraud log. Payment method overlap is deferred until V3PaymentTransaction exists.
- Results are scored and written to FraudBetScore.identityLinkageScore.
- If identity score > 50: FraudIdentityCluster record created/updated linking the accounts.
7. How Actions Reach the Platform
The fraud system writes flags to Redis. Platform services read those flags. Additionally, the fraud worker can call existing admin APIs for account-level actions.
7.1 Stake Reduction (Score >= 50)
// FRAUD WORKER writes:
redis.set(`v3:fraud:user_risk:${userId}`, score, 'EX', 86400)
// PLATFORM reads (stakeReduction.ts):
const fraudScore = await redis.get(`v3:fraud:user_risk:${userId}`);
if (fraudScore && parseInt(fraudScore) >= 50) {
userConfig.perClickWinLimit = Math.floor(userConfig.perClickWinLimit * 0.5);
userConfig.aggregateWinLimitDaily = Math.floor(userConfig.aggregateWinLimitDaily * 0.5);
}
Inline Velocity Check for Watched Users
For users already flagged (fraud score >= 50), stakeReduction.ts also performs a lightweight inline velocity check at bet time — the only "near-real-time" check in the system:
// Only for already-flagged users (cheap: one Redis GET + one INCR)
if (fraudScore && parseInt(fraudScore) >= 50) {
const key = `v3:fraud:velocity:${userId}:${Math.floor(Date.now() / 60000)}`; // 1-min window
const count = await redis.incr(key);
if (count === 1) await redis.expire(key, 120); // TTL 2 minutes
if (count > 5) {
// >5 bets/minute from a watched user — block immediately
return { acceptedStake: 0, rejected: true, reductionReason: 'FRAUD_VELOCITY' };
}
}
This catches rapid-fire betting from flagged users between batch evaluation cycles. It's NOT a general velocity check — it only triggers for users the batch system has already scored >= 50.
7.2 Account Block (Red Severity)
// FRAUD WORKER writes:
redis.set(`v3:fraud:user_block:${userId}`, '1', 'EX', 86400)
// PLATFORM reads (stakeReduction.ts):
const blocked = await redis.get(`v3:fraud:user_block:${userId}`);
if (blocked) return { acceptedStake: 0, rejected: true, reductionReason: 'FRAUD_BLOCK' };
7.3 WebSocket Alerts
// FRAUD WORKER publishes:
redis.publish('fraud:alert', JSON.stringify({ userId, agentId, severity, ... }))
// PLATFORM forwards (clientWs.ts):
// Subscribe to 'fraud:alert' channel
// Handler: io.to(`user:${alert.agentId}`).emit('fraud:alert', alert);
7.4 Account Restriction / Bet Reversal
For account holds and bet reversals, the fraud worker calls the existing platform admin API (POST /api/admin/users/:id/restrict). Bet reversal is executed by the risk team through the existing admin UI.
7.5 Platform Changes Summary
| File | Lines Added | What | Existing Code Modified? |
|---|---|---|---|
| timescaleTransport.ts | ~60 | New EVENT_TABLE_MAP entries + extractFraudValues() + dual-routing logic | Additive only — existing extractValues unchanged |
| init.sql | ~30 | fraud_events hypertable DDL + indexes + compression + retention | New table — existing tables unchanged |
| BetfairAdapter.ts | ~5 | logger.info({ tsEvent: 'exchange_tick', ... }) | No — one line added |
| BifrostAdapter.ts | ~5 | logger.info({ tsEvent: 'bookmaker_tick', ... }) | No — one line added |
| ballByBallService.ts | ~15 | tsEvent calls for ball, wicket, milestone, context, over, session | No — lines added after existing publish calls |
| tossAnalysisService.ts | ~3 | logger.info({ tsEvent: 'cricket_toss', ... }) | No — one line added |
| voidCancellation.ts | ~6 | tsEvent calls for bet_voided | No — lines added after existing void logic |
| cashOut.ts | ~3 | logger.info({ tsEvent: 'cashout', ... }) | No — one line added |
| bbook-v3-admin.ts | ~3 | logger.info({ tsEvent: 'agent_config_changed', ... }) | No — one line added |
| collusionDetection.ts | ~3 | logger.info({ tsEvent: 'agent_classification_changed', ... }) | No — one line added |
| auth.ts | ~3 | logger.info({ tsEvent: 'login_failed', ... }) | No — one line added |
| dataFeedMonitor.ts | ~3 | logger.info({ tsEvent: 'feed_suspension', ... }) | No — one line added |
| stakeReduction.ts | ~7 | Read 2 Redis fraud keys. Reduce limits or reject if flagged. | No — new code added |
| clientWs.ts | ~6 | Subscribe to fraud:alert channel. Forward to agent/admin rooms. | No — new subscription added |
| Request middleware (new) | ~30 | Populate V3DeviceFingerprint + V3IpAccessLog from HTTP context. | N/A (new middleware) |
| Total | ~180 lines | tsEvent logging + TimescaleTransport extension + fraud flag reads + device/IP middleware | Zero existing lines modified |
Part II — Detection Pipeline
8. Stage 1: Batch Ingestion & Evaluation
The fraud worker is a scheduled batch job, not a real-time consumer.
Since payments are not immediate, there is no need for real-time bet evaluation. The fraud worker runs on a configurable schedule (default: every 5 minutes) and processes all unscored bets.
8.1 Evaluation Loop
Variable batch interval: The fraud worker checks for live fixtures in Redis and adjusts its evaluation frequency:
- 60 seconds when any fixture is live (in-play) — courtsiding detection needs faster evaluation
- 5 minutes when no live fixtures — standard batch cadence for pre-match bets
class FraudEvaluationJob {
// Interval adjusts based on live fixture presence
async run() {
// 1. Find unscored bets using cursor (avoids cross-database JOIN)
// fraud_evaluation_cursor lives in TimescaleDB alongside fraud_events
const cursor = await timescalePool.query(`
SELECT last_evaluated FROM fraud_evaluation_cursor
WHERE cursor_name = 'bet_evaluation'
`);
const lastEvaluated = cursor.rows[0].last_evaluated;
const unscoredBets = await timescalePool.query(`
SELECT * FROM fraud_events
WHERE event_type = 'BET_PLACED'
AND time > $1
ORDER BY time ASC
LIMIT 500
`, [lastEvaluated]);
for (const bet of unscoredBets.rows) {
await this.evaluateBet(bet);
}
// Advance cursor to the last processed bet's timestamp
if (unscoredBets.rows.length > 0) {
const lastTime = unscoredBets.rows[unscoredBets.rows.length - 1].time;
await timescalePool.query(`
UPDATE fraud_evaluation_cursor
SET last_evaluated = $1, updated_at = NOW()
WHERE cursor_name = 'bet_evaluation'
`, [lastTime]);
}
// 2. Check pending T+1 completions
await this.completePendingT1Scores();
}
async evaluateBet(betEvent: FraudEvent) {
// Query timeline around this bet from fraud_events
const timeline = await timescalePool.query(`
SELECT * FROM fraud_events
WHERE fixture_id = $1
AND time BETWEEN ($2::timestamptz - interval '60 seconds')
AND ($2::timestamptz + interval '5 minutes')
ORDER BY time ASC
`, [betEvent.fixture_id, betEvent.time]);
// Extract context, run dimensions, run rules, write scores
const context = this.extractContext(betEvent, timeline.rows);
const scores = await ruleRegistry.evaluateAll(betEvent, context);
await this.writeFraudBetScore(betEvent, scores);
await ruleRegistry.checkDeterministicRules(betEvent, context, scores);
// Queue for T+1 if next event hasn't arrived yet
if (!context.hasT1Event) {
await this.markPendingT1(betEvent.order_id, betEvent.fixture_id);
}
}
}
8.2 Derived Events (Computed During Evaluation)
The fraud worker derives SUSPENSION and PRICE_SPIKE events from tick data during evaluation — these don't need to be stored separately since they can be computed from the timeline query:
function deriveSuspensions(timeline: FraudEvent[]): DerivedEvent[] {
const derived: DerivedEvent[] = [];
let lastStatus: Map<string, string> = new Map();
for (const event of timeline) {
if (event.event_type !== 'EXCHANGE_TICK') continue;
const key = `${event.fixture_id}:${event.market_id}`;
const prev = lastStatus.get(key);
if (prev === 'OPEN' && event.market_status === 'SUSPENDED') {
derived.push({ type: 'SUSPENSION', time: event.time, ... });
}
lastStatus.set(key, event.market_status);
}
return derived;
}
8.3 T+1 Completion
async completePendingT1Scores() {
// Find bets waiting for T+1 event
const pending = await prisma.fraudBetScore.findMany({
where: { priceMovementScore: null, createdAt: { gt: subHours(new Date(), 24) } },
take: 200,
});
for (const score of pending) {
// Check if T+1 event has arrived
const t1Event = await timescalePool.query(`
SELECT * FROM fraud_events
WHERE fixture_id = $1
AND event_type IN ('BALL', 'WICKET', 'GOAL', 'CARD', 'SUSPENSION')
AND time > $2
ORDER BY time ASC LIMIT 1
`, [score.fixtureId, score.betTime]);
if (t1Event.rows.length > 0) {
// Get price at T+1
const priceAtT1 = await timescalePool.query(`
SELECT * FROM fraud_events
WHERE fixture_id = $1 AND event_type IN ('EXCHANGE_TICK', 'BOOKMAKER_TICK')
AND time >= $2 AND time <= ($2::timestamptz + interval '5 seconds')
ORDER BY time ASC LIMIT 1
`, [score.fixtureId, t1Event.rows[0].time]);
// Score and update
const priceMovementScore = dims.priceMovement(score, priceAtT1.rows[0], t1Event.rows[0]);
await prisma.fraudBetScore.update({ where: { id: score.id }, data: { priceMovementScore } });
await flaggingService.evaluate(score.id);
}
}
}
9. Stage 2: Bet Evaluation (One Query for Context)
File: src/services/fraud/evaluation/betEvaluation.ts
When the fraud worker picks up a BET_PLACED event from fraud_events, the evaluation pipeline activates.
9.1 Full Evaluation Flow
async evaluateBet(betEvent: FraudEvent): Promise<void> {
// 1. Enrich: read full bet context from V3Bet + V3AuditTrail
const bet = await prisma.v3Bet.findUnique({
where: { id: betEvent.order_id }
});
const audit = await prisma.v3AuditTrail.findFirst({
where: { betId: bet.id, recordType: 'BET_PLACED' }
});
// 2. Query fraud_events for timeline around this bet
const timeline = await timescalePool.query(`
SELECT * FROM fraud_events
WHERE fixture_id = $1
AND time BETWEEN ($2::timestamptz - interval '60 seconds')
AND ($2::timestamptz + interval '5 minutes')
ORDER BY time ASC
`, [bet.eventId, bet.createdAt]);
// 3. Correlate exchange + bookmaker prices at query time
const priceAtT = this.correlatePrices(timeline.rows, bet.createdAt);
// priceAtT = { exchangeMidpoint, bookmakerPrice, staleness, totalMarketVolume }
// 4. Extract context from timeline
const context = this.extractContext(bet, timeline.rows, priceAtT);
// 5. Run all registered rules
const scores = await ruleRegistry.evaluateAll(bet, context);
// 6. Write FraudBetScore
await prisma.fraudBetScore.create({ data: { betId: bet.id, ...scores } });
// 7. Check deterministic rules
await ruleRegistry.checkDeterministicRules(bet, context, scores);
// 8. Queue for T+1 completion if needed
if (!context.hasT1Event) {
await prisma.fraudBetScore.update({
where: { betId: bet.id },
data: { pendingT1: true, fixtureId: bet.eventId, betTime: bet.createdAt }
});
}
}
9.2 Price Correlation at Query Time
correlatePrices(timeline: FraudEvent[], betTime: Date): CorrelatedPrice {
// Find closest exchange tick at or before bet time
const exchangeTick = timeline
.filter(e => e.event_type === 'EXCHANGE_TICK' && e.time <= betTime)
.pop();
// Find closest bookmaker tick at or before bet time
const bookmakerTick = timeline
.filter(e => e.event_type === 'BOOKMAKER_TICK' && e.time <= betTime)
.pop();
// Compute staleness
const bookmakerStaleness = bookmakerTick
? betTime.getTime() - new Date(bookmakerTick.time).getTime()
: null;
return {
exchangeMidpoint: exchangeTick?.exchange_midpoint ?? null,
exchangeBack: exchangeTick?.exchange_back ?? null,
exchangeLay: exchangeTick?.exchange_lay ?? null,
bookmakerPrice: bookmakerTick?.bookmaker_price ?? null,
bookmakerStalenessMs: bookmakerStaleness,
totalMarketVolume: exchangeTick?.total_market_volume ?? null,
availableVolume: exchangeTick?.available_volume ?? null,
marketStatus: exchangeTick?.market_status ?? null,
};
}
10. Stage 3: Five Dimension Extractors
Directory: src/services/fraud/dimensions/
| Dimension | File | Data From fraud_events | What It Catches |
|---|---|---|---|
| Exchange vs Bookmaker at T | exchangeVsBookmaker.ts | Closest EXCHANGE_TICK + BOOKMAKER_TICK: exchangeMidpoint vs bookmakerPrice. Staleness check — if bookmaker tick >5s stale, reduce confidence. | Manipulation — user gets better exchange price than bookmaker |
| Price Movement T-1→T+1 | priceMovement.ts | EXCHANGE_TICK at previous event (T-1) and next event (T+1). BALL/WICKET/GOAL as event markers. SUSPENSION events between T and T+1. | Courtsiding — market moves in user's favour at next event |
| Liquidity Exploitation | liquidityExploitation.ts | EXCHANGE_TICK: totalMarketVolume (Betfair tv). Cricket-first. For non-Betfair: fallback to count of BET_PLACED events for same market. | Manipulation — stake large relative to total market volume |
| Repetition / Consistency | repetition.ts | FraudBetScore history (30 days) for this userId. Also checks BET_CANCELLED patterns. | Pattern — same dimensions elevated repeatedly |
| Identity Linkage | identityLinkage.ts | V3DeviceFingerprint + V3IpAccessLog (NOT fraud log). USER_LOGIN events in fraud log provide temporal context. Payment method overlap deferred until V3PaymentTransaction exists. | Multi-accounting — linked accounts |
10.2 CLV Integration (V3ClvEntry — Existing Table)
The platform already tracks Closing Line Value in V3ClvEntry (fields: openingOdds, closingOdds, clvPercent, stake). Persistent positive CLV is the single strongest predictor of sharp/informed betting — a user who consistently beats the closing line is either skilled, informed, or cheating.
CLV integrates into the fraud system at two levels:
Bet-level enrichment: When evaluating a BET_PLACED, the fraud worker checks if a V3ClvEntry exists for the bet (populated after market close). If clvPercent > 0, this amplifies Dimension 1 (Exchange vs Bookmaker) and Dimension 2 (Price Movement) scores:
// In betEvaluation.ts, after dimension scoring:
const clvEntry = await prisma.v3ClvEntry.findFirst({
where: { betId: bet.id }
});
if (clvEntry && clvEntry.clvPercent > 3) {
// >3% CLV consistently = sharp bettor signal
scores.exchangeVsBookmaker = Math.min(100, scores.exchangeVsBookmaker * 1.3);
scores.priceMovement = Math.min(100, scores.priceMovement * 1.2);
}
User-level profiling: The user risk score incorporates a rolling CLV profile:
// In userScoreRecalcJob.ts:
const clvProfile = await prisma.v3ClvEntry.aggregate({
where: { userId, createdAt: { gt: subDays(new Date(), 30) } },
_avg: { clvPercent: true },
_count: true,
});
// Persistent positive CLV over 20+ bets = elevated base risk
if (clvProfile._count >= 20 && clvProfile._avg.clvPercent > 2) {
clvRiskBoost = Math.min(20, clvProfile._avg.clvPercent * 4);
}
CLV data is only available after market settlement (closing odds are known). This means CLV cannot inform the initial bet evaluation but DOES inform user risk score recalculation and retrospective re-scoring.
10.1 Extracting Context from Timeline
function extractContext(bet: V3Bet, timeline: FraudEvent[], priceAtT: CorrelatedPrice): BetContext {
const betTime = bet.createdAt.getTime();
// T-1: closest event marker before bet
const eventMarkers = ['BALL', 'WICKET', 'GOAL', 'CARD', 'MILESTONE'];
const tMinus1Event = timeline
.filter(e => eventMarkers.includes(e.event_type) && new Date(e.time).getTime() < betTime)
.pop();
// Price at T-1 (exchange tick closest to T-1 event)
const priceAtTMinus1 = tMinus1Event ? timeline
.filter(e => e.event_type === 'EXCHANGE_TICK'
&& Math.abs(new Date(e.time).getTime() - new Date(tMinus1Event.time).getTime()) < 5000)
.pop() : null;
// Suspensions between T-1 and T (derived from tick data)
const suspensions = deriveSuspensions(timeline).filter(s =>
s.time >= (tMinus1Event?.time || 0) && s.time <= betTime);
// Other bets on same fixture within 120s
const nearbyBets = timeline.filter(e => e.event_type === 'BET_PLACED'
&& e.user_id !== bet.userId
&& Math.abs(new Date(e.time).getTime() - betTime) < 120_000);
// Cancellations near this bet (reverse-bet detection)
const nearbyCancellations = timeline.filter(e => e.event_type === 'BET_CANCELLED'
&& Math.abs(new Date(e.time).getTime() - betTime) < 300_000);
// Toss result (cricket)
const toss = timeline.find(e => e.event_type === 'TOSS');
// Session break proximity
const sessionBreak = timeline.find(e => e.event_type === 'SESSION_UPDATE'
&& Math.abs(new Date(e.time).getTime() - betTime) < 60_000);
// T+1 event (first event marker after bet)
const t1Event = timeline
.filter(e => eventMarkers.includes(e.event_type) && new Date(e.time).getTime() > betTime)
.shift();
return {
priceAtT, priceAtTMinus1, tMinus1Event, t1Event,
suspensions, nearbyBets, nearbyCancellations,
toss, sessionBreak,
hasT1Event: !!t1Event,
};
}
11. Stage 4: Scoring, Flagging & Rule Registry
11.1 Rule Registry Pattern
To support adding new rules without modifying the pipeline, all dimensions, deterministic rules, and fraud signatures register declaratively:
interface FraudRule {
id: string;
name: string;
type: 'dimension' | 'deterministic' | 'signature';
requiredContext: string[]; // which BetContext fields it needs
evaluate(bet: V3Bet, context: BetContext, scores?: DimensionScores): RuleResult;
}
class RuleRegistry {
private rules: FraudRule[] = [];
register(rule: FraudRule) { this.rules.push(rule); }
async evaluateAll(bet, context): Promise<DimensionScores> {
const scores = {};
for (const rule of this.rules.filter(r => r.type === 'dimension')) {
scores[rule.id] = await rule.evaluate(bet, context);
}
return scores;
}
async checkDeterministicRules(bet, context, scores) {
for (const rule of this.rules.filter(r => r.type === 'deterministic')) {
const result = await rule.evaluate(bet, context, scores);
if (result.triggered) await this.handleTrigger(rule, bet, result);
}
}
}
// Registration — each rule is a separate file
registry.register(new ExchangeVsBookmakerDimension());
registry.register(new PriceMovementDimension());
registry.register(new LiquidityExploitationDimension());
registry.register(new RepetitionDimension());
registry.register(new IdentityLinkageDimension());
registry.register(new OppositeSideRule());
registry.register(new LiquidityDominanceRule());
registry.register(new SuspensionProbingRule());
registry.register(new WinRateRule());
registry.register(new PatternCloneRule());
registry.register(new ReverseBetRule());
registry.register(new PostTossSharpBetRule());
registry.register(new SessionBreakTimingRule());
registry.register(new AgentMultiplierSpikeRule());
registry.register(new ImpossibleTravelRule());
registry.register(new RapidCashoutRule());
registry.register(new SteamMoveExploitationRule());
registry.register(new StakeEscalationRule());
registry.register(new MarketConcentrationRule());
registry.register(new CrossAgentMigrationRule());
registry.register(new DepositBetWithdrawRule());
registry.register(new PreTossConcentrationRule());
registry.register(new PersistentClvBeaterRule());
// ... add more rules = new files, zero pipeline changes
Adding a new rule = create one file that implements FraudRule, register it. No changes to ingestion, evaluation, scoring, or flagging.
11.2 Bet Level: Independent Dimension Monitoring
| Condition | Severity | Action |
|---|---|---|
| Any dimension >= 80 | Red | Write v3:fraud:user_block:{userId} to Redis. Create case. Publish fraud:alert. |
| Two correlated dimensions both >= 60 | Red | Same as above. |
| Any dimension 60–79 | Orange | Case auto-created. Publish fraud:alert. |
| Any dimension 40–59 | Yellow | Logged in FraudBetScore. Visible in dashboard. |
| All < 40 | Green | Logged only. |
Correlated dimension pairs:
| Pair | Fraud Vector |
|---|---|
| Exchange vs bookmaker + Liquidity exploitation | Multiplier manipulation |
| Price movement + Repetition | Courtsiding |
| Identity linkage + Exchange vs bookmaker | Multi-account manipulation |
| Identity linkage + Liquidity exploitation | Syndicate operation |
11.3 User Level: Rolling Composite 0–100
userRiskScore = maxDimensionAvg_30d * 0.30
+ peakScore_7d * 0.15
+ clusterRisk * 0.15
+ clvRiskBoost * 0.15 -- from V3ClvEntry (0 if no CLV data yet)
+ accountAge * 0.10
+ flagDensity * 0.10
+ agentRiskBoost * 0.05 -- bidirectional: elevated if agent score > 70
Written to: redis.set(`v3:fraud:user_risk:${userId}`, score, 'EX', 86400)
Updated: on every bet evaluation + daily batch recalc
11.4 Agent Level: Aggregate Risk Score (New)
agentRiskScore = maxUserRiskScore_under_agent * 0.30 -- ONE confirmed fraud account matters
+ flaggedUserDensity * 0.25 -- % of users with score >= 50
+ configChangeFrequency * 0.15
+ classificationChurnRate * 0.15
+ agentPnLAnomaly * 0.15
Written to: redis.set(`v3:fraud:agent_risk:${agentId}`, score, 'EX', 86400)
Updated: daily batch recalc
Why max, not avg: One confirmed fraud account under an agent is a strong signal regardless of how many clean accounts exist alongside it. Using avg dilutes the signal — an agent with 100 clean users and 1 confirmed fraudster would appear low-risk. max (or p90 for robustness against noise) ensures a single high-risk user elevates the agent score.
Bidirectional risk propagation: When an agent's risk score crosses 70, all users under that agent receive a +10 base risk boost (written to Redis alongside their user risk scores). This catches the pattern where an agent onboards multiple fraud accounts that individually stay below thresholds but collectively indicate a compromised agent network.
Agent-level scoring catches patterns invisible at the user level: an agent systematically onboarding fraud accounts, an agent changing settings to enable manipulation, or an agent with abnormally high rates of flagged users.
12. Stage 5: Case Management & Feedback
Identical to main Design & Architecture Document sections 8–9. Cases, SLA timers (4h Red, 24h Orange), outcomes (CONFIRMED/CLEARED/INCONCLUSIVE), feedback loop (cleared → 20% score decay, confirmed → dimension reinforcement), dimension accuracy tracking (precision/recall per dimension over 90-day rolling windows).
12.1 Label Propagation (ML Training Prerequisite)
When a case is resolved, the outcome propagates back to individual bet scores:
async resolveCase(caseId: string, outcome: 'CONFIRMED' | 'CLEARED' | 'INCONCLUSIVE',
confirmedBetIds?: string[]) {
const fraudCase = await prisma.fraudCase.update({ where: { id: caseId }, data: { outcome } });
if (outcome === 'CONFIRMED' && confirmedBetIds?.length) {
// Per-bet labeling: only the specific bets confirmed as fraudulent get CONFIRMED
// Remaining bets in the case get INCONCLUSIVE (not all bets from a fraudster are fraud)
await prisma.fraudBetScore.updateMany({
where: { betId: { in: confirmedBetIds } },
data: { outcomeLabel: 'CONFIRMED', labeledAt: new Date() },
});
const remainingBetIds = fraudCase.betIds.filter(id => !confirmedBetIds.includes(id));
if (remainingBetIds.length > 0) {
await prisma.fraudBetScore.updateMany({
where: { betId: { in: remainingBetIds } },
data: { outcomeLabel: 'INCONCLUSIVE', labeledAt: new Date() },
});
}
await feedbackService.reinforceDimensions(confirmedBetIds);
} else if (outcome === 'CLEARED') {
// All bets in a cleared case are labeled CLEARED
await prisma.fraudBetScore.updateMany({
where: { betId: { in: fraudCase.betIds } },
data: { outcomeLabel: 'CLEARED', labeledAt: new Date() },
});
await feedbackService.decayScores(fraudCase.betIds, 0.20);
} else {
await prisma.fraudBetScore.updateMany({
where: { betId: { in: fraudCase.betIds } },
data: { outcomeLabel: 'INCONCLUSIVE', labeledAt: new Date() },
});
}
}
Per-bet labeling is critical: A confirmed fraudster may have 100 bets in a case, but only 5 were the actual fraud bets (e.g., the courtsiding bets during a specific match). Labeling all 100 as CONFIRMED would poison ML training data. The risk team selects which specific bets were fraudulent during case resolution.
Label propagation is critical for ML training. Every resolved case creates labeled training data at the individual bet level.
12.2 Investigation View (Unified Log Advantage)
-- Investigation query: full fixture timeline with ALL event types
SELECT event_type, time, user_id, exchange_midpoint,
bookmaker_price, total_market_volume, market_status, payload
FROM fraud_events
WHERE fixture_id = :fixtureId
AND time BETWEEN :startTime AND :endTime
ORDER BY time ASC;
-- Returns interleaved timeline:
-- 14:31:55.000 TOSS tossWinner=TeamA decision=bat
-- 14:32:01.100 EXCHANGE_TICK exch=2.10 tv=45230
-- 14:32:01.300 BOOKMAKER_TICK bookie=2.05
-- 14:32:01.500 BALL over=15.3 template=dot_ball
-- 14:32:02.200 EXCHANGE_TICK exch=2.10 tv=45280
-- 14:32:02.800 BET_PLACED user=xyz stake=5000 odds=2.10 side=BACK
-- 14:32:03.100 EXCHANGE_TICK exch=2.10 tv=50280
-- 14:32:03.500 WICKET batter=Smith bowler=Patel
-- 14:32:03.600 (derived SUSPENSION)
-- 14:32:04.000 EXCHANGE_TICK exch=2.50 tv=50300
--
-- ^ User bet 0.7s before wicket. Market suspended 0.8s after bet.
-- Price jumped from 2.10 to 2.50. Volume jumped by 5000 (= user's stake).
-- Classic courtsiding signal.
Part III — Fraud Logic
All fraud detection logic is identical to the main Design & Architecture Document. Summarised here for completeness.
13. Fraud Vectors
| Vector | Mechanism | Primary Dimensions |
|---|---|---|
| Multiplier Manipulation | Low-multiplier account moves illiquid exchange price. High-multiplier account captures opposite side at manipulated price. | Exchange vs bookmaker + Liquidity exploitation + Identity linkage |
| Latency / Courtsiding | User bets with advance knowledge of real-world event (stadium presence, fast data feed) before platform reprices. | Price movement T-1→T+1 + Repetition |
| Multi-Accounting / Syndicates | Single user or coordinated group operates multiple accounts to bypass per-account limits. | Identity linkage + Repetition |
14. Deterministic Day-0 Rules
| Rule | Trigger | Severity | Data Source |
|---|---|---|---|
| Opposite-side same-market | Two linked accounts (identity score >50) bet opposite sides within 120s | Red | BET_PLACED events in timeline (nearbyBets) |
| Liquidity dominance | Bet > 10% of total market volume (Betfair tv) | Orange | EXCHANGE_TICK totalMarketVolume + V3Bet.acceptedStake |
| Suspension probing | 5+ bets during bookmaker suspension | Red | BET_PLACED + derived SUSPENSION events in timeline |
| Probability-adjusted win rate | Win rate significantly exceeds implied probability over 20+ bets | Orange | BET_SETTLED events — rolling calculation |
| Pattern clone | New account (<7 days) >85% selection overlap with restricted account | Red | BET_PLACED events cross-referenced by userId |
| Reverse-bet pattern | Bet placed → cancelled → re-placed at different odds within 60s | Orange | BET_PLACED + BET_CANCELLED events in timeline |
| Post-toss sharp bet | Large bet placed within 30s of toss result, odds moved in bettor's favour | Orange | TOSS + BET_PLACED events in timeline |
| Session-break timing | Bets clustered immediately after innings break resume | Yellow | SESSION_UPDATE + BET_PLACED events |
| Agent multiplier spike | Agent changes config, large bet follows within 5 minutes | Red | AGENT_CONFIG_CHANGED + BET_PLACED events |
| Impossible travel | User logins from geographically impossible locations within short time | Red | USER_LOGIN events (userId, geoLocation, timestamp) |
| Rapid cashout | Cashout request within seconds of event result | Orange | CASHOUT + WICKET/GOAL events |
| Steam move exploitation | Bet placed during rapid price movement (>3% in <5s), same direction as move | Orange | EXCHANGE_TICK price deltas + BET_PLACED timing |
| Stake escalation | Stake increases >3x from user's rolling average within a session | Yellow | BET_PLACED events (user_id, stake) over rolling window |
| Market selection concentration | >80% of bets on a single market type (e.g., session runs in cricket) | Yellow | BET_PLACED events by market_id pattern over 30 days |
| Cross-agent migration | User flagged under Agent A, new account appears under Agent B with matching device/IP | Red | FraudIdentityCluster + V3DeviceFingerprint + V3IpAccessLog |
| Deposit-bet-withdraw velocity | Deposit → large bet(s) → withdrawal request within same session | Orange | BALANCE_CHANGE (deposit) + BET_PLACED + BALANCE_CHANGE (withdrawal) timing |
| Pre-toss concentration | Disproportionate bet volume in the 60s before toss result across multiple matches | Orange | BET_PLACED + TOSS events — cross-fixture pattern |
| Persistent CLV beater | User maintains >3% average CLV over 20+ settled bets | Orange | V3ClvEntry aggregation (see Section 10.2) |
15. Fraud Signatures
| Signature | Primary | Confirming | Context |
|---|---|---|---|
| Multiplier Manipulation | Exchange vs bookmaker >= 60 + Liquidity >= 60 | Identity linkage confirms linked opposite-side bet | Different multiplier levels between accounts. AGENT_CONFIG_CHANGED timing. |
| Courtsiding | Price movement >= 60 + market suspended between T and T+1 | Repetition: same pattern >60% of in-play bets over 30 days | Probability-adjusted win rate elevated. Bets cluster after SESSION_UPDATE. |
| Multi-Accounting | Identity linkage >= 60 | Repetition: correlated selections across linked accounts | Multiple USER_LOGIN from same device/IP. Multiple flagged accounts under same agent. |
16. Baseline Calibration
Baselines computed by fraud worker from fraud_events + FraudBetScore. Segmented by sport, market type, event phase. Daily first 30 days, weekly after. PSI drift monitoring.
Unified log advantage: the fraud worker can replay any historical period through the full pipeline to bootstrap baselines retroactively, validate detection quality, or test threshold changes. New rules can be backtested against historical data before going live.
Part IV — Interfaces & Workflows
17. Agent Interface
Agents see status badges only (Active / Under Review / Restricted). Referral button with category selection. No scores, dimensions, or detection logic exposed. Reports weighted by agent reliability.
18. Risk Team Dashboard & Investigation View
18.1 Investigation Timeline
The CaseDetail component includes a TimelineView that queries fraud_events and displays the complete interleaved event history for the fixture around the flagged bet. Includes ALL ~30 event types: exchange ticks, bookmaker ticks, event markers (balls, wickets, goals, milestones), bets placed/cancelled/voided/cashout, suspensions, toss results, session breaks, agent config changes — all shown chronologically with the flagged bet highlighted.
| Component | Data Source | Description |
|---|---|---|
| TimelineView | fraud_events query by fixture_id + time range | Interleaved chronological view of ALL events. Flagged bet highlighted. |
| PriceChart | EXCHANGE_TICK + BOOKMAKER_TICK events | Dual-line chart: exchange midpoint (blue) + bookmaker price (orange). Traded volume secondary axis. Bet placement vertical line. Suspensions as shaded regions. |
| EventMarkers | BALL/WICKET/GOAL/CARD/MILESTONE/TOSS/SESSION_UPDATE | Annotated on price chart. Shows what real-world events drove price changes. |
| VolumeIndicator | EXCHANGE_TICK total_market_volume | Traded volume growth. Spike at bet time = user's stake relative to market. |
| UserActivityView | USER_LOGIN + BET_PLACED + BET_CANCELLED + CASHOUT + BALANCE_CHANGE by user_id | Cross-fixture user activity timeline. |
| AgentActivityView | AGENT_CONFIG_CHANGED + AGENT_CLASSIFICATION_CHANGED + BET_PLACED by agent_id | Agent-level view: config changes, classification changes, bet volume. |
19. Sevens Integration
Auto-ticket on Red. Daily sweep. Report format unchanged. Executed by fraud worker.
20. VIP Protocol
Silent investigation. Higher threshold (two correlated dimensions). No automated actions. Human decision required.
21. Fraud Response SOP
- Fraud worker writes v3:fraud:user_block to Redis → blocks further bets on next bet attempt
- Fraud worker calls platform admin API to set account to RESTRICTED status
- Agent notified via fraud:alert Redis pub/sub
- Sevens ticket auto-generated if not already open
- Risk team reviews case within SLA (4h Red, 24h Orange)
- Risk team executes bet reversal through existing admin UI if confirmed
- All actions logged. Outcome feeds back into dimension calibration via label propagation.
Part V — Implementation
22. Fraud Worker Service
22.1 Deployment
The fraud worker is a scheduled batch job that can run as:
- A separate Docker container with a cron-based entry point
- A set of scheduled jobs within the existing backend (using node-cron or Bull queue)
- A standalone Node.js process
It connects to:
- TimescaleDB (
hannibal_analytics) for reading fraud_events — dedicated pg.Pool (separate from TimescaleTransport's pool, since the fraud worker runs in its own container) - Main PostgreSQL for reading V3Bet, V3AuditTrail and writing FraudBetScore, FraudCase
- Redis for writing fraud flags and publishing alerts
Fraud Worker Connection Pool
The fraud worker creates its own pg.Pool for TimescaleDB with settings tuned for batch reads (larger queries, longer timeouts) vs the TimescaleTransport's pool which is tuned for high-frequency small writes:
// fraud worker's TimescaleDB pool (separate from TimescaleTransport)
const fraudTimescalePool = new Pool({
host: process.env.TSDB_HOST,
port: parseInt(process.env.TSDB_PORT ?? '5432'),
database: process.env.TSDB_DB ?? 'hannibal_analytics',
user: process.env.TSDB_USER,
password: process.env.TSDB_PASSWORD,
max: 5, // 5 connections for parallel batch reads
statement_timeout: 30000, // 30s — timeline queries can be large
idle_timeout: 60000,
});
Prometheus /metrics Endpoint
The fraud worker exposes a /metrics endpoint for Prometheus scraping:
fraud_bets_evaluated_total — counter: total bets evaluated since startup
fraud_evaluation_duration_seconds — histogram: time per bet evaluation
fraud_evaluation_batch_size — histogram: bets per batch run
fraud_flags_written_total — counter by severity (red/orange/yellow)
fraud_cursor_lag_seconds — gauge: time between now and last_evaluated cursor
fraud_timescale_pool_active — gauge: active connections in fraud worker's pool
fraud_rules_triggered_total — counter by rule_id
fraud-worker:
build:
context: ./backend
dockerfile: Dockerfile.fraud
environment:
- DATABASE_URL=${DATABASE_URL}
- TSDB_HOST=hannibal-timescaledb
- TSDB_PORT=5432
- TSDB_DB=hannibal_analytics
- TSDB_USER=hannibal_analytics
- TSDB_PASSWORD=${TSDB_PASSWORD}
- REDIS_URL=${REDIS_URL}
- SEVENS_API_URL=${SEVENS_API_URL}
- PLATFORM_ADMIN_API_URL=http://backend:3001
- EVALUATION_INTERVAL_MS=300000 # 5 minutes
depends_on:
- postgres
- hannibal-timescaledb
- redis
networks:
- hannibal-network
restart: unless-stopped
22.2 Service Structure
src/services/fraud/
├── index.ts # Entry point — schedules evaluation jobs
├── evaluation/
│ ├── evaluationJob.ts # Main batch loop — find unscored bets, evaluate
│ ├── betEvaluation.ts # Query fraud_events, extract context, orchestrate
│ └── priceCorrelation.ts # Query-time exchange/bookmaker price correlation
├── dimensions/
│ ├── exchangeVsBookmaker.ts # With staleness-aware confidence
│ ├── priceMovement.ts
│ ├── liquidityExploitation.ts # Cricket-first (Betfair tv), platform volume fallback
│ ├── repetition.ts # Includes cancellation pattern detection
│ ├── identityLinkage.ts # Queries V3DeviceFingerprint + V3IpAccessLog
│ └── clvIntegration.ts # V3ClvEntry enrichment — amplifies Dim 1 & 2 scores
├── rules/
│ ├── ruleRegistry.ts # Declarative rule registration + evaluation
│ ├── oppositeSideRule.ts
│ ├── liquidityDominanceRule.ts
│ ├── suspensionProbingRule.ts
│ ├── winRateRule.ts
│ ├── patternCloneRule.ts
│ ├── reverseBetRule.ts
│ ├── postTossSharpBetRule.ts
│ ├── sessionBreakTimingRule.ts
│ ├── agentMultiplierSpikeRule.ts
│ ├── impossibleTravelRule.ts
│ ├── rapidCashoutRule.ts
│ ├── steamMoveExploitationRule.ts
│ ├── stakeEscalationRule.ts
│ ├── marketConcentrationRule.ts
│ ├── crossAgentMigrationRule.ts
│ ├── depositBetWithdrawRule.ts
│ ├── preTossConcentrationRule.ts
│ └── persistentClvBeaterRule.ts
├── scoring/
│ ├── scoringEngine.ts
│ └── flaggingService.ts
├── cases/
│ ├── caseService.ts
│ ├── feedbackService.ts
│ └── labelPropagation.ts
├── signatures/
│ ├── multiplierManipulation.ts
│ ├── courtsiding.ts
│ └── multiAccounting.ts
├── baselines/
│ └── baselineService.ts
├── external/
│ └── sevensIntegration.ts
├── jobs/
│ ├── userScoreRecalcJob.ts # Daily
│ ├── agentScoreRecalcJob.ts # Daily
│ ├── baselineCalibrationJob.ts # Daily/weekly
│ ├── sevensReportJob.ts # Daily
│ ├── slaEnforcementJob.ts # Every 15 min
│ └── mlExportJob.ts # Daily: archive to Parquet
└── api/
└── fraudRoutes.ts # REST API for dashboards
23. Database Architecture
23.1 Two Databases, Clear Separation
| Database | Purpose | Accessed By |
|---|---|---|
| Main PostgreSQL | V3Bet, V3AuditTrail, V3DeviceFingerprint, V3IpAccessLog, FraudBetScore, FraudCase, FraudIdentityCluster, FraudBaseline, FraudAgentScore | Platform (Prisma ORM) + Fraud worker (Prisma ORM) |
| TimescaleDB (hannibal_analytics) | 6 existing hypertables + fraud_events | Grafana dashboards + Fraud worker (pg Pool direct) |
The fraud worker reads from TimescaleDB (fraud_events for timeline context) and reads/writes to main PostgreSQL (bet enrichment, score storage, case management) via Prisma.
23.2 When to Consider ClickHouse
ClickHouse becomes the right choice when:
- Row counts in fraud_events exceed ~500M (more than ~12 months at current volume)
- Real-time analytical dashboards require sub-second aggregation across all fixtures/users simultaneously
- Compression savings justify the operational cost
The unified log design makes migration straightforward: one table, clean schema. The fraud worker queries through a repository interface that can be swapped from pg Pool to a ClickHouse client without changing detection logic.
23.3 Archive Strategy for ML
For ML training that needs >1 year of historical data:
- Daily export of aged-out fraud_events to Parquet files in object storage (S3/GCS)
- FraudBetScore records (with outcome labels) exported alongside
- ML pipeline reads from object storage, not from the live database
24. Database Schema
24.1 TimescaleDB (fraud_events — Section 2.3)
See Section 2.3 for the full DDL. This is the unified event log hypertable in hannibal_analytics.
24.2 Main PostgreSQL (Prisma Models)
Five fraud-specific models in the main PostgreSQL database:
| Model | Purpose | Notes |
|---|---|---|
| FraudBetScore | Per-bet dimension scores, signatures, severity, outcomeLabel | outcomeLabel + labeledAt for ML training. |
| FraudIdentityCluster | Linked account groups | |
| FraudCase | Escalation cases with outcomes | |
| FraudBaseline | Per-segment statistical baselines | |
| FraudAgentScore | Agent-level aggregate risk score | Daily recalc from user scores + config patterns. |
24.3 Identity Tables (Existing, Require Population)
| Table | Status | Used By |
|---|---|---|
| V3DeviceFingerprint | Schema exists. Needs request middleware to populate. | Dimension 5 via sharpDetection.findDeviceMatches() |
| V3IpAccessLog | Schema exists. Needs request middleware to populate. | Dimension 5 via sharpDetection.findIPCorrelations() |
| V3PaymentTransaction | Does NOT exist in Prisma schema. Deferred to Phase 2. | Dimension 5 payment method overlap — requires new model or payment provider API integration. |
| V3ClvEntry | Exists and populated by settlement flow. | CLV enrichment for bet-level scoring + user risk profiling (Section 10.2). |
25. API Routes
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /api/fraud/user/:id/score | Admin | User risk score + trend + dimension averages |
| GET | /api/fraud/user/:id/bets | Admin | Bet history with per-dimension scores |
| GET | /api/fraud/user/:id/cluster | Admin | Identity cluster |
| GET | /api/fraud/user/:id/timeline | Admin | Cross-fixture user activity timeline (from fraud_events) |
| GET | /api/fraud/user/:id/logins | Admin | Login history with device/IP/geo |
| GET | /api/fraud/agent/:id/score | Admin | Agent risk score + contributing factors |
| GET | /api/fraud/agent/:id/timeline | Admin | Agent activity: config changes + user bets (from fraud_events) |
| POST | /api/fraud/referral | Agent | Submit referral. Generic response only. |
| GET | /api/fraud/cases | Admin | Case list with filters |
| GET | /api/fraud/cases/:id | Admin | Full case detail with timeline |
| GET | /api/fraud/cases/:id/timeline | Admin | Interleaved event timeline for case fixture (from fraud_events) |
| PATCH | /api/fraud/cases/:id | Admin | Update status / record outcome (triggers label propagation) |
| POST | /api/fraud/cases/:id/action | Admin | Execute action |
| GET | /api/fraud/analytics | Admin | Dimension accuracy, FP rates |
| GET | /api/fraud/baselines | Admin | Baselines with PSI |
| GET | /api/fraud/rules | Admin | Registered rules with hit rates |
| GET | /api/fraud/agent/accounts | Agent | Status badges only |
| GET | /api/fraud/health | Public | Worker health + last evaluation time + pending bet count |
26. Background Jobs
| Job | Schedule | Description |
|---|---|---|
| Bet Evaluation | Every 5 minutes | Query fraud_events for unscored BET_PLACED events. Run dimensions + rules. Write FraudBetScore. |
| T+1 Completion | Every 5 minutes (same job) | Check pending T+1 scores. Complete when next event has arrived. |
| User Risk Score Recalc | Daily 03:00 UTC | Full recalculation of all user risk scores |
| Agent Risk Score Recalc | Daily 03:30 UTC | Aggregate user scores + config patterns per agent |
| Baseline Calibration | Daily (first 30d), weekly after | Compute from fraud_events + FraudBetScore |
| Sevens Daily Report | Daily 08:00 UTC | Sweep Orange/Red accounts, generate report |
| SLA Enforcement | Every 15 minutes | Check approaching SLA deadlines, auto-escalate |
| ML Export | Daily 05:00 UTC | Archive aged-out data to Parquet for ML training |
| Data Integrity Reconciliation | Daily 04:00 UTC | Compare bet count in fraud_events vs bet_events for last 24h. Alert if >1% discrepancy (indicates dual-routing dropped events). Also verifies cursor health — alerts if cursor_lag > 30 minutes. |
27. Frontend Components
| Component | Description |
|---|---|
| AccountStatusBadge.tsx | Agent: Active / Under Review / Restricted |
| ReferralModal.tsx | Agent: category, free-text, file upload |
| CaseQueue.tsx | Admin: sortable case list with SLA timers |
| CaseDetail.tsx | Admin: full case with dimension breakdowns + timeline |
| DimensionBreakdown.tsx | Admin: per-bet 5-dimension bar chart |
| TimelineView.tsx | Admin: interleaved chronological event view — ALL ~30 event types from fraud_events |
| PriceChart.tsx | Admin: dual-line chart (exchange + bookmaker) with volume, bet/event annotations, suspension shading |
| UserActivityView.tsx | Admin: cross-fixture user activity (logins, bets, cancellations, cashouts, deposits) |
| AgentActivityView.tsx | Admin: agent-level view (config changes, classification changes, bet volume) |
| ActionPanel.tsx | Admin: suspend, limit, clear, escalate buttons |
| FraudAnalytics.tsx | Admin: dimension accuracy, FP rates, case stats, rule hit rates |
| BaselineMonitor.tsx | Admin: baselines with PSI indicators |
| IdentityGraph.tsx | Admin: account cluster visualisation |
| RuleManager.tsx | Admin: registered rules, enable/disable, threshold adjustment |
28. Build Phases
Phase 1 — Foundation (Pre-Launch)
| Deliverable | Description |
|---|---|
| fraud_events hypertable | DDL in init.sql alongside existing 6 hypertables. Indexes, compression, retention. |
| TimescaleTransport extension | New EVENT_TABLE_MAP entries + extractFraudValues() + dual-routing for existing events. ~60 lines changed in timescaleTransport.ts. |
| Platform tsEvent additions | ~55 lines of logger.info() calls across ~10 files for missing events (price ticks, cricket, config changes, cashout, void). |
| Fraud worker container | Docker setup, entry point, scheduled evaluation job. |
| 5 Dimension Extractors | All 5 dimensions from fraud_events context. Dim 1 with query-time price correlation. Dim 3 cricket-first. Dim 5 via sharpDetection. |
| Identity middleware | Request middleware to populate V3DeviceFingerprint + V3IpAccessLog from HTTP context. Required for Dimension 5 to function. |
| Deterministic Rules | 18 day-0 rules including 13 new rules (reverse-bet, post-toss, session-break, agent-multiplier, impossible-travel, rapid-cashout, steam-move, stake-escalation, market-concentration, cross-agent-migration, deposit-bet-withdraw, pre-toss-concentration, persistent-CLV-beater) |
| Rule Registry | Declarative rule registration pattern |
| Scoring + Flagging | Independent dimension monitoring + rule triggers |
| Redis flags | v3:fraud:user_risk + v3:fraud:user_block keys |
| Platform flag reads | stakeReduction.ts (7 lines) + clientWs.ts (6 lines) |
| Fraud API (basic) | Health, user score, bet scores, timeline endpoints |
| AccountStatusBadge | Agent dashboard integration |
Phase 2 — Intelligence (Days 0–30)
| Deliverable | Description |
|---|---|
| T+1 completion | Complete price movement scoring when next event arrives in fraud_events |
| Baseline collection | Compute from fraud_events. Historical replay for bootstrap. |
| Identity clustering | FraudIdentityCluster creation on identity score > 50 |
| Case management | Case CRUD, SLA timers, outcome recording, label propagation |
| Referral button | Agent referral flow with reliability weighting |
| User risk score | Rolling composite + daily batch recalc |
| Agent risk score | Agent-level aggregate scoring |
Phase 3 — Escalation (Days 30–60)
| Deliverable | Description |
|---|---|
| Baseline-relative scoring | Switch dimensions from deterministic to z-score mode |
| Risk team dashboard | Full admin UI: investigation timeline, price chart, user activity view, agent activity view |
| Sevens integration | Auto-ticket on Red + daily sweep |
| Feedback loop | Case outcome → label propagation → dimension recalibration |
| VIP protocol | Higher thresholds, silent investigation |
| Rule manager UI | Admin can enable/disable rules, adjust thresholds |
Phase 4 — Learning (Days 60–90)
| Deliverable | Description |
|---|---|
| Dimension recalibration | Adjust thresholds from confirmed fraud vs FP data |
| Historical replay | Replay past fixtures through pipeline. Backtest new rules before deployment. |
| Drift detection | PSI monitoring with automated alerts |
| ML export pipeline | Parquet archive + feature store from FraudBetScore with outcome labels |
| ClickHouse evaluation | Assess whether volume/query patterns warrant migration |
29. Key Principles
| Principle | What It Means |
|---|---|
| Built on existing infrastructure | Winston → TimescaleTransport → TimescaleDB is already running. fraud_events is the 7th hypertable, not a new system. Same logging pattern, same batching, same database. |
| Zero new infrastructure | No Redis Streams, no consumer groups, no separate ingestion service, no Betfair stream client in fraud worker. Just a new hypertable + logger.info() calls + a scheduled job. |
| Log everything now, decide what to use later | All ~30 event types flow into fraud_events. A fraud signal you didn't anticipate needs data that was logged when it happened. |
| One log, one table, one query | Every event goes into fraud_events. Fixture-centric evaluation starts with one query. User-centric and agent-centric analysis via dedicated indexes. |
| Nullable fixtureId | User-centric events (LOGIN, DEPOSIT, AGENT_CONFIG) have no fixture. The unified log handles both market events and account lifecycle events. |
| Three query patterns | (fixture_id, time) for bet evaluation. (user_id, time) for user analysis. (agent_id, time) for agent investigation. All indexed. |
| Batch evaluation, not real-time | Payments are not immediate. Fraud worker runs every 5 minutes, evaluates unscored bets. Simpler, more reliable, no message loss risk. |
| Query-time price correlation | BetfairAdapter and BifrostAdapter each log their own ticks. Fraud worker correlates at query time. No in-memory state, no startup seeding, no crash recovery. |
| Same pattern everywhere | logger.info('...', { tsEvent: 'event_type', ...fields }) — identical to how 17 event types are already logged across 16 files. |
| Existing hypertables unchanged | The 6 existing hypertables continue serving analytics/dashboards. fraud_events is additive. |
| Dual routing | Events already logged (bet_placed, user_login, etc.) go to BOTH their existing hypertable AND fraud_events. New events go to fraud_events only. |
| Targeted platform instrumentation | ~55 lines of logger.info() calls across ~10 files + ~60 lines in TimescaleTransport. Zero existing lines modified. |
| Separate container | Fraud worker deploys, scales, and restarts independently. A fraud bug cannot break bet acceptance. |
| Rule registry | Dimensions, deterministic rules, and signatures register declaratively. Adding new rules = new files, zero pipeline changes. |
| Read from TimescaleDB, write to PostgreSQL | Fraud worker reads fraud_events (TimescaleDB) for context. Writes FraudBetScore, FraudCase, etc. to main PostgreSQL (Prisma). Sets Redis flags for platform consumption. |
| Flags, not mutations | Actions reach the platform through Redis flags. Existing code checks flags passively. Platform has zero runtime dependency on the fraud worker. |
| Identity data stays separate | V3DeviceFingerprint / V3IpAccessLog. Different cardinality, different privacy/retention needs. V3PaymentTransaction deferred until model exists. |
| CLV as strongest sharp signal | V3ClvEntry already tracks closing line value. Persistent positive CLV amplifies dimension scores and elevates user risk. Available post-settlement only. |
| Cursor-based tracking, not cross-DB JOINs | fraud_evaluation_cursor in TimescaleDB tracks what's been scored. No LEFT JOIN between TimescaleDB and main PostgreSQL. |
| Variable evaluation cadence | 60s during live fixtures (courtsiding), 5min otherwise. Fraud worker checks live fixture presence in Redis. |
| Data integrity reconciliation | Daily job compares bet counts between fraud_events and bet_events. Catches dual-routing failures before they accumulate. |
| Cricket-first volume | Betfair tv on every tick for cricket. Pinnacle doesn't expose volume. Dim 3 falls back to internal platform volume for other sports. |
| Replay capability | Any historical period can be replayed through the full pipeline. New rules can be backtested. Baselines bootstrapped retroactively. |
| Investigation-first design | Complete chronological timeline with ALL ~30 event types. Exchange + bookmaker prices, volume, game events, user actions, agent config. |
| Independent dimensions at bet level | One dimension out of range = flag. Not composite averaging. |
| Composite at user + agent level | User: rolling 0–100 over 30 days. Agent: aggregate of user scores + config patterns. |
| Event-relative, not time-based | T-1 and T+1 are natural event markers from the unified log. |
| Human judgment first | V1 flags for human review. Bet reversal is manual. Automation increases with calibration data. |
| Label propagation for ML | Case outcomes propagate to individual bet scores. Every resolved case = labeled training data. |
| Agents are weighted sensors | Referral button stays. Scores/logic never exposed. Agent-level fraud detection runs independently. |
| Flagged users never know | No notifications during investigation. Silent handling for VIPs. |
— End of Unified Event Log Architecture —
forsyt.io - Internal - Product & Engineering