[object Object]

← back to Bertha

Ken: fix signal clustering, dedup, BUY_NO risk bug, persist config

0ae54847691eda47cc78742744b281b46b9296dc · 2026-02-16 04:39:27 +0000 · DW Commit Agent

- Signal deduplication: skip inserting if same market+direction signal
  exists within 30 min unless edge changed >2%
- Multi-outcome clustering fix: KXCABOUT/KXFEDCHAIRNOM markets now
  require headline to mention specific person entity (RFK, Noem, etc.)
  instead of matching all siblings to generic topic articles
- BUY_NO risk approval: fixed time decay using Math.abs() so negative
  edges aren't unfairly penalized
- TRADE_CONFIG persistence: new ken_config table, save on change,
  load on startup with merge to preserve new code defaults

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Files touched

Diff

commit 0ae54847691eda47cc78742744b281b46b9296dc
Author: DW Commit Agent <claude@dw-agents.com>
Date:   Mon Feb 16 04:39:27 2026 +0000

    Ken: fix signal clustering, dedup, BUY_NO risk bug, persist config
    
    - Signal deduplication: skip inserting if same market+direction signal
      exists within 30 min unless edge changed >2%
    - Multi-outcome clustering fix: KXCABOUT/KXFEDCHAIRNOM markets now
      require headline to mention specific person entity (RFK, Noem, etc.)
      instead of matching all siblings to generic topic articles
    - BUY_NO risk approval: fixed time decay using Math.abs() so negative
      edges aren't unfairly penalized
    - TRADE_CONFIG persistence: new ken_config table, save on change,
      load on startup with merge to preserve new code defaults
    
    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 kalshi-dash/server.js | 161 +++++++++++++++++++++++++++++++++++++++++++++++---
 1 file changed, 154 insertions(+), 7 deletions(-)

diff --git a/kalshi-dash/server.js b/kalshi-dash/server.js
index 66a2721..5d598b0 100644
--- a/kalshi-dash/server.js
+++ b/kalshi-dash/server.js
@@ -778,6 +778,7 @@ const routes = {
         // ── Toggle Auto-Trader ──
         case 'trader_toggle': {
           TRADE_CONFIG.enabled = !TRADE_CONFIG.enabled;
+          saveTradeConfig();
           json(res, { success: true, enabled: TRADE_CONFIG.enabled, message: `Auto-trader ${TRADE_CONFIG.enabled ? 'ENABLED' : 'DISABLED'}` });
           console.log(`[Ken] Auto-trader ${TRADE_CONFIG.enabled ? 'ENABLED' : 'DISABLED'} via API`);
           break;
@@ -1158,6 +1159,7 @@ const routes = {
 
       if (body.action === 'toggle_auto_trader') {
         TRADE_CONFIG.enabled = !TRADE_CONFIG.enabled;
+        saveTradeConfig();
         console.log(`[Ken] Auto-trader ${TRADE_CONFIG.enabled ? 'ENABLED' : 'DISABLED'}`);
         return json(res, { success: true, auto_trader_enabled: TRADE_CONFIG.enabled });
       }
@@ -1168,6 +1170,7 @@ const routes = {
         if (body.max_open_positions !== undefined) TRADE_CONFIG.max_open_positions = Math.max(1, Math.min(20, body.max_open_positions));
         if (body.min_confidence !== undefined) TRADE_CONFIG.min_confidence = Math.max(50, Math.min(99, body.min_confidence));
         if (body.daily_loss_limit_cents !== undefined) TRADE_CONFIG.daily_loss_limit_cents = Math.max(100, Math.min(50000, body.daily_loss_limit_cents));
+        saveTradeConfig();
         return json(res, { success: true, trade_config: TRADE_CONFIG });
       }
 
@@ -3045,6 +3048,68 @@ const MARKET_CATEGORY_KEYWORDS = {
   'nasa': ['nasa', 'artemis', 'moon', 'mars mission'],
 };
 
+// Map ticker suffixes to searchable names for multi-outcome events
+const TICKER_ENTITY_MAP = {
+  // KXCABOUT - Cabinet members
+  'RFK':  ['rfk', 'kennedy', 'robert kennedy', 'rfk jr', 'health and human'],
+  'KNOE': ['noem', 'kristi noem', 'homeland security'],
+  'SDUF': ['duffy', 'sean duffy', 'transportation'],
+  'BROL': ['rollins', 'brooke rollins', 'agriculture'],
+  'JRAT': ['ratcliffe', 'john ratcliffe', 'cia director'],
+  'SBES': ['bessent', 'scott bessent', 'treasury secretary'],
+  'MRUB': ['rubio', 'marco rubio', 'state department', 'secretary of state'],
+  'PHEG': ['hegseth', 'pete hegseth', 'defense secretary'],
+  'TGAB': ['gabbard', 'tulsi gabbard', 'intelligence director'],
+  'MWAL': ['waltz', 'mike waltz', 'national security'],
+  'HLUT': ['lutnick', 'howard lutnick', 'commerce secretary'],
+  'DBUR': ['burgum', 'doug burgum', 'interior secretary'],
+  'CWRI': ['chris wright', 'energy secretary'],
+  'LZEL': ['zeldin', 'lee zeldin', 'epa administrator'],
+  'DCOL': ['collins', 'doug collins', 'veterans affairs'],
+  'LMCM': ['mcmahon', 'linda mcmahon', 'education secretary'],
+  'PBON': ['bondi', 'pam bondi', 'attorney general'],
+  'RVOU': ['vought', 'russell vought', 'omb director'],
+  'STUR': ['turner', 'scott turner', 'hud secretary'],
+  'LCDR': ['cruz', 'linda cruz', 'sba administrator'],
+  'MKRA': ['kratsios', 'michael kratsios'],
+  'SWIL': ['wilson', 'sean wilson'],
+  // KXFEDCHAIRNOM - Fed Chair nominees
+  'JS':   ['dimon', 'jamie dimon'],
+  'KW':   ['warsh', 'kevin warsh'],
+  'KH':   ['hassett', 'kevin hassett'],
+  'CWAL': ['waller', 'chris waller', 'christopher waller'],
+  'MBOW': ['bowman', 'michelle bowman'],
+};
+
+function extractMarketEntity(market) {
+  // Try subtitle first (Kalshi puts the specific answer there)
+  if (market.subtitle) {
+    return market.subtitle.toLowerCase().split(/\s+/).filter(w => w.length > 2);
+  }
+  // Try extracting from ticker suffix
+  const parts = market.ticker.split('-');
+  const suffix = parts[parts.length - 1];
+  if (TICKER_ENTITY_MAP[suffix]) return TICKER_ENTITY_MAP[suffix];
+  // Try the yes_sub_title field
+  if (market.yes_sub_title) {
+    return market.yes_sub_title.toLowerCase().split(/\s+/).filter(w => w.length > 2);
+  }
+  return [];
+}
+
+function isMultiOutcomeEvent(ticker) {
+  // Multi-outcome events have a common prefix followed by person/answer suffix
+  // e.g., KXCABOUT-29JAN-RFK, KXCABOUT-29JAN-KNOE all share KXCABOUT-29JAN
+  const parts = ticker.split('-');
+  return parts.length >= 3; // Event prefix + date + answer
+}
+
+function getEventPrefix(ticker) {
+  const parts = ticker.split('-');
+  if (parts.length >= 3) return parts.slice(0, -1).join('-');
+  return ticker;
+}
+
 function mapNewsToMarkets(headline, activeMarkets) {
   const headlineLower = headline.toLowerCase();
   const matches = [];
@@ -3060,9 +3125,17 @@ function mapNewsToMarkets(headline, activeMarkets) {
     }
   }
 
-  // Step 2: Find matching Kalshi markets by title/subtitle keywords
+  // Step 2: Group markets by event to detect multi-outcome events
+  const eventGroups = {};
   for (const market of activeMarkets) {
-    const marketText = `${market.title} ${market.subtitle || ''}`.toLowerCase();
+    const prefix = getEventPrefix(market.ticker);
+    if (!eventGroups[prefix]) eventGroups[prefix] = [];
+    eventGroups[prefix].push(market);
+  }
+
+  // Step 3: Find matching Kalshi markets by title/subtitle keywords
+  for (const market of activeMarkets) {
+    const marketText = `${market.title} ${market.subtitle || ''} ${market.yes_sub_title || ''}`.toLowerCase();
     let matchScore = 0;
 
     // Direct keyword overlap
@@ -3089,6 +3162,22 @@ function mapNewsToMarkets(headline, activeMarkets) {
       if (headlineLower.includes(fig) && marketText.includes(fig)) matchScore += 5;
     }
 
+    // MULTI-OUTCOME EVENT HANDLING: For events with multiple answer markets,
+    // require headline to mention the specific answer entity, not just the general topic
+    const eventPrefix = getEventPrefix(market.ticker);
+    const siblings = eventGroups[eventPrefix] || [];
+    if (siblings.length > 1 && isMultiOutcomeEvent(market.ticker)) {
+      const entityTerms = extractMarketEntity(market);
+      const entityMatch = entityTerms.some(term => headlineLower.includes(term));
+      if (entityMatch) {
+        matchScore += 8; // Strong boost for specific entity match
+      } else {
+        // Headline mentions the topic but NOT this specific answer — reject entirely
+        // With 20+ siblings, even small scores cause massive clustering
+        matchScore = 0;
+      }
+    }
+
     if (matchScore >= 3) {
       matches.push({ market, matchScore });
     }
@@ -3205,12 +3294,13 @@ function riskCheck(signal, currentExposure = {}) {
     reasons.push(`Low liquidity: ${signal.volume24h || 0} < ${RISK_LIMITS.minLiquidity} volume`);
   }
 
-  // Time decay
+  // Time decay — use absolute edge values so BUY_NO signals aren't unfairly penalized
   const signalAgeHours = (Date.now() - (signal.timestamp || Date.now())) / 3600000;
-  const decayedEdge = signal.expectedEdge - (RISK_LIMITS.timeDecayPenalty * signalAgeHours);
-  if (decayedEdge < RISK_LIMITS.minEdgeThreshold) {
+  const absEdge = Math.abs(signal.expectedEdge);
+  const decayedAbsEdge = absEdge - (RISK_LIMITS.timeDecayPenalty * signalAgeHours);
+  if (decayedAbsEdge < RISK_LIMITS.minEdgeThreshold) {
     approved = false;
-    reasons.push(`Edge decayed from ${(signal.expectedEdge * 100).toFixed(1)}% to ${(decayedEdge * 100).toFixed(1)}%`);
+    reasons.push(`Edge decayed from ${(absEdge * 100).toFixed(1)}% to ${(decayedAbsEdge * 100).toFixed(1)}%`);
   }
 
   // Size recommendation based on edge and confidence
@@ -3305,6 +3395,19 @@ async function signalPipeline(activeMarkets) {
       // Step 6: Generate signal if edge exceeds threshold
       if (Math.abs(edge) >= RISK_LIMITS.minEdgeThreshold) {
         const direction = edge > 0 ? 'BUY_YES' : 'BUY_NO';
+
+        // Dedup: skip if we already generated a signal for this market+direction in last 30 min
+        const recentDup = await kenQ(`
+          SELECT id, expected_edge, confidence FROM ken_strategy_signals
+          WHERE market_id = $1 AND direction = $2 AND created_at > NOW() - INTERVAL '30 minutes'
+          ORDER BY created_at DESC LIMIT 1
+        `, [ticker, direction]);
+        if (recentDup.rows.length > 0) {
+          const prevEdge = parseFloat(recentDup.rows[0].expected_edge);
+          // Only re-insert if edge changed significantly (>2% shift)
+          if (Math.abs(Math.abs(edge) - Math.abs(prevEdge)) < 0.02) continue;
+        }
+
         // Improved confidence: wider spread, source diversity matters, edge has non-linear impact
         const edgeScore = Math.min(1, Math.pow(Math.abs(edge) / 0.15, 1.5)); // Non-linear: big edges count much more
         const volumeScore = Math.min(1, numArticles / 8); // 8+ articles for max
@@ -3500,6 +3603,49 @@ const TRADE_CONFIG = {
   cooldown_minutes: 30,        // Don't re-trade same ticker within 30 min
 };
 
+// ── TRADE_CONFIG Persistence ──
+async function initConfigTable() {
+  try {
+    await kenQ(`
+      CREATE TABLE IF NOT EXISTS ken_config (
+        key TEXT PRIMARY KEY,
+        value JSONB NOT NULL,
+        updated_at TIMESTAMPTZ DEFAULT NOW()
+      )
+    `);
+  } catch (e) {
+    console.log(`[Ken] Config table init error: ${e.message}`);
+  }
+}
+
+async function loadTradeConfig() {
+  try {
+    await initConfigTable();
+    const { rows } = await kenQ("SELECT value FROM ken_config WHERE key = 'trade_config'");
+    if (rows.length > 0) {
+      const saved = rows[0].value;
+      // Merge saved values into TRADE_CONFIG (preserves new keys added in code)
+      for (const [k, v] of Object.entries(saved)) {
+        if (k in TRADE_CONFIG) TRADE_CONFIG[k] = v;
+      }
+      console.log(`[Ken] Loaded TRADE_CONFIG from DB: budget=$${(TRADE_CONFIG.budget_cents/100).toFixed(0)}, conf=${TRADE_CONFIG.min_confidence}%, enabled=${TRADE_CONFIG.enabled}`);
+    }
+  } catch (e) {
+    console.log(`[Ken] Failed to load trade config: ${e.message}`);
+  }
+}
+
+async function saveTradeConfig() {
+  try {
+    await kenQ(`
+      INSERT INTO ken_config (key, value, updated_at) VALUES ('trade_config', $1, NOW())
+      ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()
+    `, [JSON.stringify(TRADE_CONFIG)]);
+  } catch (e) {
+    console.log(`[Ken] Failed to save trade config: ${e.message}`);
+  }
+}
+
 async function getTraderState() {
   try {
     const openTrades = await kenQ('SELECT * FROM ken_trades WHERE status = $1 ORDER BY created_at DESC', ['open']);
@@ -4354,10 +4500,11 @@ server.listen(PORT, '0.0.0.0', () => {
     if (global.gc) global.gc();
   }
 
-  // Load stored Kalshi env preference from DB
+  // Load stored Kalshi env preference and trade config from DB
   refreshKalshiEnv().then(() => {
     console.log(`[Ken] Kalshi environment: ${KALSHI_ENV.toUpperCase()} (${KALSHI_BASE_URL})`);
   });
+  loadTradeConfig();
 
   // Initial: run reddit sweep first, THEN market scan (never concurrent)
   setTimeout(async () => {

← bf4915a Ken agent: signal quality fix, live trading toggle, OOM fix,  ·  back to Bertha  ·  Ken: fix entity extraction for multi-outcome markets 40cb0ac →