feat(oneshot): add Anti-Flip 5m microstructure execution engine

Introduces a complete, event-driven execution engine for 5-minute
Polymarket UP/DOWN markets, implementing the Anti-Flip strategy spec.

Architecture
────────────
• EventBus          — central pub/sub bus connecting all services
• MarketFeedService — discovers 5m/15m markets via Gamma API, polls
                      CLOB orderbooks every 200–500ms, emits snapshots
• FeatureEngine     — maintains a 15s rolling buffer per market and
                      computes midSlope6s, retrace3s, imbalance, spread,
                      depthTop3 for both UP and DOWN sides
• SignalEngine      — hard gate checks (TTE, spread, depth, stale) then
                      weighted score (imbalance 35%, slope 35%,
                      spread 20%, retrace 10%) + trend confirmation
• ExecutionEngine   — limit-marketable FOK buy, market-sell FOK exit,
                      GTC limit-sell for TP; dry-run short-circuits
• RiskEngine        — consecutive loss cooldown, daily USDC loss cap,
                      session halt; all via explicit canTrade() gate
• PositionEngine    — per-market position state, TP/adverse/slope/time
                      exit evaluation on every snapshot tick
• StateMachine      — explicit state graph with guarded transitions:
                      IDLE → SETUP_READY → ORDER_PENDING → POSITION_OPEN
                      → REDUCE_ONLY → IDLE | COOLDOWN | HALTED
• Telemetry         — structured JSONL logger (data/oneshot_telemetry.jsonl)
                      recording decisions, orders, exits, and transitions

Runtime sequence (per market, per tick)
────────────────────────────────────────
A  Ingest snapshot (MarketFeedService)
B  Build features — rolling slope, retrace, imbalance (FeatureEngine)
C  Hard gate check — TTE [25,120]s, spread ≤ 0.02, depth ≥ minTopSize
D  Score + trend confirm → emit ENTER_LONG / ENTER_SHORT / NO_TRADE
E  Submit FOK limit-marketable at bestAsk
F  Fill handling — full fill / partial (reduce if TTE ≤ 25s) / timeout
G  Position management — TP, adverse (2-tick), slope drop (4s), time exits
H  Risk enforcement — P&L accounting, cooldown, daily halt

New scripts
───────────
  npm run oneshot      — live trading  (DRY_RUN=false)
  npm run oneshot-sim  — simulation    (DRY_RUN=true)
  npm run oneshot-dev  — sim + nodemon

New .env variables
──────────────────
  ONESHOT_ASSETS, ONESHOT_DURATION, ONESHOT_POLL_INTERVAL_MS,
  ONESHOT_BASE_RISK_USDC, ONESHOT_TP_TICKS, ONESHOT_SCORE_THRESHOLD,
  ONESHOT_MIN_TOP_SIZE, ONESHOT_MAX_CONSEC_LOSSES,
  ONESHOT_COOLDOWN_ROUNDS, ONESHOT_DAILY_LOSS_CAP, ONESHOT_FILL_TIMEOUT_MS

Co-Authored-By: direkturcrypto <direkturcrypto.x@mail3.me>
This commit is contained in:
direkturcrypto
2026-02-24 13:00:19 +07:00
parent 526076fe6e
commit f074ca9ecb
13 changed files with 1858 additions and 1 deletions
+42
View File
@@ -124,3 +124,45 @@ SNIPER_PRICE=0.01
# Shares per side — minimum Polymarket order size is 5 shares
# At $0.01/share: 5 shares = $0.05 per side, $0.10 per market
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.
# ALWAYS test with DRY_RUN=true before going live.
# ─────────────────────────────────────────────
# Comma-separated assets to monitor (e.g. btc,eth,sol)
ONESHOT_ASSETS=btc
# Market duration: "5m" (5-minute) or "15m" (15-minute)
ONESHOT_DURATION=5m
# Book poll interval in milliseconds (200500ms recommended)
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
# Minimum composite score to trigger entry (01, higher = more selective)
ONESHOT_SCORE_THRESHOLD=0.60
# Minimum shares at the best bid AND best ask for the depth hard gate
ONESHOT_MIN_TOP_SIZE=10
# ── Risk settings ──────────────────────────────────────────────────────
# Number of consecutive losses before entering cooldown
ONESHOT_MAX_CONSEC_LOSSES=2
# Number of market slots to skip during cooldown
ONESHOT_COOLDOWN_ROUNDS=3
# Maximum cumulative daily loss in USDC before halting all trading
ONESHOT_DAILY_LOSS_CAP=20
# Maximum milliseconds to wait for a FOK fill ack (timeout → cancel → IDLE)
ONESHOT_FILL_TIMEOUT_MS=800
+4 -1
View File
@@ -12,7 +12,10 @@
"mm-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/mm.js",
"sniper": "DRY_RUN=false node src/sniper.js",
"sniper-sim": "DRY_RUN=true node src/sniper.js",
"sniper-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/sniper.js"
"sniper-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/sniper.js",
"oneshot": "DRY_RUN=false node src/oneshot.js",
"oneshot-sim": "DRY_RUN=true node src/oneshot.js",
"oneshot-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/oneshot.js"
},
"keywords": [
"polymarket",
+379
View File
@@ -0,0 +1,379 @@
/**
* src/oneshot.js
* Anti-Flip 5m OneShot Engine — main orchestrator entry point.
*
* Wires all seven engine services together via the central EventBus and
* manages a per-market StateMachine lifecycle.
*
* 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
* F → Fill handling (full / partial / timeout)
* G → PositionEngine evaluates exit on each snapshot
* H → RiskEngine updated on every close
*
* State machine (per market):
* IDLE → SETUP_READY → ORDER_PENDING → POSITION_OPEN → REDUCE_ONLY → IDLE
* ANY → COOLDOWN → IDLE
* ANY → HALTED (terminal for the session)
*/
import { initClient, getClient } from './services/client.js';
import logger from './utils/logger.js';
import eventBus from './oneshot/EventBus.js';
import { StateMachine } from './oneshot/StateMachine.js';
import { MarketFeedService } from './oneshot/MarketFeedService.js';
import { FeatureEngine } from './oneshot/FeatureEngine.js';
import { SignalEngine } from './oneshot/SignalEngine.js';
import { ExecutionEngine } from './oneshot/ExecutionEngine.js';
import { RiskEngine } from './oneshot/RiskEngine.js';
import { PositionEngine } from './oneshot/PositionEngine.js';
import { Telemetry } from './oneshot/Telemetry.js';
import { State, Signal, ReasonCode } from './oneshot/constants.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',
};
// ── Per-market state ───────────────────────────────────────────────────────────
/** @type {Map<string, StateMachine>} */
const stateMachines = new Map();
// ── Service instances ─────────────────────────────────────────────────────────
let feedService;
let featureEngine;
let signalEngine;
let execEngine;
let riskEngine;
let posEngine;
let telemetry;
// ── Entry point ───────────────────────────────────────────────────────────────
async function main() {
logger.success('=== OneShot Anti-Flip 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}`);
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 });
execEngine = new ExecutionEngine({ client, dryRun: cfg.dryRun, fillTimeoutMs: cfg.fillTimeoutMs });
featureEngine = new FeatureEngine({ eventBus });
signalEngine = new SignalEngine({
eventBus,
scoreThreshold: cfg.scoreThreshold,
minTopSize: cfg.minTopSize,
});
feedService = new MarketFeedService({
client,
assets: cfg.assets,
duration: cfg.duration,
pollIntervalMs: cfg.pollIntervalMs,
eventBus,
});
// Wire orchestrator handlers
eventBus.on('signal', onSignal);
eventBus.on('snapshot', onSnapshotForPositionMgmt);
eventBus.on('state:transition', onStateTransition);
await feedService.start();
logger.success('OneShot Engine running — waiting for market signals...');
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
// ── 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;
const sm = getOrCreateSM(marketSlug);
// Log every evaluation tick for later analysis
const sideFeatures = side ? features[side] : (features.up ?? features.down ?? {});
telemetry.logDecision({
marketSlug,
ts: snapshot.ts,
tteSec: snapshot.tteSec,
spread: sideFeatures.spread ?? 0,
imbalance: sideFeatures.imbalance ?? 0,
slope: sideFeatures.midSlope6s ?? 0,
retrace: sideFeatures.retrace3s ?? 0,
depth: sideFeatures.depthTop3 ?? 0,
gatePass: signal !== Signal.NO_TRADE,
reasonCode: reason ?? '',
score,
action: signal,
});
if (signal === Signal.NO_TRADE) return;
// Only enter from IDLE
if (!sm.is(State.IDLE)) return;
// ── Step H pre-check: risk gate ────────────────────────────────────────
const riskCheck = riskEngine.canTrade();
if (!riskCheck.ok) {
if (riskCheck.halted && sm.canTransitionTo(State.HALTED)) {
sm.transition(State.HALTED, ReasonCode.RISK_DAILY_CAP);
} else if (riskEngine.isCooldown()) {
riskEngine.decrementCooldown();
}
return;
}
// ── 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
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`,
);
sm.transition(State.SETUP_READY, 'signal_passed');
try {
sm.transition(State.ORDER_PENDING, 'submitting');
const result = await execEngine.submitBuy({
tokenId: bookSide.tokenId,
size,
price: entryPrice,
marketSlug,
});
// Log order lifecycle
telemetry.logOrder({
clientOrderId: result.orderId,
side: signal,
marketSlug,
px: entryPrice,
qty: size,
ackMs: result.ackMs,
fillMs: result.fillMs,
status: result.status,
});
// ── Step F: fill handling ──────────────────────────────────────────
if (result.status === 'filled') {
posEngine.open(marketSlug, {
tokenId: bookSide.tokenId,
side,
shares: result.filledSize,
entryPrice: result.avgFillPrice || entryPrice,
tickSize: snapshot.tickSize,
});
sm.transition(State.POSITION_OPEN, 'fill_confirmed');
logger.success(
`OneShot: position OPEN | ${marketSlug} | ` +
`${result.filledSize} shares @ $${(result.avgFillPrice || entryPrice).toFixed(4)}`,
);
} 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`);
}
} 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);
}
} catch (err) {
logger.error(`OneShot: order error on ${marketSlug}${err.message}`);
if (sm.is(State.ORDER_PENDING) || sm.is(State.SETUP_READY)) {
sm.transition(State.IDLE, ReasonCode.EXEC_SUBMIT_ERROR);
}
}
}
// ── 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)) {
stateMachines.delete(marketSlug);
return;
}
if (!sm.is(State.POSITION_OPEN) && !sm.is(State.REDUCE_ONLY)) 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);
// 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);
}
// Execute exit if required
if (exitResult.shouldExit) {
await flattenPosition(marketSlug, pos, bookSide, exitResult.reason, snapshot);
}
}
// ── Flatten helper ────────────────────────────────────────────────────────────
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;
const exitPrice = bookSide.bestBid;
logger.warn(`OneShot: flattening ${marketSlug} | reason=${reason} | exitPx=$${exitPrice}`);
try {
await execEngine.submitSell({
tokenId: pos.tokenId,
size: pos.shares,
price: exitPrice,
marketSlug,
});
const exitData = posEngine.close(marketSlug, exitPrice);
riskEngine.recordResult(exitData.pnl);
telemetry.logExit({
marketSlug,
exitReason: reason,
entryPx: pos.entryPrice,
exitPx: exitPrice,
pnl: exitData.pnl,
shares: pos.shares,
});
// Determine next state after close
const { ok, halted } = riskEngine.canTrade();
if (halted && sm.canTransitionTo(State.HALTED)) {
sm.transition(State.HALTED, ReasonCode.RISK_DAILY_CAP);
} else if (!ok && riskEngine.isCooldown() && sm.canTransitionTo(State.COOLDOWN)) {
sm.transition(State.COOLDOWN, ReasonCode.RISK_CONSEC_LOSS);
} else {
sm.transition(State.IDLE, `closed_${reason}`);
}
} catch (err) {
logger.error(`OneShot: flatten error on ${marketSlug}${err.message}`);
}
}
// ── State transition logging ──────────────────────────────────────────────────
function onStateTransition(evt) {
telemetry.logTransition(evt);
logger.info(`[SM] ${evt.marketSlug}: ${evt.from}${evt.to} | ${evt.reason}`);
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function getOrCreateSM(marketSlug) {
if (!stateMachines.has(marketSlug)) {
stateMachines.set(marketSlug, new StateMachine(marketSlug, eventBus));
}
return stateMachines.get(marketSlug);
}
// ── Graceful shutdown ─────────────────────────────────────────────────────────
async function shutdown() {
logger.warn('OneShot: shutting down...');
feedService?.stop();
const stats = riskEngine?.stats();
if (stats) {
const sign = stats.dailyPnl >= 0 ? '+' : '';
logger.money(
`Session summary | dailyPnl=${sign}$${stats.dailyPnl.toFixed(4)} | ` +
`consecLosses=${stats.consecLosses} | halted=${stats.halted}`,
);
}
process.exit(0);
}
// ── Bootstrap ─────────────────────────────────────────────────────────────────
main().catch((err) => {
logger.error(`OneShot fatal: ${err.message}`);
process.exit(1);
});
+22
View File
@@ -0,0 +1,22 @@
/**
* EventBus.js
* Central event bus for the OneShot engine.
* All inter-service communication flows through this singleton.
*
* Event catalogue:
* snapshot MarketFeedService → FeatureEngine, orchestrator
* features FeatureEngine → SignalEngine, orchestrator
* signal SignalEngine → orchestrator
* state:transition StateMachine → orchestrator, Telemetry
*/
import { EventEmitter } from 'events';
class OneShotEventBus extends EventEmitter {}
const bus = new OneShotEventBus();
// Prevent memory-leak warnings for high subscriber counts across many markets
bus.setMaxListeners(50);
export default bus;
+221
View File
@@ -0,0 +1,221 @@
/**
* ExecutionEngine.js
* Steps E & F of the runtime sequence.
*
* Responsibilities:
* - Submit a limit-marketable FOK BUY order at bestAsk
* - Wait up to fillTimeoutMs for an ack/fill response
* - Return structured fill result (filled | partial | cancelled)
* - Submit market-sell (FOK) for exits and cut-losses
* - Place GTC limit-sell for take-profit orders
*
* In dry-run mode all calls short-circuit with simulated successful results.
*/
import { Side, OrderType } from '@polymarket/clob-client';
import logger from '../utils/logger.js';
const FILL_TIMEOUT_MS = 800;
export class ExecutionEngine {
/**
* @param {Object} opts
* @param {import('@polymarket/clob-client').ClobClient} opts.client
* @param {boolean} opts.dryRun
* @param {number} [opts.fillTimeoutMs=800]
*/
constructor({ client, dryRun, fillTimeoutMs = FILL_TIMEOUT_MS }) {
this._client = client;
this._dryRun = dryRun;
this._fillTimeoutMs = fillTimeoutMs;
/** Cache tick sizes to avoid repeated API calls */
this._tickCache = new Map();
}
// ── Buy ───────────────────────────────────────────────────────────────────
/**
* Submit a limit-marketable FOK buy and wait for the fill result.
*
* @param {Object} opts
* @param {string} opts.tokenId - ERC1155 token ID (UP or DOWN)
* @param {number} opts.size - Number of shares to buy (≥ 5)
* @param {number} opts.price - Limit price (bestAsk from snapshot)
* @param {string} opts.marketSlug - For logging
*
* @returns {Promise<FillResult>}
*/
async submitBuy({ tokenId, size, price, marketSlug }) {
if (this._dryRun) {
logger.trade(`[SIM] BUY ${marketSlug} | ${size} shares @ $${price}`);
return {
orderId: `sim_buy_${Date.now()}`,
status: 'filled',
filledSize: size,
avgFillPrice: price,
ackMs: 45,
fillMs: 90,
};
}
const startTs = Date.now();
const { tickSize, negRisk } = await this._getMarketOpts(tokenId);
logger.trade(`BUY ${marketSlug} | ${size} shares @ $${price}`);
const response = await this._withTimeout(
this._client.createAndPostOrder(
{ tokenID: tokenId, price: price.toString(), size, side: Side.BUY },
{ tickSize, negRisk },
OrderType.FOK,
),
this._fillTimeoutMs,
);
const ackMs = Date.now() - startTs;
const fillMs = ackMs;
if (!response?.success) {
logger.warn(`ExecutionEngine: buy not filled — ${response?.errorMsg ?? 'no response'}`);
return { orderId: null, status: 'cancelled', filledSize: 0, ackMs, fillMs };
}
const takingAmt = parseFloat(response.takingAmount || '0');
const makingAmt = parseFloat(response.makingAmount || '0');
if (takingAmt > 0) {
const avgFillPrice = makingAmt > 0 ? makingAmt / takingAmt : price;
logger.success(`ExecutionEngine: filled ${takingAmt.toFixed(2)} shares @ avg $${avgFillPrice.toFixed(4)}`);
return { orderId: response.orderID, status: 'filled', filledSize: takingAmt, avgFillPrice, ackMs, fillMs };
}
// Some CLOB responses indicate fill via status string rather than amounts
const isMatched = /matched|filled/i.test(response.status ?? '');
if (isMatched || response.success) {
return { orderId: response.orderID, status: 'filled', filledSize: size, avgFillPrice: price, ackMs, fillMs };
}
return { orderId: response.orderID, status: 'cancelled', filledSize: 0, ackMs, fillMs };
}
// ── Sell (exit / cut-loss) ────────────────────────────────────────────────
/**
* Submit a market-sell FOK order to exit a position immediately.
*
* @param {Object} opts
* @param {string} opts.tokenId
* @param {number} opts.size - Shares to sell
* @param {number} opts.price - Minimum acceptable sell price (5% slippage floor applied internally)
* @param {string} opts.marketSlug
*/
async submitSell({ tokenId, size, price, marketSlug }) {
if (this._dryRun) {
logger.trade(`[SIM] SELL ${marketSlug} | ${size} shares @ ~$${price}`);
return { orderId: `sim_sell_${Date.now()}`, status: 'filled' };
}
const { tickSize, negRisk } = await this._getMarketOpts(tokenId);
const minPrice = Math.max(price * 0.95, 0.01);
logger.trade(`SELL ${marketSlug} | ${size} shares @ min $${minPrice.toFixed(4)}`);
const response = await this._client.createAndPostMarketOrder(
{ tokenID: tokenId, side: Side.SELL, amount: size, price: minPrice },
{ tickSize, negRisk },
OrderType.FOK,
).catch((err) => {
logger.warn(`ExecutionEngine: sell error — ${err.message}`);
return null;
});
const filled = response?.success ?? false;
if (!filled) logger.warn(`ExecutionEngine: sell not filled — ${response?.errorMsg ?? 'unknown'}`);
return { orderId: response?.orderID ?? null, status: filled ? 'filled' : 'failed' };
}
/**
* Place a GTC limit-sell order for take-profit.
* Returns the order ID so the caller can cancel it if exit conditions change.
*
* @param {Object} opts
* @param {string} opts.tokenId
* @param {number} opts.size - Shares to sell
* @param {number} opts.tpPrice - Exact target sell price (aligned to tick size)
* @param {string} opts.marketSlug
*/
async submitTPOrder({ tokenId, size, tpPrice, marketSlug }) {
if (this._dryRun) {
logger.trade(`[SIM] TP ORDER ${marketSlug} | ${size} shares @ $${tpPrice}`);
return { orderId: `sim_tp_${Date.now()}`, status: 'placed' };
}
const { tickSize, negRisk } = await this._getMarketOpts(tokenId);
const response = await this._client.createAndPostOrder(
{ tokenID: tokenId, price: tpPrice.toString(), size, side: Side.SELL },
{ tickSize, negRisk },
OrderType.GTC,
).catch((err) => {
logger.warn(`ExecutionEngine: TP order error — ${err.message}`);
return null;
});
const placed = response?.success ?? false;
logger.info(`ExecutionEngine: TP order ${placed ? 'placed' : 'failed'} | ${marketSlug} @ $${tpPrice}`);
return { orderId: response?.orderID ?? null, status: placed ? 'placed' : 'failed' };
}
/** Cancel an open order by order ID */
async cancelOrder(orderId) {
if (this._dryRun || !orderId) return;
try {
await this._client.cancelOrder({ orderID: orderId });
} catch (err) {
logger.warn(`ExecutionEngine: cancel failed for ${orderId}${err.message}`);
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
async _getMarketOpts(tokenId) {
if (this._tickCache.has(tokenId)) return this._tickCache.get(tokenId);
let tickSize = '0.01';
let negRisk = false;
try {
tickSize = String(await this._client.getTickSize(tokenId) ?? '0.01');
negRisk = await this._client.getNegRisk(tokenId).catch(() => false) ?? false;
} catch { /* use defaults */ }
const opts = { tickSize, negRisk };
this._tickCache.set(tokenId, opts);
return opts;
}
/**
* Wrap a promise with a hard timeout.
* Resolves to null on timeout rather than rejecting — execution layer
* treats null as a no-fill and transitions back to IDLE cleanly.
*/
_withTimeout(promise, ms) {
return Promise.race([
promise,
new Promise((resolve) => setTimeout(() => resolve(null), ms)),
]);
}
}
/**
* @typedef {Object} FillResult
* @property {string|null} orderId
* @property {'filled'|'partial'|'cancelled'} status
* @property {number} filledSize
* @property {number} avgFillPrice
* @property {number} ackMs
* @property {number} fillMs
*/
+174
View File
@@ -0,0 +1,174 @@
/**
* FeatureEngine.js
* Step B of the runtime sequence.
*
* Maintains a rolling 15-second buffer of market snapshots per market
* and computes the following features on each incoming snapshot:
*
* midSlope6s — Linear regression slope of the mid price over the last 6s
* (positive = upward momentum, unit: price change per second)
* retrace3s — Fractional pullback from the 6s rolling peak to current mid
* (0 = no retrace, 1 = fully retraced to baseline)
* imbalance — (depthBid - depthAsk) / (depthBid + depthAsk)
* (positive = buyers dominate, negative = sellers dominate)
* spread — Current bestAsk - bestBid
* depthTop3 — Sum of the top-3 bid levels (buy-side depth at best prices)
*
* Features are computed independently for both UP and DOWN book sides.
*
* Emits a 'features' event on the event bus with shape:
* { ts, marketSlug, tteSec, up: SideFeatures, down: SideFeatures, snapshot }
*/
const BUFFER_WINDOW_MS = 15_000;
const SLOPE_WINDOW_MS = 6_000;
const RETRACE_PEAK_MS = 6_000; // Look-back window for peak in retrace calc
const DEPTH_TOP_N = 3;
export class FeatureEngine {
/**
* @param {Object} opts
* @param {import('./EventBus.js').default} opts.eventBus
*/
constructor({ eventBus }) {
this._eventBus = eventBus;
/** @type {Map<string, Array<{ts, up_mid, down_mid, up_spread, up_depthBid, up_depthAsk, up_bestBidSize, up_bestAskSize, down_spread, down_depthBid, down_depthAsk, down_bestBidSize, down_bestAskSize}>>} */
this._buffers = new Map();
/** @type {Map<string, Object>} Most recent features per market */
this._latest = new Map();
this._eventBus.on('snapshot', (snap) => this._onSnapshot(snap));
}
/** Retrieve the most recently computed features for a given market */
getLatest(marketSlug) {
return this._latest.get(marketSlug) ?? null;
}
// ── Internal ──────────────────────────────────────────────────────────────
_onSnapshot(snap) {
const { marketSlug, ts, tteSec, up, down } = snap;
// Add to rolling buffer
if (!this._buffers.has(marketSlug)) this._buffers.set(marketSlug, []);
const buf = this._buffers.get(marketSlug);
buf.push({
ts,
up_mid: up.mid,
up_spread: up.spread,
up_depthBid: up.depthBid,
up_depthAsk: up.depthAsk,
up_bestBidSize: up.bestBidSize,
up_bestAskSize: up.bestAskSize,
down_mid: down.mid,
down_spread: down.spread,
down_depthBid: down.depthBid,
down_depthAsk: down.depthAsk,
down_bestBidSize: down.bestBidSize,
down_bestAskSize: down.bestAskSize,
});
// Evict entries older than the buffer window
const cutoff = ts - BUFFER_WINDOW_MS;
while (buf.length > 0 && buf[0].ts < cutoff) buf.shift();
const features = {
ts,
marketSlug,
tteSec,
up: this._computeSideFeatures(buf, 'up', up),
down: this._computeSideFeatures(buf, 'down', down),
snapshot: snap,
};
this._latest.set(marketSlug, features);
this._eventBus.emit('features', features);
}
/**
* Compute all features for one book side using the rolling buffer.
*
* @param {Array} buf - Rolling buffer entries (ascending ts)
* @param {string} side - 'up' or 'down'
* @param {Object} currentBook - Live BookSide from current snapshot
*/
_computeSideFeatures(buf, side, currentBook) {
const now = buf[buf.length - 1]?.ts ?? Date.now();
const midKey = `${side}_mid`;
// Slice for slope window (last 6s)
const slopeBuf = buf.filter((e) => e.ts >= now - SLOPE_WINDOW_MS);
const mids6s = slopeBuf.map((e) => e[midKey]);
// Slice for retrace peak look-back (last 6s)
const retraceBuf = buf.filter((e) => e.ts >= now - RETRACE_PEAK_MS);
const midsRetrace = retraceBuf.map((e) => e[midKey]);
const midSlope6s = this._linearSlope(mids6s);
const retrace3s = this._retrace(midsRetrace, currentBook.mid);
// Imbalance from depth
const totalDepth = currentBook.depthBid + currentBook.depthAsk;
const imbalance = totalDepth > 0
? (currentBook.depthBid - currentBook.depthAsk) / totalDepth
: 0;
// Top-3 bid depth from current book
const depthTop3 = currentBook.bids
.slice(0, DEPTH_TOP_N)
.reduce((s, l) => s + l.size, 0);
return {
midSlope6s,
retrace3s,
imbalance,
spread: currentBook.spread,
depthTop3,
bufLen: slopeBuf.length, // diagnostic
};
}
/**
* Ordinary least-squares slope through an array of mid-price values.
* Returns slope in units of "price change per sample interval".
* Returns 0 if fewer than 2 data points are available.
*/
_linearSlope(values) {
const n = values.length;
if (n < 2) return 0;
const meanX = (n - 1) / 2;
const meanY = values.reduce((a, b) => a + b, 0) / n;
let num = 0;
let den = 0;
for (let i = 0; i < n; i++) {
const dx = i - meanX;
num += dx * (values[i] - meanY);
den += dx * dx;
}
return den === 0 ? 0 : num / den;
}
/**
* Fractional retrace: how far the current mid has pulled back from
* the rolling peak within the look-back window.
*
* 0 = price is at its peak (no retrace)
* 1 = price is at its trough (full retrace)
*/
_retrace(mids, currentMid) {
if (mids.length === 0) return 0;
const peak = Math.max(...mids, currentMid);
const trough = Math.min(...mids, currentMid);
const range = peak - trough;
if (range < 1e-9) return 0;
return Math.max(0, (peak - currentMid) / range);
}
}
+266
View File
@@ -0,0 +1,266 @@
/**
* MarketFeedService.js
* Step A of the runtime sequence.
*
* Responsibilities:
* 1. Discover active 5m/15m UP↑DOWN↓ markets for configured assets via Gamma API
* 2. Poll the CLOB orderbook for both UP and DOWN tokens every pollIntervalMs
* 3. Normalise raw book data into a consistent snapshot format
* 4. Detect stale books (no levels, or fetch latency > STALE_THRESHOLD_MS)
* 5. Emit 'snapshot' events on the event bus
*
* Snapshot shape:
* { ts, marketSlug, conditionId, tteSec, tickSize, up: BookSide, down: BookSide, stale }
*
* BookSide shape:
* { tokenId, bids, asks, bestBid, bestAsk, mid, spread, depthBid, depthAsk }
*/
import logger from '../utils/logger.js';
const GAMMA_HOST = 'https://gamma-api.polymarket.com';
const STALE_THRESHOLD_MS = 1500;
const TOP_N_LEVELS = 5; // Levels counted for depth calculation
const DISCOVER_INTERVAL = 30_000; // Re-scan for new markets every 30s
export class MarketFeedService {
/**
* @param {Object} opts
* @param {import('@polymarket/clob-client').ClobClient} opts.client
* @param {string[]} opts.assets - e.g. ['btc', 'eth', 'sol']
* @param {string} opts.duration - '5m' or '15m'
* @param {number} opts.pollIntervalMs - Book poll cadence in ms (200500)
* @param {import('./EventBus.js').default} opts.eventBus
*/
constructor({ client, assets, duration = '5m', pollIntervalMs = 300, eventBus }) {
this._client = client;
this._assets = assets;
this._duration = duration;
this._durationMin = duration === '15m' ? 15 : 5;
this._pollMs = pollIntervalMs;
this._eventBus = eventBus;
/** @type {Map<string, MarketRecord>} slug → market record */
this._markets = new Map();
this._pollTimer = null;
this._discoverTimer = null;
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
async start() {
await this._discoverMarkets();
this._pollTimer = setInterval(() => this._tick().catch(() => {}), this._pollMs);
this._discoverTimer = setInterval(() => this._discoverMarkets().catch(() => {}), DISCOVER_INTERVAL);
logger.info(`MarketFeedService: started | assets=[${this._assets}] interval=${this._pollMs}ms`);
}
stop() {
clearInterval(this._pollTimer);
clearInterval(this._discoverTimer);
logger.info('MarketFeedService: stopped');
}
/** Active market slugs currently being polled */
get activeMarkets() {
return [...this._markets.keys()];
}
// ── Market discovery ──────────────────────────────────────────────────────
async _discoverMarkets() {
const durationMin = this._durationMin;
// Probe current slot and the immediately upcoming slot
const slots = [
this._slotTs(),
this._slotTs() + durationMin * 60,
];
for (const asset of this._assets) {
for (const slotTs of slots) {
const slug = `${asset}-updown-${this._duration}-${slotTs}`;
if (this._markets.has(slug)) continue;
try {
const market = await this._fetchMarketBySlug(slug);
if (!market) continue;
const endTs = this._parseEndTs(market);
if (!endTs || Date.now() >= endTs) continue;
const { upTokenId, downTokenId } = this._extractTokenIds(market);
if (!upTokenId || !downTokenId) {
logger.warn(`MarketFeedService: could not extract token IDs for ${slug}`);
continue;
}
const tickSize = await this._fetchTickSize(upTokenId);
this._markets.set(slug, {
slug,
conditionId: market.conditionId || market.condition_id,
upTokenId,
downTokenId,
endTs,
tickSize,
negRisk: market.negRisk || market.neg_risk || false,
});
const secLeft = Math.floor((endTs - Date.now()) / 1000);
logger.success(`MarketFeedService: tracking ${slug} (closes in ${secLeft}s)`);
} catch {
// Network blip — will retry on next discovery cycle
}
}
}
// Prune expired markets (add a 5s grace period for final snapshots)
for (const [slug, mkt] of this._markets) {
if (Date.now() > mkt.endTs + 5_000) {
this._markets.delete(slug);
logger.info(`MarketFeedService: pruned expired market ${slug}`);
}
}
}
/** Deterministic UTC slot boundary timestamp (seconds) */
_slotTs() {
const slotMs = this._durationMin * 60_000;
return Math.floor(Date.now() / slotMs) * slotMs / 1000;
}
async _fetchMarketBySlug(slug) {
const url = `${GAMMA_HOST}/markets?slug=${encodeURIComponent(slug)}&limit=1`;
const resp = await fetch(url);
if (!resp.ok) return null;
const data = await resp.json();
const market = Array.isArray(data) ? data[0] : data;
return market?.conditionId || market?.condition_id ? market : null;
}
_parseEndTs(market) {
const raw = market.endDateIso || market.end_date_iso || market.endDate;
if (!raw) return null;
const ts = new Date(raw).getTime();
return Number.isFinite(ts) ? ts : null;
}
_extractTokenIds(market) {
let upTokenId = null;
let downTokenId = null;
// Shape 1: tokens[] array with { tokenId, outcome }
const tokens = market.tokens;
if (Array.isArray(tokens)) {
for (const t of tokens) {
const outcome = String(t.outcome || t.title || '').toLowerCase();
const id = t.tokenId || t.token_id || t.id || t.asset;
if (!id) continue;
if (outcome.includes('up') || outcome === 'yes') upTokenId = String(id);
if (outcome.includes('down') || outcome === 'no') downTokenId = String(id);
}
}
// Shape 2: clobTokenIds[0/1]
if ((!upTokenId || !downTokenId) && Array.isArray(market.clobTokenIds) && market.clobTokenIds.length >= 2) {
upTokenId = upTokenId ?? String(market.clobTokenIds[0]);
downTokenId = downTokenId ?? String(market.clobTokenIds[1]);
}
return { upTokenId, downTokenId };
}
async _fetchTickSize(tokenId) {
try {
const ts = await this._client.getTickSize(tokenId);
return parseFloat(ts) || 0.01;
} catch {
return 0.01;
}
}
// ── Book polling ──────────────────────────────────────────────────────────
async _tick() {
for (const [, mkt] of this._markets) {
const tteSec = Math.floor((mkt.endTs - Date.now()) / 1000);
if (tteSec <= 0) continue;
const fetchStart = Date.now();
try {
const [upBook, downBook] = await Promise.all([
this._client.getOrderBook(mkt.upTokenId),
this._client.getOrderBook(mkt.downTokenId),
]);
const fetchMs = Date.now() - fetchStart;
const stale = fetchMs > STALE_THRESHOLD_MS;
const snapshot = this._buildSnapshot(mkt, upBook, downBook, tteSec, stale);
this._eventBus.emit('snapshot', snapshot);
} catch {
// Silent — stale snapshot will suppress entry via gate check
}
}
}
_buildSnapshot(mkt, upBook, downBook, tteSec, stale) {
return {
ts: Date.now(),
marketSlug: mkt.slug,
conditionId: mkt.conditionId,
tteSec,
tickSize: mkt.tickSize,
negRisk: mkt.negRisk,
up: this._buildSide(mkt.upTokenId, upBook),
down: this._buildSide(mkt.downTokenId, downBook),
stale: stale || this._isBooksEmpty(upBook, downBook),
};
}
_buildSide(tokenId, book) {
const parse = (raw = []) =>
(Array.isArray(raw) ? raw : [])
.filter((l) => l?.price && l?.size)
.map((l) => ({ price: parseFloat(l.price), size: parseFloat(l.size) }))
.filter((l) => l.price > 0 && l.size > 0);
const bids = parse(book?.bids).sort((a, b) => b.price - a.price);
const asks = parse(book?.asks).sort((a, b) => a.price - b.price);
const bestBid = bids[0]?.price ?? 0;
const bestAsk = asks[0]?.price ?? 1;
const mid = bestBid > 0 && bestAsk < 1
? (bestBid + bestAsk) / 2
: (bestBid || bestAsk || 0.5);
const spread = Math.max(0, bestAsk - bestBid);
const topN = Math.min(TOP_N_LEVELS, Math.max(bids.length, asks.length));
const depthBid = bids.slice(0, topN).reduce((s, l) => s + l.size, 0);
const depthAsk = asks.slice(0, topN).reduce((s, l) => s + l.size, 0);
return {
tokenId,
bids,
asks,
bestBid,
bestAsk,
mid,
spread,
depthBid,
depthAsk,
bestBidSize: bids[0]?.size ?? 0,
bestAskSize: asks[0]?.size ?? 0,
};
}
_isBooksEmpty(upBook, downBook) {
const isEmpty = (b) => !b || (!Array.isArray(b.bids) && !Array.isArray(b.asks));
return isEmpty(upBook) || isEmpty(downBook);
}
}
+168
View File
@@ -0,0 +1,168 @@
/**
* PositionEngine.js
* Step G of the runtime sequence.
*
* 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)
*/
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
*/
constructor({ tpTicks = 1 }) {
this._tpTicks = tpTicks;
/** @type {Map<string, PositionState>} */
this._positions = new Map();
}
// ── Position lifecycle ─────────────────────────────────────────────────
/**
* Record a newly filled position.
*
* @param {string} marketSlug
* @param {Object} data
* @param {string} data.tokenId
* @param {'up'|'down'} data.side
* @param {number} data.shares
* @param {number} data.entryPrice
* @param {number} data.tickSize
*/
open(marketSlug, { tokenId, side, shares, entryPrice, tickSize }) {
this._positions.set(marketSlug, {
marketSlug,
tokenId,
side,
shares,
entryPrice,
tickSize,
openedAt: Date.now(),
tpPrice: this._roundToTick(entryPrice + this._tpTicks * tickSize, tickSize),
_slopeDropTs: null, // timestamp when slope first went <= 0
});
}
/** @returns {PositionState|null} */
getPosition(marketSlug) {
return this._positions.get(marketSlug) ?? null;
}
hasPosition(marketSlug) {
return this._positions.has(marketSlug);
}
/**
* Close the position and return exit data including realised P&L.
*
* @param {string} marketSlug
* @param {number} exitPrice - Actual fill price of the exit order
* @returns {{ pnl: number, shares: number, entryPrice: number, exitPrice: number }}
*/
close(marketSlug, exitPrice) {
const pos = this._positions.get(marketSlug);
if (!pos) return { pnl: 0, shares: 0, entryPrice: 0, exitPrice };
const pnl = (exitPrice - pos.entryPrice) * pos.shares;
this._positions.delete(marketSlug);
return { pnl, shares: pos.shares, entryPrice: pos.entryPrice, exitPrice };
}
// ── Exit evaluation ────────────────────────────────────────────────────
/**
* Evaluate whether the current position should be exited.
* Called on every snapshot tick while in POSITION_OPEN or REDUCE_ONLY 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 }}
*/
evaluateExit(marketSlug, snapshot, features) {
const pos = this._positions.get(marketSlug);
if (!pos) return { shouldExit: false, reason: null, isReduceOnly: 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 };
}
// 2. Take-profit hit
if (bookSide.bestBid >= pos.tpPrice) {
return { shouldExit: true, reason: ReasonCode.EXIT_TP_HIT, isReduceOnly: 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;
}
}
/**
* @typedef {Object} PositionState
* @property {string} marketSlug
* @property {string} tokenId
* @property {'up'|'down'} side
* @property {number} shares
* @property {number} entryPrice
* @property {number} tickSize
* @property {number} tpPrice
* @property {number} openedAt
* @property {number|null} _slopeDropTs
*/
+138
View File
@@ -0,0 +1,138 @@
/**
* RiskEngine.js
* Step H of the runtime sequence.
*
* Global risk enforcement across all markets in the same session:
*
* Consecutive loss cap — after N consecutive losses, enter COOLDOWN for
* `cooldownRounds` market opportunities
* Daily loss cap — if total daily P&L drops below -dailyLossCap,
* HALT all trading for the rest of the day
*
* All policy violations are surfaced via canTrade() so the orchestrator
* can gate entries without needing direct access to internal state.
*/
import logger from '../utils/logger.js';
import { ReasonCode } from './constants.js';
export class RiskEngine {
/**
* @param {Object} opts
* @param {number} opts.maxConsecLosses - Consecutive losses before cooldown
* @param {number} opts.cooldownRounds - Market slots to skip during cooldown
* @param {number} opts.dailyLossCap - Max cumulative daily loss in USDC (positive number)
*/
constructor({ maxConsecLosses = 2, cooldownRounds = 3, dailyLossCap = 20 }) {
this._maxConsecLosses = maxConsecLosses;
this._cooldownRounds = cooldownRounds;
this._dailyLossCap = dailyLossCap;
this._dailyPnl = 0;
this._consecLosses = 0;
this._cooldownLeft = 0;
this._halted = false;
this._sessionStart = Date.now();
}
// ── Public API ────────────────────────────────────────────────────────────
/**
* Check whether a new entry is allowed.
* @returns {{ ok: boolean, reason: string|null, halted: boolean }}
*/
canTrade() {
if (this._halted) {
return { ok: false, reason: ReasonCode.RISK_DAILY_CAP, halted: true };
}
if (this._cooldownLeft > 0) {
return { ok: false, reason: ReasonCode.RISK_CONSEC_LOSS, halted: false };
}
return { ok: true, reason: null, halted: false };
}
/** True if the engine is in cooldown (but not halted) */
isCooldown() {
return !this._halted && this._cooldownLeft > 0;
}
/** True if trading has been permanently halted for today */
isHalted() {
return this._halted;
}
/**
* Record the P&L of a closed position and update risk counters.
* @param {number} pnl - Realised P&L in USDC (negative = loss)
*/
recordResult(pnl) {
this._dailyPnl += pnl;
if (pnl < 0) {
this._consecLosses++;
if (this._consecLosses >= this._maxConsecLosses) {
this._cooldownLeft = this._cooldownRounds;
logger.warn(
`RiskEngine: ${this._consecLosses} consecutive losses — ` +
`entering cooldown for ${this._cooldownRounds} rounds`,
);
}
} else {
// Reset consecutive loss streak on any win
this._consecLosses = 0;
}
// Daily cap check
if (this._dailyPnl <= -Math.abs(this._dailyLossCap)) {
this._halted = true;
logger.error(
`RiskEngine: daily loss cap hit ($${this._dailyPnl.toFixed(2)}) — ` +
`trading HALTED for the rest of the session`,
);
}
this._logState(pnl);
}
/**
* Decrement the cooldown counter by one market slot.
* Called by the orchestrator each time a new market opportunity is seen
* while in cooldown mode.
*/
decrementCooldown() {
if (this._cooldownLeft > 0) {
this._cooldownLeft--;
logger.info(`RiskEngine: cooldown rounds remaining: ${this._cooldownLeft}`);
if (this._cooldownLeft === 0) {
this._consecLosses = 0;
logger.success('RiskEngine: cooldown lifted — resuming normal trading');
}
}
}
/** Current session statistics snapshot */
stats() {
return {
dailyPnl: this._dailyPnl,
consecLosses: this._consecLosses,
cooldownLeft: this._cooldownLeft,
halted: this._halted,
};
}
// ── Internal ──────────────────────────────────────────────────────────────
_logState(pnl) {
const sign = pnl >= 0 ? '+' : '';
const stats = this.stats();
logger.info(
`RiskEngine: pnl=${sign}$${pnl.toFixed(4)} | ` +
`daily=$${stats.dailyPnl.toFixed(4)} | ` +
`streak=${stats.consecLosses} | ` +
`cooldown=${stats.cooldownLeft}`,
);
}
}
+196
View File
@@ -0,0 +1,196 @@
/**
* SignalEngine.js
* Steps C & D of the runtime sequence.
*
* 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
*
* Signal event shape:
* { ts, marketSlug, tteSec, signal, side, score, reason, snapshot, features }
*/
import { Signal, ReasonCode } from './constants.js';
// ── Score weights ──────────────────────────────────────────────────────────────
const W_IMBALANCE = 0.35;
const W_SLOPE = 0.35;
const W_SPREAD = 0.20;
const W_RETRACE = 0.10;
// ── 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
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)
*/
constructor({ eventBus, scoreThreshold, minTopSize, tteMin = 25, tteMax = 120 }) {
this._eventBus = eventBus;
this._scoreThreshold = scoreThreshold;
this._minTopSize = minTopSize;
this._tteMin = tteMin;
this._tteMax = tteMax;
this._eventBus.on('features', (feat) => this._onFeatures(feat));
}
// ── Internal ──────────────────────────────────────────────────────────────
_onFeatures(feat) {
const { ts, marketSlug, tteSec, up, down, snapshot } = feat;
// ── Step C: hard gate check ─────────────────────────────────────────
const gate = this._hardGates(snapshot, tteSec);
if (!gate.pass) {
this._emit(marketSlug, Signal.NO_TRADE, null, 0, gate.reason, ts, snapshot, feat);
return;
}
// ── Step D: evaluate each side, pick best qualifying signal ─────────
const upResult = this._evaluateSide(up, snapshot.up);
const downResult = this._evaluateSide(down, snapshot.down);
// Determine which side (if any) qualifies
const upQual = upResult.score >= this._scoreThreshold && upResult.confirmed;
const downQual = downResult.score >= this._scoreThreshold && downResult.confirmed;
if (!upQual && !downQual) {
// Emit the reason from whichever side had the higher score
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);
return;
}
// Pick the stronger qualifying side
let signal;
let side;
let score;
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;
}
this._emit(marketSlug, signal, side, score, null, ts, snapshot, feat);
}
/**
* Hard gates — any failure aborts the evaluation immediately.
* @returns {{ pass: boolean, reason: string|null }}
*/
_hardGates(snapshot, tteSec) {
if (snapshot.stale)
return { pass: false, reason: ReasonCode.GATE_STALE_BOOK };
if (tteSec < this._tteMin || tteSec > this._tteMax)
return { pass: false, reason: ReasonCode.GATE_TTE_FAIL };
if (snapshot.up.spread > SPREAD_MAX || snapshot.down.spread > SPREAD_MAX)
return { pass: false, reason: ReasonCode.GATE_SPREAD_WIDE };
const thinUp = snapshot.up.bestBidSize < this._minTopSize
|| snapshot.up.bestAskSize < this._minTopSize;
const thinDown = snapshot.down.bestBidSize < this._minTopSize
|| snapshot.down.bestAskSize < this._minTopSize;
if (thinUp && thinDown)
return { pass: false, reason: ReasonCode.GATE_DEPTH_THIN };
return { pass: true, reason: null };
}
/**
* Score and confirm one side.
* @param {Object} sideFeatures - From FeatureEngine (midSlope6s, imbalance, ...)
* @param {Object} sideBook - Current BookSide from snapshot
* @returns {{ score: number, confirmed: boolean }}
*/
_evaluateSide(sideFeatures, sideBook) {
const { midSlope6s, retrace3s, imbalance, spread } = sideFeatures;
// Score components (each 01, then weighted)
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;
// Trend confirmation: positive momentum + low retrace
const confirmed = midSlope6s > CONFIRM_SLOPE && retrace3s < CONFIRM_RTRC;
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;
return 0;
}
_scoreImbalance(imb) {
if (imb >= IMB_STRONG) return 1.0;
if (imb >= IMB_WEAK) return 0.5;
if (imb > 0) return 0.2;
return 0;
}
_scoreSpread(spread) {
if (spread <= SPREAD_TIGHT) return 1.0;
if (spread <= SPREAD_MAX) return 0.5;
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,
marketSlug,
tteSec: snapshot.tteSec,
signal,
side,
score,
reason,
snapshot,
features,
});
}
}
+69
View File
@@ -0,0 +1,69 @@
/**
* StateMachine.js
* Explicit per-market state machine with strict transition guards.
*
* Rule: never derive state from floating booleans.
* Every state change must go through transition() to be validated and logged.
*/
import { State, TRANSITIONS } from './constants.js';
export class StateMachine {
/**
* @param {string} marketSlug - Market identifier (used in error messages and logs)
* @param {import('./EventBus.js').default} eventBus
*/
constructor(marketSlug, eventBus) {
this._state = State.IDLE;
this._slug = marketSlug;
this._eventBus = eventBus;
}
/** Current state string */
get state() {
return this._state;
}
/**
* Attempt a state transition.
* Throws if the transition is not in the allowed graph — this is intentional:
* a programming error that bypasses the guard should be loud and traceable.
*
* @param {string} nextState - One of the State enum values
* @param {string} [reason] - Human-readable reason for the transition
* @returns {StateMachine} - Returns `this` for chaining
*/
transition(nextState, reason = '') {
const allowed = TRANSITIONS[this._state] ?? [];
if (!allowed.includes(nextState)) {
throw new Error(
`[StateMachine] Invalid transition: ${this._state}${nextState}` +
` (market: ${this._slug}, reason: ${reason})`,
);
}
const from = this._state;
this._state = nextState;
this._eventBus.emit('state:transition', {
marketSlug: this._slug,
from,
to: nextState,
reason,
ts: Date.now(),
});
return this;
}
/** @param {string} state */
is(state) {
return this._state === state;
}
/** @param {string} state */
canTransitionTo(state) {
return (TRANSITIONS[this._state] ?? []).includes(state);
}
}
+109
View File
@@ -0,0 +1,109 @@
/**
* Telemetry.js
* Structured JSONL logger for the OneShot engine.
*
* Every decision tick, order lifecycle event, position exit, and state
* transition is recorded to data/oneshot_telemetry.jsonl — one JSON object
* per line — for offline analysis and strategy tuning.
*/
import fs from 'fs';
import path from 'path';
import logger from '../utils/logger.js';
const DATA_DIR = path.resolve('data');
const LOG_FILE = path.join(DATA_DIR, 'oneshot_telemetry.jsonl');
export class Telemetry {
constructor() {
// Ensure data/ directory exists
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
}
// ── Public log methods ───────────────────────────────────────────────────
/**
* Log a per-decision-tick evaluation record.
* Called for every signal evaluation, whether entry is taken or not.
*
* @param {Object} d
* @param {string} d.marketSlug
* @param {number} d.ts
* @param {number} d.tteSec
* @param {number} d.spread
* @param {number} d.imbalance
* @param {number} d.slope
* @param {number} d.retrace
* @param {number} d.depth
* @param {boolean} d.gatePass
* @param {string} d.reasonCode
* @param {number} d.score
* @param {string} d.action
*/
logDecision(d) {
this._write({ type: 'decision', ...d });
}
/**
* Log an order lifecycle event (submit → ack → fill / cancel).
*
* @param {Object} d
* @param {string} d.clientOrderId
* @param {string} d.side
* @param {string} d.marketSlug
* @param {number} d.px
* @param {number} d.qty
* @param {number} d.ackMs
* @param {number} d.fillMs
* @param {string} d.status
*/
logOrder(d) {
this._write({ type: 'order', ...d });
}
/**
* Log a position exit event.
*
* @param {Object} d
* @param {string} d.marketSlug
* @param {string} d.exitReason
* @param {number} d.entryPx
* @param {number} d.exitPx
* @param {number} d.pnl
* @param {number} d.shares
*/
logExit(d) {
this._write({ type: 'exit', ...d });
const pnlStr = d.pnl >= 0
? `+$${d.pnl.toFixed(4)}`
: `-$${Math.abs(d.pnl).toFixed(4)}`;
logger.money(`[Telemetry] exit ${d.marketSlug} | ${d.exitReason} | pnl=${pnlStr}`);
}
/**
* Log a state machine transition.
*
* @param {Object} d
* @param {string} d.marketSlug
* @param {string} d.from
* @param {string} d.to
* @param {string} d.reason
* @param {number} d.ts
*/
logTransition(d) {
this._write({ type: 'transition', ...d });
}
// ── Internal ─────────────────────────────────────────────────────────────
_write(record) {
const line = JSON.stringify({ ...record, ts: record.ts ?? Date.now() }) + '\n';
fs.appendFile(LOG_FILE, line, (err) => {
if (err) logger.warn(`[Telemetry] write error: ${err.message}`);
});
}
}
+70
View File
@@ -0,0 +1,70 @@
/**
* constants.js
* Shared enums and reason codes for the Anti-Flip 5m OneShot Engine.
* All objects are frozen to prevent accidental mutation at runtime.
*/
// ── State machine states ───────────────────────────────────────────────────────
export const State = Object.freeze({
IDLE: 'IDLE', // Waiting for a qualifying signal
SETUP_READY: 'SETUP_READY', // Signal passed — about to submit order
ORDER_PENDING: 'ORDER_PENDING', // Order submitted, awaiting fill ack
POSITION_OPEN: 'POSITION_OPEN', // Filled — actively managing position
REDUCE_ONLY: 'REDUCE_ONLY', // Time threshold reached — exit only, no new entry
COOLDOWN: 'COOLDOWN', // Short suspension after consecutive losses
HALTED: 'HALTED', // Daily stop-loss hit — no more trading today
});
// ── Reason / decision codes ────────────────────────────────────────────────────
export const ReasonCode = Object.freeze({
// Hard gate failures
GATE_TTE_FAIL: 'GATE_TTE_FAIL', // TTE outside [25, 120] range
GATE_SPREAD_WIDE: 'GATE_SPREAD_WIDE', // Spread exceeds maximum threshold
GATE_DEPTH_THIN: 'GATE_DEPTH_THIN', // Best bid/ask size below minimum
GATE_STALE_BOOK: 'GATE_STALE_BOOK', // Book snapshot is stale or empty
// Signal evaluation failures
SIG_SCORE_LOW: 'SIG_SCORE_LOW', // Composite score below threshold
SIG_NO_CONFIRM: 'SIG_NO_CONFIRM', // Trend confirmation failed
// Execution failures
EXEC_TIMEOUT_NO_FILL: 'EXEC_TIMEOUT_NO_FILL', // FOK timed out without fill
EXEC_PARTIAL_REDUCE: 'EXEC_PARTIAL_REDUCE', // Partial fill reduced & closed
EXEC_SUBMIT_ERROR: 'EXEC_SUBMIT_ERROR', // Order submission threw error
// Risk policy
RISK_CONSEC_LOSS: 'RISK_CONSEC_LOSS', // Consecutive loss limit triggered cooldown
RISK_DAILY_CAP: 'RISK_DAILY_CAP', // Daily loss cap reached — halted
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_RISK_FORCED: 'EXIT_RISK_FORCED', // Risk engine forced exit
});
// ── Signal directions ──────────────────────────────────────────────────────────
export const Signal = Object.freeze({
NO_TRADE: 'NO_TRADE', // Conditions not met — skip
ENTER_LONG: 'ENTER_LONG', // Buy UP token
ENTER_SHORT: 'ENTER_SHORT', // Buy DOWN token
});
// ── Valid state transitions ────────────────────────────────────────────────────
// Used by StateMachine to enforce the explicit transition graph.
export const TRANSITIONS = Object.freeze({
[State.IDLE]: [State.SETUP_READY, State.COOLDOWN, State.HALTED],
[State.SETUP_READY]: [State.ORDER_PENDING, State.IDLE, State.COOLDOWN, State.HALTED],
[State.ORDER_PENDING]: [State.POSITION_OPEN, State.IDLE, State.COOLDOWN, State.HALTED],
[State.POSITION_OPEN]: [State.REDUCE_ONLY, State.IDLE, State.COOLDOWN, State.HALTED],
[State.REDUCE_ONLY]: [State.IDLE, State.COOLDOWN, State.HALTED],
[State.COOLDOWN]: [State.IDLE, State.HALTED],
[State.HALTED]: [],
});