fix(oneshot): add momentum scoring, widen gates to fix rare-entry problem

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 <noreply@anthropic.com>
This commit is contained in:
direkturcrypto
2026-02-24 14:48:36 +07:00
co-authored by Claude Sonnet 4.6
parent 89a7803aa8
commit 8296c4129b
4 changed files with 152 additions and 94 deletions
+11 -8
View File
@@ -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.550.65. The momentum gate provides additional conviction filtering.
ONESHOT_MIN_DOMINANT_MID=0.58
# Minimum composite score to trigger entry (01).
# 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
+4 -4
View File
@@ -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'),
+136 -82
View File
@@ -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 (01)
* @param {number} opts.scoreThreshold - Minimum composite score to trigger entry (01)
* @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.600.69 → 0.4 (marginal dominance, acceptable)
* 0.700.79 → 0.7 (solid dominance)
* 0.800.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.600.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;
}
+1
View File
@@ -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