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
+30 -9
View File
@@ -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 (01, 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 (01).
# 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
+125 -89
View File
@@ -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}`,
);
}
+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
});