diff --git a/.env.example b/.env.example index c0e94f9..ec2dca4 100644 --- a/.env.example +++ b/.env.example @@ -127,9 +127,11 @@ 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. +# Dominant Side Hold strategy. +# Enters ONLY the side that the market already prices as the probable +# winner (mid >= ONESHOT_MIN_DOMINANT_MID), then holds the position +# to market expiry for on-chain redemption at $1.00. +# No take-profit sells. No momentum-based exits. # ALWAYS test with DRY_RUN=true before going live. # ───────────────────────────────────────────── @@ -145,17 +147,36 @@ 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 +# ── Entry filters ────────────────────────────────────────────────────── -# Minimum composite score to trigger entry (0–1, higher = more selective) -ONESHOT_SCORE_THRESHOLD=0.60 +# Minimum mid price for the dominant side to qualify as an entry candidate. +# Example: 0.60 means the token must be priced at ≥60% probability of winning. +# Lower = more trades but more uncertain outcomes. Higher = fewer but more confident. +ONESHOT_MIN_DOMINANT_MID=0.60 + +# Minimum composite score to trigger entry (0–1). +# Score is based on: mid price strength (45%), order-book imbalance (35%), spread (20%). +ONESHOT_SCORE_THRESHOLD=0.55 + +# TTE (time-to-expiry) window in seconds for entry. +# Only enter when the market is between TTE_MIN and TTE_MAX seconds from closing. +# Narrowing this window means entering later when direction is clearer. +ONESHOT_TTE_MIN=20 +ONESHOT_TTE_MAX=90 # Minimum shares at the best bid AND best ask for the depth hard gate ONESHOT_MIN_TOP_SIZE=10 +# ── Exit settings ────────────────────────────────────────────────────── + +# Emergency stop-loss: exit if the token's mid price drops below this absolute level. +# Protects against a complete market reversal (e.g. entered UP at 0.70, price drops to 0.18). +# Set to 0 to disable (pure hold-to-expiry — binary win/loss outcome). +ONESHOT_STOP_LOSS_MID=0.20 + # ── Risk settings ────────────────────────────────────────────────────── -# Number of consecutive losses before entering cooldown + +# Number of consecutive emergency exits (losses) before entering cooldown ONESHOT_MAX_CONSEC_LOSSES=2 # Number of market slots to skip during cooldown @@ -167,7 +188,7 @@ ONESHOT_DAILY_LOSS_CAP=20 # Maximum milliseconds to wait for a FOK fill ack (timeout → cancel → IDLE) ONESHOT_FILL_TIMEOUT_MS=800 -# Enable verbose debug logging (discovery probes, gate results, feature scores, heartbeat) +# Enable verbose debug logging (discovery probes, gate results, scoring, heartbeat) # Can also be enabled with: npm run oneshot-debug # Or on the command line: ONESHOT_DEBUG=true npm run oneshot ONESHOT_DEBUG=false diff --git a/src/oneshot.js b/src/oneshot.js index dd158c7..652a387 100644 --- a/src/oneshot.js +++ b/src/oneshot.js @@ -1,21 +1,23 @@ /** * src/oneshot.js - * Anti-Flip 5m OneShot Engine — main orchestrator entry point. + * Dominant Side Hold Engine — main orchestrator entry point. * - * Wires all seven engine services together via the central EventBus and - * manages a per-market StateMachine lifecycle. + * Strategy: enter the probable winner (dominant side, mid >= minDominantMid), + * hold the position until the market expires, then let redeemer.js claim + * the on-chain payout. There are no take-profit sells or momentum-based exits. * * 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 + * C+D → SignalEngine evaluates gates + dominant side, emits 'signal' + * E → Orchestrator submits FOK buy on ENTER signal * F → Fill handling (full / partial / timeout) * G → PositionEngine evaluates exit on each snapshot - * H → RiskEngine updated on every close + * H → RiskEngine updated on emergency exits only * * State machine (per market): - * IDLE → SETUP_READY → ORDER_PENDING → POSITION_OPEN → REDUCE_ONLY → IDLE + * IDLE → SETUP_READY → ORDER_PENDING → POSITION_OPEN → IDLE (expired) + * POSITION_OPEN → IDLE (emergency stop-loss exit) * ANY → COOLDOWN → IDLE * ANY → HALTED (terminal for the session) */ @@ -36,21 +38,23 @@ import { State, Signal, ReasonCode } from './oneshot/constants.js'; import { DEBUG, dbg } from './oneshot/debug.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', + 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'), + minDominantMid: parseFloat(process.env.ONESHOT_MIN_DOMINANT_MID || '0.60'), + stopLossMid: parseFloat(process.env.ONESHOT_STOP_LOSS_MID || '0.20'), + scoreThreshold: parseFloat(process.env.ONESHOT_SCORE_THRESHOLD || '0.55'), + pollIntervalMs: parseInt(process.env.ONESHOT_POLL_INTERVAL_MS || '300', 10), + minTopSize: parseFloat(process.env.ONESHOT_MIN_TOP_SIZE || '10'), + tteMin: parseInt(process.env.ONESHOT_TTE_MIN || '20', 10), + tteMax: parseInt(process.env.ONESHOT_TTE_MAX || '90', 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 ─────────────────────────────────────────────────────────── @@ -71,33 +75,42 @@ let telemetry; // ── Entry point ─────────────────────────────────────────────────────────────── async function main() { - logger.success('=== OneShot Anti-Flip Engine starting ==='); + logger.success('=== OneShot Dominant Side Hold 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}`); + logger.info( + `Strategy: enter dominant side (mid >= ${cfg.minDominantMid}) | ` + + `TTE window: ${cfg.tteMin}–${cfg.tteMax}s | hold to expiry`, + ); + logger.info( + `Risk: baseRisk=$${cfg.baseRiskUsdc} | stopLoss=${cfg.stopLossMid > 0 ? cfg.stopLossMid : 'disabled'} | ` + + `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 }); + posEngine = new PositionEngine({ stopLossMid: cfg.stopLossMid }); execEngine = new ExecutionEngine({ client, dryRun: cfg.dryRun, fillTimeoutMs: cfg.fillTimeoutMs }); featureEngine = new FeatureEngine({ eventBus }); signalEngine = new SignalEngine({ eventBus, - scoreThreshold: cfg.scoreThreshold, - minTopSize: cfg.minTopSize, + scoreThreshold: cfg.scoreThreshold, + minTopSize: cfg.minTopSize, + minDominantMid: cfg.minDominantMid, + tteMin: cfg.tteMin, + tteMax: cfg.tteMax, }); feedService = new MarketFeedService({ client, - assets: cfg.assets, - duration: cfg.duration, - pollIntervalMs: cfg.pollIntervalMs, + assets: cfg.assets, + duration: cfg.duration, + pollIntervalMs: cfg.pollIntervalMs, eventBus, }); @@ -107,11 +120,13 @@ async function main() { eventBus.on('state:transition', onStateTransition); await feedService.start(); - logger.success('OneShot Engine running — waiting for market signals...'); + logger.success('OneShot Engine running — waiting for dominant side signals...'); if (DEBUG) { - logger.info('[DBG] Debug mode active. Tags: FEED=discovery/poll, GATE=hard gates, FEAT=features, SCORE=scores, SIGNAL=entry trigger, SM=state changes'); - // Heartbeat: every 5s log the overall engine status + logger.info( + '[DBG] Debug mode active. Tags: FEED=discovery/poll, GATE=hard gates, ' + + 'SCORE=dominant side scoring, SIGNAL=entry trigger, SM=state changes, HEART=heartbeat', + ); setInterval(() => { const markets = feedService.activeMarkets; const states = markets.map((slug) => { @@ -133,10 +148,6 @@ async function main() { // ── 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; @@ -161,10 +172,11 @@ async function onSignal(evt) { if (signal === Signal.NO_TRADE) return; - // Only enter from IDLE + // Only enter from IDLE — one position per market slot if (!sm.is(State.IDLE)) return; - // ── Step H pre-check: risk gate ──────────────────────────────────────── + // ── Risk gate ───────────────────────────────────────────────────────── + const riskCheck = riskEngine.canTrade(); if (!riskCheck.ok) { @@ -177,16 +189,17 @@ async function onSignal(evt) { } // ── 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 + // Size: floor(baseRiskUSDC / entryPrice), minimum 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`, + `mid=${bookSide.mid.toFixed(4)} px=$${entryPrice} | size=${size} | score=${score.toFixed(3)} | tte=${snapshot.tteSec}s`, ); sm.transition(State.SETUP_READY, 'signal_passed'); @@ -201,7 +214,6 @@ async function onSignal(evt) { marketSlug, }); - // Log order lifecycle telemetry.logOrder({ clientOrderId: result.orderId, side: signal, @@ -214,6 +226,7 @@ async function onSignal(evt) { }); // ── Step F: fill handling ────────────────────────────────────────── + if (result.status === 'filled') { posEngine.open(marketSlug, { tokenId: bookSide.tokenId, @@ -225,35 +238,23 @@ async function onSignal(evt) { sm.transition(State.POSITION_OPEN, 'fill_confirmed'); logger.success( `OneShot: position OPEN | ${marketSlug} | ` + - `${result.filledSize} shares @ $${(result.avgFillPrice || entryPrice).toFixed(4)}`, + `${result.filledSize} shares @ $${(result.avgFillPrice || entryPrice).toFixed(4)} | ` + + `holding to expiry`, ); } 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`); - } + // Accept partial fill and hold to expiry + 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 | holding to expiry`); } 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); } @@ -268,57 +269,81 @@ async function onSignal(evt) { // ── 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)) { + // Clean up state machines for fully expired markets with no open position + if (tteSec < -10 && sm.is(State.IDLE)) { stateMachines.delete(marketSlug); return; } - if (!sm.is(State.POSITION_OPEN) && !sm.is(State.REDUCE_ONLY)) return; + if (!sm.is(State.POSITION_OPEN)) 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); + const exitResult = posEngine.evaluateExit(marketSlug, snapshot); - // 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); + // Market expired — position goes to on-chain redeemer + if (exitResult.isExpired) { + await expirePosition(marketSlug, pos); + return; } - // Execute exit if required + // Emergency stop-loss (catastrophic market reversal) if (exitResult.shouldExit) { + const bookSide = pos.side === 'up' ? snapshot.up : snapshot.down; await flattenPosition(marketSlug, pos, bookSide, exitResult.reason, snapshot); } } -// ── Flatten helper ──────────────────────────────────────────────────────────── +// ── Expire helper (market closed, pending on-chain redemption) ──────────────── + +async function expirePosition(marketSlug, pos) { + const sm = stateMachines.get(marketSlug); + if (!sm) return; + + logger.success( + `OneShot: market EXPIRED | ${marketSlug} | ` + + `${pos.shares} shares of ${pos.side.toUpperCase()} @ entry $${pos.entryPrice.toFixed(4)} | ` + + `pending on-chain redemption`, + ); + + posEngine.closeExpired(marketSlug); + + telemetry.logExit({ + marketSlug, + exitReason: ReasonCode.EXIT_EXPIRED, + entryPx: pos.entryPrice, + exitPx: null, // unknown until redemption settles + pnl: null, // settled on-chain by redeemer.js + shares: pos.shares, + }); + + if (sm.canTransitionTo(State.IDLE)) { + sm.transition(State.IDLE, ReasonCode.EXIT_EXPIRED); + } +} + +// ── Emergency flatten helper (adverse-move stop-loss only) ──────────────────── 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; + if (!sm || !sm.is(State.POSITION_OPEN)) return; const exitPrice = bookSide.bestBid; - logger.warn(`OneShot: flattening ${marketSlug} | reason=${reason} | exitPx=$${exitPrice}`); + logger.warn( + `OneShot: EMERGENCY EXIT | ${marketSlug} | reason=${reason} | ` + + `mid=${bookSide.mid.toFixed(4)} exitPx=$${exitPrice.toFixed(4)}`, + ); try { await execEngine.submitSell({ @@ -340,7 +365,6 @@ async function flattenPosition(marketSlug, pos, bookSide, reason, snapshot) { shares: pos.shares, }); - // Determine next state after close const { ok, halted } = riskEngine.canTrade(); if (halted && sm.canTransitionTo(State.HALTED)) { @@ -348,7 +372,7 @@ async function flattenPosition(marketSlug, pos, bookSide, reason, snapshot) { } else if (!ok && riskEngine.isCooldown() && sm.canTransitionTo(State.COOLDOWN)) { sm.transition(State.COOLDOWN, ReasonCode.RISK_CONSEC_LOSS); } else { - sm.transition(State.IDLE, `closed_${reason}`); + sm.transition(State.IDLE, `emergency_exit_${reason}`); } } catch (err) { @@ -360,7 +384,7 @@ async function flattenPosition(marketSlug, pos, bookSide, reason, snapshot) { function onStateTransition(evt) { telemetry.logTransition(evt); - logger.info(`[SM] ${evt.marketSlug}: ${evt.from} → ${evt.to} | ${evt.reason}`); + dbg('SM', `${evt.marketSlug}: ${evt.from} → ${evt.to} | ${evt.reason}`); } // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -378,11 +402,23 @@ async function shutdown() { logger.warn('OneShot: shutting down...'); feedService?.stop(); + // Report any positions still open at shutdown + const markets = feedService?.activeMarkets ?? []; + for (const slug of markets) { + const pos = posEngine?.getPosition(slug); + if (pos) { + logger.warn( + `OneShot: position still open at shutdown — ${slug} | ` + + `${pos.shares} shares @ $${pos.entryPrice.toFixed(4)} | redeemer.js will settle`, + ); + } + } + const stats = riskEngine?.stats(); if (stats) { const sign = stats.dailyPnl >= 0 ? '+' : ''; logger.money( - `Session summary | dailyPnl=${sign}$${stats.dailyPnl.toFixed(4)} | ` + + `Session summary | emergencyExitPnl=${sign}$${stats.dailyPnl.toFixed(4)} | ` + `consecLosses=${stats.consecLosses} | halted=${stats.halted}`, ); } diff --git a/src/oneshot/PositionEngine.js b/src/oneshot/PositionEngine.js index 52c2f91..bbad5a3 100644 --- a/src/oneshot/PositionEngine.js +++ b/src/oneshot/PositionEngine.js @@ -5,28 +5,30 @@ * 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) + * Strategy: Hold to Expiry (Dominant Side) + * ───────────────────────────────────────── + * Positions entered on the dominant (probable winner) side are held until + * the market expires and the payout is claimed via the on-chain redeemer. + * There are no take-profit sells, no momentum-based exits. + * + * Exit conditions (priority order): + * 1. EXIT_EXPIRED — TTE <= 0: market has closed, pending on-chain redemption + * 2. EXIT_ADVERSE_MOVE — Token mid has collapsed below the stop-loss floor + * (configurable absolute threshold, e.g. 0.20) + * Protects against a complete market reversal while still + * allowing normal price fluctuations in the dominant range. */ 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 + * @param {number} [opts.stopLossMid=0.20] - Exit if token mid falls below this absolute level. + * Set to 0 to disable the stop-loss entirely. */ - constructor({ tpTicks = 1 }) { - this._tpTicks = tpTicks; + constructor({ stopLossMid = 0.20 } = {}) { + this._stopLossMid = stopLossMid; /** @type {Map} */ this._positions = new Map(); @@ -53,9 +55,7 @@ export class PositionEngine { shares, entryPrice, tickSize, - openedAt: Date.now(), - tpPrice: this._roundToTick(entryPrice + this._tpTicks * tickSize, tickSize), - _slopeDropTs: null, // timestamp when slope first went <= 0 + openedAt: Date.now(), }); } @@ -69,10 +69,10 @@ export class PositionEngine { } /** - * Close the position and return exit data including realised P&L. + * Close the position actively (adverse-move emergency exit) and return exit data. * * @param {string} marketSlug - * @param {number} exitPrice - Actual fill price of the exit order + * @param {number} exitPrice - Actual fill price of the sell order * @returns {{ pnl: number, shares: number, entryPrice: number, exitPrice: number }} */ close(marketSlug, exitPrice) { @@ -85,72 +85,48 @@ export class PositionEngine { return { pnl, shares: pos.shares, entryPrice: pos.entryPrice, exitPrice }; } + /** + * Mark a position as expired (market closed, pending on-chain redemption). + * Does NOT compute final P&L — that is settled by the redeemer service. + * + * @param {string} marketSlug + * @returns {PositionState|null} + */ + closeExpired(marketSlug) { + const pos = this._positions.get(marketSlug) ?? null; + if (pos) this._positions.delete(marketSlug); + return pos; + } + // ── Exit evaluation ──────────────────────────────────────────────────── /** * Evaluate whether the current position should be exited. - * Called on every snapshot tick while in POSITION_OPEN or REDUCE_ONLY state. + * Called on every snapshot tick while in POSITION_OPEN 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 }} + * @returns {{ shouldExit: boolean, reason: string|null, isExpired: boolean }} */ - evaluateExit(marketSlug, snapshot, features) { + evaluateExit(marketSlug, snapshot) { const pos = this._positions.get(marketSlug); - if (!pos) return { shouldExit: false, reason: null, isReduceOnly: false }; + if (!pos) return { shouldExit: false, reason: null, isExpired: 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 }; + // 1. Market expired — hand off to on-chain redeemer + if (tteSec <= 0) { + return { shouldExit: false, reason: ReasonCode.EXIT_EXPIRED, isExpired: true }; } - // 2. Take-profit hit - if (bookSide.bestBid >= pos.tpPrice) { - return { shouldExit: true, reason: ReasonCode.EXIT_TP_HIT, isReduceOnly: false }; + // 2. Catastrophic stop-loss: token has completely collapsed + // (market reversed strongly against us — salvage remaining value) + if (this._stopLossMid > 0 && bookSide.mid < this._stopLossMid) { + return { shouldExit: true, reason: ReasonCode.EXIT_ADVERSE_MOVE, isExpired: 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; + return { shouldExit: false, reason: null, isExpired: false }; } } @@ -162,7 +138,5 @@ export class PositionEngine { * @property {number} shares * @property {number} entryPrice * @property {number} tickSize - * @property {number} tpPrice * @property {number} openedAt - * @property {number|null} _slopeDropTs */ diff --git a/src/oneshot/SignalEngine.js b/src/oneshot/SignalEngine.js index d94d445..e238eaf 100644 --- a/src/oneshot/SignalEngine.js +++ b/src/oneshot/SignalEngine.js @@ -2,12 +2,19 @@ * SignalEngine.js * Steps C & D of the runtime sequence. * + * Strategy: Dominant Side Hold + * ──────────────────────────── + * Unlike a scalper that chases momentum on any side, this engine enters ONLY the + * side that the market already considers the PROBABLE WINNER (mid > 50%). The + * position is then held to expiry (redeemed at $1.00 on-chain) rather than sold + * back to the order book. + * * 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 + * 1. Hard gate check — stale, TTE out of range, spread too wide, depth thin + * 2. Dominant side — compare up.mid vs down.mid; require a clear gap + * 3. Mid threshold — dominant side mid must be >= minDominantMid (e.g. 0.60) + * 4. Composite score — weighted (mid strength, imbalance, spread) + * 5. Emit signal — NO_TRADE (with reason) or ENTER_LONG / ENTER_SHORT * * Signal event shape: * { ts, marketSlug, tteSec, signal, side, score, reason, snapshot, features } @@ -17,41 +24,38 @@ import { Signal, ReasonCode } from './constants.js'; import { dbg, DEBUG } from './debug.js'; // ── Score weights ────────────────────────────────────────────────────────────── -const W_IMBALANCE = 0.35; -const W_SLOPE = 0.35; -const W_SPREAD = 0.20; -const W_RETRACE = 0.10; +// Mid price strength is the most important factor — it reflects market consensus. +const W_MID = 0.45; // How strongly the market favours this side +const W_IMBALANCE = 0.35; // Order-book depth confirms the dominant direction +const W_SPREAD = 0.20; // Execution cost (tight spread = better fill) -// ── 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 +// ── Thresholds ───────────────────────────────────────────────────────────────── +const MIN_MID_GAP = 0.05; // Minimum |up.mid - down.mid| to consider a side dominant +const SPREAD_TIGHT = 0.01; // Spread considered tight +const SPREAD_MAX = 0.02; // Gate maximum (hard gate uses this too) +const IMB_STRONG = 0.20; // Strong bid-side depth dominance +const IMB_WEAK = 0.05; // Mild bid-side depth dominance -/** Throttle debug output: log gate+score detail every N evaluations per market */ +/** Throttle debug output: log detail every N evaluations per market */ const DEBUG_EVERY = 5; 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) + * @param {number} opts.scoreThreshold - Minimum score to trigger entry (0–1) + * @param {number} opts.minTopSize - Minimum shares at best bid/ask for depth gate + * @param {number} opts.minDominantMid - Dominant side must have mid >= this (e.g. 0.60) + * @param {number} [opts.tteMin=20] - Minimum TTE in seconds + * @param {number} [opts.tteMax=90] - Maximum TTE in seconds */ - constructor({ eventBus, scoreThreshold, minTopSize, tteMin = 25, tteMax = 120 }) { - this._eventBus = eventBus; - this._scoreThreshold = scoreThreshold; - this._minTopSize = minTopSize; - this._tteMin = tteMin; - this._tteMax = tteMax; + constructor({ eventBus, scoreThreshold, minTopSize, minDominantMid = 0.60, tteMin = 20, tteMax = 90 }) { + this._eventBus = eventBus; + this._scoreThreshold = scoreThreshold; + this._minTopSize = minTopSize; + this._minDominantMid = minDominantMid; + this._tteMin = tteMin; + this._tteMax = tteMax; /** Per-market evaluation counter for throttled debug logs */ this._evalCount = new Map(); @@ -62,7 +66,7 @@ export class SignalEngine { // ── Internal ────────────────────────────────────────────────────────────── _onFeatures(feat) { - const { ts, marketSlug, tteSec, up, down, snapshot } = feat; + const { ts, marketSlug, tteSec, snapshot } = feat; // Track evaluation count for throttled debug output const evalN = (this._evalCount.get(marketSlug) ?? 0) + 1; @@ -83,7 +87,7 @@ export class SignalEngine { } else { dbg('GATE', `${marketSlug} | tte=${tteSec}s | PASS | ` + - `upSprd=${snapshot.up.spread.toFixed(4)} dnSprd=${snapshot.down.spread.toFixed(4)}`, + `upMid=${snapshot.up.mid.toFixed(4)} dnMid=${snapshot.down.mid.toFixed(4)}`, ); } } @@ -93,51 +97,81 @@ export class SignalEngine { return; } - // ── Step D: evaluate each side, pick best qualifying signal ───────── + // ── Step D: identify dominant side ────────────────────────────────── + // The dominant side is whichever token the market prices higher. + // We only ever buy the probable winner — never the underdog. - const upResult = this._evaluateSide(up, snapshot.up, marketSlug, 'UP', logThis); - const downResult = this._evaluateSide(down, snapshot.down, marketSlug, 'DOWN', logThis); + const upMid = snapshot.up.mid; + const downMid = snapshot.down.mid; + const midGap = Math.abs(upMid - downMid); - // Determine which side (if any) qualifies - const upQual = upResult.score >= this._scoreThreshold && upResult.confirmed; - const downQual = downResult.score >= this._scoreThreshold && downResult.confirmed; - - if (logThis) { - dbg('SCORE', - `${marketSlug} | threshold=${this._scoreThreshold} | ` + - `UP score=${upResult.score.toFixed(3)} confirmed=${upResult.confirmed} → ${upQual ? 'QUALIFY' : 'skip'} | ` + - `DOWN score=${downResult.score.toFixed(3)} confirmed=${downResult.confirmed} → ${downQual ? 'QUALIFY' : 'skip'}`, - ); - } - - if (!upQual && !downQual) { - 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); + if (midGap < MIN_MID_GAP) { + // Market is too balanced to pick a winner + if (logThis) { + dbg('SCORE', + `${marketSlug} | NO_DOMINANT | upMid=${upMid.toFixed(4)} dnMid=${downMid.toFixed(4)} ` + + `gap=${midGap.toFixed(4)} < ${MIN_MID_GAP}`, + ); + } + this._emit(marketSlug, Signal.NO_TRADE, null, 0, ReasonCode.SIG_NO_DOMINANT, ts, snapshot, feat); return; } - // Pick the stronger qualifying side - let signal; - let side; - let score; + const isDominantUp = upMid > downMid; + const dominantMid = isDominantUp ? upMid : downMid; + const dominantBook = isDominantUp ? snapshot.up : snapshot.down; + const dominantFeat = isDominantUp ? feat.up : feat.down; + const signal = isDominantUp ? Signal.ENTER_LONG : Signal.ENTER_SHORT; + const side = isDominantUp ? 'up' : 'down'; - 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; + // ── Minimum probability gate ───────────────────────────────────────── + // Require the dominant token to be priced at least minDominantMid. + // Below this threshold the market is too uncertain (e.g. 0.55 = only 55% + // confident — not worth the binary risk of holding to expiry). + + if (dominantMid < this._minDominantMid) { + if (logThis) { + dbg('SCORE', + `${marketSlug} | ${side.toUpperCase()} | LOW_DOMINANT | ` + + `mid=${dominantMid.toFixed(4)} < ${this._minDominantMid}`, + ); + } + this._emit(marketSlug, Signal.NO_TRADE, null, 0, ReasonCode.SIG_LOW_DOMINANT, ts, snapshot, feat); + return; + } + + // ── Composite score ────────────────────────────────────────────────── + + const midScore = this._scoreMid(dominantMid); + const imbalanceScore = this._scoreImbalance(dominantFeat.imbalance); + const spreadScore = this._scoreSpread(dominantBook.spread); + + const score = W_MID * midScore + W_IMBALANCE * imbalanceScore + W_SPREAD * spreadScore; + + if (logThis) { + dbg('SCORE', + `${marketSlug} | ${side.toUpperCase()} dominant | mid=${dominantMid.toFixed(4)} gap=${midGap.toFixed(4)} | ` + + `midS=${midScore.toFixed(2)} imbS=${imbalanceScore.toFixed(2)} sprdS=${spreadScore.toFixed(2)} ` + + `→ score=${score.toFixed(3)} (need ${this._scoreThreshold})`, + ); + } + + if (score < this._scoreThreshold) { + this._emit(marketSlug, Signal.NO_TRADE, null, score, ReasonCode.SIG_SCORE_LOW, ts, snapshot, feat); + return; } // Always log qualifying entries regardless of throttle - dbg('SIGNAL', `>>> ${signal} | ${marketSlug} | score=${score.toFixed(3)} | tte=${tteSec}s`); + dbg('SIGNAL', + `>>> ${signal} | ${marketSlug} | mid=${dominantMid.toFixed(4)} ` + + `score=${score.toFixed(3)} tte=${tteSec}s`, + ); this._emit(marketSlug, signal, side, score, null, ts, snapshot, feat); } + // ── Hard gates ──────────────────────────────────────────────────────────── + /** * Hard gates — any failure aborts the evaluation immediately. * @returns {{ pass: boolean, reason: string|null }} @@ -163,59 +197,35 @@ export class SignalEngine { return { pass: true, reason: null }; } - /** - * Score and confirm one side. - * @param {Object} sideFeatures - From FeatureEngine (midSlope6s, imbalance, ...) - * @param {Object} sideBook - Current BookSide from snapshot - * @param {string} marketSlug - For debug logs - * @param {string} label - 'UP' or 'DOWN' for debug logs - * @param {boolean} logThis - Whether to emit debug output this tick - * @returns {{ score: number, confirmed: boolean }} - */ - _evaluateSide(sideFeatures, sideBook, marketSlug, label, logThis) { - const { midSlope6s, retrace3s, imbalance, spread } = sideFeatures; - - 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; - - const confirmed = midSlope6s > CONFIRM_SLOPE && retrace3s < CONFIRM_RTRC; - - if (logThis) { - dbg('FEAT', - `${marketSlug} ${label} | ` + - `slope=${midSlope6s.toFixed(6)}(s=${slopeScore.toFixed(2)}) ` + - `imb=${imbalance.toFixed(3)}(s=${imbalanceScore.toFixed(2)}) ` + - `sprd=${spread.toFixed(4)}(s=${spreadScore.toFixed(2)}) ` + - `rtrc=${retrace3s.toFixed(3)}(s=${retraceScore.toFixed(2)}) ` + - `→ total=${score.toFixed(3)} confirm=${confirmed}`, - ); - } - - 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; + /** + * Score how strongly the market favours this side. + * Higher mid = market is more confident = higher score. + * 0.60–0.69 → 0.4 (marginal dominance, acceptable) + * 0.70–0.79 → 0.7 (solid dominance) + * 0.80–0.89 → 0.9 (strong dominance) + * 0.90+ → 1.0 (near-certain — but low payout) + */ + _scoreMid(mid) { + if (mid >= 0.90) return 1.0; + if (mid >= 0.80) return 0.9; + if (mid >= 0.70) return 0.7; + if (mid >= 0.60) return 0.4; return 0; } + /** + * Score order-book imbalance for the dominant side. + * Positive imbalance means more buy depth (bids > asks) — confirms direction. + * A mildly negative imbalance is tolerated (some ask pressure is normal). + */ _scoreImbalance(imb) { if (imb >= IMB_STRONG) return 1.0; - if (imb >= IMB_WEAK) return 0.5; - if (imb > 0) return 0.2; - return 0; + if (imb >= IMB_WEAK) return 0.7; + if (imb >= -0.10) return 0.4; // neutral to slight ask pressure — still ok + if (imb >= -0.25) return 0.1; // notable selling pressure — cautious + return 0; // strongly negative — skip } _scoreSpread(spread) { @@ -224,12 +234,6 @@ export class SignalEngine { 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, diff --git a/src/oneshot/constants.js b/src/oneshot/constants.js index 0d96760..55099f1 100644 --- a/src/oneshot/constants.js +++ b/src/oneshot/constants.js @@ -27,7 +27,9 @@ export const ReasonCode = Object.freeze({ // Signal evaluation failures SIG_SCORE_LOW: 'SIG_SCORE_LOW', // Composite score below threshold - SIG_NO_CONFIRM: 'SIG_NO_CONFIRM', // Trend confirmation failed + SIG_NO_CONFIRM: 'SIG_NO_CONFIRM', // Trend confirmation failed (legacy) + SIG_NO_DOMINANT: 'SIG_NO_DOMINANT', // Neither side is clearly dominant (mid gap too small) + SIG_LOW_DOMINANT: 'SIG_LOW_DOMINANT', // Dominant side mid below minimum threshold // Execution failures EXEC_TIMEOUT_NO_FILL: 'EXEC_TIMEOUT_NO_FILL', // FOK timed out without fill @@ -40,11 +42,8 @@ export const ReasonCode = Object.freeze({ 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_ADVERSE_MOVE: 'EXIT_ADVERSE_MOVE', // Token mid collapsed below stop-loss floor + EXIT_EXPIRED: 'EXIT_EXPIRED', // Market expired — position pending on-chain redemption EXIT_RISK_FORCED: 'EXIT_RISK_FORCED', // Risk engine forced exit });