diff --git a/.env.example b/.env.example index 52717c2..2b57306 100644 --- a/.env.example +++ b/.env.example @@ -124,3 +124,45 @@ SNIPER_PRICE=0.01 # Shares per side — minimum Polymarket order size is 5 shares # At $0.01/share: 5 shares = $0.05 per side, $0.10 per market SNIPER_SHARES=5 + +# ───────────────────────────────────────────── +# ONESHOT ENGINE (oneshot.js / npm run oneshot-sim) +# Anti-Flip 5m microstructure execution engine. +# Evaluates book features on every tick and enters only when +# momentum, depth, and spread conditions align. +# ALWAYS test with DRY_RUN=true before going live. +# ───────────────────────────────────────────── + +# Comma-separated assets to monitor (e.g. btc,eth,sol) +ONESHOT_ASSETS=btc + +# Market duration: "5m" (5-minute) or "15m" (15-minute) +ONESHOT_DURATION=5m + +# Book poll interval in milliseconds (200–500ms recommended) +ONESHOT_POLL_INTERVAL_MS=300 + +# USDC risk per trade — size = floor(ONESHOT_BASE_RISK_USDC / entryPrice), min 5 shares +ONESHOT_BASE_RISK_USDC=5 + +# Take-profit in ticks above entry price (1 tick = tickSize, e.g. 0.01) +ONESHOT_TP_TICKS=1 + +# Minimum composite score to trigger entry (0–1, higher = more selective) +ONESHOT_SCORE_THRESHOLD=0.60 + +# Minimum shares at the best bid AND best ask for the depth hard gate +ONESHOT_MIN_TOP_SIZE=10 + +# ── Risk settings ────────────────────────────────────────────────────── +# Number of consecutive losses before entering cooldown +ONESHOT_MAX_CONSEC_LOSSES=2 + +# Number of market slots to skip during cooldown +ONESHOT_COOLDOWN_ROUNDS=3 + +# Maximum cumulative daily loss in USDC before halting all trading +ONESHOT_DAILY_LOSS_CAP=20 + +# Maximum milliseconds to wait for a FOK fill ack (timeout → cancel → IDLE) +ONESHOT_FILL_TIMEOUT_MS=800 diff --git a/package.json b/package.json index 67274c3..ef92826 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,10 @@ "mm-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/mm.js", "sniper": "DRY_RUN=false node src/sniper.js", "sniper-sim": "DRY_RUN=true node src/sniper.js", - "sniper-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/sniper.js" + "sniper-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/sniper.js", + "oneshot": "DRY_RUN=false node src/oneshot.js", + "oneshot-sim": "DRY_RUN=true node src/oneshot.js", + "oneshot-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/oneshot.js" }, "keywords": [ "polymarket", diff --git a/src/oneshot.js b/src/oneshot.js new file mode 100644 index 0000000..936965e --- /dev/null +++ b/src/oneshot.js @@ -0,0 +1,379 @@ +/** + * src/oneshot.js + * Anti-Flip 5m OneShot Engine — main orchestrator entry point. + * + * Wires all seven engine services together via the central EventBus and + * manages a per-market StateMachine lifecycle. + * + * Runtime sequence (per market, per tick): + * A → MarketFeedService emits 'snapshot' + * B → FeatureEngine processes snapshot, emits 'features' + * C+D → SignalEngine evaluates gates + score, emits 'signal' + * E → Orchestrator submits order on ENTER signal + * F → Fill handling (full / partial / timeout) + * G → PositionEngine evaluates exit on each snapshot + * H → RiskEngine updated on every close + * + * State machine (per market): + * IDLE → SETUP_READY → ORDER_PENDING → POSITION_OPEN → REDUCE_ONLY → IDLE + * ANY → COOLDOWN → IDLE + * ANY → HALTED (terminal for the session) + */ + +import { initClient, getClient } from './services/client.js'; +import logger from './utils/logger.js'; + +import eventBus from './oneshot/EventBus.js'; +import { StateMachine } from './oneshot/StateMachine.js'; +import { MarketFeedService } from './oneshot/MarketFeedService.js'; +import { FeatureEngine } from './oneshot/FeatureEngine.js'; +import { SignalEngine } from './oneshot/SignalEngine.js'; +import { ExecutionEngine } from './oneshot/ExecutionEngine.js'; +import { RiskEngine } from './oneshot/RiskEngine.js'; +import { PositionEngine } from './oneshot/PositionEngine.js'; +import { Telemetry } from './oneshot/Telemetry.js'; +import { State, Signal, ReasonCode } from './oneshot/constants.js'; + +// ── Configuration ────────────────────────────────────────────────────────────── +// All values read from .env. Sensible defaults are provided for optional fields. + +const cfg = { + assets: (process.env.ONESHOT_ASSETS || 'btc').split(',').map((s) => s.trim().toLowerCase()), + duration: process.env.ONESHOT_DURATION || '5m', + baseRiskUsdc: parseFloat(process.env.ONESHOT_BASE_RISK_USDC || '5'), + tpTicks: parseInt(process.env.ONESHOT_TP_TICKS || '1', 10), + scoreThreshold: parseFloat(process.env.ONESHOT_SCORE_THRESHOLD || '0.60'), + pollIntervalMs: parseInt(process.env.ONESHOT_POLL_INTERVAL_MS || '300', 10), + minTopSize: parseFloat(process.env.ONESHOT_MIN_TOP_SIZE || '10'), + maxConsecLosses: parseInt(process.env.ONESHOT_MAX_CONSEC_LOSSES || '2', 10), + cooldownRounds: parseInt(process.env.ONESHOT_COOLDOWN_ROUNDS || '3', 10), + dailyLossCap: parseFloat(process.env.ONESHOT_DAILY_LOSS_CAP || '20'), + fillTimeoutMs: parseInt(process.env.ONESHOT_FILL_TIMEOUT_MS || '800', 10), + dryRun: process.env.DRY_RUN !== 'false', +}; + +// ── Per-market state ─────────────────────────────────────────────────────────── + +/** @type {Map} */ +const stateMachines = new Map(); + +// ── Service instances ───────────────────────────────────────────────────────── + +let feedService; +let featureEngine; +let signalEngine; +let execEngine; +let riskEngine; +let posEngine; +let telemetry; + +// ── Entry point ─────────────────────────────────────────────────────────────── + +async function main() { + logger.success('=== OneShot Anti-Flip Engine starting ==='); + logger.info(`Assets: [${cfg.assets}] | Duration: ${cfg.duration} | DRY_RUN: ${cfg.dryRun}`); + logger.info(`Risk: baseRisk=$${cfg.baseRiskUsdc} | tpTicks=${cfg.tpTicks} | scoreMin=${cfg.scoreThreshold}`); + + await initClient(); + const client = getClient(); + + // Initialise all services + telemetry = new Telemetry(); + riskEngine = new RiskEngine({ + maxConsecLosses: cfg.maxConsecLosses, + cooldownRounds: cfg.cooldownRounds, + dailyLossCap: cfg.dailyLossCap, + }); + posEngine = new PositionEngine({ tpTicks: cfg.tpTicks }); + execEngine = new ExecutionEngine({ client, dryRun: cfg.dryRun, fillTimeoutMs: cfg.fillTimeoutMs }); + featureEngine = new FeatureEngine({ eventBus }); + signalEngine = new SignalEngine({ + eventBus, + scoreThreshold: cfg.scoreThreshold, + minTopSize: cfg.minTopSize, + }); + feedService = new MarketFeedService({ + client, + assets: cfg.assets, + duration: cfg.duration, + pollIntervalMs: cfg.pollIntervalMs, + eventBus, + }); + + // Wire orchestrator handlers + eventBus.on('signal', onSignal); + eventBus.on('snapshot', onSnapshotForPositionMgmt); + eventBus.on('state:transition', onStateTransition); + + await feedService.start(); + logger.success('OneShot Engine running — waiting for market signals...'); + + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); +} + +// ── Signal handler (Steps C/D/E/F) ─────────────────────────────────────────── + +/** + * Process a signal emitted by SignalEngine. + * Coordinates state transitions and order submission for the target market. + */ +async function onSignal(evt) { + const { marketSlug, signal, side, score, reason, snapshot, features } = evt; + + const sm = getOrCreateSM(marketSlug); + + // Log every evaluation tick for later analysis + const sideFeatures = side ? features[side] : (features.up ?? features.down ?? {}); + telemetry.logDecision({ + marketSlug, + ts: snapshot.ts, + tteSec: snapshot.tteSec, + spread: sideFeatures.spread ?? 0, + imbalance: sideFeatures.imbalance ?? 0, + slope: sideFeatures.midSlope6s ?? 0, + retrace: sideFeatures.retrace3s ?? 0, + depth: sideFeatures.depthTop3 ?? 0, + gatePass: signal !== Signal.NO_TRADE, + reasonCode: reason ?? '', + score, + action: signal, + }); + + if (signal === Signal.NO_TRADE) return; + + // Only enter from IDLE + if (!sm.is(State.IDLE)) return; + + // ── Step H pre-check: risk gate ──────────────────────────────────────── + const riskCheck = riskEngine.canTrade(); + + if (!riskCheck.ok) { + if (riskCheck.halted && sm.canTransitionTo(State.HALTED)) { + sm.transition(State.HALTED, ReasonCode.RISK_DAILY_CAP); + } else if (riskEngine.isCooldown()) { + riskEngine.decrementCooldown(); + } + return; + } + + // ── Step E: order submission ─────────────────────────────────────────── + const bookSide = side === 'up' ? snapshot.up : snapshot.down; + const entryPrice = bookSide.bestAsk; + + // Size per spec: floor(baseRiskUSDC / entryPrice), clamped to ≥ 5 shares + const rawSize = cfg.baseRiskUsdc / entryPrice; + const size = Math.max(5, Math.floor(rawSize)); + + logger.trade( + `OneShot ENTER | ${signal} | ${marketSlug} | ` + + `px=$${entryPrice} | size=${size} | score=${score.toFixed(3)} | tte=${snapshot.tteSec}s`, + ); + + sm.transition(State.SETUP_READY, 'signal_passed'); + + try { + sm.transition(State.ORDER_PENDING, 'submitting'); + + const result = await execEngine.submitBuy({ + tokenId: bookSide.tokenId, + size, + price: entryPrice, + marketSlug, + }); + + // Log order lifecycle + telemetry.logOrder({ + clientOrderId: result.orderId, + side: signal, + marketSlug, + px: entryPrice, + qty: size, + ackMs: result.ackMs, + fillMs: result.fillMs, + status: result.status, + }); + + // ── Step F: fill handling ────────────────────────────────────────── + if (result.status === 'filled') { + posEngine.open(marketSlug, { + tokenId: bookSide.tokenId, + side, + shares: result.filledSize, + entryPrice: result.avgFillPrice || entryPrice, + tickSize: snapshot.tickSize, + }); + sm.transition(State.POSITION_OPEN, 'fill_confirmed'); + logger.success( + `OneShot: position OPEN | ${marketSlug} | ` + + `${result.filledSize} shares @ $${(result.avgFillPrice || entryPrice).toFixed(4)}`, + ); + + } else if (result.status === 'partial' && result.filledSize > 0) { + if (snapshot.tteSec <= 25) { + // Immediate reduce-only: close the partial fill right away + logger.warn(`OneShot: partial fill + low TTE (${snapshot.tteSec}s) — reducing immediately`); + await execEngine.submitSell({ + tokenId: bookSide.tokenId, + size: result.filledSize, + price: bookSide.bestBid, + marketSlug, + }); + sm.transition(State.IDLE, ReasonCode.EXEC_PARTIAL_REDUCE); + } else { + // Accept partial and manage as a smaller position + posEngine.open(marketSlug, { + tokenId: bookSide.tokenId, + side, + shares: result.filledSize, + entryPrice: result.avgFillPrice || entryPrice, + tickSize: snapshot.tickSize, + }); + sm.transition(State.POSITION_OPEN, 'partial_fill_accepted'); + logger.warn(`OneShot: partial fill accepted | ${result.filledSize}/${size} shares`); + } + + } else { + // FOK timed out or was cancelled + logger.warn(`OneShot: no fill on ${marketSlug} — returning to IDLE`); + sm.transition(State.IDLE, ReasonCode.EXEC_TIMEOUT_NO_FILL); + } + + } catch (err) { + logger.error(`OneShot: order error on ${marketSlug} — ${err.message}`); + if (sm.is(State.ORDER_PENDING) || sm.is(State.SETUP_READY)) { + sm.transition(State.IDLE, ReasonCode.EXEC_SUBMIT_ERROR); + } + } +} + +// ── Position management handler (Step G) ────────────────────────────────────── + +/** + * Called on every snapshot tick. + * If the market has an open position, evaluates exit conditions and + * coordinates exits through ExecutionEngine. + */ +async function onSnapshotForPositionMgmt(snapshot) { + const { marketSlug, tteSec } = snapshot; + const sm = stateMachines.get(marketSlug); + if (!sm) return; + + // Remove state machines for fully expired markets + if (tteSec <= 0 && sm.is(State.IDLE)) { + stateMachines.delete(marketSlug); + return; + } + + if (!sm.is(State.POSITION_OPEN) && !sm.is(State.REDUCE_ONLY)) return; + + const pos = posEngine.getPosition(marketSlug); + if (!pos) { + // Position state is gone but SM isn't — recover gracefully + if (sm.canTransitionTo(State.IDLE)) sm.transition(State.IDLE, 'position_missing'); + return; + } + + const features = featureEngine.getLatest(marketSlug); + const bookSide = pos.side === 'up' ? snapshot.up : snapshot.down; + + // Evaluate exit conditions + const exitResult = posEngine.evaluateExit(marketSlug, snapshot, features); + + // Transition to REDUCE_ONLY when TTE window triggers + if (exitResult.isReduceOnly && sm.is(State.POSITION_OPEN)) { + sm.transition(State.REDUCE_ONLY, ReasonCode.EXIT_TIME_REDUCE); + } + + // Execute exit if required + if (exitResult.shouldExit) { + await flattenPosition(marketSlug, pos, bookSide, exitResult.reason, snapshot); + } +} + +// ── Flatten helper ──────────────────────────────────────────────────────────── + +async function flattenPosition(marketSlug, pos, bookSide, reason, snapshot) { + const sm = stateMachines.get(marketSlug); + if (!sm || (!sm.is(State.POSITION_OPEN) && !sm.is(State.REDUCE_ONLY))) return; + + const exitPrice = bookSide.bestBid; + + logger.warn(`OneShot: flattening ${marketSlug} | reason=${reason} | exitPx=$${exitPrice}`); + + try { + await execEngine.submitSell({ + tokenId: pos.tokenId, + size: pos.shares, + price: exitPrice, + marketSlug, + }); + + const exitData = posEngine.close(marketSlug, exitPrice); + riskEngine.recordResult(exitData.pnl); + + telemetry.logExit({ + marketSlug, + exitReason: reason, + entryPx: pos.entryPrice, + exitPx: exitPrice, + pnl: exitData.pnl, + shares: pos.shares, + }); + + // Determine next state after close + const { ok, halted } = riskEngine.canTrade(); + + if (halted && sm.canTransitionTo(State.HALTED)) { + sm.transition(State.HALTED, ReasonCode.RISK_DAILY_CAP); + } else if (!ok && riskEngine.isCooldown() && sm.canTransitionTo(State.COOLDOWN)) { + sm.transition(State.COOLDOWN, ReasonCode.RISK_CONSEC_LOSS); + } else { + sm.transition(State.IDLE, `closed_${reason}`); + } + + } catch (err) { + logger.error(`OneShot: flatten error on ${marketSlug} — ${err.message}`); + } +} + +// ── State transition logging ────────────────────────────────────────────────── + +function onStateTransition(evt) { + telemetry.logTransition(evt); + logger.info(`[SM] ${evt.marketSlug}: ${evt.from} → ${evt.to} | ${evt.reason}`); +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function getOrCreateSM(marketSlug) { + if (!stateMachines.has(marketSlug)) { + stateMachines.set(marketSlug, new StateMachine(marketSlug, eventBus)); + } + return stateMachines.get(marketSlug); +} + +// ── Graceful shutdown ───────────────────────────────────────────────────────── + +async function shutdown() { + logger.warn('OneShot: shutting down...'); + feedService?.stop(); + + const stats = riskEngine?.stats(); + if (stats) { + const sign = stats.dailyPnl >= 0 ? '+' : ''; + logger.money( + `Session summary | dailyPnl=${sign}$${stats.dailyPnl.toFixed(4)} | ` + + `consecLosses=${stats.consecLosses} | halted=${stats.halted}`, + ); + } + + process.exit(0); +} + +// ── Bootstrap ───────────────────────────────────────────────────────────────── + +main().catch((err) => { + logger.error(`OneShot fatal: ${err.message}`); + process.exit(1); +}); diff --git a/src/oneshot/EventBus.js b/src/oneshot/EventBus.js new file mode 100644 index 0000000..8173fdc --- /dev/null +++ b/src/oneshot/EventBus.js @@ -0,0 +1,22 @@ +/** + * EventBus.js + * Central event bus for the OneShot engine. + * All inter-service communication flows through this singleton. + * + * Event catalogue: + * snapshot MarketFeedService → FeatureEngine, orchestrator + * features FeatureEngine → SignalEngine, orchestrator + * signal SignalEngine → orchestrator + * state:transition StateMachine → orchestrator, Telemetry + */ + +import { EventEmitter } from 'events'; + +class OneShotEventBus extends EventEmitter {} + +const bus = new OneShotEventBus(); + +// Prevent memory-leak warnings for high subscriber counts across many markets +bus.setMaxListeners(50); + +export default bus; diff --git a/src/oneshot/ExecutionEngine.js b/src/oneshot/ExecutionEngine.js new file mode 100644 index 0000000..9c201f7 --- /dev/null +++ b/src/oneshot/ExecutionEngine.js @@ -0,0 +1,221 @@ +/** + * ExecutionEngine.js + * Steps E & F of the runtime sequence. + * + * Responsibilities: + * - Submit a limit-marketable FOK BUY order at bestAsk + * - Wait up to fillTimeoutMs for an ack/fill response + * - Return structured fill result (filled | partial | cancelled) + * - Submit market-sell (FOK) for exits and cut-losses + * - Place GTC limit-sell for take-profit orders + * + * In dry-run mode all calls short-circuit with simulated successful results. + */ + +import { Side, OrderType } from '@polymarket/clob-client'; +import logger from '../utils/logger.js'; + +const FILL_TIMEOUT_MS = 800; + +export class ExecutionEngine { + /** + * @param {Object} opts + * @param {import('@polymarket/clob-client').ClobClient} opts.client + * @param {boolean} opts.dryRun + * @param {number} [opts.fillTimeoutMs=800] + */ + constructor({ client, dryRun, fillTimeoutMs = FILL_TIMEOUT_MS }) { + this._client = client; + this._dryRun = dryRun; + this._fillTimeoutMs = fillTimeoutMs; + + /** Cache tick sizes to avoid repeated API calls */ + this._tickCache = new Map(); + } + + // ── Buy ─────────────────────────────────────────────────────────────────── + + /** + * Submit a limit-marketable FOK buy and wait for the fill result. + * + * @param {Object} opts + * @param {string} opts.tokenId - ERC1155 token ID (UP or DOWN) + * @param {number} opts.size - Number of shares to buy (≥ 5) + * @param {number} opts.price - Limit price (bestAsk from snapshot) + * @param {string} opts.marketSlug - For logging + * + * @returns {Promise} + */ + async submitBuy({ tokenId, size, price, marketSlug }) { + if (this._dryRun) { + logger.trade(`[SIM] BUY ${marketSlug} | ${size} shares @ $${price}`); + return { + orderId: `sim_buy_${Date.now()}`, + status: 'filled', + filledSize: size, + avgFillPrice: price, + ackMs: 45, + fillMs: 90, + }; + } + + const startTs = Date.now(); + const { tickSize, negRisk } = await this._getMarketOpts(tokenId); + + logger.trade(`BUY ${marketSlug} | ${size} shares @ $${price}`); + + const response = await this._withTimeout( + this._client.createAndPostOrder( + { tokenID: tokenId, price: price.toString(), size, side: Side.BUY }, + { tickSize, negRisk }, + OrderType.FOK, + ), + this._fillTimeoutMs, + ); + + const ackMs = Date.now() - startTs; + const fillMs = ackMs; + + if (!response?.success) { + logger.warn(`ExecutionEngine: buy not filled — ${response?.errorMsg ?? 'no response'}`); + return { orderId: null, status: 'cancelled', filledSize: 0, ackMs, fillMs }; + } + + const takingAmt = parseFloat(response.takingAmount || '0'); + const makingAmt = parseFloat(response.makingAmount || '0'); + + if (takingAmt > 0) { + const avgFillPrice = makingAmt > 0 ? makingAmt / takingAmt : price; + logger.success(`ExecutionEngine: filled ${takingAmt.toFixed(2)} shares @ avg $${avgFillPrice.toFixed(4)}`); + return { orderId: response.orderID, status: 'filled', filledSize: takingAmt, avgFillPrice, ackMs, fillMs }; + } + + // Some CLOB responses indicate fill via status string rather than amounts + const isMatched = /matched|filled/i.test(response.status ?? ''); + if (isMatched || response.success) { + return { orderId: response.orderID, status: 'filled', filledSize: size, avgFillPrice: price, ackMs, fillMs }; + } + + return { orderId: response.orderID, status: 'cancelled', filledSize: 0, ackMs, fillMs }; + } + + // ── Sell (exit / cut-loss) ──────────────────────────────────────────────── + + /** + * Submit a market-sell FOK order to exit a position immediately. + * + * @param {Object} opts + * @param {string} opts.tokenId + * @param {number} opts.size - Shares to sell + * @param {number} opts.price - Minimum acceptable sell price (5% slippage floor applied internally) + * @param {string} opts.marketSlug + */ + async submitSell({ tokenId, size, price, marketSlug }) { + if (this._dryRun) { + logger.trade(`[SIM] SELL ${marketSlug} | ${size} shares @ ~$${price}`); + return { orderId: `sim_sell_${Date.now()}`, status: 'filled' }; + } + + const { tickSize, negRisk } = await this._getMarketOpts(tokenId); + const minPrice = Math.max(price * 0.95, 0.01); + + logger.trade(`SELL ${marketSlug} | ${size} shares @ min $${minPrice.toFixed(4)}`); + + const response = await this._client.createAndPostMarketOrder( + { tokenID: tokenId, side: Side.SELL, amount: size, price: minPrice }, + { tickSize, negRisk }, + OrderType.FOK, + ).catch((err) => { + logger.warn(`ExecutionEngine: sell error — ${err.message}`); + return null; + }); + + const filled = response?.success ?? false; + if (!filled) logger.warn(`ExecutionEngine: sell not filled — ${response?.errorMsg ?? 'unknown'}`); + + return { orderId: response?.orderID ?? null, status: filled ? 'filled' : 'failed' }; + } + + /** + * Place a GTC limit-sell order for take-profit. + * Returns the order ID so the caller can cancel it if exit conditions change. + * + * @param {Object} opts + * @param {string} opts.tokenId + * @param {number} opts.size - Shares to sell + * @param {number} opts.tpPrice - Exact target sell price (aligned to tick size) + * @param {string} opts.marketSlug + */ + async submitTPOrder({ tokenId, size, tpPrice, marketSlug }) { + if (this._dryRun) { + logger.trade(`[SIM] TP ORDER ${marketSlug} | ${size} shares @ $${tpPrice}`); + return { orderId: `sim_tp_${Date.now()}`, status: 'placed' }; + } + + const { tickSize, negRisk } = await this._getMarketOpts(tokenId); + + const response = await this._client.createAndPostOrder( + { tokenID: tokenId, price: tpPrice.toString(), size, side: Side.SELL }, + { tickSize, negRisk }, + OrderType.GTC, + ).catch((err) => { + logger.warn(`ExecutionEngine: TP order error — ${err.message}`); + return null; + }); + + const placed = response?.success ?? false; + logger.info(`ExecutionEngine: TP order ${placed ? 'placed' : 'failed'} | ${marketSlug} @ $${tpPrice}`); + + return { orderId: response?.orderID ?? null, status: placed ? 'placed' : 'failed' }; + } + + /** Cancel an open order by order ID */ + async cancelOrder(orderId) { + if (this._dryRun || !orderId) return; + try { + await this._client.cancelOrder({ orderID: orderId }); + } catch (err) { + logger.warn(`ExecutionEngine: cancel failed for ${orderId} — ${err.message}`); + } + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + async _getMarketOpts(tokenId) { + if (this._tickCache.has(tokenId)) return this._tickCache.get(tokenId); + + let tickSize = '0.01'; + let negRisk = false; + + try { + tickSize = String(await this._client.getTickSize(tokenId) ?? '0.01'); + negRisk = await this._client.getNegRisk(tokenId).catch(() => false) ?? false; + } catch { /* use defaults */ } + + const opts = { tickSize, negRisk }; + this._tickCache.set(tokenId, opts); + return opts; + } + + /** + * Wrap a promise with a hard timeout. + * Resolves to null on timeout rather than rejecting — execution layer + * treats null as a no-fill and transitions back to IDLE cleanly. + */ + _withTimeout(promise, ms) { + return Promise.race([ + promise, + new Promise((resolve) => setTimeout(() => resolve(null), ms)), + ]); + } +} + +/** + * @typedef {Object} FillResult + * @property {string|null} orderId + * @property {'filled'|'partial'|'cancelled'} status + * @property {number} filledSize + * @property {number} avgFillPrice + * @property {number} ackMs + * @property {number} fillMs + */ diff --git a/src/oneshot/FeatureEngine.js b/src/oneshot/FeatureEngine.js new file mode 100644 index 0000000..e64fb1a --- /dev/null +++ b/src/oneshot/FeatureEngine.js @@ -0,0 +1,174 @@ +/** + * FeatureEngine.js + * Step B of the runtime sequence. + * + * Maintains a rolling 15-second buffer of market snapshots per market + * and computes the following features on each incoming snapshot: + * + * midSlope6s — Linear regression slope of the mid price over the last 6s + * (positive = upward momentum, unit: price change per second) + * retrace3s — Fractional pullback from the 6s rolling peak to current mid + * (0 = no retrace, 1 = fully retraced to baseline) + * imbalance — (depthBid - depthAsk) / (depthBid + depthAsk) + * (positive = buyers dominate, negative = sellers dominate) + * spread — Current bestAsk - bestBid + * depthTop3 — Sum of the top-3 bid levels (buy-side depth at best prices) + * + * Features are computed independently for both UP and DOWN book sides. + * + * Emits a 'features' event on the event bus with shape: + * { ts, marketSlug, tteSec, up: SideFeatures, down: SideFeatures, snapshot } + */ + +const BUFFER_WINDOW_MS = 15_000; +const SLOPE_WINDOW_MS = 6_000; +const RETRACE_PEAK_MS = 6_000; // Look-back window for peak in retrace calc +const DEPTH_TOP_N = 3; + +export class FeatureEngine { + /** + * @param {Object} opts + * @param {import('./EventBus.js').default} opts.eventBus + */ + constructor({ eventBus }) { + this._eventBus = eventBus; + + /** @type {Map>} */ + this._buffers = new Map(); + + /** @type {Map} Most recent features per market */ + this._latest = new Map(); + + this._eventBus.on('snapshot', (snap) => this._onSnapshot(snap)); + } + + /** Retrieve the most recently computed features for a given market */ + getLatest(marketSlug) { + return this._latest.get(marketSlug) ?? null; + } + + // ── Internal ────────────────────────────────────────────────────────────── + + _onSnapshot(snap) { + const { marketSlug, ts, tteSec, up, down } = snap; + + // Add to rolling buffer + if (!this._buffers.has(marketSlug)) this._buffers.set(marketSlug, []); + const buf = this._buffers.get(marketSlug); + + buf.push({ + ts, + up_mid: up.mid, + up_spread: up.spread, + up_depthBid: up.depthBid, + up_depthAsk: up.depthAsk, + up_bestBidSize: up.bestBidSize, + up_bestAskSize: up.bestAskSize, + down_mid: down.mid, + down_spread: down.spread, + down_depthBid: down.depthBid, + down_depthAsk: down.depthAsk, + down_bestBidSize: down.bestBidSize, + down_bestAskSize: down.bestAskSize, + }); + + // Evict entries older than the buffer window + const cutoff = ts - BUFFER_WINDOW_MS; + while (buf.length > 0 && buf[0].ts < cutoff) buf.shift(); + + const features = { + ts, + marketSlug, + tteSec, + up: this._computeSideFeatures(buf, 'up', up), + down: this._computeSideFeatures(buf, 'down', down), + snapshot: snap, + }; + + this._latest.set(marketSlug, features); + this._eventBus.emit('features', features); + } + + /** + * Compute all features for one book side using the rolling buffer. + * + * @param {Array} buf - Rolling buffer entries (ascending ts) + * @param {string} side - 'up' or 'down' + * @param {Object} currentBook - Live BookSide from current snapshot + */ + _computeSideFeatures(buf, side, currentBook) { + const now = buf[buf.length - 1]?.ts ?? Date.now(); + const midKey = `${side}_mid`; + + // Slice for slope window (last 6s) + const slopeBuf = buf.filter((e) => e.ts >= now - SLOPE_WINDOW_MS); + const mids6s = slopeBuf.map((e) => e[midKey]); + + // Slice for retrace peak look-back (last 6s) + const retraceBuf = buf.filter((e) => e.ts >= now - RETRACE_PEAK_MS); + const midsRetrace = retraceBuf.map((e) => e[midKey]); + + const midSlope6s = this._linearSlope(mids6s); + const retrace3s = this._retrace(midsRetrace, currentBook.mid); + + // Imbalance from depth + const totalDepth = currentBook.depthBid + currentBook.depthAsk; + const imbalance = totalDepth > 0 + ? (currentBook.depthBid - currentBook.depthAsk) / totalDepth + : 0; + + // Top-3 bid depth from current book + const depthTop3 = currentBook.bids + .slice(0, DEPTH_TOP_N) + .reduce((s, l) => s + l.size, 0); + + return { + midSlope6s, + retrace3s, + imbalance, + spread: currentBook.spread, + depthTop3, + bufLen: slopeBuf.length, // diagnostic + }; + } + + /** + * Ordinary least-squares slope through an array of mid-price values. + * Returns slope in units of "price change per sample interval". + * Returns 0 if fewer than 2 data points are available. + */ + _linearSlope(values) { + const n = values.length; + if (n < 2) return 0; + + const meanX = (n - 1) / 2; + const meanY = values.reduce((a, b) => a + b, 0) / n; + + let num = 0; + let den = 0; + for (let i = 0; i < n; i++) { + const dx = i - meanX; + num += dx * (values[i] - meanY); + den += dx * dx; + } + return den === 0 ? 0 : num / den; + } + + /** + * Fractional retrace: how far the current mid has pulled back from + * the rolling peak within the look-back window. + * + * 0 = price is at its peak (no retrace) + * 1 = price is at its trough (full retrace) + */ + _retrace(mids, currentMid) { + if (mids.length === 0) return 0; + + const peak = Math.max(...mids, currentMid); + const trough = Math.min(...mids, currentMid); + const range = peak - trough; + + if (range < 1e-9) return 0; + return Math.max(0, (peak - currentMid) / range); + } +} diff --git a/src/oneshot/MarketFeedService.js b/src/oneshot/MarketFeedService.js new file mode 100644 index 0000000..97259f0 --- /dev/null +++ b/src/oneshot/MarketFeedService.js @@ -0,0 +1,266 @@ +/** + * MarketFeedService.js + * Step A of the runtime sequence. + * + * Responsibilities: + * 1. Discover active 5m/15m UP↑DOWN↓ markets for configured assets via Gamma API + * 2. Poll the CLOB orderbook for both UP and DOWN tokens every pollIntervalMs + * 3. Normalise raw book data into a consistent snapshot format + * 4. Detect stale books (no levels, or fetch latency > STALE_THRESHOLD_MS) + * 5. Emit 'snapshot' events on the event bus + * + * Snapshot shape: + * { ts, marketSlug, conditionId, tteSec, tickSize, up: BookSide, down: BookSide, stale } + * + * BookSide shape: + * { tokenId, bids, asks, bestBid, bestAsk, mid, spread, depthBid, depthAsk } + */ + +import logger from '../utils/logger.js'; + +const GAMMA_HOST = 'https://gamma-api.polymarket.com'; +const STALE_THRESHOLD_MS = 1500; +const TOP_N_LEVELS = 5; // Levels counted for depth calculation +const DISCOVER_INTERVAL = 30_000; // Re-scan for new markets every 30s + +export class MarketFeedService { + /** + * @param {Object} opts + * @param {import('@polymarket/clob-client').ClobClient} opts.client + * @param {string[]} opts.assets - e.g. ['btc', 'eth', 'sol'] + * @param {string} opts.duration - '5m' or '15m' + * @param {number} opts.pollIntervalMs - Book poll cadence in ms (200–500) + * @param {import('./EventBus.js').default} opts.eventBus + */ + constructor({ client, assets, duration = '5m', pollIntervalMs = 300, eventBus }) { + this._client = client; + this._assets = assets; + this._duration = duration; + this._durationMin = duration === '15m' ? 15 : 5; + this._pollMs = pollIntervalMs; + this._eventBus = eventBus; + + /** @type {Map} slug → market record */ + this._markets = new Map(); + + this._pollTimer = null; + this._discoverTimer = null; + } + + // ── Lifecycle ───────────────────────────────────────────────────────────── + + async start() { + await this._discoverMarkets(); + this._pollTimer = setInterval(() => this._tick().catch(() => {}), this._pollMs); + this._discoverTimer = setInterval(() => this._discoverMarkets().catch(() => {}), DISCOVER_INTERVAL); + logger.info(`MarketFeedService: started | assets=[${this._assets}] interval=${this._pollMs}ms`); + } + + stop() { + clearInterval(this._pollTimer); + clearInterval(this._discoverTimer); + logger.info('MarketFeedService: stopped'); + } + + /** Active market slugs currently being polled */ + get activeMarkets() { + return [...this._markets.keys()]; + } + + // ── Market discovery ────────────────────────────────────────────────────── + + async _discoverMarkets() { + const durationMin = this._durationMin; + + // Probe current slot and the immediately upcoming slot + const slots = [ + this._slotTs(), + this._slotTs() + durationMin * 60, + ]; + + for (const asset of this._assets) { + for (const slotTs of slots) { + const slug = `${asset}-updown-${this._duration}-${slotTs}`; + if (this._markets.has(slug)) continue; + + try { + const market = await this._fetchMarketBySlug(slug); + if (!market) continue; + + const endTs = this._parseEndTs(market); + if (!endTs || Date.now() >= endTs) continue; + + const { upTokenId, downTokenId } = this._extractTokenIds(market); + if (!upTokenId || !downTokenId) { + logger.warn(`MarketFeedService: could not extract token IDs for ${slug}`); + continue; + } + + const tickSize = await this._fetchTickSize(upTokenId); + + this._markets.set(slug, { + slug, + conditionId: market.conditionId || market.condition_id, + upTokenId, + downTokenId, + endTs, + tickSize, + negRisk: market.negRisk || market.neg_risk || false, + }); + + const secLeft = Math.floor((endTs - Date.now()) / 1000); + logger.success(`MarketFeedService: tracking ${slug} (closes in ${secLeft}s)`); + + } catch { + // Network blip — will retry on next discovery cycle + } + } + } + + // Prune expired markets (add a 5s grace period for final snapshots) + for (const [slug, mkt] of this._markets) { + if (Date.now() > mkt.endTs + 5_000) { + this._markets.delete(slug); + logger.info(`MarketFeedService: pruned expired market ${slug}`); + } + } + } + + /** Deterministic UTC slot boundary timestamp (seconds) */ + _slotTs() { + const slotMs = this._durationMin * 60_000; + return Math.floor(Date.now() / slotMs) * slotMs / 1000; + } + + async _fetchMarketBySlug(slug) { + const url = `${GAMMA_HOST}/markets?slug=${encodeURIComponent(slug)}&limit=1`; + const resp = await fetch(url); + if (!resp.ok) return null; + const data = await resp.json(); + const market = Array.isArray(data) ? data[0] : data; + return market?.conditionId || market?.condition_id ? market : null; + } + + _parseEndTs(market) { + const raw = market.endDateIso || market.end_date_iso || market.endDate; + if (!raw) return null; + const ts = new Date(raw).getTime(); + return Number.isFinite(ts) ? ts : null; + } + + _extractTokenIds(market) { + let upTokenId = null; + let downTokenId = null; + + // Shape 1: tokens[] array with { tokenId, outcome } + const tokens = market.tokens; + if (Array.isArray(tokens)) { + for (const t of tokens) { + const outcome = String(t.outcome || t.title || '').toLowerCase(); + const id = t.tokenId || t.token_id || t.id || t.asset; + if (!id) continue; + if (outcome.includes('up') || outcome === 'yes') upTokenId = String(id); + if (outcome.includes('down') || outcome === 'no') downTokenId = String(id); + } + } + + // Shape 2: clobTokenIds[0/1] + if ((!upTokenId || !downTokenId) && Array.isArray(market.clobTokenIds) && market.clobTokenIds.length >= 2) { + upTokenId = upTokenId ?? String(market.clobTokenIds[0]); + downTokenId = downTokenId ?? String(market.clobTokenIds[1]); + } + + return { upTokenId, downTokenId }; + } + + async _fetchTickSize(tokenId) { + try { + const ts = await this._client.getTickSize(tokenId); + return parseFloat(ts) || 0.01; + } catch { + return 0.01; + } + } + + // ── Book polling ────────────────────────────────────────────────────────── + + async _tick() { + for (const [, mkt] of this._markets) { + const tteSec = Math.floor((mkt.endTs - Date.now()) / 1000); + if (tteSec <= 0) continue; + + const fetchStart = Date.now(); + + try { + const [upBook, downBook] = await Promise.all([ + this._client.getOrderBook(mkt.upTokenId), + this._client.getOrderBook(mkt.downTokenId), + ]); + + const fetchMs = Date.now() - fetchStart; + const stale = fetchMs > STALE_THRESHOLD_MS; + + const snapshot = this._buildSnapshot(mkt, upBook, downBook, tteSec, stale); + this._eventBus.emit('snapshot', snapshot); + + } catch { + // Silent — stale snapshot will suppress entry via gate check + } + } + } + + _buildSnapshot(mkt, upBook, downBook, tteSec, stale) { + return { + ts: Date.now(), + marketSlug: mkt.slug, + conditionId: mkt.conditionId, + tteSec, + tickSize: mkt.tickSize, + negRisk: mkt.negRisk, + up: this._buildSide(mkt.upTokenId, upBook), + down: this._buildSide(mkt.downTokenId, downBook), + stale: stale || this._isBooksEmpty(upBook, downBook), + }; + } + + _buildSide(tokenId, book) { + const parse = (raw = []) => + (Array.isArray(raw) ? raw : []) + .filter((l) => l?.price && l?.size) + .map((l) => ({ price: parseFloat(l.price), size: parseFloat(l.size) })) + .filter((l) => l.price > 0 && l.size > 0); + + const bids = parse(book?.bids).sort((a, b) => b.price - a.price); + const asks = parse(book?.asks).sort((a, b) => a.price - b.price); + + const bestBid = bids[0]?.price ?? 0; + const bestAsk = asks[0]?.price ?? 1; + const mid = bestBid > 0 && bestAsk < 1 + ? (bestBid + bestAsk) / 2 + : (bestBid || bestAsk || 0.5); + const spread = Math.max(0, bestAsk - bestBid); + + const topN = Math.min(TOP_N_LEVELS, Math.max(bids.length, asks.length)); + const depthBid = bids.slice(0, topN).reduce((s, l) => s + l.size, 0); + const depthAsk = asks.slice(0, topN).reduce((s, l) => s + l.size, 0); + + return { + tokenId, + bids, + asks, + bestBid, + bestAsk, + mid, + spread, + depthBid, + depthAsk, + bestBidSize: bids[0]?.size ?? 0, + bestAskSize: asks[0]?.size ?? 0, + }; + } + + _isBooksEmpty(upBook, downBook) { + const isEmpty = (b) => !b || (!Array.isArray(b.bids) && !Array.isArray(b.asks)); + return isEmpty(upBook) || isEmpty(downBook); + } +} diff --git a/src/oneshot/PositionEngine.js b/src/oneshot/PositionEngine.js new file mode 100644 index 0000000..52c2f91 --- /dev/null +++ b/src/oneshot/PositionEngine.js @@ -0,0 +1,168 @@ +/** + * PositionEngine.js + * Step G of the runtime sequence. + * + * Maintains position state per market and evaluates exit conditions + * on every incoming snapshot tick. + * + * Exit priority (highest → lowest): + * 1. EXIT_TIME_FLATTEN — TTE <= 12s (hard close, overrides everything) + * 2. EXIT_TP_HIT — Current bestBid >= entryPrice + tpTicks × tickSize + * 3. EXIT_ADVERSE_MOVE — Mid has dropped >= 2 ticks below entry + * 4. EXIT_SLOPE_DROP — Slope has been <= 0 continuously for >= 4 seconds + * 5. EXIT_TIME_REDUCE — TTE <= 20s (signals REDUCE_ONLY mode to orchestrator) + */ + +import { ReasonCode } from './constants.js'; + +const HARD_FLATTEN_TTE = 12; // seconds +const REDUCE_TTE = 20; // seconds +const ADVERSE_TICKS = 2; // how many ticks below entry triggers adverse exit +const SLOPE_DROP_HOLD_MS = 4_000; // ms slope must remain <= 0 to trigger exit + +export class PositionEngine { + /** + * @param {Object} opts + * @param {number} opts.tpTicks - Take-profit in ticks above entry price + */ + constructor({ tpTicks = 1 }) { + this._tpTicks = tpTicks; + + /** @type {Map} */ + this._positions = new Map(); + } + + // ── Position lifecycle ───────────────────────────────────────────────── + + /** + * Record a newly filled position. + * + * @param {string} marketSlug + * @param {Object} data + * @param {string} data.tokenId + * @param {'up'|'down'} data.side + * @param {number} data.shares + * @param {number} data.entryPrice + * @param {number} data.tickSize + */ + open(marketSlug, { tokenId, side, shares, entryPrice, tickSize }) { + this._positions.set(marketSlug, { + marketSlug, + tokenId, + side, + shares, + entryPrice, + tickSize, + openedAt: Date.now(), + tpPrice: this._roundToTick(entryPrice + this._tpTicks * tickSize, tickSize), + _slopeDropTs: null, // timestamp when slope first went <= 0 + }); + } + + /** @returns {PositionState|null} */ + getPosition(marketSlug) { + return this._positions.get(marketSlug) ?? null; + } + + hasPosition(marketSlug) { + return this._positions.has(marketSlug); + } + + /** + * Close the position and return exit data including realised P&L. + * + * @param {string} marketSlug + * @param {number} exitPrice - Actual fill price of the exit order + * @returns {{ pnl: number, shares: number, entryPrice: number, exitPrice: number }} + */ + close(marketSlug, exitPrice) { + const pos = this._positions.get(marketSlug); + if (!pos) return { pnl: 0, shares: 0, entryPrice: 0, exitPrice }; + + const pnl = (exitPrice - pos.entryPrice) * pos.shares; + this._positions.delete(marketSlug); + + return { pnl, shares: pos.shares, entryPrice: pos.entryPrice, exitPrice }; + } + + // ── Exit evaluation ──────────────────────────────────────────────────── + + /** + * Evaluate whether the current position should be exited. + * Called on every snapshot tick while in POSITION_OPEN or REDUCE_ONLY state. + * + * @param {string} marketSlug + * @param {Object} snapshot - Current market snapshot + * @param {Object|null} features - Latest features from FeatureEngine (may be null) + * @returns {{ shouldExit: boolean, reason: string|null, isReduceOnly: boolean }} + */ + evaluateExit(marketSlug, snapshot, features) { + const pos = this._positions.get(marketSlug); + if (!pos) return { shouldExit: false, reason: null, isReduceOnly: false }; + + const { tteSec } = snapshot; + const bookSide = pos.side === 'up' ? snapshot.up : snapshot.down; + const sideFeat = features ? (pos.side === 'up' ? features.up : features.down) : null; + const now = Date.now(); + + // 1. Hard time flatten + if (tteSec <= HARD_FLATTEN_TTE) { + return { shouldExit: true, reason: ReasonCode.EXIT_TIME_FLATTEN, isReduceOnly: false }; + } + + // 2. Take-profit hit + if (bookSide.bestBid >= pos.tpPrice) { + return { shouldExit: true, reason: ReasonCode.EXIT_TP_HIT, isReduceOnly: false }; + } + + // 3. Adverse move: mid has fallen >= 2 ticks below entry + const adverseFloor = pos.entryPrice - ADVERSE_TICKS * pos.tickSize; + if (bookSide.mid < adverseFloor) { + return { shouldExit: true, reason: ReasonCode.EXIT_ADVERSE_MOVE, isReduceOnly: false }; + } + + // 4. Slope drop: slope <= 0 sustained for SLOPE_DROP_HOLD_MS + if (sideFeat) { + if (sideFeat.midSlope6s <= 0) { + if (!pos._slopeDropTs) { + // Start the slope-drop timer + this._positions.set(marketSlug, { ...pos, _slopeDropTs: now }); + } else if (now - pos._slopeDropTs >= SLOPE_DROP_HOLD_MS) { + return { shouldExit: true, reason: ReasonCode.EXIT_SLOPE_DROP, isReduceOnly: false }; + } + } else { + // Positive slope — reset the drop timer + if (pos._slopeDropTs) { + this._positions.set(marketSlug, { ...pos, _slopeDropTs: null }); + } + } + } + + // 5. Reduce-only signal (non-exiting, just changes state in orchestrator) + if (tteSec <= REDUCE_TTE) { + return { shouldExit: false, reason: ReasonCode.EXIT_TIME_REDUCE, isReduceOnly: true }; + } + + return { shouldExit: false, reason: null, isReduceOnly: false }; + } + + // ── Helpers ─────────────────────────────────────────────────────────── + + _roundToTick(price, tickSize) { + const factor = Math.round(1 / tickSize); + return Math.round(price * factor) / factor; + } +} + +/** + * @typedef {Object} PositionState + * @property {string} marketSlug + * @property {string} tokenId + * @property {'up'|'down'} side + * @property {number} shares + * @property {number} entryPrice + * @property {number} tickSize + * @property {number} tpPrice + * @property {number} openedAt + * @property {number|null} _slopeDropTs + */ diff --git a/src/oneshot/RiskEngine.js b/src/oneshot/RiskEngine.js new file mode 100644 index 0000000..c929c0b --- /dev/null +++ b/src/oneshot/RiskEngine.js @@ -0,0 +1,138 @@ +/** + * RiskEngine.js + * Step H of the runtime sequence. + * + * Global risk enforcement across all markets in the same session: + * + * Consecutive loss cap — after N consecutive losses, enter COOLDOWN for + * `cooldownRounds` market opportunities + * Daily loss cap — if total daily P&L drops below -dailyLossCap, + * HALT all trading for the rest of the day + * + * All policy violations are surfaced via canTrade() so the orchestrator + * can gate entries without needing direct access to internal state. + */ + +import logger from '../utils/logger.js'; +import { ReasonCode } from './constants.js'; + +export class RiskEngine { + /** + * @param {Object} opts + * @param {number} opts.maxConsecLosses - Consecutive losses before cooldown + * @param {number} opts.cooldownRounds - Market slots to skip during cooldown + * @param {number} opts.dailyLossCap - Max cumulative daily loss in USDC (positive number) + */ + constructor({ maxConsecLosses = 2, cooldownRounds = 3, dailyLossCap = 20 }) { + this._maxConsecLosses = maxConsecLosses; + this._cooldownRounds = cooldownRounds; + this._dailyLossCap = dailyLossCap; + + this._dailyPnl = 0; + this._consecLosses = 0; + this._cooldownLeft = 0; + this._halted = false; + + this._sessionStart = Date.now(); + } + + // ── Public API ──────────────────────────────────────────────────────────── + + /** + * Check whether a new entry is allowed. + * @returns {{ ok: boolean, reason: string|null, halted: boolean }} + */ + canTrade() { + if (this._halted) { + return { ok: false, reason: ReasonCode.RISK_DAILY_CAP, halted: true }; + } + if (this._cooldownLeft > 0) { + return { ok: false, reason: ReasonCode.RISK_CONSEC_LOSS, halted: false }; + } + return { ok: true, reason: null, halted: false }; + } + + /** True if the engine is in cooldown (but not halted) */ + isCooldown() { + return !this._halted && this._cooldownLeft > 0; + } + + /** True if trading has been permanently halted for today */ + isHalted() { + return this._halted; + } + + /** + * Record the P&L of a closed position and update risk counters. + * @param {number} pnl - Realised P&L in USDC (negative = loss) + */ + recordResult(pnl) { + this._dailyPnl += pnl; + + if (pnl < 0) { + this._consecLosses++; + + if (this._consecLosses >= this._maxConsecLosses) { + this._cooldownLeft = this._cooldownRounds; + logger.warn( + `RiskEngine: ${this._consecLosses} consecutive losses — ` + + `entering cooldown for ${this._cooldownRounds} rounds`, + ); + } + } else { + // Reset consecutive loss streak on any win + this._consecLosses = 0; + } + + // Daily cap check + if (this._dailyPnl <= -Math.abs(this._dailyLossCap)) { + this._halted = true; + logger.error( + `RiskEngine: daily loss cap hit ($${this._dailyPnl.toFixed(2)}) — ` + + `trading HALTED for the rest of the session`, + ); + } + + this._logState(pnl); + } + + /** + * Decrement the cooldown counter by one market slot. + * Called by the orchestrator each time a new market opportunity is seen + * while in cooldown mode. + */ + decrementCooldown() { + if (this._cooldownLeft > 0) { + this._cooldownLeft--; + logger.info(`RiskEngine: cooldown rounds remaining: ${this._cooldownLeft}`); + + if (this._cooldownLeft === 0) { + this._consecLosses = 0; + logger.success('RiskEngine: cooldown lifted — resuming normal trading'); + } + } + } + + /** Current session statistics snapshot */ + stats() { + return { + dailyPnl: this._dailyPnl, + consecLosses: this._consecLosses, + cooldownLeft: this._cooldownLeft, + halted: this._halted, + }; + } + + // ── Internal ────────────────────────────────────────────────────────────── + + _logState(pnl) { + const sign = pnl >= 0 ? '+' : ''; + const stats = this.stats(); + logger.info( + `RiskEngine: pnl=${sign}$${pnl.toFixed(4)} | ` + + `daily=$${stats.dailyPnl.toFixed(4)} | ` + + `streak=${stats.consecLosses} | ` + + `cooldown=${stats.cooldownLeft}`, + ); + } +} diff --git a/src/oneshot/SignalEngine.js b/src/oneshot/SignalEngine.js new file mode 100644 index 0000000..57c1e81 --- /dev/null +++ b/src/oneshot/SignalEngine.js @@ -0,0 +1,196 @@ +/** + * SignalEngine.js + * Steps C & D of the runtime sequence. + * + * Pipeline per features event: + * 1. Hard gate check — immediately reject on any hard failure + * 2. Side selection — evaluate UP and DOWN sides independently + * 3. Score — weighted composite (imbalance, slope, spread, retrace) + * 4. Trend confirm — slope positive + retrace small + * 5. Emit signal — NO_TRADE (with reason) or ENTER_LONG / ENTER_SHORT + * + * Signal event shape: + * { ts, marketSlug, tteSec, signal, side, score, reason, snapshot, features } + */ + +import { Signal, ReasonCode } from './constants.js'; + +// ── Score weights ────────────────────────────────────────────────────────────── +const W_IMBALANCE = 0.35; +const W_SLOPE = 0.35; +const W_SPREAD = 0.20; +const W_RETRACE = 0.10; + +// ── Scoring thresholds ───────────────────────────────────────────────────────── +const SLOPE_STRONG = 0.0015; // Strong momentum (price/sample) +const SLOPE_WEAK = 0.0003; // Weak-but-positive momentum +const IMB_STRONG = 0.25; // Strong bid-side dominance +const IMB_WEAK = 0.08; // Mild bid-side dominance +const SPREAD_TIGHT = 0.01; // Tight spread +const SPREAD_MAX = 0.02; // Gate maximum (hard gate uses this too) +const RETRACE_SMALL = 0.15; // Essentially no retrace +const RETRACE_MID = 0.35; // Moderate retrace +const CONFIRM_SLOPE = 0.0001; // Minimum positive slope for trend confirmation +const CONFIRM_RTRC = 0.30; // Maximum retrace for trend confirmation + +export class SignalEngine { + /** + * @param {Object} opts + * @param {import('./EventBus.js').default} opts.eventBus + * @param {number} opts.scoreThreshold - Minimum score to trigger entry (0–1) + * @param {number} opts.minTopSize - Minimum shares at best bid/ask for gate + * @param {number} [opts.tteMin=25] - Minimum TTE in seconds (gate lower bound) + * @param {number} [opts.tteMax=120] - Maximum TTE in seconds (gate upper bound) + */ + constructor({ eventBus, scoreThreshold, minTopSize, tteMin = 25, tteMax = 120 }) { + this._eventBus = eventBus; + this._scoreThreshold = scoreThreshold; + this._minTopSize = minTopSize; + this._tteMin = tteMin; + this._tteMax = tteMax; + + this._eventBus.on('features', (feat) => this._onFeatures(feat)); + } + + // ── Internal ────────────────────────────────────────────────────────────── + + _onFeatures(feat) { + const { ts, marketSlug, tteSec, up, down, snapshot } = feat; + + // ── Step C: hard gate check ───────────────────────────────────────── + + const gate = this._hardGates(snapshot, tteSec); + if (!gate.pass) { + this._emit(marketSlug, Signal.NO_TRADE, null, 0, gate.reason, ts, snapshot, feat); + return; + } + + // ── Step D: evaluate each side, pick best qualifying signal ───────── + + const upResult = this._evaluateSide(up, snapshot.up); + const downResult = this._evaluateSide(down, snapshot.down); + + // Determine which side (if any) qualifies + const upQual = upResult.score >= this._scoreThreshold && upResult.confirmed; + const downQual = downResult.score >= this._scoreThreshold && downResult.confirmed; + + if (!upQual && !downQual) { + // Emit the reason from whichever side had the higher score + const dominant = upResult.score >= downResult.score ? upResult : downResult; + const reason = dominant.confirmed ? ReasonCode.SIG_SCORE_LOW : ReasonCode.SIG_NO_CONFIRM; + this._emit(marketSlug, Signal.NO_TRADE, null, dominant.score, reason, ts, snapshot, feat); + return; + } + + // Pick the stronger qualifying side + let signal; + let side; + let score; + + if (upQual && (!downQual || upResult.score >= downResult.score)) { + signal = Signal.ENTER_LONG; + side = 'up'; + score = upResult.score; + } else { + signal = Signal.ENTER_SHORT; + side = 'down'; + score = downResult.score; + } + + this._emit(marketSlug, signal, side, score, null, ts, snapshot, feat); + } + + /** + * Hard gates — any failure aborts the evaluation immediately. + * @returns {{ pass: boolean, reason: string|null }} + */ + _hardGates(snapshot, tteSec) { + if (snapshot.stale) + return { pass: false, reason: ReasonCode.GATE_STALE_BOOK }; + + if (tteSec < this._tteMin || tteSec > this._tteMax) + return { pass: false, reason: ReasonCode.GATE_TTE_FAIL }; + + if (snapshot.up.spread > SPREAD_MAX || snapshot.down.spread > SPREAD_MAX) + return { pass: false, reason: ReasonCode.GATE_SPREAD_WIDE }; + + const thinUp = snapshot.up.bestBidSize < this._minTopSize + || snapshot.up.bestAskSize < this._minTopSize; + const thinDown = snapshot.down.bestBidSize < this._minTopSize + || snapshot.down.bestAskSize < this._minTopSize; + + if (thinUp && thinDown) + return { pass: false, reason: ReasonCode.GATE_DEPTH_THIN }; + + return { pass: true, reason: null }; + } + + /** + * Score and confirm one side. + * @param {Object} sideFeatures - From FeatureEngine (midSlope6s, imbalance, ...) + * @param {Object} sideBook - Current BookSide from snapshot + * @returns {{ score: number, confirmed: boolean }} + */ + _evaluateSide(sideFeatures, sideBook) { + const { midSlope6s, retrace3s, imbalance, spread } = sideFeatures; + + // Score components (each 0–1, then weighted) + const slopeScore = this._scoreSlope(midSlope6s); + const imbalanceScore = this._scoreImbalance(imbalance); + const spreadScore = this._scoreSpread(spread); + const retraceScore = this._scoreRetrace(retrace3s); + + const score = + W_SLOPE * slopeScore + + W_IMBALANCE * imbalanceScore + + W_SPREAD * spreadScore + + W_RETRACE * retraceScore; + + // Trend confirmation: positive momentum + low retrace + const confirmed = midSlope6s > CONFIRM_SLOPE && retrace3s < CONFIRM_RTRC; + + return { score, confirmed }; + } + + // ── Scoring helpers ─────────────────────────────────────────────────────── + + _scoreSlope(slope) { + if (slope >= SLOPE_STRONG) return 1.0; + if (slope >= SLOPE_WEAK) return 0.5; + if (slope > 0) return 0.2; + return 0; + } + + _scoreImbalance(imb) { + if (imb >= IMB_STRONG) return 1.0; + if (imb >= IMB_WEAK) return 0.5; + if (imb > 0) return 0.2; + return 0; + } + + _scoreSpread(spread) { + if (spread <= SPREAD_TIGHT) return 1.0; + if (spread <= SPREAD_MAX) return 0.5; + return 0; + } + + _scoreRetrace(retrace) { + if (retrace <= RETRACE_SMALL) return 1.0; + if (retrace <= RETRACE_MID) return 0.5; + return 0; + } + + _emit(marketSlug, signal, side, score, reason, ts, snapshot, features) { + this._eventBus.emit('signal', { + ts, + marketSlug, + tteSec: snapshot.tteSec, + signal, + side, + score, + reason, + snapshot, + features, + }); + } +} diff --git a/src/oneshot/StateMachine.js b/src/oneshot/StateMachine.js new file mode 100644 index 0000000..942103f --- /dev/null +++ b/src/oneshot/StateMachine.js @@ -0,0 +1,69 @@ +/** + * StateMachine.js + * Explicit per-market state machine with strict transition guards. + * + * Rule: never derive state from floating booleans. + * Every state change must go through transition() to be validated and logged. + */ + +import { State, TRANSITIONS } from './constants.js'; + +export class StateMachine { + /** + * @param {string} marketSlug - Market identifier (used in error messages and logs) + * @param {import('./EventBus.js').default} eventBus + */ + constructor(marketSlug, eventBus) { + this._state = State.IDLE; + this._slug = marketSlug; + this._eventBus = eventBus; + } + + /** Current state string */ + get state() { + return this._state; + } + + /** + * Attempt a state transition. + * Throws if the transition is not in the allowed graph — this is intentional: + * a programming error that bypasses the guard should be loud and traceable. + * + * @param {string} nextState - One of the State enum values + * @param {string} [reason] - Human-readable reason for the transition + * @returns {StateMachine} - Returns `this` for chaining + */ + transition(nextState, reason = '') { + const allowed = TRANSITIONS[this._state] ?? []; + + if (!allowed.includes(nextState)) { + throw new Error( + `[StateMachine] Invalid transition: ${this._state} → ${nextState}` + + ` (market: ${this._slug}, reason: ${reason})`, + ); + } + + const from = this._state; + this._state = nextState; + + this._eventBus.emit('state:transition', { + marketSlug: this._slug, + from, + to: nextState, + reason, + ts: Date.now(), + }); + + return this; + } + + /** @param {string} state */ + is(state) { + return this._state === state; + } + + /** @param {string} state */ + canTransitionTo(state) { + return (TRANSITIONS[this._state] ?? []).includes(state); + } +} diff --git a/src/oneshot/Telemetry.js b/src/oneshot/Telemetry.js new file mode 100644 index 0000000..bcfece8 --- /dev/null +++ b/src/oneshot/Telemetry.js @@ -0,0 +1,109 @@ +/** + * Telemetry.js + * Structured JSONL logger for the OneShot engine. + * + * Every decision tick, order lifecycle event, position exit, and state + * transition is recorded to data/oneshot_telemetry.jsonl — one JSON object + * per line — for offline analysis and strategy tuning. + */ + +import fs from 'fs'; +import path from 'path'; +import logger from '../utils/logger.js'; + +const DATA_DIR = path.resolve('data'); +const LOG_FILE = path.join(DATA_DIR, 'oneshot_telemetry.jsonl'); + +export class Telemetry { + constructor() { + // Ensure data/ directory exists + if (!fs.existsSync(DATA_DIR)) { + fs.mkdirSync(DATA_DIR, { recursive: true }); + } + } + + // ── Public log methods ─────────────────────────────────────────────────── + + /** + * Log a per-decision-tick evaluation record. + * Called for every signal evaluation, whether entry is taken or not. + * + * @param {Object} d + * @param {string} d.marketSlug + * @param {number} d.ts + * @param {number} d.tteSec + * @param {number} d.spread + * @param {number} d.imbalance + * @param {number} d.slope + * @param {number} d.retrace + * @param {number} d.depth + * @param {boolean} d.gatePass + * @param {string} d.reasonCode + * @param {number} d.score + * @param {string} d.action + */ + logDecision(d) { + this._write({ type: 'decision', ...d }); + } + + /** + * Log an order lifecycle event (submit → ack → fill / cancel). + * + * @param {Object} d + * @param {string} d.clientOrderId + * @param {string} d.side + * @param {string} d.marketSlug + * @param {number} d.px + * @param {number} d.qty + * @param {number} d.ackMs + * @param {number} d.fillMs + * @param {string} d.status + */ + logOrder(d) { + this._write({ type: 'order', ...d }); + } + + /** + * Log a position exit event. + * + * @param {Object} d + * @param {string} d.marketSlug + * @param {string} d.exitReason + * @param {number} d.entryPx + * @param {number} d.exitPx + * @param {number} d.pnl + * @param {number} d.shares + */ + logExit(d) { + this._write({ type: 'exit', ...d }); + + const pnlStr = d.pnl >= 0 + ? `+$${d.pnl.toFixed(4)}` + : `-$${Math.abs(d.pnl).toFixed(4)}`; + + logger.money(`[Telemetry] exit ${d.marketSlug} | ${d.exitReason} | pnl=${pnlStr}`); + } + + /** + * Log a state machine transition. + * + * @param {Object} d + * @param {string} d.marketSlug + * @param {string} d.from + * @param {string} d.to + * @param {string} d.reason + * @param {number} d.ts + */ + logTransition(d) { + this._write({ type: 'transition', ...d }); + } + + // ── Internal ───────────────────────────────────────────────────────────── + + _write(record) { + const line = JSON.stringify({ ...record, ts: record.ts ?? Date.now() }) + '\n'; + fs.appendFile(LOG_FILE, line, (err) => { + if (err) logger.warn(`[Telemetry] write error: ${err.message}`); + }); + } +} diff --git a/src/oneshot/constants.js b/src/oneshot/constants.js new file mode 100644 index 0000000..0d96760 --- /dev/null +++ b/src/oneshot/constants.js @@ -0,0 +1,70 @@ +/** + * constants.js + * Shared enums and reason codes for the Anti-Flip 5m OneShot Engine. + * All objects are frozen to prevent accidental mutation at runtime. + */ + +// ── State machine states ─────────────────────────────────────────────────────── + +export const State = Object.freeze({ + IDLE: 'IDLE', // Waiting for a qualifying signal + SETUP_READY: 'SETUP_READY', // Signal passed — about to submit order + ORDER_PENDING: 'ORDER_PENDING', // Order submitted, awaiting fill ack + POSITION_OPEN: 'POSITION_OPEN', // Filled — actively managing position + REDUCE_ONLY: 'REDUCE_ONLY', // Time threshold reached — exit only, no new entry + COOLDOWN: 'COOLDOWN', // Short suspension after consecutive losses + HALTED: 'HALTED', // Daily stop-loss hit — no more trading today +}); + +// ── Reason / decision codes ──────────────────────────────────────────────────── + +export const ReasonCode = Object.freeze({ + // Hard gate failures + GATE_TTE_FAIL: 'GATE_TTE_FAIL', // TTE outside [25, 120] range + GATE_SPREAD_WIDE: 'GATE_SPREAD_WIDE', // Spread exceeds maximum threshold + GATE_DEPTH_THIN: 'GATE_DEPTH_THIN', // Best bid/ask size below minimum + GATE_STALE_BOOK: 'GATE_STALE_BOOK', // Book snapshot is stale or empty + + // Signal evaluation failures + SIG_SCORE_LOW: 'SIG_SCORE_LOW', // Composite score below threshold + SIG_NO_CONFIRM: 'SIG_NO_CONFIRM', // Trend confirmation failed + + // Execution failures + EXEC_TIMEOUT_NO_FILL: 'EXEC_TIMEOUT_NO_FILL', // FOK timed out without fill + EXEC_PARTIAL_REDUCE: 'EXEC_PARTIAL_REDUCE', // Partial fill reduced & closed + EXEC_SUBMIT_ERROR: 'EXEC_SUBMIT_ERROR', // Order submission threw error + + // Risk policy + RISK_CONSEC_LOSS: 'RISK_CONSEC_LOSS', // Consecutive loss limit triggered cooldown + RISK_DAILY_CAP: 'RISK_DAILY_CAP', // Daily loss cap reached — halted + RISK_STATE_BLOCK: 'RISK_STATE_BLOCK', // Risk engine blocked entry (cooldown/halted) + + // Exit reasons + EXIT_TP_HIT: 'EXIT_TP_HIT', // Take-profit price reached + EXIT_TIME_FLATTEN: 'EXIT_TIME_FLATTEN', // Hard flatten at TTE <= 12s + EXIT_TIME_REDUCE: 'EXIT_TIME_REDUCE', // Reduce-only mode at TTE <= 20s + EXIT_ADVERSE_MOVE: 'EXIT_ADVERSE_MOVE', // Mid dropped >= 2 ticks from entry + EXIT_SLOPE_DROP: 'EXIT_SLOPE_DROP', // Slope <= 0 sustained for 4s + EXIT_RISK_FORCED: 'EXIT_RISK_FORCED', // Risk engine forced exit +}); + +// ── Signal directions ────────────────────────────────────────────────────────── + +export const Signal = Object.freeze({ + NO_TRADE: 'NO_TRADE', // Conditions not met — skip + ENTER_LONG: 'ENTER_LONG', // Buy UP token + ENTER_SHORT: 'ENTER_SHORT', // Buy DOWN token +}); + +// ── Valid state transitions ──────────────────────────────────────────────────── +// Used by StateMachine to enforce the explicit transition graph. + +export const TRANSITIONS = Object.freeze({ + [State.IDLE]: [State.SETUP_READY, State.COOLDOWN, State.HALTED], + [State.SETUP_READY]: [State.ORDER_PENDING, State.IDLE, State.COOLDOWN, State.HALTED], + [State.ORDER_PENDING]: [State.POSITION_OPEN, State.IDLE, State.COOLDOWN, State.HALTED], + [State.POSITION_OPEN]: [State.REDUCE_ONLY, State.IDLE, State.COOLDOWN, State.HALTED], + [State.REDUCE_ONLY]: [State.IDLE, State.COOLDOWN, State.HALTED], + [State.COOLDOWN]: [State.IDLE, State.HALTED], + [State.HALTED]: [], +});