Skill: cnica/polymarket-predictradar-daily-anomalies-skills (version 1.0.0)

--- name: polymarket-daily-anomalies description: "Polymarket Daily Anomaly Report. Scans for 3 types of market anomalies: Black Swan (sudden probability shifts in 2h windows), Whale Wars (large opposing bets on same market), Insider Watch (suspicious new-wallet large buys on low-probability outcomes). Generates a narrative daily report with real news context. Trigger words: daily report, anomaly report, market anomalies, insider watch, whale wars, black swan, daily brief, market monitor, what happened today, any anomalies, unusual activity. Auto-trigger when user asks 'generate today's report', 'any insider activity', 'any whale wars today', 'black swan events', 'market anomalies'." --- # Polymarket Daily Anomaly Report Skill v1.0 You are a Polymarket content analyst. Generate a daily anomaly report covering three signal categories: Black Swan (sudden probability shifts), Whale Wars (opposing whale bets), and Insider Watch (suspicious new-wallet activity). **Core Principles: All data MUST come from live queries to the data sources below. NEVER fabricate any numbers, prices, addresses, or news. Market names must be clickable hyperlinks to the Polymarket page. All wallet addresses must be displayed in full 42-character format (0x + 40 hex chars) for user verification and copy-trading. Output must NEVER expose internal data infrastructure details (database names, API endpoints, credentials, etc.).** --- ## Data Sources ### Data Source A: MCP Data Service (Trade Aggregation + Position Queries) Query trade data via the MCP client for all three signal types. The MCP client provides SQL query capability over trade and position data. The current live MCP service requires an `initialize` + `notifications/initialized` handshake. The shared `mcp-client.js` wrapper already handles this automatically. **MCP Client Path**: ```js const mcp = require('../../polymarket-data-layer/scripts/mcp-client'); ``` **Reference**: https://github.com/predictradar-ai/predictradar-skills/blob/main/polymarket-data-layer/scripts/mcp-examples.js **Available Methods**: - `mcp.query(sql)` — Execute SQL query (SELECT only), returns row array - `mcp.queryWithRetry(sql, { retries: 3 })` — SQL query with retry - `mcp.ping()` — Health check, returns true/false - `mcp.getMarketStats(period)` — Get market statistics - `mcp.getTraderDetail(address)` — Get trader details **Key Tables**: `trades` (recent 2-7 day data), `positions` **Mapping note**: prefer the `condition_id` already returned in `trades` results. Do not rely on any legacy sync-mapping table. **Key Columns in `trades` table**: | Column | Type | Description | |--------|------|-------------| | market_id | String | Market identifier | | wallet_address | String | Trader wallet address | | condition_id | String | Condition contract hash | | side | String | 'buy' or 'sell' | | type | String | 'trade' for actual trades | | outcome | String | 'Yes' or 'No' | | outcome_index | Int32 | 0 = Yes token, 1 = No token | | price | Decimal | Trade price (0-1, represents probability) | | usd_amount | Decimal | Trade amount in USD | | traded_at | DateTime | Trade timestamp | **CRITICAL: outcome_index filtering** The `trades` table stores both Yes (outcome_index=0) and No (outcome_index=1) token trades under the same `market_id`. When analyzing price movements or buy prices, you MUST filter by `outcome_index = 0` (Yes token only) to avoid mixing Yes and No token prices, which creates fake price movements. For example, without filtering, `min(price)` might pick up a Yes trade at 0.11 and `max(price)` a No trade at 0.88, creating a phantom 77pp "change" that never happened. ### Data Source B: Gamma API (Market Metadata + Current Price + URL Slug) Provides market question, expiry date, current probability price, category, URL slug. ``` GET https://gamma-api.polymarket.com/markets?condition_ids=<ID1>,<ID2>&limit=50 ``` **Key Fields**: | Field | Description | Example | |-------|-------------|---------| | question | Market question | "Will crude oil hit $200?" | | conditionId | Condition contract hash | "0xabc..." | | slug | Market-level URL slug | "crude-oil-200" | | events[0].slug | Event-level URL slug (USE THIS for URLs) | "crude-oil-march" | | endDate | Market expiry (ISO 8601) | "2026-03-16T00:00:00Z" | | category | Category label | "Politics" | | volume | Total market volume | "4200000" | | outcomePrices | Current prices JSON | "[\"0.44\",\"0.56\"]" | **URL Construction**: Always use `events[0].slug` (event-level), NOT `m.slug` (market-level): ``` https://polymarket.com/event/{events[0].slug} ``` Fallback to `m.slug` only if `events[0].slug` is unavailable. Using `m.slug` causes 404 errors on many markets. ### Data Source C: Web Search (News Context) For non-sports events (domain = GEO/FIN/POL/CRY/TEC), search for real news context from the past 48 hours using WebSearch. NEVER fabricate news. Sports events (SPT/GEN) only need brief match descriptions. --- ## Domain Classification Classify each market into a domain using these rules (applied to market question text): | Code | Domain | Keywords | |------|--------|----------| | POL | Politics | trump, election, congress, president, cabinet, ballot, tariff | | GEO | Geopolitics | war, iran, ceasefire, sanction, ukraine, russia, israel, hamas | | FIN | Finance | fed, interest rate, s&p, crude oil, gdp, inflation, recession | | CRY | Crypto | bitcoin, ethereum, solana, defi, nft, blockchain | | SPT | Sports | nba, nfl, ufc, premier league, world cup, tennis, formula 1 | | TEC | Technology | ai, openai, spacex, apple, tesla, nvidia, quantum | | CUL | Culture | oscar, grammy, box office, billboard, emmy | | GEN | General | Unmatched by any above | **Display Priority**: GEO > FIN > POL > CRY > TEC > CUL > SPT > GEN --- ## Signal Type 1: Black Swan (Probability Shock) **Definition**: A market's Yes token price moves >=70 percentage points within a 2-hour window, with significant volume. ### SQL Query ```sql SELECT market_id, toDateTime(intDiv(toUnixTimestamp(traded_at), 7200) * 7200) AS time_bucket, min(price) AS min_price, max(price) AS max_price, max(price) - min(price) AS abs_change, round((max(price) - min(price)) * 100, 1) AS change_pp, argMin(price, traded_at) AS first_price, argMax(price, traded_at) AS last_price, sum(usd_amount) AS vol_2h_usd, count() AS trades_2h FROM trades WHERE traded_at >= now() - INTERVAL 2 DAY AND traded_at IS NOT NULL AND type = 'trade' AND outcome_index = 0 AND price > 0 AND usd_amount > 0 AND market_id IS NOT NULL GROUP BY market_id, time_bucket HAVING min_price >= 0.05 AND max_price <= 0.95 AND (max_price - min_price) >= 0.70 AND vol_2h_usd >= 100000 ORDER BY abs_change DESC, vol_2h_usd DESC LIMIT 200 ``` ### Post-Processing 1. **Deduplicate**: Keep only the highest-change time bucket per market_id 2. **Direction**: Calculate from `first_price` → `last_price`: - `last_price > first_price` → UP (Yes probability rising) - `last_price < first_price` → DOWN (Yes probability falling) 3. **Sports Filter**: Remove markets where `domain IN (GEN, SPT) AND market_end <= today` (completed sports events with normal live-score volatility) 4. **Enrich**: Get market name via condition_id → Gamma API ### Output Format (per event) ```markdown **[Market question Chinese interpretation + Yes/No direction + change magnitude]** [-> Polymarket](URL) - Time window: YYYY-MM-DD HH:MM - Price direction: **Yes X% -> X%** (up/down +/-XXpp, one-sentence interpretation) - 2h volume: **$XXX,XXX** (XXX trades) [1-2 paragraphs of real news context, NEVER fabricated] ``` ### Direction Description Rules (MOST IMPORTANT) You MUST explicitly tell the reader: 1. What the market question asks 2. What "Yes" means in real-world terms 3. Whether the price went UP or DOWN 4. What this means for reality Examples: - Market: "US-Iran ceasefire by March 15?" → Yes price 11% -> 88% → "Market briefly considered ceasefire very likely" - Market: "Crude oil hits $200?" → Yes price 92% -> 8% → "Market rapidly ruled out the $200 scenario" **NEVER allow ambiguity** about which direction moved and what it means. --- ## Signal Type 2: Whale Wars (Opposing Large Bets) **Definition**: In the same market within 24h, two whales each bet >=\$25k in opposite directions. ### Step 1: Find high-volume markets ```sql SELECT market_id, sum(usd_amount) AS volume_24h, count() AS trades_24h FROM trades WHERE traded_at >= now() - INTERVAL 24 HOUR AND type = 'trade' AND market_id IS NOT NULL AND usd_amount > 0 GROUP BY market_id HAVING volume_24h >= 200000 ORDER BY volume_24h DESC LIMIT 300 ``` ### Step 2: Find per-wallet buy/sell totals ```sql SELECT market_id, wallet_address, sumIf(usd_amount, side = 'buy') AS buy_total, sumIf(usd_amount, side = 'sell') AS sell_total, count() AS trade_count FROM trades WHERE traded_at >= now() - INTERVAL 24 HOUR AND type = 'trade' AND market_id IN (<high_volume_market_ids>) AND wallet_address IS NOT NULL AND usd_amount > 0 GROUP BY market_id, wallet_address HAVING buy_total >= 25000 OR sell_total >= 25000 ORDER BY market_id, greatest(buy_total, sell_total) DESC LIMIT 3000 ``` ### Step 3: Match opposing pairs For each market, find the top buyer (buy_total > sell_total, buy_total >= $25k) and top seller (sell_total > buy_total, sell_total >= $25k). If both exist, it's a whale war. ### Output Format (per pair) ```markdown **[Narrative title with market name + bet amounts]** [-> Polymarket](URL) - Market 24h volume **$X,XXX,XXX** / Total opposing bet **$XXX,XXX** - Bull (Yes): `0xFULL_ADDRESS` -> **$XX,XXX** - Bear (No): `0xFULL_ADDRESS` -> **$XX,XXX** [1-2 paragraphs of real news context for non-sports events] ``` ### Cross-Whale Detection If the same wallet address appears in multiple Whale Wars entries, note this pattern: - "0x... appears in both [Market A] (buyer) and [Market B] (seller), active cross-market whale" --- ## Signal Type 3: Insider Watch (Suspicious New-Wallet Activity) **Definition**: A new wallet (<=5 historical trades) makes an unusually large buy (>=\$1,000, >=2x market average) on a low-probability outcome (Yes price <=50%). ### Step 1: Find qualifying buys ```sql SELECT market_id, wallet_address, price AS buy_price, usd_amount AS trade_size, traded_at FROM trades WHERE traded_at >= now() - INTERVAL 24 HOUR AND type = 'trade' AND side = 'buy' AND outcome_index = 0 AND price <= 0.50 AND price > 0 AND usd_amount >= 1000 AND market_id IN (<active_market_ids>) AND wallet_address IS NOT NULL AND wallet_address != '' ORDER BY usd_amount DESC LIMIT 3000 ``` ### Step 2: Check wallet history ```sql SELECT wallet_address, count() AS total_trades FROM trades WHERE type = 'trade' AND wallet_address IN (<candidate_wallets>) GROUP BY wallet_address ``` ### Step 3: Filter 1. Keep only wallets with `total_trades <= 5` (new addresses) 2. Keep only trades where `trade_size >= 2 * market_avg_trade_size` (abnormally large) ### Step 4: Multi-wallet aggregation If multiple new wallets bet on the same market, aggregate them: - Combined USD amount - Weighted average buy price - List all wallet addresses individually - Flag as "X new addresses" in the title ### Output Format ```markdown **[X new wallets bet $Xk on "market name"]** (if multi-wallet) **[New wallet bets $X,XXX on "market name"]** (if single wallet) [-> Polymarket](URL) - Wallet(s): `0xFULL_ADDRESS` (N historical trades, new address) - If multi-wallet, list ALL full addresses on separate lines - Buy price: **Yes XX%** (below 50%, betting on the underdog) - Amount: **$X,XXX** (market avg $XX, Xx amplification) - Time: YYYY-MM-DD HH:MM [Brief interpretation of why this is suspicious] ``` --- ## Execution Workflow ### Option A: Run data collection script (Recommended) The repo includes a data collection script at `polymarket-daily-anomalies/scripts/content-analysis.js`. It uses the shared `polymarket-data-layer` data layer (MCP client + Gamma client) — no direct database credentials needed. ```bash # From the repo root node polymarket-daily-anomalies/scripts/content-analysis.js ``` This generates `content-signals-YYYY-MM-DD.json` in the current directory containing all three signal types with enriched market names, URLs, domain classifications, and price direction data. Then read the generated JSON and proceed to Step 2. ### Option B: Inline MCP queries (if script unavailable) If the script is not available in the current environment, run the SQL queries from each signal type section directly via the shared MCP wrapper: ```js // Run from the repo root const mcp = require('./polymarket-data-layer/scripts/mcp-client'); async function mcpQuery(sql, maxRows = 500) { return mcp.query(sql, { maxRows }); } ``` Execute all SQL queries from Signal Types 1-3 sections above, then resolve market names via Gamma API (`GET https://gamma-api.polymarket.com/markets?condition_ids=...`). ### Step 2: Search for news context For non-sports events (domain = GEO/FIN/POL/CRY/TEC), use WebSearch to find real news from the past 48 hours. **NEVER fabricate any data or news context.** Sports events (SPT/GEN) need only brief match descriptions. ### Step 3: Generate report Follow the output template below. --- ## Output Template ```markdown ## [Month] [Day] Polymarket Anomaly Report --- ### Black Swan — 48h Probability Shocks > Markets where the Yes token probability shifted dramatically in a 2-hour window. Completed sports events filtered out. [Events listed by domain priority: GEO > FIN > POL > CRY > TEC > CUL > SPT > GEN] --- ### Whale Wars — Opposing Whale Bets > Two whales each bet >= $25k in opposite directions on the same market within 24h. [Events listed by total_bet_usd descending, non-sports prioritized] --- ### Insider Watch — Suspicious New-Wallet Activity > New addresses (<=5 historical trades) making abnormally large bets on low-probability outcomes. [Events listed by trade_usd descending] --- ### Daily Summary | Signal | Count | Highlight | |--------|-------|-----------| | Black Swan | X events | [Key takeaway] | | Whale Wars | X pairs | [Key takeaway] | | Insider Watch | X events | [Key takeaway] | **Key Takeaway:** [1-2 sentence summary of the most notable signals] --- *Data source: Polymarket · Generated: ISO_TIMESTAMP* ``` --- ## Configuration Parameters ```json { "base": { "min_volume_24h": 50000, "min_trades_24h": 20 }, "insider": { "max_wallet_trades": 5, "min_trade_usd": 1000, "max_buy_price": 0.50, "min_size_vs_avg": 2.0, "limit": 50 }, "whale_wars": { "min_market_vol": 200000, "min_wallet_usd": 25000, "limit": 15 }, "black_swan": { "min_abs_change": 0.70, "min_start_price": 0.05, "max_end_price": 0.95, "min_vol_2h": 100000, "lookback_days": 2, "limit": 30 } } ``` --- ## Self-Verification Checklist Before outputting, verify ALL of the following: - [ ] Every number comes from real query results, not fabricated - [ ] Every wallet address is full 42 characters (0x + 40 hex), never abbreviated - [ ] Every market name links to a working Polymarket URL using `events[0].slug` - [ ] Black Swan direction clearly states: what the question asks, what Yes means, price went UP or DOWN, what this means - [ ] News context comes from real WebSearch results with source attribution - [ ] No internal infrastructure details exposed (no database names, no API endpoints, no credentials) - [ ] Sports events in Black Swan are filtered if market has already ended - [ ] Whale Wars notes any cross-market whale patterns - [ ] Insider Watch multi-wallet aggregation is properly displayed with all individual addresses - [ ] Domain priority ordering is respected: GEO > FIN > POL > CRY > TEC > CUL > SPT > GEN - [ ] Currency formatted with $ and thousands separators ($123,456) - [ ] All wallet addresses in Whale Wars and Insider Watch are shown in full --- ## Quick Command > Run `node polymarket-daily-anomalies/scripts/content-analysis.js` from the repo root (or use inline MCP wrapper queries if the script is unavailable), then read the latest JSON data and generate today's report following this SKILL.md format. Search real news for non-sports events. Make sure Black Swan uses first_price/last_price/direction fields to clarify Yes/No direction. --- ## Improvements Planned - [ ] Wallet historical win rate and PnL dimensions (requires positions table query) - [ ] Current market price (live Gamma API pull at report time) - [ ] Prediction tracking (record historical pushes, periodic P&L review) - [ ] Domain-segmented output (Geopolitics section / Finance section / Sports section)

path: scripts/content-analysis.js
content: /**
 * content-analysis.js
 * Polymarket Daily Anomaly Detection — 3 signal types + JSON export
 *
 * Signal 1: Insider Watch (suspicious new-wallet activity)  — daily, ≤50
 * Signal 2: Whale Wars   (opposing whale bets)              — daily, ≤15
 * Signal 3: Black Swan    (probability shocks)              — 48h,   ≤30
 *
 * Usage:
 *   MCP_URL=https://api.predictradar.ai node content-analysis.js
 *
 * Requires: polymarket-data-layer shared data layer (mcp-client, gamma-client)
 */

'use strict';

const fs   = require('fs');
const path = require('path');

// ── Shared data layer ────────────────────────────────────────
const mcp   = require('../../polymarket-data-layer/scripts/mcp-client');
const gamma = require('../../polymarket-data-layer/scripts/gamma-client');

// ── Configuration ────────────────────────────────────────────
const CFG = {
  base: {
    min_volume_24h: 50000,
    min_trades_24h: 20,
  },
  insider: {
    max_wallet_trades: 5,
    min_trade_usd:  1000,
    max_buy_price:  0.50,
    min_size_vs_avg: 2.0,
    limit:           50,
  },
  whale_wars: {
    min_market_vol: 200000,
    min_wallet_usd:  25000,
    limit:            15,
  },
  black_swan: {
    min_abs_change:  0.70,
    min_start_price: 0.05,
    max_end_price:   0.95,
    min_vol_2h:   100000,
    lookback_days:     2,
    limit:            30,
  },
};

// ── Domain classification (same rules as tag-all-domains.js) ──
const DOMAIN_LABELS = {
  POL: 'Politics', GEO: 'Geopolitics', FIN: 'Finance',
  CRY: 'Crypto',   SPT: 'Sports',      TEC: 'Technology',
  CUL: 'Culture',  GEN: 'General',
};

const CAT_MAP = {
  'us-current-affairs': 'POL', 'politics': 'POL',
  'global politics': 'GEO', 'global-politics': 'GEO', 'ukraine & russia': 'GEO',
  'business': 'FIN', 'economics': 'FIN', 'finance': 'FIN',
  'crypto': 'CRY', 'nfts': 'CRY', 'defi': 'CRY',
  'sports': 'SPT', 'nba playoffs': 'SPT', 'nba': 'SPT', 'nfl': 'SPT',
  'olympics': 'SPT', 'chess': 'SPT', 'poker': 'SPT',
  'tech': 'TEC', 'science': 'TEC', 'space': 'TEC',
  'pop-culture': 'CUL', 'pop-culture ': 'CUL', 'art': 'CUL',
};

const Q_RULES = [
  [/\b(trump|biden|harris|clinton|obama|election|president|congress|senate|democrat|republican|white house|tariff|gov.shutdown|vp |cabinet|inaugur|ballot|midterm|primary|nato|un |imf )\b/i, 'POL'],
  [/\b(nfl|nba|ufc|soccer|premier.?league|world.?cup|super.?bowl|wimbledon|us.?open|masters|pga|formula.?1|f1 |formula 1|moto.?gp|nascar|basketball|football|baseball|hockey|tennis|boxing|mma|chess|poker|esport|olympic|world.?series|champions.?league|euro.?cup|copa|afc|nfc|playoffs?|championship|t20|cricket|ipl|ashes)\b/i, 'SPT'],
  [/\b(ukraine|russia|china|israel|iran(?:ian)?|hamas|hezbollah|nato|war|ceasefire|sanction|xi |putin|zelensky|middle.?east|taiwan|venezuela|geopolit|kim jong|north korea|south korea|india|pakistan|saudi|cartel|border|migrant|refugee|regime)\b/i, 'GEO'],
  [/\b(fed |federal reserve|interest.?rate|rate.?cut|s&p|stock|nasdaq|dow |ipo |earnings|gdp|inflation|recession|oil.?price|crude|gold.?price|commodit|treasury|bond.?yield|cpi |ppi |jobs.?report|unemployment|fomc)\b/i, 'FIN'],
  [/\b(bitcoin|btc |eth |ethereum|crypto|solana|sol |doge|xrp|bnb |chainlink|polygon|avalanche|defi |nft |blockchain|coinbase|binance|sec.+crypto|stablecoin|altcoin|halving|layer.?2|web3)\b/i, 'CRY'],
  [/\b(ai |artificial.?intelligence|openai|chatgpt|gpt.?\d|gemini|claude |anthropic|spacex|nasa|starship|rocket|elon.?musk.+tweet|twitter|x\.com|apple|google|microsoft|meta |tesla|nvidia|semiconductor|quantum|cyber|hack|data.?breach)\b/i, 'TEC'],
  [/\b(oscar|grammy|golden.?globe|academy.?award|bafta|emmy|vma|movie|film|box.?office|album|billboard|chart|taylor.?swift|beyonce|kanye|celebrity|kim.?kardashian|reality.?tv|tiktok.+ban|super.?bowl.+halftime)\b/i, 'CUL'],
];

function marketToDomain(question, category) {
  const q = (question || '').toLowerCase();
  for (const [re, d] of Q_RULES) {
    if (re.test(q)) return d;
  }
  if (category) {
    const c = category.trim().toLowerCase();
    if (CAT_MAP[c]) return CAT_MAP[c];
  }
  return null;
}

const sleep = ms => new Promise(r => setTimeout(r, ms));

// ── Helpers ──────────────────────────────────────────────────
function sep(title) {
  console.log('\n' + '='.repeat(72));
  console.log('  ' + title);
  console.log('='.repeat(72));
}

function printTable(rows, fields) {
  if (!rows?.length) { console.log('  (no results)'); return; }
  const cols = fields || Object.keys(rows[0]);
  const widths = cols.map(k =>
    Math.max(k.length, ...rows.map(r => String(r[k] ?? '').length))
  );
  console.log('  ' + cols.map((k, i) => k.padEnd(widths[i])).join(' | '));
  console.log('  ' + widths.map(w => '-'.repeat(w)).join('-+-'));
  for (const row of rows)
    console.log('  ' + cols.map((k, i) => String(row[k] ?? '').padEnd(widths[i])).join(' | '));
  console.log(`\n  Total: ${rows.length} rows`);
}

// ── Active markets (shared filter) ───────────────────────────
async function getActiveMarkets() {
  process.stdout.write(`  [base] Active markets (vol>=$${(CFG.base.min_volume_24h/1000).toFixed(0)}k, trades>=${CFG.base.min_trades_24h})...`);
  const rows = await mcp.queryWithRetry(`
    SELECT
      market_id,
      count()          AS trades_24h,
      sum(usd_amount)  AS volume_24h,
      avg(usd_amount)  AS avg_trade_size
    FROM trades
    WHERE traded_at >= now() - INTERVAL 24 HOUR
      AND type = 'trade'
      AND usd_amount > 0
      AND market_id IS NOT NULL
    GROUP BY market_id
    HAVING volume_24h >= ${CFG.base.min_volume_24h}
       AND trades_24h >= ${CFG.base.min_trades_24h}
    ORDER BY volume_24h DESC
    LIMIT 3000
  `, { maxRows: 3000, retries: 3 });
  console.log(` ${rows.length} markets`);
  return rows;
}

// ── Signal 1: Insider Watch ──────────────────────────────────
async function runInsiderWatch(activeMarkets) {
  sep('Signal 1: Insider Watch');
  const C = CFG.insider;
  console.log(`  Criteria: new wallet (trades<=${C.max_wallet_trades}) | buy>=$${C.min_trade_usd.toLocaleString()} | price<=${C.max_buy_price} | size>=${C.min_size_vs_avg}x avg`);

  if (!activeMarkets.length) { console.log('  No active markets'); return []; }

  const marketMap = new Map(activeMarkets.map(r => [r.market_id, r]));
  const activeIds = activeMarkets.map(r => `'${r.market_id}'`).join(',');

  // Q1: 24h buys matching criteria
  process.stdout.write(`  [Q1] 24h buys (price<=${C.max_buy_price}, amount>=$${C.min_trade_usd.toLocaleString()})...`);
  const buys = await mcp.queryWithRetry(`
    SELECT
      market_id,
      wallet_address,
      price        AS buy_price,
      usd_amount   AS trade_size,
      traded_at
    FROM trades
    WHERE traded_at >= now() - INTERVAL 24 HOUR
      AND type = 'trade'
      AND side = 'buy'
      AND outcome_index = 0
      AND price <= ${C.max_buy_price}
      AND price > 0
      AND usd_amount >= ${C.min_trade_usd}
      AND market_id IN (${activeIds})
      AND wallet_address IS NOT NULL
      AND wallet_address != ''
    ORDER BY usd_amount DESC
    LIMIT 3000
  `, { maxRows: 3000, retries: 3 });
  console.log(` ${buys.length} rows`);

  if (!buys.length) return [];

  // Q2: Historical trade counts for these wallets
  const walletSet = [...new Set(buys.map(r => r.wallet_address))];
  const walletIn  = walletSet.map(w => `'${w}'`).join(',');
  process.stdout.write(`  [Q2] ${walletSet.length} wallets historical trade count...`);
  const walletCounts = await mcp.queryWithRetry(`
    SELECT wallet_address, count() AS total_trades
    FROM trades
    WHERE type = 'trade'
      AND wallet_address IN (${walletIn})
    GROUP BY wallet_address
  `, { maxRows: 5000, retries: 3 });
  console.log(` done (${walletCounts.length} rows)`);

  const wcMap = new Map(walletCounts.map(r => [r.wallet_address, Number(r.total_trades)]));

  // Filter
  const allPassed = [];
  for (const b of buys) {
    const totalTrades = wcMap.get(b.wallet_address) ?? 9999;
    if (totalTrades > C.max_wallet_trades) continue;

    const mkt = marketMap.get(b.market_id);
    if (!mkt) continue;

    const avgSize   = parseFloat(mkt.avg_trade_size);
    const tradeSize = parseFloat(b.trade_size);
    if (tradeSize < C.min_size_vs_avg * avgSize) continue;

    allPassed.push({
      market_id_short: b.market_id.slice(0, 16) + '...',
      wallet_short:    b.wallet_address.slice(0, 10) + '...',
      buy_price:       +parseFloat(b.buy_price).toFixed(4),
      trade_usd:       Math.round(tradeSize),
      avg_mkt_usd:     Math.round(avgSize),
      x_avg:           +(tradeSize / avgSize).toFixed(1),
      wallet_trades:   totalTrades,
      traded_at:       b.traded_at,
      market_id:       b.market_id,
      wallet_address:  b.wallet_address,
    });
  }

  allPassed.sort((a, b) => b.trade_usd - a.trade_usd);

  // Multi-wallet aggregation: same market, multiple new wallets
  const byMarket = new Map();
  for (const r of allPassed) {
    if (!byMarket.has(r.market_id)) byMarket.set(r.market_id, []);
    byMarket.get(r.market_id).push(r);
  }
  const aggregated = [];
  for (const [mid, trades] of byMarket) {
    if (trades.length >= 2) {
      const totalUsd = trades.reduce((s, t) => s + t.trade_usd, 0);
      const avgPrice = trades.reduce((s, t) => s + t.buy_price * t.trade_usd, 0) / totalUsd;
      const wallets = trades.map(t => t.wallet_address);
      aggregated.push({
        ...trades[0],
        wallet_count:   trades.length,
        wallets,
        wallet_address: wallets[0],
        wallet_short:   `${trades.length} new addresses`,
        trade_usd:      totalUsd,
        buy_price:      +avgPrice.toFixed(4),
        avg_buy_price:  +avgPrice.toFixed(4),
        x_avg:          +(totalUsd / trades[0].avg_mkt_usd).toFixed(1),
        is_aggregated:  true,
      });
    } else {
      trades[0].wallet_count = 1;
      trades[0].is_aggregated = false;
      aggregated.push(trades[0]);
    }
  }
  aggregated.sort((a, b) => b.trade_usd - a.trade_usd);

  const final = aggregated.slice(0, C.limit);
  console.log(`\n  Filtered: ${allPassed.length} -> top ${final.length}`);
  printTable(final, ['wallet_short', 'buy_price', 'trade_usd', 'avg_mkt_usd', 'x_avg', 'wallet_trades', 'market_id_short']);

  const byStep = {
    total_buys:     buys.length,
    after_new_addr: buys.filter(b => (wcMap.get(b.wallet_address) ?? 9999) <= C.max_wallet_trades).length,
    after_size_mul: allPassed.length,
  };
  console.log(`\n  [diag] total_buys=${byStep.total_buys} -> new_addr=${byStep.after_new_addr} -> size_filter=${byStep.after_size_mul}`);

  return final;
}

// ── Signal 2: Whale Wars ─────────────────────────────────────
async function runWhaleWars() {
  sep('Signal 2: Whale Wars');
  const C = CFG.whale_wars;
  console.log(`  Criteria: same market | each side >=$${(C.min_wallet_usd/1000).toFixed(0)}k | opposing | 24h | market vol >=$${(C.min_market_vol/1000).toFixed(0)}k`);

  // Q1: High-volume markets
  process.stdout.write(`  [Q1] Markets (vol>=$${(C.min_market_vol/1000).toFixed(0)}k, 24h)...`);
  const bigMarkets = await mcp.queryWithRetry(`
    SELECT
      market_id,
      sum(usd_amount) AS volume_24h,
      count()         AS trades_24h
    FROM trades
    WHERE traded_at >= now() - INTERVAL 24 HOUR
      AND type = 'trade'
      AND market_id IS NOT NULL
      AND usd_amount > 0
    GROUP BY market_id
    HAVING volume_24h >= ${C.min_market_vol}
    ORDER BY volume_24h DESC
    LIMIT 300
  `, { maxRows: 300, retries: 3 });
  console.log(` ${bigMarkets.length} markets`);

  if (!bigMarkets.length) { console.log('  No qualifying markets'); return []; }

  const bigMarketMap = new Map(bigMarkets.map(r => [r.market_id, r]));
  const bigIds = bigMarkets.map(r => `'${r.market_id}'`).join(',');

  // Q2: Per-wallet buy/sell totals
  process.stdout.write('  [Q2] Per-wallet buy/sell aggregation...');
  const walletSides = await mcp.queryWithRetry(`
    SELECT
      market_id,
      wallet_address,
      sumIf(usd_amount, side = 'buy')  AS buy_total,
      sumIf(usd_amount, side = 'sell') AS sell_total,
      count()                          AS trade_count
    FROM trades
    WHERE traded_at >= now() - INTERVAL 24 HOUR
      AND type = 'trade'
      AND market_id IN (${bigIds})
      AND wallet_address IS NOT NULL
      AND usd_amount > 0
    GROUP BY market_id, wallet_address
    HAVING buy_total >= ${C.min_wallet_usd} OR sell_total >= ${C.min_wallet_usd}
    ORDER BY market_id, greatest(buy_total, sell_total) DESC
    LIMIT 3000
  `, { maxRows: 3000, retries: 3 });
  console.log(` ${walletSides.length} rows`);

  // Find opposing pairs
  const byMarket = new Map();
  for (const r of walletSides) {
    if (!byMarket.has(r.market_id)) byMarket.set(r.market_id, []);
    byMarket.get(r.market_id).push(r);
  }

  const pairs = [];
  for (const [marketId, wallets] of byMarket) {
    const buyers  = wallets
      .filter(w => parseFloat(w.buy_total) >= C.min_wallet_usd && parseFloat(w.buy_total) > parseFloat(w.sell_total))
      .sort((a, b) => parseFloat(b.buy_total) - parseFloat(a.buy_total));
    const sellers = wallets
      .filter(w => parseFloat(w.sell_total) >= C.min_wallet_usd && parseFloat(w.sell_total) > parseFloat(w.buy_total))
      .sort((a, b) => parseFloat(b.sell_total) - parseFloat(a.sell_total));

    if (!buyers.length || !sellers.length) continue;

    const A      = buyers[0];
    const B      = sellers[0];
    const mkt    = bigMarketMap.get(marketId);
    const buyAmt  = Math.round(parseFloat(A.buy_total));
    const sellAmt = Math.round(parseFloat(B.sell_total));

    pairs.push({
      market_id:       marketId,
      market_id_short: marketId.slice(0, 16) + '...',
      vol_24h_usd:    Math.round(parseFloat(mkt.volume_24h)),
      buyer_wallet:    A.wallet_address,
      buyer_short:     A.wallet_address.slice(0, 10) + '...',
      buyer_usd:       buyAmt,
      seller_wallet:   B.wallet_address,
      seller_short:    B.wallet_address.slice(0, 10) + '...',
      seller_usd:      sellAmt,
      total_bet_usd:   buyAmt + sellAmt,
    });
  }

  pairs.sort((a, b) => b.total_bet_usd - a.total_bet_usd);
  const final = pairs.slice(0, C.limit);

  console.log(`\n  Opposing pairs: ${pairs.length} -> top ${final.length}`);
  printTable(final, ['market_id_short', 'vol_24h_usd', 'buyer_short', 'buyer_usd', 'seller_short', 'seller_usd', 'total_bet_usd']);
  return final;
}

// ── Signal 3: Black Swan ─────────────────────────────────────
async function runBlackSwan() {
  sep('Signal 3: Black Swan');
  const C = CFG.black_swan;
  console.log(`  Criteria: 2h abs change>=${C.min_abs_change} | start>=${C.min_start_price} | vol>=$${(C.min_vol_2h/1000).toFixed(0)}k | past ${C.lookback_days} days`);

  process.stdout.write('  [Q1] 2h price window scan...');
  const rows = await mcp.queryWithRetry(`
    SELECT
      market_id,
      toDateTime(intDiv(toUnixTimestamp(traded_at), 7200) * 7200) AS time_bucket,
      min(price)      AS min_price,
      max(price)      AS max_price,
      max(price) - min(price)  AS abs_change,
      round((max(price) - min(price)) * 100, 1) AS change_pp,
      argMin(price, traded_at) AS first_price,
      argMax(price, traded_at) AS last_price,
      sum(usd_amount) AS vol_2h_usd,
      count()         AS trades_2h
    FROM trades
    WHERE traded_at >= now() - INTERVAL ${C.lookback_days} DAY
      AND traded_at IS NOT NULL
      AND type = 'trade'
      AND outcome_index = 0
      AND price > 0
      AND usd_amount > 0
      AND market_id IS NOT NULL
    GROUP BY market_id, time_bucket
    HAVING min_price >= ${C.min_start_price}
       AND max_price <= ${C.max_end_price}
       AND (max_price - min_price) >= ${C.min_abs_change}
       AND vol_2h_usd >= ${C.min_vol_2h}
    ORDER BY abs_change DESC, vol_2h_usd DESC
    LIMIT 200
  `, { maxRows: 200, retries: 3 });
  console.log(` ${rows.length} candidates`);

  // Keep only highest-change window per market
  const deduped = new Map();
  for (const r of rows) {
    const prev = deduped.get(r.market_id);
    if (!prev || parseFloat(r.abs_change) > parseFloat(prev.abs_change)) {
      deduped.set(r.market_id, r);
    }
  }

  const results = [...deduped.values()]
    .sort((a, b) => parseFloat(b.abs_change) - parseFloat(a.abs_change))
    .slice(0, C.limit)
    .map(r => {
      const fp = +parseFloat(r.first_price).toFixed(4);
      const lp = +parseFloat(r.last_price).toFixed(4);
      const direction = lp > fp ? 'UP' : lp < fp ? 'DOWN' : 'FLAT';
      return {
        market_id:       r.market_id,
        market_id_short: r.market_id.slice(0, 16) + '...',
        time_bucket:     r.time_bucket,
        min_price:       +parseFloat(r.min_price).toFixed(4),
        max_price:       +parseFloat(r.max_price).toFixed(4),
        first_price:     fp,
        last_price:      lp,
        direction,
        change_pp:       parseFloat(r.change_pp) + 'pp',
        vol_2h_usd:      Math.round(parseFloat(r.vol_2h_usd)),
        trades_2h:       Number(r.trades_2h),
      };
    });

  printTable(results, ['market_id_short', 'time_bucket', 'first_price', 'last_price', 'direction', 'change_pp', 'vol_2h_usd', 'trades_2h']);
  return results;
}

// ── Market name enrichment via Gamma API ─────────────────────
async function enrichMarketNames(allMarketIds) {
  if (!allMarketIds.length) return new Map();

  // Step 1: market_id -> condition_id via MCP trades table.
  // trades.condition_id is the canonical mapping in the MCP-backed workflow.
  process.stdout.write('  [names] Mapping market_id -> condition_id...');
  const inList = allMarketIds.map(id => `'${id}'`).join(',');
  let cidRows = [];
  try {
    cidRows = await mcp.queryWithRetry(`
      SELECT DISTINCT market_id, condition_id
      FROM trades
      WHERE market_id IN (${inList})
        AND condition_id IS NOT NULL
        AND condition_id != ''
      LIMIT 500
    `, { maxRows: 500, retries: 3 });
    console.log(` ${cidRows.length} mappings`);
  } catch (e) {
    console.log(` mapping query failed: ${e.message}`);
  }

  if (!cidRows.length) return new Map();

  const cidMap = new Map(cidRows.map(r => [r.market_id, r.condition_id]));

  // Step 2: condition_id -> market metadata via Gamma API
  const conditionIds = cidRows.map(r => r.condition_id);
  process.stdout.write(`  [names] Gamma API (${conditionIds.length} condition IDs)...`);
  const gammaMarkets = await gamma.fetchByConditionIds(conditionIds);
  console.log(` ${gammaMarkets.length} resolved`);

  // Build conditionId -> market info map
  const marketInfoMap = new Map();
  for (const m of gammaMarkets) {
    if (m.conditionId) {
      const eventSlug = (m.events && m.events[0] && m.events[0].slug) || m.slug || '';
      marketInfoMap.set(m.conditionId, {
        question:       m.question  || '',
        slug:           eventSlug,
        category:       m.category  || '',
        end_date:       m.endDate   ? m.endDate.slice(0, 10) : '',
        polymarket_url: eventSlug ? `https://polymarket.com/event/${eventSlug}` : '',
      });
    }
  }

  // market_id -> market info
  const result = new Map();
  for (const [mid, cid] of cidMap) {
    const info = marketInfoMap.get(cid);
    if (info) result.set(mid, info);
  }
  return result;
}

// ── Main ─────────────────────────────────────────────────────
async function main() {
  const runAt = new Date().toISOString();
  console.log('\n' + '='.repeat(72));
  console.log('  Polymarket Daily Anomaly Detection');
  console.log('  Run at:', runAt);
  console.log('='.repeat(72));

  // Health check
  const ok = await mcp.ping();
  if (!ok) {
    console.error('  [ERROR] MCP service unavailable. Set MCP_URL env var if needed.');
    process.exit(1);
  }
  console.log('  [MCP] Connected');

  const activeMarkets = await getActiveMarkets();

  const insider   = await runInsiderWatch(activeMarkets);
  await sleep(500);
  const whaleWars = await runWhaleWars();
  await sleep(500);
  const blackSwan = await runBlackSwan();

  // ── Market name enrichment ──────────────────────────────────
  sep('Market Name Resolution');
  const allMids = [
    ...insider.map(r => r.market_id),
    ...whaleWars.map(r => r.market_id),
    ...blackSwan.map(r => r.market_id),
  ].filter((v, i, a) => a.indexOf(v) === i);

  const nameMap = await enrichMarketNames(allMids);
  console.log(`  Resolved ${nameMap.size} / ${allMids.length} market names`);

  // Attach names + domain
  function attachName(r) {
    const info = nameMap.get(r.market_id) || {};
    return {
      ...r,
      market_name: info.question     || '',
      market_url:  info.polymarket_url || '',
      market_end:  info.end_date     || '',
      domain:      marketToDomain(info.question, info.category) || 'GEN',
    };
  }

  const insiderNamed   = insider.map(attachName);
  const whaleWarsNamed = whaleWars.map(attachName);
  let   blackSwanNamed = blackSwan.map(attachName);

  // Black Swan: filter out completed sports events
  const today = runAt.slice(0, 10);
  const beforeFilter = blackSwanNamed.length;
  blackSwanNamed = blackSwanNamed.filter(r => {
    if (!['GEN', 'SPT'].includes(r.domain)) return true;
    if (r.market_end && r.market_end <= today) return false;
    return true;
  });
  if (beforeFilter !== blackSwanNamed.length) {
    console.log(`\n  [filter] Black Swan: ${beforeFilter} -> ${blackSwanNamed.length} (removed ${beforeFilter - blackSwanNamed.length} completed sports events)`);
  }

  // Print preview
  console.log('\n  Insider Watch:');
  insiderNamed.forEach(r => console.log(`    [${r.market_id.slice(0,8)}...] [${r.domain}] ${r.market_name || '(unknown)'}`));
  console.log('\n  Whale Wars:');
  whaleWarsNamed.forEach(r => console.log(`    [${r.market_id.slice(0,8)}...] [${r.domain}] ${r.market_name || '(unknown)'}`));
  console.log('\n  Black Swan (top 5):');
  blackSwanNamed.slice(0,5).forEach(r => console.log(`    [${r.market_id.slice(0,8)}...] [${r.domain}] ${r.market_name || '(unknown)'}`));

  // ── Summary ──────────────────────────────────────────────────
  sep('Summary');
  console.log(`  Insider Watch: ${insiderNamed.length} events`);
  console.log(`  Whale Wars:    ${whaleWarsNamed.length} pairs`);
  console.log(`  Black Swan:    ${blackSwanNamed.length} events`);

  // ── JSON export ──────────────────────────────────────────────
  const output = {
    generated_at: runAt,
    date:         runAt.slice(0, 10),
    config: CFG,
    results: {
      insider_watch: insiderNamed.map(r => ({
        market_id:      r.market_id,
        market_name:    r.market_name,
        market_url:     r.market_url,
        market_end:     r.market_end,
        domain:         r.domain,
        wallet_count:   r.wallet_count || 1,
        wallets:        r.wallets || [r.wallet_address],
        wallet_address: r.wallet_address,
        buy_price:      r.buy_price,
        trade_usd:      r.trade_usd,
        avg_mkt_usd:    r.avg_mkt_usd,
        x_avg:          r.x_avg,
        wallet_trades:  r.wallet_trades,
        traded_at:      r.traded_at,
        is_aggregated:  r.is_aggregated || false,
      })),
      whale_wars: whaleWarsNamed.map(r => ({
        market_id:     r.market_id,
        market_name:   r.market_name,
        market_url:    r.market_url,
        market_end:    r.market_end,
        domain:        r.domain,
        vol_24h_usd:   r.vol_24h_usd,
        buyer_wallet:  r.buyer_wallet,
        buyer_usd:     r.buyer_usd,
        seller_wallet: r.seller_wallet,
        seller_usd:    r.seller_usd,
        total_bet_usd: r.total_bet_usd,
      })),
      black_swan: blackSwanNamed.map(r => ({
        market_id:   r.market_id,
        market_name: r.market_name,
        market_url:  r.market_url,
        market_end:  r.market_end,
        domain:      r.domain,
        time_bucket: r.time_bucket,
        min_price:   r.min_price,
        max_price:   r.max_price,
        first_price: r.first_price,
        last_price:  r.last_price,
        direction:   r.direction,
        change_pp:   r.change_pp,
        vol_2h_usd:  r.vol_2h_usd,
        trades_2h:   r.trades_2h,
      })),
    },
  };

  const filename = `content-signals-${runAt.slice(0, 10)}.json`;
  fs.writeFileSync(filename, JSON.stringify(output, null, 2), 'utf-8');
  const kb = (JSON.stringify(output).length / 1024).toFixed(1);
  console.log(`\n  [export] ${filename}  (${kb} KB)`);
}

main().catch(err => {
  console.error('\n[FATAL]', err.message);
  process.exit(1);
});


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)