← back to Ken

kalshi-dash/scripts/reconcile-canary.mjs

88 lines

#!/usr/bin/env node
// Ken paper-sim RECONCILIATION CANARY (born 2026-08-03 after the bigint-string over-credit bug).
// Read-only. Verifies the ken_portfolios rollup columns still equal the authoritative per-trade
// ledger, and that balance = seed - invested + returned. Any divergence beyond a small rounding
// tolerance means a credit/settle bug has crept back in (the exact class of the 441x over-credit
// where node-postgres bigint-as-string made `cost_cents + pnl` concatenate).
//
// Exit 0 = reconciled (silent-ish). Exit 1 = DIVERGENCE (prints ALERT). Never writes anything.
// Run: KEN_DATABASE_URL=... node scripts/reconcile-canary.mjs
import pg from 'pg';

const TOL_CENTS = 200; // $2 tolerance absorbs benign rounding; the bug produced 100x+ divergence
const url = process.env.KEN_DATABASE_URL;
if (!url) { console.error('reconcile-canary: KEN_DATABASE_URL not set'); process.exit(2); }

const pool = new pg.Pool({ connectionString: url });

// TK-11999: local Postgres restarts (brew/launchd) made this canary exit 2 when a tick landed
// mid-restart ("database system is starting up", socket ENOENT, ECONNREFUSED with an empty
// message). Retry ONLY those transient connection states with backoff; a persistent failure
// still exits 2 so a genuinely dead DB stays visible.
const TRANSIENT = new Set(['57P03', '57P01', '57P02', 'ECONNREFUSED', 'ENOENT', 'ECONNRESET']);
const isTransient = (e) => TRANSIENT.has(e?.code) ||
  (Array.isArray(e?.errors) && e.errors.some(x => TRANSIENT.has(x?.code))) ||
  /starting up|shutting down/i.test(e?.message || '');
async function queryWithRetry(sql, attempts = 6) {
  for (let i = 1; ; i++) {
    try { return await pool.query(sql); }
    catch (e) {
      if (i >= attempts || !isTransient(e)) throw e;
      const waitMs = Math.min(5000 * i, 20000);
      console.error(`[reconcile-canary] transient db error (${e.code || 'no-code'}: ${e.message || 'empty'}) — retry ${i}/${attempts - 1} in ${waitMs / 1000}s`);
      await new Promise(r => setTimeout(r, waitMs));
    }
  }
}

try {
  const { rows: [r] } = await queryWithRetry(`
    SELECT
      (SELECT COALESCE(SUM(total_returned_cents),0) FROM ken_portfolios)                       AS rollup_returned,
      (SELECT COALESCE(SUM(CASE WHEN pnl_cents>0 THEN cost_cents+pnl_cents
                                WHEN pnl_cents=0 AND resolved_at IS NOT NULL THEN cost_cents
                                ELSE 0 END),0) FROM ken_portfolio_trades)                       AS ledger_returned,
      (SELECT COALESCE(SUM(total_invested_cents),0) FROM ken_portfolios)                        AS rollup_invested,
      (SELECT COALESCE(SUM(cost_cents),0) FROM ken_portfolio_trades)                            AS ledger_invested,
      (SELECT COALESCE(SUM(balance_cents),0) FROM ken_portfolios)                               AS balance,
      (SELECT COALESCE(SUM(starting_balance_cents),0) FROM ken_portfolios)                      AS seed
  `);

  // pg returns bigint as string — coerce (the very bug this canary guards against).
  const n = (v) => Number(v);
  const rollupRet = n(r.rollup_returned), ledgerRet = n(r.ledger_returned);
  const rollupInv = n(r.rollup_invested), ledgerInv = n(r.ledger_invested);
  const balance = n(r.balance), seed = n(r.seed);
  const expectedBalance = seed - rollupInv + rollupRet;

  const checks = [
    { name: 'returned  (rollup vs ledger)', a: rollupRet, b: ledgerRet },
    { name: 'invested  (rollup vs ledger)', a: rollupInv, b: ledgerInv },
    { name: 'balance   (vs seed-inv+ret)',  a: balance,   b: expectedBalance },
  ];
  const fails = checks.filter(c => Math.abs(c.a - c.b) > TOL_CENTS);
  const usd = (c) => '$' + (c / 100).toFixed(2);

  if (fails.length === 0) {
    console.log(`[reconcile-canary] OK — returned=${usd(rollupRet)} invested=${usd(rollupInv)} balance=${usd(balance)} (all reconcile within $${TOL_CENTS/100})`);
    process.exit(0);
  }
  console.error('🚨 [reconcile-canary] DIVERGENCE — a credit/settle bug is back:');
  const lines = fails.map(c => `${c.name}: ${usd(c.a)} vs ${usd(c.b)} (off ${usd(Math.abs(c.a - c.b))}, ${(c.b !== 0 ? (c.a / c.b).toFixed(1) : '∞')}x)`);
  for (const l of lines) console.error('   ' + l);
  // Best-effort alert to CNCP parking-lot (local, no auth). Never let alerting failure mask the exit code.
  try {
    await fetch('http://127.0.0.1:3333/api/parking-lot', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ project: 'ken', title: 'Ken reconcile-canary DIVERGENCE (paper accounting bug back)', note: lines.join(' | ') }),
      signal: AbortSignal.timeout(5000),
    });
  } catch { /* CNCP down — the log + exit 1 still carry the alert */ }
  process.exit(1);
} catch (e) {
  console.error('[reconcile-canary] error:', e.code || 'no-code', e.message || String(e));
  process.exit(2);
} finally {
  await pool.end();
}