From 8296c4129bc84366944bf853a69bd754819eed18 Mon Sep 17 00:00:00 2001 From: direkturcrypto Date: Tue, 24 Feb 2026 14:48:36 +0700 Subject: [PATCH] fix(oneshot): add momentum scoring, widen gates to fix rare-entry problem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root causes identified and fixed: 1. SPREAD_MAX 0.02 → 0.04 (biggest culprit — near-expiry books often have 0.03 spread, hard gate was blocking all valid entries) 2. tteMax 90s → 150s (direction is established by TTE=150s on 5m markets; previous 90s window was too narrow, skipped the "trend building" phase) 3. Added momentum as scoring factor W_MOMENTUM=0.30 ("follow where odds are moving" — midSlope6s from FeatureEngine now drives 30% of entry score) 4. Added SLOPE_CANCEL momentum gate (-0.0020): if dominant side's mid is actively falling (reversal risk), block entry regardless of mid level. New reason code: SIG_FADING_DOMINANT 5. Revised score weights: MID 45%→35%, IMBALANCE 35%→20%, SPREAD 20%→15%, MOMENTUM 0%→30% 6. Score threshold 0.55 → 0.42 (now calibrated for 4-factor scoring) 7. minDominantMid default 0.60 → 0.58, tteMin 20 → 15 8. Spread gate now checks min(up.spread, down.spread) — dominant side only needs to be tradeable, not both sides Updated .env and .env.example defaults to match new parameters. Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 19 ++-- src/oneshot.js | 8 +- src/oneshot/SignalEngine.js | 218 ++++++++++++++++++++++-------------- src/oneshot/constants.js | 1 + 4 files changed, 152 insertions(+), 94 deletions(-) diff --git a/.env.example b/.env.example index f21bb6e..a985e73 100644 --- a/.env.example +++ b/.env.example @@ -150,19 +150,22 @@ ONESHOT_BASE_RISK_USDC=5 # ── Entry filters ────────────────────────────────────────────────────── # 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. +# Example: 0.58 means the token must be priced at ≥58% probability of winning. # Lower = more trades but more uncertain outcomes. Higher = fewer but more confident. -ONESHOT_MIN_DOMINANT_MID=0.60 +# Recommended range: 0.55–0.65. The momentum gate provides additional conviction filtering. +ONESHOT_MIN_DOMINANT_MID=0.58 # 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 +# Score = mid strength (35%) + momentum direction (30%) + book imbalance (20%) + spread (15%). +# Lower threshold captures more "decent but not perfect" setups. +ONESHOT_SCORE_THRESHOLD=0.42 # 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 +# TTE_MAX=150 captures the "direction establishment" phase (last 2.5 minutes). +# TTE_MIN=15 ensures enough time to get a fill before market locks. +# Tighter window = higher conviction but fewer entries per session. +ONESHOT_TTE_MIN=15 +ONESHOT_TTE_MAX=150 # Minimum shares at the best bid AND best ask for the depth hard gate ONESHOT_MIN_TOP_SIZE=10 diff --git a/src/oneshot.js b/src/oneshot.js index e76d978..4dc81e6 100644 --- a/src/oneshot.js +++ b/src/oneshot.js @@ -44,13 +44,13 @@ 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'), - minDominantMid: parseFloat(process.env.ONESHOT_MIN_DOMINANT_MID || '0.60'), + minDominantMid: parseFloat(process.env.ONESHOT_MIN_DOMINANT_MID || '0.58'), stopLossMid: parseFloat(process.env.ONESHOT_STOP_LOSS_MID || '0.20'), - scoreThreshold: parseFloat(process.env.ONESHOT_SCORE_THRESHOLD || '0.55'), + scoreThreshold: parseFloat(process.env.ONESHOT_SCORE_THRESHOLD || '0.42'), 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), + tteMin: parseInt(process.env.ONESHOT_TTE_MIN || '15', 10), + tteMax: parseInt(process.env.ONESHOT_TTE_MAX || '150', 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'), diff --git a/src/oneshot/SignalEngine.js b/src/oneshot/SignalEngine.js index e238eaf..d4f4c9d 100644 --- a/src/oneshot/SignalEngine.js +++ b/src/oneshot/SignalEngine.js @@ -2,39 +2,54 @@ * 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. + * Strategy: Dominant Side Hold — Momentum-Aware Entry + * ──────────────────────────────────────────────────── + * Enters ONLY the side that the market already prices as probable winner + * (mid > 50%) AND whose price is either rising or stable. * - * Pipeline per features event: - * 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 + * "Follow where the odds are moving" — midSlope6s from FeatureEngine is now + * a first-class scoring factor. A dominant side that is actively FADING + * (slope < SLOPE_CANCEL) is blocked entirely even if its mid is still > 0.60, + * because a fading dominant signals a potential reversal. * - * Signal event shape: - * { ts, marketSlug, tteSec, signal, side, score, reason, snapshot, features } + * Entry pipeline (per 'features' event): + * 1. Hard gates — stale, TTE out of [tteMin, tteMax], spread > SPREAD_MAX, depth thin + * 2. Dominant side — identify which token the market prices higher; require mid gap >= MIN_MID_GAP + * 3. Min probability — dominant mid must be >= minDominantMid (e.g. 0.58) + * 4. Momentum gate — dominant midSlope6s must be >= SLOPE_CANCEL (not actively fading) + * 5. Score — weighted: mid strength (35%) + momentum (30%) + imbalance (20%) + spread (15%) + * 6. Threshold — score >= scoreThreshold + * + * Key parameter changes vs previous version: + * - SPREAD_MAX: 0.02 → 0.04 (near-expiry books often have 0.03 spread) + * - tteMax: 90 → 150s (catch direction when it is being established) + * - Added W_MOMENTUM = 0.30 (replaces old W_SLOPE/W_RETRACE scalper metrics) + * - Added momentum gate (SIG_FADING_DOMINANT) to block reversals */ import { Signal, ReasonCode } from './constants.js'; import { dbg, DEBUG } from './debug.js'; // ── Score weights ────────────────────────────────────────────────────────────── -// 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) +const W_MID = 0.35; // How strongly the market prices this side as winner +const W_MOMENTUM = 0.30; // Is the dominant odds direction being maintained? +const W_IMBALANCE = 0.20; // Order-book depth confirms the direction +const W_SPREAD = 0.15; // Execution cost (less critical for hold-to-expiry) -// ── 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 +// ── Gate thresholds ──────────────────────────────────────────────────────────── +const SPREAD_MAX = 0.04; // Hard gate: spread wider than this → skip +const MIN_MID_GAP = 0.08; // Hard gate: |up.mid - down.mid| must exceed this + +// ── Momentum constants ───────────────────────────────────────────────────────── +// SLOPE_CANCEL: if dominant side's 6s slope is below this, the market may be +// reversing — block entry even if mid is still above threshold. +const SLOPE_CANCEL = -0.0020; // Active fade = potential reversal, do not enter +const SLOPE_STRONG = 0.0020; // Clearly rising — best signal +const SLOPE_MILD = 0.0005; // Gently rising — still good + +// ── Imbalance constants ──────────────────────────────────────────────────────── +const IMB_STRONG = 0.20; +const IMB_WEAK = 0.05; /** Throttle debug output: log detail every N evaluations per market */ const DEBUG_EVERY = 5; @@ -43,13 +58,13 @@ 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.scoreThreshold - Minimum composite 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 + * @param {number} opts.minDominantMid - Dominant side mid must be >= this (e.g. 0.58) + * @param {number} [opts.tteMin=15] - Minimum TTE in seconds + * @param {number} [opts.tteMax=150] - Maximum TTE in seconds */ - constructor({ eventBus, scoreThreshold, minTopSize, minDominantMid = 0.60, tteMin = 20, tteMax = 90 }) { + constructor({ eventBus, scoreThreshold, minTopSize, minDominantMid = 0.58, tteMin = 15, tteMax = 150 }) { this._eventBus = eventBus; this._scoreThreshold = scoreThreshold; this._minTopSize = minTopSize; @@ -68,12 +83,11 @@ export class SignalEngine { _onFeatures(feat) { const { ts, marketSlug, tteSec, snapshot } = feat; - // Track evaluation count for throttled debug output const evalN = (this._evalCount.get(marketSlug) ?? 0) + 1; this._evalCount.set(marketSlug, evalN); const logThis = DEBUG && (evalN % DEBUG_EVERY === 1); - // ── Step C: hard gate check ───────────────────────────────────────── + // ── Step C: hard gates ────────────────────────────────────────────── const gate = this._hardGates(snapshot, tteSec); @@ -81,13 +95,13 @@ export class SignalEngine { if (!gate.pass) { dbg('GATE', `${marketSlug} | tte=${tteSec}s | FAIL → ${gate.reason} | ` + - `upSprd=${snapshot.up.spread.toFixed(4)} dnSprd=${snapshot.down.spread.toFixed(4)} ` + - `upBidSz=${snapshot.up.bestBidSize.toFixed(1)} upAskSz=${snapshot.up.bestAskSize.toFixed(1)}`, + `upSprd=${snapshot.up.spread.toFixed(3)} dnSprd=${snapshot.down.spread.toFixed(3)} ` + + `upMid=${snapshot.up.mid.toFixed(3)} dnMid=${snapshot.down.mid.toFixed(3)}`, ); } else { dbg('GATE', `${marketSlug} | tte=${tteSec}s | PASS | ` + - `upMid=${snapshot.up.mid.toFixed(4)} dnMid=${snapshot.down.mid.toFixed(4)}`, + `upMid=${snapshot.up.mid.toFixed(3)} dnMid=${snapshot.down.mid.toFixed(3)}`, ); } } @@ -97,61 +111,79 @@ export class SignalEngine { return; } - // ── Step D: identify dominant side ────────────────────────────────── + // ── Step D1: identify dominant side ───────────────────────────────── // The dominant side is whichever token the market prices higher. - // We only ever buy the probable winner — never the underdog. const upMid = snapshot.up.mid; const downMid = snapshot.down.mid; const midGap = Math.abs(upMid - downMid); 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}`, + `${marketSlug} | NO_DOMINANT | upMid=${upMid.toFixed(3)} dnMid=${downMid.toFixed(3)} ` + + `gap=${midGap.toFixed(3)} < ${MIN_MID_GAP}`, ); } this._emit(marketSlug, Signal.NO_TRADE, null, 0, ReasonCode.SIG_NO_DOMINANT, ts, snapshot, feat); return; } - 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'; + 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'; + const slope = dominantFeat?.midSlope6s ?? 0; - // ── 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). + // ── Step D2: minimum probability gate ─────────────────────────────── if (dominantMid < this._minDominantMid) { if (logThis) { dbg('SCORE', `${marketSlug} | ${side.toUpperCase()} | LOW_DOMINANT | ` + - `mid=${dominantMid.toFixed(4)} < ${this._minDominantMid}`, + `mid=${dominantMid.toFixed(3)} < ${this._minDominantMid}`, ); } this._emit(marketSlug, Signal.NO_TRADE, null, 0, ReasonCode.SIG_LOW_DOMINANT, ts, snapshot, feat); return; } - // ── Composite score ────────────────────────────────────────────────── + // ── Step D3: momentum gate ─────────────────────────────────────────── + // If the dominant side's price is actively falling, the market may be + // reversing. A fading dominant is more dangerous than a weak dominant. + + if (slope < SLOPE_CANCEL) { + if (logThis) { + dbg('SCORE', + `${marketSlug} | ${side.toUpperCase()} | FADING | ` + + `slope=${slope.toFixed(5)} < ${SLOPE_CANCEL} (reversal risk)`, + ); + } + this._emit(marketSlug, Signal.NO_TRADE, null, 0, ReasonCode.SIG_FADING_DOMINANT, ts, snapshot, feat); + return; + } + + // ── Step D4: composite score ───────────────────────────────────────── const midScore = this._scoreMid(dominantMid); - const imbalanceScore = this._scoreImbalance(dominantFeat.imbalance); + const momentumScore = this._scoreMomentum(slope); + const imbalanceScore = this._scoreImbalance(dominantFeat?.imbalance ?? 0); const spreadScore = this._scoreSpread(dominantBook.spread); - const score = W_MID * midScore + W_IMBALANCE * imbalanceScore + W_SPREAD * spreadScore; + const score = + W_MID * midScore + + W_MOMENTUM * momentumScore + + 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)} ` + + `${marketSlug} | ${side.toUpperCase()} dominant | ` + + `mid=${dominantMid.toFixed(3)} gap=${midGap.toFixed(3)} slope=${slope.toFixed(5)} | ` + + `midS=${midScore.toFixed(2)} momS=${momentumScore.toFixed(2)} ` + + `imbS=${imbalanceScore.toFixed(2)} sprdS=${spreadScore.toFixed(2)} ` + `→ score=${score.toFixed(3)} (need ${this._scoreThreshold})`, ); } @@ -163,7 +195,8 @@ export class SignalEngine { // Always log qualifying entries regardless of throttle dbg('SIGNAL', - `>>> ${signal} | ${marketSlug} | mid=${dominantMid.toFixed(4)} ` + + `>>> ${signal} | ${marketSlug} | ` + + `mid=${dominantMid.toFixed(3)} slope=${slope.toFixed(5)} ` + `score=${score.toFixed(3)} tte=${tteSec}s`, ); @@ -172,10 +205,6 @@ export class SignalEngine { // ── Hard gates ──────────────────────────────────────────────────────────── - /** - * 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 }; @@ -183,9 +212,13 @@ export class SignalEngine { 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) + // Use the dominant side's spread only — underdog's spread is irrelevant + // since we never buy the underdog. + const dominantSpread = Math.min(snapshot.up.spread, snapshot.down.spread); + if (dominantSpread > SPREAD_MAX) return { pass: false, reason: ReasonCode.GATE_SPREAD_WIDE }; + // Require adequate depth on at least one side (dominant side check happens after) const thinUp = snapshot.up.bestBidSize < this._minTopSize || snapshot.up.bestAskSize < this._minTopSize; const thinDown = snapshot.down.bestBidSize < this._minTopSize @@ -200,37 +233,58 @@ export class SignalEngine { // ── Scoring helpers ─────────────────────────────────────────────────────── /** - * 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) + * Score market confidence in the dominant side. + * Higher mid price = market is more certain = higher score. + * Entry "sweet spot" is 0.60–0.80 (clear direction, still worth holding). */ _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; + if (mid >= 0.85) return 1.00; + if (mid >= 0.75) return 0.85; + if (mid >= 0.65) return 0.65; + if (mid >= 0.58) return 0.40; 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). + * Score the momentum (direction) of the dominant side's price movement. + * This is the "follow where the odds are moving" factor. + * + * Positive slope = dominant side is getting more expensive = conviction increasing. + * Flat slope = direction held, acceptable. + * Mild negative = slight give-back, cautious but still allowed. + * SLOPE_CANCEL = actively fading = blocked by momentum gate before reaching here. */ - _scoreImbalance(imb) { - if (imb >= IMB_STRONG) return 1.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 + _scoreMomentum(slope) { + if (slope >= SLOPE_STRONG) return 1.00; // Strong, fast move in dominant direction + if (slope >= SLOPE_MILD) return 0.75; // Steady climb + if (slope >= 0) return 0.50; // Flat / holding + if (slope >= -0.0005) return 0.20; // Slight give-back — cautious + return 0.05; // Between -0.0005 and SLOPE_CANCEL — marginal } + /** + * Score order-book imbalance for the dominant side. + * Positive = more buy depth on dominant side = confirms direction. + * Mildly negative = tolerated (sellers exist on winner too, normal). + */ + _scoreImbalance(imb) { + if (imb >= IMB_STRONG) return 1.00; + if (imb >= IMB_WEAK) return 0.70; + if (imb >= -0.10) return 0.40; // Neutral to slight sell pressure + if (imb >= -0.25) return 0.10; // Notable sell pressure + return 0; + } + + /** + * Score execution cost (spread). + * For hold-to-expiry the spread is paid once at entry, so wider spreads + * are more tolerated than in a scalping strategy — hence 4 tiers up to SPREAD_MAX. + */ _scoreSpread(spread) { - if (spread <= SPREAD_TIGHT) return 1.0; - if (spread <= SPREAD_MAX) return 0.5; + if (spread <= 0.01) return 1.00; + if (spread <= 0.02) return 0.70; + if (spread <= 0.03) return 0.40; + if (spread <= 0.04) return 0.10; return 0; } diff --git a/src/oneshot/constants.js b/src/oneshot/constants.js index 55099f1..7263350 100644 --- a/src/oneshot/constants.js +++ b/src/oneshot/constants.js @@ -30,6 +30,7 @@ export const ReasonCode = Object.freeze({ 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 + SIG_FADING_DOMINANT: 'SIG_FADING_DOMINANT', // Dominant side mid is actively falling — reversal risk // Execution failures EXEC_TIMEOUT_NO_FILL: 'EXEC_TIMEOUT_NO_FILL', // FOK timed out without fill