← back to Ken
kalshi-dash/follow-the-winners.mjs
116 lines
// follow-the-winners.mjs — Ken v2 signal (Steve, 2026-08-03, TK-10166)
// READ-ONLY. "Bet what the WINNING faux portfolios are doing."
//
// Win-record source priority (churn-immune first):
// 1) ken_daily_pnl — durable per-portfolio rollup (wins/losses/cumulative_pnl); survives
// the raw-trade-table churn that a `pm2 restart ken` sim-replay causes.
// 2) ken_portfolios — live counters, fallback while daily_pnl is still empty (rebuilding).
// Direction source: ken_portfolio_trades open positions of the proven winners (live).
//
// Gate: a portfolio must clear WIN_GATE win-rate over >= MIN_RESOLVED resolved trades
// before we ever follow it. Emits a ranked signal; places ZERO orders, writes nothing.
import pg from 'pg';
const ken = new pg.Pool({ connectionString: process.env.KEN_DATABASE_URL || 'postgresql://localhost:5432/ken' });
const MIN_RESOLVED = Number(process.env.FTW_MIN_RESOLVED || 20);
const WIN_GATE = Number(process.env.FTW_WIN_GATE || 0.60);
const TOP_K = Number(process.env.FTW_TOP_K || 8);
const num = v => Number(v || 0);
// --- 1) load the durable win-record, preferring ken_daily_pnl ---
async function loadWinRecord() {
const dp = await ken.query(`
SELECT p.id, p.name, p.strategy,
COALESCE(SUM(d.wins),0) AS w,
COALESCE(SUM(d.losses),0) AS l,
COALESCE(SUM(d.daily_pnl_cents),0) AS net_c -- SUM(daily), not the broken cumulative col
FROM ken_portfolios p LEFT JOIN ken_daily_pnl d ON d.portfolio_id = p.id
GROUP BY p.id, p.name, p.strategy`);
const anyDaily = dp.rows.some(r => num(r.w) + num(r.l) > 0);
if (anyDaily) return { src: 'ken_daily_pnl (durable)', rows: dp.rows };
// fallback: live counters (used while the durable rollup is still empty/rebuilding)
const lp = await ken.query(`
SELECT id, name, strategy, trades_won AS w, trades_lost AS l,
(total_returned_cents - total_invested_cents) AS net_c
FROM ken_portfolios`);
return { src: 'ken_portfolios (live fallback)', rows: lp.rows };
}
const { src, rows } = await loadWinRecord();
const scored = rows.map(p => {
const resolved = num(p.w) + num(p.l);
const winPct = resolved ? num(p.w) / resolved : 0;
const netC = num(p.net_c); // real accumulated realized P&L, cents
const perTrade = resolved ? netC / resolved : 0; // avg profit per resolved trade
// PROFIT-BASED score: dollars earned, dampened by small-sample uncertainty
const score = (netC / 100) * Math.log10(resolved + 1);
return { ...p, resolved, winPct, netC, perTrade, score };
});
// A "winner" must be PROFITABLE (net > 0), win >= gate, over a real sample.
const winners = scored
.filter(p => p.resolved >= MIN_RESOLVED && p.winPct >= WIN_GATE && p.netC > 0)
.sort((a, b) => b.netC - a.netC) // rank by actual profit
.slice(0, TOP_K);
console.log(`\nwin-record source: ${src}`);
const totalResolved = scored.reduce((s, p) => s + p.resolved, 0);
if (totalResolved === 0) {
console.log(`\n⏳ SIM REBUILDING — no resolved paper trades on record yet (a restart replays the`);
console.log(` simulation from scratch). No winners to follow. Re-run once the paper sim has`);
console.log(` accumulated resolved trades; this engine will light up automatically. Do NOT bet live.`);
await ken.end(); process.exit(0);
}
console.log(`=== PROVEN WINNERS (PROFITABLE: net>0, win% >= ${(WIN_GATE*100)|0}, resolved >= ${MIN_RESOLVED}) ===`);
if (!winners.length) {
console.log(` (none clear the bar — ${totalResolved} resolved trades on record, but no portfolio is`);
console.log(` BOTH >=${(WIN_GATE*100)|0}% AND net-profitable over >=${MIN_RESOLVED} trades. Do NOT bet live.)`);
await ken.end(); process.exit(0);
}
console.log(` ${'portfolio'.padEnd(12)} ${'net$'.padStart(7)} ${'¢/trade'.padStart(7)} ${'win%'.padStart(5)} W/L`);
for (const p of winners) {
console.log(` ${p.name.padEnd(12)} ${('$'+(p.netC/100).toFixed(2)).padStart(7)} ${(p.perTrade.toFixed(1)+'¢').padStart(7)} ` +
`${String(Math.round(p.winPct*100)).padStart(4)}% ${p.w}W/${p.l}L`);
}
// --- 2) winners' current open positions → weighted consensus per market ---
const winnerIds = winners.map(p => p.id);
const wById = Object.fromEntries(winners.map(p => [p.id, p]));
const { rows: open } = await ken.query(`
SELECT portfolio_id, market_id, market_title, direction, entry_price_cents
FROM ken_portfolio_trades WHERE status='open' AND portfolio_id = ANY($1)`, [winnerIds]);
const mkt = {};
for (const t of open) {
const m = mkt[t.market_id] ||= { title: t.market_title, dir: {}, holders: new Set(), prices: [] };
const w = Math.max(0.01, wById[t.portfolio_id]?.score || 0.01);
m.dir[t.direction] = (m.dir[t.direction] || 0) + w;
m.holders.add(t.portfolio_id);
m.prices.push(num(t.entry_price_cents));
}
const signals = Object.entries(mkt).map(([id, m]) => {
const entries = Object.entries(m.dir).sort((a, b) => b[1] - a[1]);
const [topDir, topW] = entries[0];
const totalW = entries.reduce((s, [, w]) => s + w, 0);
return { title: m.title, dir: topDir, agreement: totalW ? topW / totalW : 0,
holders: m.holders.size, avgPrice: Math.round(m.prices.reduce((s,p)=>s+p,0)/m.prices.length),
conviction: topW };
}).sort((a, b) => (b.conviction * b.agreement) - (a.conviction * a.agreement));
console.log(`\n=== LIVE-BET SIGNAL — what the winners hold open RIGHT NOW (ranked) ===`);
if (!signals.length) { console.log(' (winners hold no open positions this moment)'); }
else {
console.log(` ${'DIR'.padEnd(8)} ${'agree'.padStart(5)} ${'#win'.padStart(4)} ${'~price'.padStart(6)} market`);
for (const s of signals.slice(0, 15))
console.log(` ${s.dir.padEnd(8)} ${String(Math.round(s.agreement*100)).padStart(4)}% ` +
`${String(s.holders).padStart(4)} ${(s.avgPrice+'¢').padStart(6)} ${s.title.slice(0,52)}`);
console.log(`\n ${signals.length} markets carry a winner signal. A live re-arm would bet ONLY top`);
console.log(` unanimous-high-conviction rows, and ONLY after the sample/edge gate is committed.`);
}
await ken.end();