From babeb7605a21be31e5977d3f27be5a0247949899 Mon Sep 17 00:00:00 2001 From: multipleof4 Date: Mon, 16 Mar 2026 21:51:52 -0700 Subject: [PATCH] Feat: Add Early Fader mean reversion strategy --- lib/strategies/early-fader.js | 102 ++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 lib/strategies/early-fader.js diff --git a/lib/strategies/early-fader.js b/lib/strategies/early-fader.js new file mode 100644 index 0000000..9586003 --- /dev/null +++ b/lib/strategies/early-fader.js @@ -0,0 +1,102 @@ +import { BaseStrategy } from './base.js'; + +export class EarlyFaderStrategy extends BaseStrategy { + constructor(config = {}) { + super('early-fader', { + spikeThreshold: config.spikeThreshold || 82, // When a side spikes to this % + maxElapsedMin: config.maxElapsedMin || 6, // Only fade early spikes (first 6 mins) + betSize: config.betSize || 2, + slippage: config.slippage || 3, + cooldownMs: config.cooldownMs || 20000, + marketDurationMin: config.marketDurationMin || 15, + ...config + }); + + this._lastTrade = { + paper: { time: 0, ticker: null }, + live: { time: 0, ticker: null } + }; + } + + evaluate(state, caller = 'paper') { + if (!state || !this.enabled || !state.closeTime) return null; + + const track = this._lastTrade[caller] || this._lastTrade.paper; + const now = Date.now(); + + if (now - track.time < this.config.cooldownMs) return null; + if (state.ticker === track.ticker) return null; // 1 trade per market + + const closeTs = new Date(state.closeTime).getTime(); + const timeLeftMs = closeTs - now; + if (!Number.isFinite(timeLeftMs) || timeLeftMs <= 0) return null; + + const elapsedMin = (this.config.marketDurationMin * 60000 - timeLeftMs) / 60000; + + // Only trade in the designated early window + if (elapsedMin < 0 || elapsedMin > this.config.maxElapsedMin) return null; + + const { yesPct, noPct } = state; + const threshold = this.config.spikeThreshold; + + let targetSide = null; + let targetPrice = null; + + // If YES spikes early, we fade it by buying NO + if (yesPct >= threshold && yesPct <= 95) { + targetSide = 'no'; + targetPrice = noPct; + } + // If NO spikes early, we fade it by buying YES + else if (noPct >= threshold && noPct <= 95) { + targetSide = 'yes'; + targetPrice = yesPct; + } + + if (!targetSide) return null; + + // Ensure the opposite side is actually cheap (sanity check against weird book states) + if (targetPrice > (100 - threshold + 5)) return null; + + // For live trading, check actual orderbook liquidity to avoid blind fills + if (caller === 'live') { + const bestAsk = this._getBestAsk(state, targetSide); + if (bestAsk == null) return null; + if (bestAsk > targetPrice + this.config.slippage) return null; + } + + const spikeSide = targetSide === 'no' ? 'YES' : 'NO'; + const signal = { + strategy: this.name, + side: targetSide, + price: targetPrice, + maxPrice: Math.min(targetPrice + this.config.slippage, 40), + size: this.config.betSize, + reason: `Fading early spike (t+${elapsedMin.toFixed(1)}m): ${spikeSide} > ${threshold}¢. Bought ${targetSide.toUpperCase()} @ ${targetPrice}¢`, + ticker: state.ticker + }; + + track.time = now; + track.ticker = state.ticker; + return signal; + } + + _getBestAsk(state, side) { + const ob = state.orderbook; + if (!ob) return null; + + const oppSide = side === 'yes' ? 'no' : 'yes'; + if (ob[oppSide]?.length) { + const bestOppBid = ob[oppSide][0]?.[0]; + if (bestOppBid != null) return 100 - bestOppBid; + } + return null; + } + + toJSON() { + return { + ...super.toJSON(), + lastTradeTicker: this._lastTrade.live.ticker || this._lastTrade.paper.ticker + }; + } +}