refactor(oneshot): switch from scalper to Dominant Side Hold strategy

Previous behaviour: enter any side with positive momentum, exit at TP (+1 tick),
cycle back to IDLE — causing rapid buy-sell-buy loops on low-probability tokens.

New strategy:
- Enter ONLY the side the market already prices as probable winner (mid >= MIN_DOMINANT_MID)
- Hold position to market expiry; on-chain redeemer settles at $1.00 win / $0.00 loss
- Emergency stop-loss only (absolute mid floor, e.g. 0.20) for catastrophic reversals
- One entry per market slot — no re-entry while POSITION_OPEN

Key changes:
- SignalEngine: detect dominant side (up.mid vs down.mid), require MIN_DOMINANT_MID
  threshold, new scoring weights (mid 45% / imbalance 35% / spread 20%)
- PositionEngine: remove TP, slope-drop, time-reduce exits; add expired handler;
  stop-loss is now an absolute mid floor instead of relative-to-entry ticks
- oneshot.js: expirePosition() clears state without submitting sell orders;
  flattenPosition() only called for emergency stops; update cfg vars
- constants.js: add SIG_NO_DOMINANT, SIG_LOW_DOMINANT, EXIT_EXPIRED reason codes
- .env.example: replace ONESHOT_TP_TICKS with MIN_DOMINANT_MID, STOP_LOSS_MID,
  TTE_MIN, TTE_MAX

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
direkturcrypto
2026-02-24 14:05:44 +07:00
co-authored by Claude Sonnet 4.6
parent 707d654749
commit bf09d30376
5 changed files with 325 additions and 291 deletions
+44 -70
View File
@@ -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<string, PositionState>} */
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
*/
+121 -117
View File
@@ -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 (01)
* @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 (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
*/
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.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)
*/
_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,
+5 -6
View File
@@ -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
});