-- Polymarket Screener β Database Schema
CREATE TABLE IF NOT EXISTS screener_markets (
id TEXT PRIMARY KEY, -- conditionId
question TEXT NOT NULL,
slug TEXT,
event_id TEXT,
event_title TEXT,
event_slug TEXT,
category TEXT, -- primary category (Politics, Sports, Crypto, etc.)
tags TEXT, -- JSON array of all tag labels
image_url TEXT,
yes_price REAL DEFAULT 0,
no_price REAL DEFAULT 0,
spread REAL DEFAULT 0,
volume_24h REAL DEFAULT 0,
volume_total REAL DEFAULT 0,
liquidity REAL DEFAULT 0,
end_date TEXT,
neg_risk INTEGER DEFAULT 0,
clob_token_ids TEXT, -- JSON array [yes_token_id, no_token_id]
group_item_title TEXT, -- for multi-outcome events (e.g. "Spain")
active INTEGER DEFAULT 1,
resolved INTEGER DEFAULT 0, -- 1 once we've captured the resolved outcome
winning_outcome TEXT, -- outcome label that won (CLOB token winner=true), NULL until resolved
resolve_checked_at TEXT, -- last time the resolve cron polled CLOB for this market (backoff/round-robin)
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_markets_category ON screener_markets(category);
CREATE INDEX IF NOT EXISTS idx_markets_volume ON screener_markets(volume_24h DESC);
CREATE INDEX IF NOT EXISTS idx_markets_active ON screener_markets(active);
CREATE INDEX IF NOT EXISTS idx_markets_end_date ON screener_markets(end_date);
CREATE INDEX IF NOT EXISTS idx_markets_event ON screener_markets(event_id);
CREATE TABLE IF NOT EXISTS screener_stats (
id INTEGER PRIMARY KEY DEFAULT 1,
total_markets INTEGER DEFAULT 0,
total_events INTEGER DEFAULT 0,
total_volume_24h REAL DEFAULT 0,
top_mover_question TEXT,
top_mover_change REAL DEFAULT 0,
avg_spread REAL DEFAULT 0,
categories TEXT, -- JSON {name: count}
fetched_at TEXT DEFAULT (datetime('now'))
);
-- Ensure stats row exists
INSERT OR IGNORE INTO screener_stats (id) VALUES (1);
-- Price alerts
CREATE TABLE IF NOT EXISTS alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
market_id TEXT NOT NULL,
side TEXT NOT NULL DEFAULT 'yes', -- 'yes' or 'no'
direction TEXT NOT NULL DEFAULT 'above', -- 'above' or 'below'
threshold REAL NOT NULL,
triggered INTEGER DEFAULT 0,
triggered_at TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_alerts_market ON alerts(market_id);
CREATE INDEX IF NOT EXISTS idx_alerts_active ON alerts(triggered);
-- Whale alerts: subscribe to a whale's FUTURE trades. last_trade_ts is the
-- newest trade seen at subscribe/last-check time; the whale cron bumps
-- pending_count when newer trades arrive for a subscribed address.
CREATE TABLE IF NOT EXISTS whale_alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
whale_address TEXT NOT NULL UNIQUE,
last_trade_ts TEXT,
pending_count INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_whale_alerts_addr ON whale_alerts(whale_address);
-- Volume daily snapshots (one row per market per day for anomaly detection)
CREATE TABLE IF NOT EXISTS volume_daily (
market_id TEXT NOT NULL,
date TEXT NOT NULL, -- YYYY-MM-DD
volume_24h REAL DEFAULT 0,
liquidity REAL DEFAULT 0,
PRIMARY KEY (market_id, date)
);
CREATE INDEX IF NOT EXISTS idx_volume_daily_date ON volume_daily(date);
-- Detected volume anomalies
CREATE TABLE IF NOT EXISTS volume_anomalies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
market_id TEXT NOT NULL,
question TEXT,
category TEXT,
score REAL NOT NULL, -- volume_24h / avg_7d (e.g. 5.0 = 500%)
volume_24h REAL DEFAULT 0,
avg_volume REAL DEFAULT 0, -- 7-day average
liquidity REAL DEFAULT 0,
detected_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_anomalies_score ON volume_anomalies(score DESC);
CREATE INDEX IF NOT EXISTS idx_anomalies_detected ON volume_anomalies(detected_at);
-- Trade history cache (from Polymarket Data API /activity)
CREATE TABLE IF NOT EXISTS trade_history (
id TEXT PRIMARY KEY, -- unique trade id
address TEXT NOT NULL, -- wallet address (lowercase)
market_id TEXT,
market_question TEXT,
market_slug TEXT,
side TEXT, -- 'BUY' or 'SELL'
outcome TEXT, -- 'Yes' or 'No'
size REAL DEFAULT 0, -- number of shares
price REAL DEFAULT 0, -- price per share
amount REAL DEFAULT 0, -- total USDC amount
fee REAL DEFAULT 0,
realized_pnl REAL DEFAULT 0,
trade_type TEXT, -- 'TRADE', 'REDEEM', 'DEPOSIT', 'WITHDRAWAL'
timestamp TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_trades_address ON trade_history(address);
CREATE INDEX IF NOT EXISTS idx_trades_timestamp ON trade_history(timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_trades_market ON trade_history(market_id);
-- Whale trades (large trades detected from Data API)
CREATE TABLE IF NOT EXISTS whale_trades (
id TEXT PRIMARY KEY, -- unique trade id from Data API
address TEXT NOT NULL, -- trader wallet address (lowercase)
market_id TEXT NOT NULL,
market_question TEXT,
market_slug TEXT,
side TEXT NOT NULL, -- 'BUY' or 'SELL'
outcome TEXT NOT NULL, -- 'Yes' or 'No'
size REAL NOT NULL DEFAULT 0, -- number of shares
price REAL NOT NULL DEFAULT 0, -- price per share
amount REAL NOT NULL DEFAULT 0, -- total USDC amount
timestamp TEXT NOT NULL,
detected_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_whale_trades_address ON whale_trades(address);
CREATE INDEX IF NOT EXISTS idx_whale_trades_amount ON whale_trades(amount DESC);
CREATE INDEX IF NOT EXISTS idx_whale_trades_timestamp ON whale_trades(timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_whale_trades_market ON whale_trades(market_id);
-- Whale wallets (aggregated profiles of large traders)
CREATE TABLE IF NOT EXISTS whale_wallets (
address TEXT PRIMARY KEY, -- wallet address (lowercase)
label TEXT, -- auto-generated label ("Whale #1", "Political Shark", etc.)
total_trades INTEGER DEFAULT 0,
total_volume REAL DEFAULT 0, -- lifetime USDC volume
avg_trade_size REAL DEFAULT 0,
largest_trade REAL DEFAULT 0,
win_rate REAL, -- 0.0-1.0, NULL if unknown
top_category TEXT, -- most traded category
first_seen TEXT,
last_seen TEXT,
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_whale_wallets_volume ON whale_wallets(total_volume DESC);
CREATE INDEX IF NOT EXISTS idx_whale_wallets_last_seen ON whale_wallets(last_seen DESC);
-- Edge scanner scores (heuristic model)
CREATE TABLE IF NOT EXISTS edge_scores (
market_id TEXT PRIMARY KEY,
question TEXT,
category TEXT,
image_url TEXT,
market_price REAL NOT NULL, -- current YES price
edge_score REAL NOT NULL, -- composite 0.0-1.0
signal TEXT NOT NULL DEFAULT 'NEUTRAL', -- BUY_YES, BUY_NO, NEUTRAL
confidence TEXT NOT NULL DEFAULT 'LOW', -- HIGH, MED, LOW
spread_factor REAL DEFAULT 0,
volume_factor REAL DEFAULT 0,
whale_factor REAL DEFAULT 0,
momentum_factor REAL DEFAULT 0,
price_factor REAL DEFAULT 0,
whale_yes_pct REAL DEFAULT 0, -- % of whale volume on YES side
volume_ratio REAL DEFAULT 0, -- volume_24h / liquidity
whale_count INTEGER DEFAULT 0, -- # of qualifying whale trades behind signal
whale_volume_usd REAL DEFAULT 0, -- total $ whale volume behind signal
scored_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_edge_score ON edge_scores(edge_score DESC);
CREATE INDEX IF NOT EXISTS idx_edge_signal ON edge_scores(signal);
-- ============================================================
-- Copy-trading (model B). User identity = wallet address (SIWE auth).
-- ============================================================
-- Users: wallet-based identity (no email/password). address = user_id.
CREATE TABLE IF NOT EXISTS users (
address TEXT PRIMARY KEY, -- lowercase EOA = user_id (SIWE)
vault_address TEXT, -- their vault (NULL until custody setup)
email TEXT, -- optional, notifications only
created_at TEXT DEFAULT (datetime('now')),
last_login TEXT
);
-- Per-user custom vault (model B: holds funds, delegate may trade not withdraw).
CREATE TABLE IF NOT EXISTS vaults (
vault_address TEXT PRIMARY KEY, -- deployed vault contract
owner_address TEXT NOT NULL, -- user EOA (= users.address)
delegate_address TEXT, -- our server signer enrolled in vault
chain_id INTEGER NOT NULL DEFAULT 137,
status TEXT NOT NULL DEFAULT 'pending', -- pending | active | revoked
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_vaults_owner ON vaults(owner_address);
-- Copy subscriptions: who copies whom + config (JSON CopyConfig from the UI).
CREATE TABLE IF NOT EXISTS copy_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_address TEXT NOT NULL, -- follower (= users.address)
leader_address TEXT NOT NULL, -- whale being copied
config TEXT NOT NULL, -- JSON: alloc/limits/categories/price/mirror/drawdown
status TEXT NOT NULL DEFAULT 'active', -- active | paused
created_at TEXT DEFAULT (datetime('now')),
UNIQUE(user_address, leader_address)
);
CREATE INDEX IF NOT EXISTS idx_copysub_user ON copy_subscriptions(user_address);
CREATE INDEX IF NOT EXISTS idx_copysub_leader ON copy_subscriptions(leader_address);
-- Per-user deposit-wallet: where the copy demon trades on the user's behalf.
-- Keyed by the same identity as copy_subscriptions.user_address (Privy DID or SIWE addr).
-- Reported by the client after onboarding (deterministic from the embedded EOA).
CREATE TABLE IF NOT EXISTS user_wallets (
user_address TEXT PRIMARY KEY,
deposit_wallet TEXT NOT NULL, -- lowercase deposit-wallet address
owner_eoa TEXT, -- lowercase embedded EOA (signs CLOB orders via Privy delegate)
updated_at TEXT DEFAULT (datetime('now'))
);
-- Copy positions: trades mirrored on behalf of a user. One row per (user, token_id).
-- Multi-user ledger replacing the daemon's single-user positions.json. The daemon runs
-- off-box (Malaysia VPS) so it reads/writes this over the service API, not direct SQLite.
CREATE TABLE IF NOT EXISTS copy_positions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_address TEXT NOT NULL,
leader_address TEXT NOT NULL,
market_id TEXT NOT NULL,
token_id TEXT, -- CLOB outcome tokenID (asset) β ledger key with user_address
outcome TEXT,
title TEXT,
event_slug TEXT,
side TEXT, -- BUY | SELL
size REAL,
entry_price REAL, -- OUR actual fill (honest, not leader's)
leader_price REAL, -- leader's price for reference
status TEXT NOT NULL DEFAULT 'open', -- pending | open | closed
cost REAL, -- notional spent entering ($)
mark_pnl REAL, -- last live mark-to-market P&L
realized_pnl REAL, -- banked P&L once closed
pnl REAL, -- legacy/compat
leader_exit_seen INTEGER DEFAULT 0, -- consecutive reconcile cycles leader absent (mirror-exit confirm)
opened_at INTEGER, -- epoch seconds of entry (reconcile age math)
last_order_id TEXT, -- resting BUY order id (cancel if pending times out)
close_order_id TEXT, -- resting SELL/close order id
closed_reason TEXT, -- why closed (mirror-leader-exit / resolved-onchain-absent / ...)
source_trade_id TEXT, -- whale_trades row we copied
close_requested_at INTEGER, -- epoch secs of manual-close request (statusβ'closing'); daemon executes
created_at TEXT DEFAULT (datetime('now')),
closed_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_copypos_user ON copy_positions(user_address);
-- NOTE: the (user_address, token_id) unique index is created in db.rs AFTER the idempotent
-- ALTER that adds token_id β putting it here would panic on existing DBs (the no-op CREATE
-- TABLE leaves the column absent when execute_batch(SCHEMA) reaches this index).