← back to Ken

src/lib/risk.js

157 lines

const { query } = require('./db');

async function getRiskState() {
  const res = await query('SELECT * FROM risk_state ORDER BY id DESC LIMIT 1');
  return res.rows[0] || null;
}

async function updateRiskState(updates) {
  const state = await getRiskState();
  if (!state) return null;

  const fields = [];
  const values = [];
  let idx = 1;

  for (const [key, val] of Object.entries(updates)) {
    fields.push(`${key} = $${idx}`);
    values.push(val);
    idx++;
  }
  fields.push(`updated_at = NOW()`);
  values.push(state.id);

  await query(`UPDATE risk_state SET ${fields.join(', ')} WHERE id = $${idx}`, values);
  return getRiskState();
}

async function getBankroll() {
  const res = await query('SELECT balance_after FROM bankroll_ledger ORDER BY ts DESC LIMIT 1');
  return res.rows[0]?.balance_after || 50;
}

async function getDailyPnL() {
  const res = await query(`
    SELECT COALESCE(SUM(pnl), 0) as daily_pnl
    FROM trades
    WHERE match_time >= CURRENT_DATE
    AND match_time < CURRENT_DATE + INTERVAL '1 day'
  `);
  return parseFloat(res.rows[0]?.daily_pnl || 0);
}

async function getOpenExposure() {
  const res = await query(`
    SELECT COALESCE(SUM(size_usd), 0) as exposure
    FROM orders
    WHERE status IN ('pending', 'open', 'partial')
  `);
  return parseFloat(res.rows[0]?.exposure || 0);
}

function calculateEdge(pYes, marketMid, feeBps = 0) {
  const feeAdjust = feeBps / 10000;
  const edgeYes = pYes - marketMid - feeAdjust;
  const edgeNo = (1 - pYes) - (1 - marketMid) - feeAdjust;
  return { edgeYes, edgeNo, bestSide: edgeYes > edgeNo ? 'YES' : 'NO', bestEdge: Math.max(edgeYes, edgeNo) };
}

function kellySize(edge, odds, fraction = 0.1) {
  // Fractional Kelly: f* = fraction * (p*b - q) / b
  // where p = prob of winning, b = odds, q = 1-p
  if (edge <= 0) return 0;
  const p = 0.5 + edge / 2; // approximate
  const b = odds;
  const q = 1 - p;
  const fullKelly = (p * b - q) / b;
  return Math.max(0, fullKelly * fraction);
}

async function evaluateTrade({ pYes, marketMid, spread, liquidity, conditionId }) {
  const state = await getRiskState();
  if (!state) return { approved: false, reason: 'no_risk_state' };

  const config = state.config;
  const bankroll = parseFloat(state.bankroll);
  const dailyPnl = await getDailyPnL();
  const openExposure = await getOpenExposure();

  // Kill switch check
  if (state.kill_switch_active) {
    return { approved: false, reason: `kill_switch: ${state.kill_reason}` };
  }

  // Paper mode check
  const paperMode = config.paper_mode !== false;

  // Edge calculation
  const { bestEdge, bestSide } = calculateEdge(pYes, marketMid);

  // Edge threshold
  if (bestEdge < config.min_edge_threshold) {
    return { approved: false, reason: `insufficient_edge: ${bestEdge.toFixed(4)} < ${config.min_edge_threshold}`, edge: bestEdge };
  }

  // Liquidity check
  if (liquidity < config.min_liquidity) {
    return { approved: false, reason: `low_liquidity: ${liquidity} < ${config.min_liquidity}`, edge: bestEdge };
  }

  // Daily loss limit
  if (dailyPnl < -(config.max_daily_loss_usd || 5)) {
    return { approved: false, reason: `daily_loss_limit: ${dailyPnl.toFixed(2)}`, edge: bestEdge };
  }

  // Open exposure limit
  if (openExposure >= (config.max_open_exposure_usd || 5)) {
    return { approved: false, reason: `exposure_limit: ${openExposure.toFixed(2)}`, edge: bestEdge };
  }

  // Drawdown limit
  if (state.max_drawdown_pct >= (config.max_drawdown_pct || 20)) {
    return { approved: false, reason: `drawdown_limit: ${state.max_drawdown_pct.toFixed(1)}%`, edge: bestEdge };
  }

  // Sizing
  const kellyFraction = kellySize(bestEdge, 1 / marketMid, config.fractional_kelly || 0.1);
  const kellyBet = kellyFraction * bankroll;
  const fixedBet = Math.min(0.25, bankroll * 0.005);
  const betSize = Math.min(kellyBet || fixedBet, fixedBet, config.max_bet_usd || 0.50);
  const finalBet = Math.max(0.01, Math.round(betSize * 100) / 100);

  return {
    approved: true,
    paper_mode: paperMode,
    side: bestSide,
    edge: bestEdge,
    bet_usd: finalBet,
    kelly_fraction: kellyFraction,
    bankroll,
    daily_pnl: dailyPnl,
    open_exposure: openExposure,
    reasoning: {
      edge_threshold: config.min_edge_threshold,
      edge_actual: bestEdge,
      spread,
      liquidity,
      kelly_raw: kellyFraction,
      bet_capped: finalBet
    }
  };
}

async function activateKillSwitch(reason) {
  await updateRiskState({ kill_switch_active: true, kill_reason: reason });
  return { activated: true, reason };
}

async function deactivateKillSwitch() {
  await updateRiskState({ kill_switch_active: false, kill_reason: null });
  return { deactivated: true };
}

module.exports = {
  getRiskState, updateRiskState, getBankroll, getDailyPnL, getOpenExposure,
  calculateEdge, kellySize, evaluateTrade, activateKillSwitch, deactivateKillSwitch
};