← 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
};