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:
co-authored by
Claude Sonnet 4.6
parent
707d654749
commit
bf09d30376
+30
-9
@@ -127,9 +127,11 @@ SNIPER_SHARES=5
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# ONESHOT ENGINE (oneshot.js / npm run oneshot-sim)
|
||||
# Anti-Flip 5m microstructure execution engine.
|
||||
# Evaluates book features on every tick and enters only when
|
||||
# momentum, depth, and spread conditions align.
|
||||
# Dominant Side Hold strategy.
|
||||
# Enters ONLY the side that the market already prices as the probable
|
||||
# winner (mid >= ONESHOT_MIN_DOMINANT_MID), then holds the position
|
||||
# to market expiry for on-chain redemption at $1.00.
|
||||
# No take-profit sells. No momentum-based exits.
|
||||
# ALWAYS test with DRY_RUN=true before going live.
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
@@ -145,17 +147,36 @@ ONESHOT_POLL_INTERVAL_MS=300
|
||||
# USDC risk per trade — size = floor(ONESHOT_BASE_RISK_USDC / entryPrice), min 5 shares
|
||||
ONESHOT_BASE_RISK_USDC=5
|
||||
|
||||
# Take-profit in ticks above entry price (1 tick = tickSize, e.g. 0.01)
|
||||
ONESHOT_TP_TICKS=1
|
||||
# ── Entry filters ──────────────────────────────────────────────────────
|
||||
|
||||
# Minimum composite score to trigger entry (0–1, higher = more selective)
|
||||
ONESHOT_SCORE_THRESHOLD=0.60
|
||||
# Minimum mid price for the dominant side to qualify as an entry candidate.
|
||||
# Example: 0.60 means the token must be priced at ≥60% probability of winning.
|
||||
# Lower = more trades but more uncertain outcomes. Higher = fewer but more confident.
|
||||
ONESHOT_MIN_DOMINANT_MID=0.60
|
||||
|
||||
# Minimum composite score to trigger entry (0–1).
|
||||
# Score is based on: mid price strength (45%), order-book imbalance (35%), spread (20%).
|
||||
ONESHOT_SCORE_THRESHOLD=0.55
|
||||
|
||||
# TTE (time-to-expiry) window in seconds for entry.
|
||||
# Only enter when the market is between TTE_MIN and TTE_MAX seconds from closing.
|
||||
# Narrowing this window means entering later when direction is clearer.
|
||||
ONESHOT_TTE_MIN=20
|
||||
ONESHOT_TTE_MAX=90
|
||||
|
||||
# Minimum shares at the best bid AND best ask for the depth hard gate
|
||||
ONESHOT_MIN_TOP_SIZE=10
|
||||
|
||||
# ── Exit settings ──────────────────────────────────────────────────────
|
||||
|
||||
# Emergency stop-loss: exit if the token's mid price drops below this absolute level.
|
||||
# Protects against a complete market reversal (e.g. entered UP at 0.70, price drops to 0.18).
|
||||
# Set to 0 to disable (pure hold-to-expiry — binary win/loss outcome).
|
||||
ONESHOT_STOP_LOSS_MID=0.20
|
||||
|
||||
# ── Risk settings ──────────────────────────────────────────────────────
|
||||
# Number of consecutive losses before entering cooldown
|
||||
|
||||
# Number of consecutive emergency exits (losses) before entering cooldown
|
||||
ONESHOT_MAX_CONSEC_LOSSES=2
|
||||
|
||||
# Number of market slots to skip during cooldown
|
||||
@@ -167,7 +188,7 @@ ONESHOT_DAILY_LOSS_CAP=20
|
||||
# Maximum milliseconds to wait for a FOK fill ack (timeout → cancel → IDLE)
|
||||
ONESHOT_FILL_TIMEOUT_MS=800
|
||||
|
||||
# Enable verbose debug logging (discovery probes, gate results, feature scores, heartbeat)
|
||||
# Enable verbose debug logging (discovery probes, gate results, scoring, heartbeat)
|
||||
# Can also be enabled with: npm run oneshot-debug
|
||||
# Or on the command line: ONESHOT_DEBUG=true npm run oneshot
|
||||
ONESHOT_DEBUG=false
|
||||
|
||||
+125
-89
@@ -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}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -2,12 +2,19 @@
|
||||
* SignalEngine.js
|
||||
* Steps C & D of the runtime sequence.
|
||||
*
|
||||
* Strategy: Dominant Side Hold
|
||||
* ────────────────────────────
|
||||
* Unlike a scalper that chases momentum on any side, this engine enters ONLY the
|
||||
* side that the market already considers the PROBABLE WINNER (mid > 50%). The
|
||||
* position is then held to expiry (redeemed at $1.00 on-chain) rather than sold
|
||||
* back to the order book.
|
||||
*
|
||||
* Pipeline per features event:
|
||||
* 1. Hard gate check — immediately reject on any hard failure
|
||||
* 2. Side selection — evaluate UP and DOWN sides independently
|
||||
* 3. Score — weighted composite (imbalance, slope, spread, retrace)
|
||||
* 4. Trend confirm — slope positive + retrace small
|
||||
* 5. Emit signal — NO_TRADE (with reason) or ENTER_LONG / ENTER_SHORT
|
||||
* 1. Hard gate check — stale, TTE out of range, spread too wide, depth thin
|
||||
* 2. Dominant side — compare up.mid vs down.mid; require a clear gap
|
||||
* 3. Mid threshold — dominant side mid must be >= minDominantMid (e.g. 0.60)
|
||||
* 4. Composite score — weighted (mid strength, imbalance, spread)
|
||||
* 5. Emit signal — NO_TRADE (with reason) or ENTER_LONG / ENTER_SHORT
|
||||
*
|
||||
* Signal event shape:
|
||||
* { ts, marketSlug, tteSec, signal, side, score, reason, snapshot, features }
|
||||
@@ -17,41 +24,38 @@ import { Signal, ReasonCode } from './constants.js';
|
||||
import { dbg, DEBUG } from './debug.js';
|
||||
|
||||
// ── Score weights ──────────────────────────────────────────────────────────────
|
||||
const W_IMBALANCE = 0.35;
|
||||
const W_SLOPE = 0.35;
|
||||
const W_SPREAD = 0.20;
|
||||
const W_RETRACE = 0.10;
|
||||
// Mid price strength is the most important factor — it reflects market consensus.
|
||||
const W_MID = 0.45; // How strongly the market favours this side
|
||||
const W_IMBALANCE = 0.35; // Order-book depth confirms the dominant direction
|
||||
const W_SPREAD = 0.20; // Execution cost (tight spread = better fill)
|
||||
|
||||
// ── Scoring thresholds ─────────────────────────────────────────────────────────
|
||||
const SLOPE_STRONG = 0.0015; // Strong momentum (price/sample)
|
||||
const SLOPE_WEAK = 0.0003; // Weak-but-positive momentum
|
||||
const IMB_STRONG = 0.25; // Strong bid-side dominance
|
||||
const IMB_WEAK = 0.08; // Mild bid-side dominance
|
||||
const SPREAD_TIGHT = 0.01; // Tight spread
|
||||
const SPREAD_MAX = 0.02; // Gate maximum (hard gate uses this too)
|
||||
const RETRACE_SMALL = 0.15; // Essentially no retrace
|
||||
const RETRACE_MID = 0.35; // Moderate retrace
|
||||
const CONFIRM_SLOPE = 0.0001; // Minimum positive slope for trend confirmation
|
||||
const CONFIRM_RTRC = 0.30; // Maximum retrace for trend confirmation
|
||||
// ── Thresholds ─────────────────────────────────────────────────────────────────
|
||||
const MIN_MID_GAP = 0.05; // Minimum |up.mid - down.mid| to consider a side dominant
|
||||
const SPREAD_TIGHT = 0.01; // Spread considered tight
|
||||
const SPREAD_MAX = 0.02; // Gate maximum (hard gate uses this too)
|
||||
const IMB_STRONG = 0.20; // Strong bid-side depth dominance
|
||||
const IMB_WEAK = 0.05; // Mild bid-side depth dominance
|
||||
|
||||
/** Throttle debug output: log gate+score detail every N evaluations per market */
|
||||
/** Throttle debug output: log detail every N evaluations per market */
|
||||
const DEBUG_EVERY = 5;
|
||||
|
||||
export class SignalEngine {
|
||||
/**
|
||||
* @param {Object} opts
|
||||
* @param {import('./EventBus.js').default} opts.eventBus
|
||||
* @param {number} opts.scoreThreshold - Minimum score to trigger entry (0–1)
|
||||
* @param {number} opts.minTopSize - Minimum shares at best bid/ask for gate
|
||||
* @param {number} [opts.tteMin=25] - Minimum TTE in seconds (gate lower bound)
|
||||
* @param {number} [opts.tteMax=120] - Maximum TTE in seconds (gate upper bound)
|
||||
* @param {number} opts.scoreThreshold - Minimum score to trigger entry (0–1)
|
||||
* @param {number} opts.minTopSize - Minimum shares at best bid/ask for depth gate
|
||||
* @param {number} opts.minDominantMid - Dominant side must have mid >= this (e.g. 0.60)
|
||||
* @param {number} [opts.tteMin=20] - Minimum TTE in seconds
|
||||
* @param {number} [opts.tteMax=90] - Maximum TTE in seconds
|
||||
*/
|
||||
constructor({ eventBus, scoreThreshold, minTopSize, tteMin = 25, tteMax = 120 }) {
|
||||
this._eventBus = eventBus;
|
||||
this._scoreThreshold = scoreThreshold;
|
||||
this._minTopSize = minTopSize;
|
||||
this._tteMin = tteMin;
|
||||
this._tteMax = tteMax;
|
||||
constructor({ eventBus, scoreThreshold, minTopSize, minDominantMid = 0.60, tteMin = 20, tteMax = 90 }) {
|
||||
this._eventBus = eventBus;
|
||||
this._scoreThreshold = scoreThreshold;
|
||||
this._minTopSize = minTopSize;
|
||||
this._minDominantMid = minDominantMid;
|
||||
this._tteMin = tteMin;
|
||||
this._tteMax = tteMax;
|
||||
|
||||
/** Per-market evaluation counter for throttled debug logs */
|
||||
this._evalCount = new Map();
|
||||
@@ -62,7 +66,7 @@ export class SignalEngine {
|
||||
// ── Internal ──────────────────────────────────────────────────────────────
|
||||
|
||||
_onFeatures(feat) {
|
||||
const { ts, marketSlug, tteSec, up, down, snapshot } = feat;
|
||||
const { ts, marketSlug, tteSec, snapshot } = feat;
|
||||
|
||||
// Track evaluation count for throttled debug output
|
||||
const evalN = (this._evalCount.get(marketSlug) ?? 0) + 1;
|
||||
@@ -83,7 +87,7 @@ export class SignalEngine {
|
||||
} else {
|
||||
dbg('GATE',
|
||||
`${marketSlug} | tte=${tteSec}s | PASS | ` +
|
||||
`upSprd=${snapshot.up.spread.toFixed(4)} dnSprd=${snapshot.down.spread.toFixed(4)}`,
|
||||
`upMid=${snapshot.up.mid.toFixed(4)} dnMid=${snapshot.down.mid.toFixed(4)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -93,51 +97,81 @@ export class SignalEngine {
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Step D: evaluate each side, pick best qualifying signal ─────────
|
||||
// ── Step D: identify dominant side ──────────────────────────────────
|
||||
// The dominant side is whichever token the market prices higher.
|
||||
// We only ever buy the probable winner — never the underdog.
|
||||
|
||||
const upResult = this._evaluateSide(up, snapshot.up, marketSlug, 'UP', logThis);
|
||||
const downResult = this._evaluateSide(down, snapshot.down, marketSlug, 'DOWN', logThis);
|
||||
const upMid = snapshot.up.mid;
|
||||
const downMid = snapshot.down.mid;
|
||||
const midGap = Math.abs(upMid - downMid);
|
||||
|
||||
// Determine which side (if any) qualifies
|
||||
const upQual = upResult.score >= this._scoreThreshold && upResult.confirmed;
|
||||
const downQual = downResult.score >= this._scoreThreshold && downResult.confirmed;
|
||||
|
||||
if (logThis) {
|
||||
dbg('SCORE',
|
||||
`${marketSlug} | threshold=${this._scoreThreshold} | ` +
|
||||
`UP score=${upResult.score.toFixed(3)} confirmed=${upResult.confirmed} → ${upQual ? 'QUALIFY' : 'skip'} | ` +
|
||||
`DOWN score=${downResult.score.toFixed(3)} confirmed=${downResult.confirmed} → ${downQual ? 'QUALIFY' : 'skip'}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!upQual && !downQual) {
|
||||
const dominant = upResult.score >= downResult.score ? upResult : downResult;
|
||||
const reason = dominant.confirmed ? ReasonCode.SIG_SCORE_LOW : ReasonCode.SIG_NO_CONFIRM;
|
||||
this._emit(marketSlug, Signal.NO_TRADE, null, dominant.score, reason, ts, snapshot, feat);
|
||||
if (midGap < MIN_MID_GAP) {
|
||||
// Market is too balanced to pick a winner
|
||||
if (logThis) {
|
||||
dbg('SCORE',
|
||||
`${marketSlug} | NO_DOMINANT | upMid=${upMid.toFixed(4)} dnMid=${downMid.toFixed(4)} ` +
|
||||
`gap=${midGap.toFixed(4)} < ${MIN_MID_GAP}`,
|
||||
);
|
||||
}
|
||||
this._emit(marketSlug, Signal.NO_TRADE, null, 0, ReasonCode.SIG_NO_DOMINANT, ts, snapshot, feat);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pick the stronger qualifying side
|
||||
let signal;
|
||||
let side;
|
||||
let score;
|
||||
const isDominantUp = upMid > downMid;
|
||||
const dominantMid = isDominantUp ? upMid : downMid;
|
||||
const dominantBook = isDominantUp ? snapshot.up : snapshot.down;
|
||||
const dominantFeat = isDominantUp ? feat.up : feat.down;
|
||||
const signal = isDominantUp ? Signal.ENTER_LONG : Signal.ENTER_SHORT;
|
||||
const side = isDominantUp ? 'up' : 'down';
|
||||
|
||||
if (upQual && (!downQual || upResult.score >= downResult.score)) {
|
||||
signal = Signal.ENTER_LONG;
|
||||
side = 'up';
|
||||
score = upResult.score;
|
||||
} else {
|
||||
signal = Signal.ENTER_SHORT;
|
||||
side = 'down';
|
||||
score = downResult.score;
|
||||
// ── Minimum probability gate ─────────────────────────────────────────
|
||||
// Require the dominant token to be priced at least minDominantMid.
|
||||
// Below this threshold the market is too uncertain (e.g. 0.55 = only 55%
|
||||
// confident — not worth the binary risk of holding to expiry).
|
||||
|
||||
if (dominantMid < this._minDominantMid) {
|
||||
if (logThis) {
|
||||
dbg('SCORE',
|
||||
`${marketSlug} | ${side.toUpperCase()} | LOW_DOMINANT | ` +
|
||||
`mid=${dominantMid.toFixed(4)} < ${this._minDominantMid}`,
|
||||
);
|
||||
}
|
||||
this._emit(marketSlug, Signal.NO_TRADE, null, 0, ReasonCode.SIG_LOW_DOMINANT, ts, snapshot, feat);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Composite score ──────────────────────────────────────────────────
|
||||
|
||||
const midScore = this._scoreMid(dominantMid);
|
||||
const imbalanceScore = this._scoreImbalance(dominantFeat.imbalance);
|
||||
const spreadScore = this._scoreSpread(dominantBook.spread);
|
||||
|
||||
const score = W_MID * midScore + W_IMBALANCE * imbalanceScore + W_SPREAD * spreadScore;
|
||||
|
||||
if (logThis) {
|
||||
dbg('SCORE',
|
||||
`${marketSlug} | ${side.toUpperCase()} dominant | mid=${dominantMid.toFixed(4)} gap=${midGap.toFixed(4)} | ` +
|
||||
`midS=${midScore.toFixed(2)} imbS=${imbalanceScore.toFixed(2)} sprdS=${spreadScore.toFixed(2)} ` +
|
||||
`→ score=${score.toFixed(3)} (need ${this._scoreThreshold})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (score < this._scoreThreshold) {
|
||||
this._emit(marketSlug, Signal.NO_TRADE, null, score, ReasonCode.SIG_SCORE_LOW, ts, snapshot, feat);
|
||||
return;
|
||||
}
|
||||
|
||||
// Always log qualifying entries regardless of throttle
|
||||
dbg('SIGNAL', `>>> ${signal} | ${marketSlug} | score=${score.toFixed(3)} | tte=${tteSec}s`);
|
||||
dbg('SIGNAL',
|
||||
`>>> ${signal} | ${marketSlug} | mid=${dominantMid.toFixed(4)} ` +
|
||||
`score=${score.toFixed(3)} tte=${tteSec}s`,
|
||||
);
|
||||
|
||||
this._emit(marketSlug, signal, side, score, null, ts, snapshot, feat);
|
||||
}
|
||||
|
||||
// ── Hard gates ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hard gates — any failure aborts the evaluation immediately.
|
||||
* @returns {{ pass: boolean, reason: string|null }}
|
||||
@@ -163,59 +197,35 @@ export class SignalEngine {
|
||||
return { pass: true, reason: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Score and confirm one side.
|
||||
* @param {Object} sideFeatures - From FeatureEngine (midSlope6s, imbalance, ...)
|
||||
* @param {Object} sideBook - Current BookSide from snapshot
|
||||
* @param {string} marketSlug - For debug logs
|
||||
* @param {string} label - 'UP' or 'DOWN' for debug logs
|
||||
* @param {boolean} logThis - Whether to emit debug output this tick
|
||||
* @returns {{ score: number, confirmed: boolean }}
|
||||
*/
|
||||
_evaluateSide(sideFeatures, sideBook, marketSlug, label, logThis) {
|
||||
const { midSlope6s, retrace3s, imbalance, spread } = sideFeatures;
|
||||
|
||||
const slopeScore = this._scoreSlope(midSlope6s);
|
||||
const imbalanceScore = this._scoreImbalance(imbalance);
|
||||
const spreadScore = this._scoreSpread(spread);
|
||||
const retraceScore = this._scoreRetrace(retrace3s);
|
||||
|
||||
const score =
|
||||
W_SLOPE * slopeScore +
|
||||
W_IMBALANCE * imbalanceScore +
|
||||
W_SPREAD * spreadScore +
|
||||
W_RETRACE * retraceScore;
|
||||
|
||||
const confirmed = midSlope6s > CONFIRM_SLOPE && retrace3s < CONFIRM_RTRC;
|
||||
|
||||
if (logThis) {
|
||||
dbg('FEAT',
|
||||
`${marketSlug} ${label} | ` +
|
||||
`slope=${midSlope6s.toFixed(6)}(s=${slopeScore.toFixed(2)}) ` +
|
||||
`imb=${imbalance.toFixed(3)}(s=${imbalanceScore.toFixed(2)}) ` +
|
||||
`sprd=${spread.toFixed(4)}(s=${spreadScore.toFixed(2)}) ` +
|
||||
`rtrc=${retrace3s.toFixed(3)}(s=${retraceScore.toFixed(2)}) ` +
|
||||
`→ total=${score.toFixed(3)} confirm=${confirmed}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { score, confirmed };
|
||||
}
|
||||
|
||||
// ── Scoring helpers ───────────────────────────────────────────────────────
|
||||
|
||||
_scoreSlope(slope) {
|
||||
if (slope >= SLOPE_STRONG) return 1.0;
|
||||
if (slope >= SLOPE_WEAK) return 0.5;
|
||||
if (slope > 0) return 0.2;
|
||||
/**
|
||||
* Score how strongly the market favours this side.
|
||||
* Higher mid = market is more confident = higher score.
|
||||
* 0.60–0.69 → 0.4 (marginal dominance, acceptable)
|
||||
* 0.70–0.79 → 0.7 (solid dominance)
|
||||
* 0.80–0.89 → 0.9 (strong dominance)
|
||||
* 0.90+ → 1.0 (near-certain — but low payout)
|
||||
*/
|
||||
_scoreMid(mid) {
|
||||
if (mid >= 0.90) return 1.0;
|
||||
if (mid >= 0.80) return 0.9;
|
||||
if (mid >= 0.70) return 0.7;
|
||||
if (mid >= 0.60) return 0.4;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Score order-book imbalance for the dominant side.
|
||||
* Positive imbalance means more buy depth (bids > asks) — confirms direction.
|
||||
* A mildly negative imbalance is tolerated (some ask pressure is normal).
|
||||
*/
|
||||
_scoreImbalance(imb) {
|
||||
if (imb >= IMB_STRONG) return 1.0;
|
||||
if (imb >= IMB_WEAK) return 0.5;
|
||||
if (imb > 0) return 0.2;
|
||||
return 0;
|
||||
if (imb >= IMB_WEAK) return 0.7;
|
||||
if (imb >= -0.10) return 0.4; // neutral to slight ask pressure — still ok
|
||||
if (imb >= -0.25) return 0.1; // notable selling pressure — cautious
|
||||
return 0; // strongly negative — skip
|
||||
}
|
||||
|
||||
_scoreSpread(spread) {
|
||||
@@ -224,12 +234,6 @@ export class SignalEngine {
|
||||
return 0;
|
||||
}
|
||||
|
||||
_scoreRetrace(retrace) {
|
||||
if (retrace <= RETRACE_SMALL) return 1.0;
|
||||
if (retrace <= RETRACE_MID) return 0.5;
|
||||
return 0;
|
||||
}
|
||||
|
||||
_emit(marketSlug, signal, side, score, reason, ts, snapshot, features) {
|
||||
this._eventBus.emit('signal', {
|
||||
ts,
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user