← 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 →