Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b28dcc228 | |||
| 28cba8b0fb | |||
| 0c1e9f114c | |||
| bac4e320f5 | |||
| dbaba5914f | |||
| 6a2bbfa008 | |||
| 8296c4129b | |||
| 89a7803aa8 | |||
| 2692694309 | |||
| bf09d30376 | |||
| 707d654749 | |||
| acf3943f80 | |||
| 202bf98fe7 | |||
| f074ca9ecb |
@@ -124,3 +124,78 @@ 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)
|
||||
# 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.
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
# 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 (200–500ms 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
|
||||
|
||||
# ── Entry filters ──────────────────────────────────────────────────────
|
||||
|
||||
# Minimum mid price for the dominant side to qualify as an entry candidate.
|
||||
# Example: 0.58 means the token must be priced at ≥58% probability of winning.
|
||||
# Lower = more trades but more uncertain outcomes. Higher = fewer but more confident.
|
||||
# Recommended range: 0.55–0.65. The momentum gate provides additional conviction filtering.
|
||||
ONESHOT_MIN_DOMINANT_MID=0.58
|
||||
|
||||
# Minimum composite score to trigger entry (0–1).
|
||||
# Score = mid strength (35%) + momentum direction (30%) + book imbalance (20%) + spread (15%).
|
||||
# Lower threshold captures more "decent but not perfect" setups.
|
||||
ONESHOT_SCORE_THRESHOLD=0.42
|
||||
|
||||
# TTE (time-to-expiry) window in seconds for entry.
|
||||
# TTE_MAX=150 captures the "direction establishment" phase (last 2.5 minutes).
|
||||
# TTE_MIN=15 ensures enough time to get a fill before market locks.
|
||||
# Tighter window = higher conviction but fewer entries per session.
|
||||
ONESHOT_TTE_MIN=15
|
||||
ONESHOT_TTE_MAX=150
|
||||
|
||||
# Minimum shares at the best bid AND best ask for the depth hard gate
|
||||
ONESHOT_MIN_TOP_SIZE=10
|
||||
|
||||
# ── 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 emergency exits (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
|
||||
|
||||
# How often (ms) to poll for on-chain redemption after market expiry.
|
||||
# 5m markets typically settle on-chain within 2–5 minutes after close.
|
||||
ONESHOT_REDEEM_POLL_MS=30000
|
||||
|
||||
# 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
|
||||
|
||||
+5
-1
@@ -12,7 +12,11 @@
|
||||
"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",
|
||||
"oneshot-debug": "DRY_RUN=true ONESHOT_DEBUG=true node src/oneshot.js"
|
||||
},
|
||||
"keywords": [
|
||||
"polymarket",
|
||||
|
||||
+465
@@ -0,0 +1,465 @@
|
||||
/**
|
||||
* src/oneshot.js
|
||||
* Dominant Side Hold Engine — main orchestrator entry point.
|
||||
*
|
||||
* 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 + 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 emergency exits only
|
||||
*
|
||||
* State machine (per market):
|
||||
* 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)
|
||||
*/
|
||||
|
||||
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 { RedeemEngine } from './oneshot/RedeemEngine.js';
|
||||
import { State, Signal, ReasonCode } from './oneshot/constants.js';
|
||||
import { DEBUG, dbg } from './oneshot/debug.js';
|
||||
|
||||
// ── Configuration ──────────────────────────────────────────────────────────────
|
||||
|
||||
const cfg = {
|
||||
assets: (process.env.ONESHOT_ASSETS || 'btc').split(',').map((s) => s.trim().toLowerCase()),
|
||||
duration: process.env.ONESHOT_DURATION || '5m',
|
||||
baseRiskUsdc: parseFloat(process.env.ONESHOT_BASE_RISK_USDC || '5'),
|
||||
minDominantMid: parseFloat(process.env.ONESHOT_MIN_DOMINANT_MID || '0.58'),
|
||||
stopLossMid: parseFloat(process.env.ONESHOT_STOP_LOSS_MID || '0.20'),
|
||||
scoreThreshold: parseFloat(process.env.ONESHOT_SCORE_THRESHOLD || '0.42'),
|
||||
pollIntervalMs: parseInt(process.env.ONESHOT_POLL_INTERVAL_MS || '300', 10),
|
||||
minTopSize: parseFloat(process.env.ONESHOT_MIN_TOP_SIZE || '10'),
|
||||
tteMin: parseInt(process.env.ONESHOT_TTE_MIN || '15', 10),
|
||||
tteMax: parseInt(process.env.ONESHOT_TTE_MAX || '150', 10),
|
||||
maxConsecLosses: parseInt(process.env.ONESHOT_MAX_CONSEC_LOSSES || '2', 10),
|
||||
cooldownRounds: parseInt(process.env.ONESHOT_COOLDOWN_ROUNDS || '3', 10),
|
||||
dailyLossCap: parseFloat(process.env.ONESHOT_DAILY_LOSS_CAP || '20'),
|
||||
fillTimeoutMs: parseInt(process.env.ONESHOT_FILL_TIMEOUT_MS || '800', 10),
|
||||
redeemPollMs: parseInt(process.env.ONESHOT_REDEEM_POLL_MS || '30000', 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 redeemEngine;
|
||||
let telemetry;
|
||||
|
||||
// ── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
logger.success('=== OneShot Dominant Side Hold Engine starting ===');
|
||||
logger.info(`Assets: [${cfg.assets}] | Duration: ${cfg.duration} | DRY_RUN: ${cfg.dryRun}`);
|
||||
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();
|
||||
|
||||
telemetry = new Telemetry();
|
||||
redeemEngine = new RedeemEngine({
|
||||
dryRun: cfg.dryRun,
|
||||
pollIntervalMs: cfg.redeemPollMs,
|
||||
eventBus,
|
||||
});
|
||||
riskEngine = new RiskEngine({
|
||||
maxConsecLosses: cfg.maxConsecLosses,
|
||||
cooldownRounds: cfg.cooldownRounds,
|
||||
dailyLossCap: cfg.dailyLossCap,
|
||||
});
|
||||
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,
|
||||
minDominantMid: cfg.minDominantMid,
|
||||
tteMin: cfg.tteMin,
|
||||
tteMax: cfg.tteMax,
|
||||
});
|
||||
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);
|
||||
|
||||
redeemEngine.start();
|
||||
await feedService.start();
|
||||
|
||||
// Report final P&L when a redemption settles
|
||||
eventBus.on('redemption:complete', ({ marketSlug, won, pnl }) => {
|
||||
riskEngine.recordResult(pnl);
|
||||
logger.info(`[REDEEM] ${marketSlug} settled | ${won ? 'WIN' : 'LOSS'} | pnl=${won ? '+' : ''}$${pnl.toFixed(4)}`);
|
||||
});
|
||||
|
||||
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, ' +
|
||||
'SCORE=dominant side scoring, SIGNAL=entry trigger, SM=state changes, HEART=heartbeat',
|
||||
);
|
||||
setInterval(() => {
|
||||
const markets = feedService.activeMarkets;
|
||||
const states = markets.map((slug) => {
|
||||
const sm = stateMachines.get(slug);
|
||||
return `${slug.split('-')[0]}:${sm?.state ?? 'none'}`;
|
||||
}).join(' | ') || '(none)';
|
||||
const risk = riskEngine.stats();
|
||||
dbg('HEART',
|
||||
`active=${markets.length} | states=[${states}] | ` +
|
||||
`dailyPnl=$${risk.dailyPnl.toFixed(4)} | consec=${risk.consecLosses} | ` +
|
||||
`cooldown=${risk.cooldownLeft} | halted=${risk.halted}`,
|
||||
);
|
||||
}, 5_000);
|
||||
}
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
}
|
||||
|
||||
// ── Signal handler (Steps C/D/E/F) ───────────────────────────────────────────
|
||||
|
||||
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 — one position per market slot
|
||||
if (!sm.is(State.IDLE)) return;
|
||||
|
||||
// ── 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: 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} | ` +
|
||||
`mid=${bookSide.mid.toFixed(4)} 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,
|
||||
});
|
||||
|
||||
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,
|
||||
conditionId: snapshot.conditionId,
|
||||
negRisk: snapshot.negRisk,
|
||||
});
|
||||
sm.transition(State.POSITION_OPEN, 'fill_confirmed');
|
||||
logger.success(
|
||||
`OneShot: position OPEN | ${marketSlug} | ` +
|
||||
`${result.filledSize} shares @ $${(result.avgFillPrice || entryPrice).toFixed(4)} | ` +
|
||||
`holding to expiry`,
|
||||
);
|
||||
|
||||
} else if (result.status === 'partial' && result.filledSize > 0) {
|
||||
// Accept partial fill and hold to expiry
|
||||
posEngine.open(marketSlug, {
|
||||
tokenId: bookSide.tokenId,
|
||||
side,
|
||||
shares: result.filledSize,
|
||||
entryPrice: result.avgFillPrice || entryPrice,
|
||||
tickSize: snapshot.tickSize,
|
||||
conditionId: snapshot.conditionId,
|
||||
negRisk: snapshot.negRisk,
|
||||
});
|
||||
sm.transition(State.POSITION_OPEN, 'partial_fill_accepted');
|
||||
logger.warn(`OneShot: partial fill accepted | ${result.filledSize}/${size} shares | holding to expiry`);
|
||||
|
||||
} else {
|
||||
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) ──────────────────────────────────────
|
||||
|
||||
async function onSnapshotForPositionMgmt(snapshot) {
|
||||
const { marketSlug, tteSec } = snapshot;
|
||||
const sm = stateMachines.get(marketSlug);
|
||||
if (!sm) return;
|
||||
|
||||
// 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)) return;
|
||||
|
||||
const pos = posEngine.getPosition(marketSlug);
|
||||
if (!pos) {
|
||||
if (sm.canTransitionTo(State.IDLE)) sm.transition(State.IDLE, 'position_missing');
|
||||
return;
|
||||
}
|
||||
|
||||
// Evaluate exit conditions
|
||||
const exitResult = posEngine.evaluateExit(marketSlug, snapshot);
|
||||
|
||||
// Market expired — position goes to on-chain redeemer
|
||||
if (exitResult.isExpired) {
|
||||
await expirePosition(marketSlug, pos);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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)} | ` +
|
||||
`queuing for auto-redemption`,
|
||||
);
|
||||
|
||||
posEngine.closeExpired(marketSlug);
|
||||
|
||||
telemetry.logExit({
|
||||
marketSlug,
|
||||
exitReason: ReasonCode.EXIT_EXPIRED,
|
||||
entryPx: pos.entryPrice,
|
||||
exitPx: null, // settled on-chain — see redemption:complete event
|
||||
pnl: null,
|
||||
shares: pos.shares,
|
||||
});
|
||||
|
||||
// Hand off to RedeemEngine — it will poll until settled and report final P&L
|
||||
redeemEngine.queueRedemption({
|
||||
conditionId: pos.conditionId,
|
||||
marketSlug,
|
||||
side: pos.side,
|
||||
shares: pos.shares,
|
||||
entryPrice: pos.entryPrice,
|
||||
negRisk: pos.negRisk,
|
||||
});
|
||||
|
||||
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)) return;
|
||||
|
||||
const exitPrice = bookSide.bestBid;
|
||||
|
||||
logger.warn(
|
||||
`OneShot: EMERGENCY EXIT | ${marketSlug} | reason=${reason} | ` +
|
||||
`mid=${bookSide.mid.toFixed(4)} exitPx=$${exitPrice.toFixed(4)}`,
|
||||
);
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
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, `emergency_exit_${reason}`);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
logger.error(`OneShot: flatten error on ${marketSlug} — ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── State transition logging ──────────────────────────────────────────────────
|
||||
|
||||
function onStateTransition(evt) {
|
||||
telemetry.logTransition(evt);
|
||||
dbg('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();
|
||||
redeemEngine?.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 | emergencyExitPnl=${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);
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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
|
||||
*/
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
* Market discovery mirrors the logic in sniperDetector.js / mmDetector.js:
|
||||
* - API endpoint: /markets/slug/{slug} (not /markets?slug=...)
|
||||
* - Token IDs: clobTokenIds[0/1] (JSON string parsed if needed)
|
||||
* - Tick size: market.orderPriceMinTickSize (no separate API call)
|
||||
* - Slot formula: Math.floor(Date.now()/1000/SLOT_SEC) * SLOT_SEC
|
||||
*
|
||||
* Snapshot shape:
|
||||
* { ts, marketSlug, conditionId, tteSec, tickSize, up: BookSide, down: BookSide, stale }
|
||||
*
|
||||
* BookSide shape:
|
||||
* { tokenId, bids, asks, bestBid, bestAsk, mid, spread, depthBid, depthAsk,
|
||||
* bestBidSize, bestAskSize }
|
||||
*/
|
||||
|
||||
import config from '../config/index.js';
|
||||
import logger from '../utils/logger.js';
|
||||
import { dbg, DEBUG } from './debug.js';
|
||||
|
||||
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
|
||||
const DEBUG_POLL_EVERY = 10; // Throttle: log one poll summary every N ticks
|
||||
|
||||
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 (200–500)
|
||||
* @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._slotSec = duration === '15m' ? 900 : 300; // same as sniperDetector/mmDetector
|
||||
this._pollMs = pollIntervalMs;
|
||||
this._eventBus = eventBus;
|
||||
|
||||
/** @type {Map<string, MarketRecord>} slug → market record */
|
||||
this._markets = new Map();
|
||||
|
||||
this._pollTimer = null;
|
||||
this._discoverTimer = null;
|
||||
|
||||
/** Per-market tick counter for throttled debug logs */
|
||||
this._pollCount = new Map();
|
||||
}
|
||||
|
||||
// ── 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`);
|
||||
if (DEBUG) logger.info('[DBG:FEED] Debug mode ON — verbose feed logging enabled');
|
||||
}
|
||||
|
||||
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() {
|
||||
// Probe current slot AND next upcoming slot (same as sniperDetector)
|
||||
const curr = this._currentSlot();
|
||||
const next = curr + this._slotSec;
|
||||
const slots = [curr, next];
|
||||
|
||||
dbg('FEED', `--- discovery cycle | curr=${curr} next=${next} | probing ${this._assets.length * 2} slug(s) ---`);
|
||||
|
||||
for (const asset of this._assets) {
|
||||
for (const slotTs of slots) {
|
||||
const slug = `${asset}-updown-${this._duration}-${slotTs}`;
|
||||
|
||||
if (this._markets.has(slug)) {
|
||||
dbg('FEED', ` ${slug} → already tracked`);
|
||||
continue;
|
||||
}
|
||||
|
||||
dbg('FEED', ` probing ${slug} ...`);
|
||||
|
||||
try {
|
||||
// ── Use /markets/slug/{slug} — same endpoint as sniperDetector ──
|
||||
const market = await this._fetchBySlug(slug);
|
||||
|
||||
if (!market) {
|
||||
dbg('FEED', ` ${slug} → not found (API returned null)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Extract end time ─────────────────────────────────────────
|
||||
// endDate = "2026-02-24T06:35:00Z" (full datetime — use this)
|
||||
// endDateIso = "2026-02-24" (date only, parses to midnight UTC — skip)
|
||||
const endTs = this._parseEndTs(market);
|
||||
if (!endTs) {
|
||||
dbg('FEED', ` ${slug} → found but endDate unparseable (keys: ${Object.keys(market).slice(0, 8).join(',')})`);
|
||||
continue;
|
||||
}
|
||||
if (Date.now() >= endTs) {
|
||||
dbg('FEED', ` ${slug} → found but expired (endTs=${new Date(endTs).toISOString()})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Extract token IDs — same logic as sniperDetector/mmDetector ──
|
||||
const { upTokenId, downTokenId } = this._extractTokenIds(market);
|
||||
if (!upTokenId || !downTokenId) {
|
||||
logger.warn(`MarketFeedService: missing token IDs for ${slug}`);
|
||||
dbg('FEED', ` clobTokenIds raw: ${JSON.stringify(market.clobTokenIds)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Tick size from market object — same as mmDetector ────────
|
||||
const tickSize = parseFloat(
|
||||
market.orderPriceMinTickSize ??
|
||||
market.minimum_tick_size ??
|
||||
market.minimumTickSize ??
|
||||
'0.01',
|
||||
) || 0.01;
|
||||
|
||||
const negRisk = market.negRisk ?? market.neg_risk ?? false;
|
||||
|
||||
this._markets.set(slug, {
|
||||
slug,
|
||||
conditionId: market.conditionId || market.condition_id,
|
||||
upTokenId,
|
||||
downTokenId,
|
||||
endTs,
|
||||
tickSize,
|
||||
negRisk,
|
||||
});
|
||||
|
||||
const secLeft = Math.floor((endTs - Date.now()) / 1000);
|
||||
logger.success(`MarketFeedService: tracking ${slug} (closes in ${secLeft}s)`);
|
||||
dbg('FEED',
|
||||
` up=${upTokenId.slice(0, 16)}... ` +
|
||||
`down=${downTokenId.slice(0, 16)}... ` +
|
||||
`tick=${tickSize} negRisk=${negRisk}`,
|
||||
);
|
||||
|
||||
} catch (err) {
|
||||
dbg('FEED', ` ${slug} → error: ${err.message}`);
|
||||
// Network blip — will retry on next cycle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prune markets that have fully expired (5s grace for final snapshots)
|
||||
for (const [slug, mkt] of this._markets) {
|
||||
if (Date.now() > mkt.endTs + 5_000) {
|
||||
this._markets.delete(slug);
|
||||
this._pollCount.delete(slug);
|
||||
logger.info(`MarketFeedService: pruned ${slug}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (this._markets.size === 0) {
|
||||
dbg('FEED', 'No active markets — retrying in 30s');
|
||||
} else {
|
||||
dbg('FEED', `Tracking: [${[...this._markets.keys()].join(', ')}]`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Slot helpers (identical to sniperDetector / mmDetector) ──────────────
|
||||
|
||||
_currentSlot() {
|
||||
return Math.floor(Date.now() / 1000 / this._slotSec) * this._slotSec;
|
||||
}
|
||||
|
||||
// ── Gamma API ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Uses /markets/slug/{slug} — the same direct endpoint as sniperDetector */
|
||||
async _fetchBySlug(slug) {
|
||||
const resp = await fetch(`${config.gammaHost}/markets/slug/${slug}`);
|
||||
if (!resp.ok) return null;
|
||||
const data = await resp.json();
|
||||
// Returns a single object (not an array) when using the slug endpoint
|
||||
return data?.conditionId || data?.condition_id ? data : null;
|
||||
}
|
||||
|
||||
_parseEndTs(market) {
|
||||
// endDate = "2026-02-24T06:35:00Z" → correct full datetime
|
||||
// endDateIso = "2026-02-24" → date-only, parses to midnight UTC (wrong!)
|
||||
const raw = market.endDate || market.end_date || market.endDateIso || market.end_date_iso;
|
||||
if (!raw) return null;
|
||||
const ts = new Date(raw).getTime();
|
||||
return Number.isFinite(ts) ? ts : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract UP/DOWN token IDs using the same logic as sniperDetector / mmDetector.
|
||||
*
|
||||
* clobTokenIds may be:
|
||||
* - a real JS array: ["123...", "456..."]
|
||||
* - a JSON string: '["123...","456..."]'
|
||||
* UP = clobTokenIds[0] (YES / Up)
|
||||
* DOWN = clobTokenIds[1] (NO / Down)
|
||||
*/
|
||||
_extractTokenIds(market) {
|
||||
let tokenIds = market.clobTokenIds ?? market.clob_token_ids;
|
||||
|
||||
// Unwrap JSON string if the API returned it encoded
|
||||
if (typeof tokenIds === 'string') {
|
||||
try { tokenIds = JSON.parse(tokenIds); } catch { tokenIds = null; }
|
||||
}
|
||||
|
||||
let upTokenId = null;
|
||||
let downTokenId = null;
|
||||
|
||||
if (Array.isArray(tokenIds) && tokenIds.length >= 2) {
|
||||
[upTokenId, downTokenId] = tokenIds.map(String);
|
||||
} else if (Array.isArray(market.tokens) && market.tokens.length >= 2) {
|
||||
// Fallback: named tokens array (less common)
|
||||
upTokenId = String(market.tokens[0]?.token_id ?? market.tokens[0]?.tokenId ?? '');
|
||||
downTokenId = String(market.tokens[1]?.token_id ?? market.tokens[1]?.tokenId ?? '');
|
||||
if (!upTokenId || !downTokenId) { upTokenId = null; downTokenId = null; }
|
||||
}
|
||||
|
||||
return { upTokenId, downTokenId };
|
||||
}
|
||||
|
||||
// ── Book polling ──────────────────────────────────────────────────────────
|
||||
|
||||
async _tick() {
|
||||
if (this._markets.size === 0) return;
|
||||
|
||||
for (const [, mkt] of this._markets) {
|
||||
const tteSec = Math.floor((mkt.endTs - Date.now()) / 1000);
|
||||
|
||||
// Market has expired — emit a synthetic snapshot so the position manager
|
||||
// can detect the expiry and queue the position for on-chain redemption.
|
||||
// Without this, positions in expired markets never reach RedeemEngine.
|
||||
if (tteSec < 0) {
|
||||
this._eventBus.emit('snapshot', this._buildExpiredSnapshot(mkt, tteSec));
|
||||
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);
|
||||
|
||||
// ── Throttled debug poll summary ──────────────────────────────
|
||||
if (DEBUG) {
|
||||
const count = (this._pollCount.get(mkt.slug) ?? 0) + 1;
|
||||
this._pollCount.set(mkt.slug, count);
|
||||
|
||||
if (count % DEBUG_POLL_EVERY === 1) {
|
||||
const u = snapshot.up;
|
||||
const d = snapshot.down;
|
||||
dbg('POLL',
|
||||
`${mkt.slug} | tte=${tteSec}s | fetchMs=${fetchMs}ms${stale ? ' [STALE]' : ''}\n` +
|
||||
` UP bid=${u.bestBid.toFixed(4)}/ask=${u.bestAsk.toFixed(4)} ` +
|
||||
`sprd=${u.spread.toFixed(4)} mid=${u.mid.toFixed(4)} ` +
|
||||
`dBid=${u.depthBid.toFixed(1)} dAsk=${u.depthAsk.toFixed(1)}\n` +
|
||||
` DOWN bid=${d.bestBid.toFixed(4)}/ask=${d.bestAsk.toFixed(4)} ` +
|
||||
`sprd=${d.spread.toFixed(4)} mid=${d.mid.toFixed(4)} ` +
|
||||
`dBid=${d.depthBid.toFixed(1)} dAsk=${d.depthAsk.toFixed(1)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
dbg('POLL', `${mkt.slug} → poll error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Snapshot builder ──────────────────────────────────────────────────────
|
||||
|
||||
/** Synthetic snapshot emitted when a market has already closed (tteSec < 0). */
|
||||
_buildExpiredSnapshot(mkt, tteSec) {
|
||||
const emptySide = (tokenId) => ({
|
||||
tokenId,
|
||||
bids: [], asks: [],
|
||||
bestBid: 0, bestAsk: 1, mid: 0.5,
|
||||
spread: 1, depthBid: 0, depthAsk: 0,
|
||||
bestBidSize: 0, bestAskSize: 0,
|
||||
});
|
||||
return {
|
||||
ts: Date.now(),
|
||||
marketSlug: mkt.slug,
|
||||
conditionId: mkt.conditionId,
|
||||
tteSec,
|
||||
tickSize: mkt.tickSize,
|
||||
negRisk: mkt.negRisk,
|
||||
up: emptySide(mkt.upTokenId),
|
||||
down: emptySide(mkt.downTokenId),
|
||||
stale: true, // blocks SignalEngine gates — no new entries on expired book
|
||||
};
|
||||
}
|
||||
|
||||
_buildSnapshot(mkt, upBook, downBook, tteSec, stale) {
|
||||
const up = this._buildSide(mkt.upTokenId, upBook);
|
||||
const down = this._buildSide(mkt.downTokenId, downBook);
|
||||
return {
|
||||
ts: Date.now(),
|
||||
marketSlug: mkt.slug,
|
||||
conditionId: mkt.conditionId,
|
||||
tteSec,
|
||||
tickSize: mkt.tickSize,
|
||||
negRisk: mkt.negRisk,
|
||||
up,
|
||||
down,
|
||||
stale: stale || up.bestBid === 0 || down.bestBid === 0,
|
||||
};
|
||||
}
|
||||
|
||||
_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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* PositionEngine.js
|
||||
* Step G of the runtime sequence.
|
||||
*
|
||||
* Maintains position state per market and evaluates exit conditions
|
||||
* on every incoming snapshot tick.
|
||||
*
|
||||
* 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';
|
||||
|
||||
export class PositionEngine {
|
||||
/**
|
||||
* @param {Object} opts
|
||||
* @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({ stopLossMid = 0.20 } = {}) {
|
||||
this._stopLossMid = stopLossMid;
|
||||
|
||||
/** @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
|
||||
* @param {string} [data.conditionId] - Required for auto-redemption
|
||||
* @param {boolean} [data.negRisk] - Which CTF contract to use for redemption
|
||||
*/
|
||||
open(marketSlug, { tokenId, side, shares, entryPrice, tickSize, conditionId = null, negRisk = false }) {
|
||||
this._positions.set(marketSlug, {
|
||||
marketSlug,
|
||||
tokenId,
|
||||
side,
|
||||
shares,
|
||||
entryPrice,
|
||||
tickSize,
|
||||
conditionId,
|
||||
negRisk,
|
||||
openedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
/** @returns {PositionState|null} */
|
||||
getPosition(marketSlug) {
|
||||
return this._positions.get(marketSlug) ?? null;
|
||||
}
|
||||
|
||||
hasPosition(marketSlug) {
|
||||
return this._positions.has(marketSlug);
|
||||
}
|
||||
|
||||
/** True if ANY position is open across all tracked markets */
|
||||
hasAnyPosition() {
|
||||
return this._positions.size > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the position actively (adverse-move emergency exit) and return exit data.
|
||||
*
|
||||
* @param {string} marketSlug
|
||||
* @param {number} exitPrice - Actual fill price of the sell 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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 state.
|
||||
*
|
||||
* @param {string} marketSlug
|
||||
* @param {Object} snapshot - Current market snapshot
|
||||
* @returns {{ shouldExit: boolean, reason: string|null, isExpired: boolean }}
|
||||
*/
|
||||
evaluateExit(marketSlug, snapshot) {
|
||||
const pos = this._positions.get(marketSlug);
|
||||
if (!pos) return { shouldExit: false, reason: null, isExpired: false };
|
||||
|
||||
const { tteSec } = snapshot;
|
||||
const bookSide = pos.side === 'up' ? snapshot.up : snapshot.down;
|
||||
|
||||
// 1. Market expired — hand off to on-chain redeemer
|
||||
if (tteSec <= 0) {
|
||||
return { shouldExit: false, reason: ReasonCode.EXIT_EXPIRED, isExpired: true };
|
||||
}
|
||||
|
||||
// 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 };
|
||||
}
|
||||
|
||||
return { shouldExit: false, reason: null, isExpired: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} PositionState
|
||||
* @property {string} marketSlug
|
||||
* @property {string} tokenId
|
||||
* @property {'up'|'down'} side
|
||||
* @property {number} shares
|
||||
* @property {number} entryPrice
|
||||
* @property {number} tickSize
|
||||
* @property {string|null} conditionId - CTF condition ID for on-chain redemption
|
||||
* @property {boolean} negRisk - Whether to use NegRisk CTF contract
|
||||
* @property {number} openedAt
|
||||
*/
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* RedeemEngine.js
|
||||
* Auto-redemption service for the OneShot Dominant Side Hold engine.
|
||||
*
|
||||
* When a market expires and the position is cleared, this service queues the
|
||||
* position and polls at a regular interval until the CTF contract shows a
|
||||
* non-zero payout denominator (i.e. the market has been resolved on-chain).
|
||||
* It then either:
|
||||
* - DRY_RUN=true → simulates the outcome, logs win/loss P&L
|
||||
* - DRY_RUN=false → submits a real redeemPositions() transaction on Polygon
|
||||
*
|
||||
* Resolution flow:
|
||||
* 1. Gamma API check → market.closed || market.resolved
|
||||
* 2. On-chain check → CTF.payoutDenominator(conditionId) > 0
|
||||
* 3. Compute payout → payouts[0] for UP (YES), payouts[1] for DOWN (NO)
|
||||
* 4. Execute / log
|
||||
* 5. Emit 'redemption:complete' on EventBus with final P&L
|
||||
*
|
||||
* Payout index mapping:
|
||||
* side === 'up' → outcome index 0 (YES / Up token)
|
||||
* side === 'down' → outcome index 1 (NO / Down token)
|
||||
*/
|
||||
|
||||
import { ethers } from 'ethers';
|
||||
import logger from '../utils/logger.js';
|
||||
import { getPolygonProvider } from '../services/client.js';
|
||||
import { redeemPosition, CTF_ADDRESS } from '../services/ctf.js';
|
||||
import { dbg } from './debug.js';
|
||||
|
||||
// ── On-chain constants (read-only — no writes go through EOA) ─────────────────
|
||||
|
||||
const CTF_ABI = [
|
||||
'function payoutNumerators(bytes32 conditionId, uint256 outcomeIndex) view returns (uint256)',
|
||||
'function payoutDenominator(bytes32 conditionId) view returns (uint256)',
|
||||
];
|
||||
|
||||
export class RedeemEngine {
|
||||
/**
|
||||
* @param {Object} opts
|
||||
* @param {boolean} opts.dryRun - If true, simulate instead of real tx
|
||||
* @param {number} [opts.pollIntervalMs] - How often to check pending queue (ms)
|
||||
* @param {import('./EventBus.js').default} opts.eventBus
|
||||
*/
|
||||
constructor({ dryRun, pollIntervalMs = 30_000, eventBus }) {
|
||||
this._dryRun = dryRun;
|
||||
this._pollMs = pollIntervalMs;
|
||||
this._eventBus = eventBus;
|
||||
this._pollTimer = null;
|
||||
|
||||
/**
|
||||
* @type {Map<string, PendingRedemption>}
|
||||
* Key: conditionId
|
||||
*/
|
||||
this._queue = new Map();
|
||||
|
||||
/** Prevent concurrent processing of the same conditionId */
|
||||
this._processing = new Set();
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
|
||||
start() {
|
||||
this._pollTimer = setInterval(() => this._poll().catch(() => {}), this._pollMs);
|
||||
logger.info(`RedeemEngine: started | poll every ${this._pollMs / 1000}s | dryRun=${this._dryRun}`);
|
||||
}
|
||||
|
||||
stop() {
|
||||
clearInterval(this._pollTimer);
|
||||
if (this._queue.size > 0) {
|
||||
logger.warn(`RedeemEngine: stopped — ${this._queue.size} position(s) still pending redemption:`);
|
||||
for (const [, item] of this._queue) {
|
||||
logger.warn(` → ${item.marketSlug} | ${item.side.toUpperCase()} | ${item.shares} shares @ $${item.entryPrice.toFixed(4)}`);
|
||||
}
|
||||
} else {
|
||||
logger.info('RedeemEngine: stopped — no pending redemptions');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Add an expired position to the redemption queue.
|
||||
* Safe to call multiple times — duplicate conditionIds are ignored.
|
||||
*
|
||||
* @param {Object} data
|
||||
* @param {string} data.conditionId
|
||||
* @param {string} data.marketSlug
|
||||
* @param {'up'|'down'} data.side
|
||||
* @param {number} data.shares
|
||||
* @param {number} data.entryPrice
|
||||
* @param {boolean} data.negRisk
|
||||
*/
|
||||
queueRedemption({ conditionId, marketSlug, side, shares, entryPrice, negRisk }) {
|
||||
if (!conditionId) {
|
||||
logger.warn(`RedeemEngine: missing conditionId for ${marketSlug} — skipping queue`);
|
||||
return;
|
||||
}
|
||||
if (this._queue.has(conditionId)) return;
|
||||
|
||||
this._queue.set(conditionId, {
|
||||
conditionId,
|
||||
marketSlug,
|
||||
side,
|
||||
shares,
|
||||
entryPrice,
|
||||
negRisk: negRisk ?? false,
|
||||
queuedAt: Date.now(),
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`RedeemEngine: queued ${marketSlug} | ${side.toUpperCase()} | ` +
|
||||
`${shares} shares @ $${entryPrice.toFixed(4)} | pending on-chain resolution`,
|
||||
);
|
||||
|
||||
// Trigger an immediate check rather than waiting for the first poll tick
|
||||
this._checkAndRedeem(this._queue.get(conditionId)).catch(() => {});
|
||||
}
|
||||
|
||||
/** Number of positions waiting to be redeemed */
|
||||
get pendingCount() {
|
||||
return this._queue.size;
|
||||
}
|
||||
|
||||
// ── Poll loop ─────────────────────────────────────────────────────────────
|
||||
|
||||
async _poll() {
|
||||
if (this._queue.size === 0) return;
|
||||
dbg('REDEEM', `poll — ${this._queue.size} pending: [${[...this._queue.keys()].map((id) => id.slice(0, 8) + '...').join(', ')}]`);
|
||||
|
||||
for (const [, item] of this._queue) {
|
||||
if (this._processing.has(item.conditionId)) continue;
|
||||
this._processing.add(item.conditionId);
|
||||
this._checkAndRedeem(item)
|
||||
.catch((err) => logger.error(`RedeemEngine: error on ${item.marketSlug} — ${err.message}`))
|
||||
.finally(() => this._processing.delete(item.conditionId));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Resolution check ──────────────────────────────────────────────────────
|
||||
|
||||
async _checkAndRedeem(item) {
|
||||
// Always use on-chain as ground truth for payout data
|
||||
const onChain = await this._checkOnChainPayout(item.conditionId);
|
||||
|
||||
if (!onChain.resolved) {
|
||||
// Gamma API as a secondary status check (informational only)
|
||||
const gammaResolved = await this._checkGammaResolution(item.conditionId);
|
||||
const secWaiting = Math.floor((Date.now() - item.queuedAt) / 1000);
|
||||
|
||||
dbg('REDEEM',
|
||||
`${item.marketSlug} | not yet settled on-chain | ` +
|
||||
`gammaResolved=${gammaResolved} | waited=${secWaiting}s`,
|
||||
);
|
||||
return; // retry on next poll tick
|
||||
}
|
||||
|
||||
await this._settle(item, onChain.payouts);
|
||||
}
|
||||
|
||||
// ── Settlement ────────────────────────────────────────────────────────────
|
||||
|
||||
async _settle(item, payouts) {
|
||||
// UP token = outcome index 0 (YES), DOWN token = outcome index 1 (NO)
|
||||
const outcomeIdx = item.side === 'up' ? 0 : 1;
|
||||
const payoutFraction = payouts[outcomeIdx] ?? 0;
|
||||
const won = payoutFraction > 0;
|
||||
const received = payoutFraction * item.shares; // USDC back from CTF
|
||||
const cost = item.entryPrice * item.shares; // USDC paid at entry
|
||||
const pnl = received - cost;
|
||||
|
||||
if (this._dryRun) {
|
||||
// Simulate: just log the outcome without touching the chain
|
||||
this._logSettlement(item, won, pnl, received, cost);
|
||||
} else {
|
||||
// Always attempt redeemPositions — even for losses (burns the token, cleans wallet)
|
||||
const success = await this._executeRedeem(item);
|
||||
if (!success && won) {
|
||||
// Win but tx failed — USDC unclaimed, keep in queue and retry next poll
|
||||
logger.warn(`RedeemEngine: redemption tx failed for ${item.marketSlug} — will retry`);
|
||||
return;
|
||||
}
|
||||
// Loss: clear from queue regardless of tx result — payout is 0, nothing to collect
|
||||
this._logSettlement(item, won, pnl, received, cost);
|
||||
}
|
||||
|
||||
// Clear from queue and notify orchestrator
|
||||
this._queue.delete(item.conditionId);
|
||||
|
||||
this._eventBus.emit('redemption:complete', {
|
||||
conditionId: item.conditionId,
|
||||
marketSlug: item.marketSlug,
|
||||
side: item.side,
|
||||
won,
|
||||
pnl,
|
||||
shares: item.shares,
|
||||
entryPrice: item.entryPrice,
|
||||
});
|
||||
}
|
||||
|
||||
_logSettlement(item, won, pnl, received, cost) {
|
||||
const tag = this._dryRun ? '[SIM]' : '';
|
||||
|
||||
if (won) {
|
||||
const pct = cost > 0 ? ((pnl / cost) * 100).toFixed(1) : '0.0';
|
||||
logger.money(
|
||||
`${tag} RedeemEngine WIN | ${item.marketSlug} | ${item.side.toUpperCase()} won | ` +
|
||||
`+$${pnl.toFixed(4)} (+${pct}%) | ` +
|
||||
`${item.shares} shares: paid $${cost.toFixed(4)} → received $${received.toFixed(4)}`,
|
||||
);
|
||||
} else {
|
||||
logger.error(
|
||||
`${tag} RedeemEngine LOSS | ${item.marketSlug} | ${item.side.toUpperCase()} lost | ` +
|
||||
`-$${cost.toFixed(4)} (-100%) | ${item.shares} shares @ $${item.entryPrice.toFixed(4)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
async _checkGammaResolution(conditionId) {
|
||||
try {
|
||||
const url = `${config.gammaHost}/markets?condition_id=${conditionId}`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) return false;
|
||||
const markets = await resp.json();
|
||||
if (!Array.isArray(markets) || markets.length === 0) return false;
|
||||
const m = markets[0];
|
||||
return !!(m.closed || m.resolved);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read payoutNumerators and payoutDenominator from the CTF contract.
|
||||
* Returns resolved=true only when denominator > 0 (market has been settled).
|
||||
*/
|
||||
async _checkOnChainPayout(conditionId) {
|
||||
try {
|
||||
const provider = await getPolygonProvider();
|
||||
const ctf = new ethers.Contract(CTF_ADDRESS, CTF_ABI, provider);
|
||||
|
||||
const denom = await ctf.payoutDenominator(conditionId);
|
||||
if (denom.isZero()) return { resolved: false, payouts: [] };
|
||||
|
||||
const payouts = [];
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const num = await ctf.payoutNumerators(conditionId, i);
|
||||
payouts.push(num.toNumber() / denom.toNumber());
|
||||
}
|
||||
return { resolved: true, payouts };
|
||||
} catch {
|
||||
return { resolved: false, payouts: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/** Submit redeemPositions() via Gnosis Safe proxy wallet (same path as MM) */
|
||||
async _executeRedeem(item) {
|
||||
try {
|
||||
logger.info(`RedeemEngine: submitting redeem tx | ${item.marketSlug}...`);
|
||||
await redeemPosition(item.conditionId, item.negRisk);
|
||||
logger.success(`RedeemEngine: redeemed | ${item.marketSlug}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.error(`RedeemEngine: tx error | ${item.marketSlug} — ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} PendingRedemption
|
||||
* @property {string} conditionId
|
||||
* @property {string} marketSlug
|
||||
* @property {'up'|'down'} side
|
||||
* @property {number} shares
|
||||
* @property {number} entryPrice
|
||||
* @property {boolean} negRisk
|
||||
* @property {number} queuedAt - timestamp when queued
|
||||
*/
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* SignalEngine.js
|
||||
* Steps C & D of the runtime sequence.
|
||||
*
|
||||
* Strategy: Dominant Side Hold — Momentum-Aware Entry
|
||||
* ────────────────────────────────────────────────────
|
||||
* Enters ONLY the side that the market already prices as probable winner
|
||||
* (mid > 50%) AND whose price is either rising or stable.
|
||||
*
|
||||
* "Follow where the odds are moving" — midSlope6s from FeatureEngine is now
|
||||
* a first-class scoring factor. A dominant side that is actively FADING
|
||||
* (slope < SLOPE_CANCEL) is blocked entirely even if its mid is still > 0.60,
|
||||
* because a fading dominant signals a potential reversal.
|
||||
*
|
||||
* Entry pipeline (per 'features' event):
|
||||
* 1. Hard gates — stale, TTE out of [tteMin, tteMax], spread > SPREAD_MAX, depth thin
|
||||
* 2. Dominant side — identify which token the market prices higher; require mid gap >= MIN_MID_GAP
|
||||
* 3. Min probability — dominant mid must be >= minDominantMid (e.g. 0.58)
|
||||
* 4. Momentum gate — dominant midSlope6s must be >= SLOPE_CANCEL (not actively fading)
|
||||
* 5. Score — weighted: mid strength (35%) + momentum (30%) + imbalance (20%) + spread (15%)
|
||||
* 6. Threshold — score >= scoreThreshold
|
||||
*
|
||||
* Key parameter changes vs previous version:
|
||||
* - SPREAD_MAX: 0.02 → 0.04 (near-expiry books often have 0.03 spread)
|
||||
* - tteMax: 90 → 150s (catch direction when it is being established)
|
||||
* - Added W_MOMENTUM = 0.30 (replaces old W_SLOPE/W_RETRACE scalper metrics)
|
||||
* - Added momentum gate (SIG_FADING_DOMINANT) to block reversals
|
||||
*/
|
||||
|
||||
import { Signal, ReasonCode } from './constants.js';
|
||||
import { dbg, DEBUG } from './debug.js';
|
||||
|
||||
// ── Score weights ──────────────────────────────────────────────────────────────
|
||||
const W_MID = 0.35; // How strongly the market prices this side as winner
|
||||
const W_MOMENTUM = 0.30; // Is the dominant odds direction being maintained?
|
||||
const W_IMBALANCE = 0.20; // Order-book depth confirms the direction
|
||||
const W_SPREAD = 0.15; // Execution cost (less critical for hold-to-expiry)
|
||||
|
||||
// ── Gate thresholds ────────────────────────────────────────────────────────────
|
||||
const SPREAD_MAX = 0.04; // Hard gate: spread wider than this → skip
|
||||
const MIN_MID_GAP = 0.08; // Hard gate: |up.mid - down.mid| must exceed this
|
||||
|
||||
// ── Momentum constants ─────────────────────────────────────────────────────────
|
||||
// SLOPE_CANCEL: if dominant side's 6s slope is below this, the market may be
|
||||
// reversing — block entry even if mid is still above threshold.
|
||||
const SLOPE_CANCEL = -0.0020; // Active fade = potential reversal, do not enter
|
||||
const SLOPE_STRONG = 0.0020; // Clearly rising — best signal
|
||||
const SLOPE_MILD = 0.0005; // Gently rising — still good
|
||||
|
||||
// ── Imbalance constants ────────────────────────────────────────────────────────
|
||||
const IMB_STRONG = 0.20;
|
||||
const IMB_WEAK = 0.05;
|
||||
|
||||
/** Throttle debug output: log detail every N evaluations per market */
|
||||
const DEBUG_EVERY = 5;
|
||||
|
||||
export class SignalEngine {
|
||||
/**
|
||||
* @param {Object} opts
|
||||
* @param {import('./EventBus.js').default} opts.eventBus
|
||||
* @param {number} opts.scoreThreshold - Minimum composite 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 mid must be >= this (e.g. 0.58)
|
||||
* @param {number} [opts.tteMin=15] - Minimum TTE in seconds
|
||||
* @param {number} [opts.tteMax=150] - Maximum TTE in seconds
|
||||
*/
|
||||
constructor({ eventBus, scoreThreshold, minTopSize, minDominantMid = 0.58, tteMin = 15, tteMax = 150 }) {
|
||||
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();
|
||||
|
||||
this._eventBus.on('features', (feat) => this._onFeatures(feat));
|
||||
}
|
||||
|
||||
// ── Internal ──────────────────────────────────────────────────────────────
|
||||
|
||||
_onFeatures(feat) {
|
||||
const { ts, marketSlug, tteSec, snapshot } = feat;
|
||||
|
||||
const evalN = (this._evalCount.get(marketSlug) ?? 0) + 1;
|
||||
this._evalCount.set(marketSlug, evalN);
|
||||
const logThis = DEBUG && (evalN % DEBUG_EVERY === 1);
|
||||
|
||||
// ── Step C: hard gates ──────────────────────────────────────────────
|
||||
|
||||
const gate = this._hardGates(snapshot, tteSec);
|
||||
|
||||
if (logThis) {
|
||||
if (!gate.pass) {
|
||||
dbg('GATE',
|
||||
`${marketSlug} | tte=${tteSec}s | FAIL → ${gate.reason} | ` +
|
||||
`upSprd=${snapshot.up.spread.toFixed(3)} dnSprd=${snapshot.down.spread.toFixed(3)} ` +
|
||||
`upMid=${snapshot.up.mid.toFixed(3)} dnMid=${snapshot.down.mid.toFixed(3)}`,
|
||||
);
|
||||
} else {
|
||||
dbg('GATE',
|
||||
`${marketSlug} | tte=${tteSec}s | PASS | ` +
|
||||
`upMid=${snapshot.up.mid.toFixed(3)} dnMid=${snapshot.down.mid.toFixed(3)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!gate.pass) {
|
||||
this._emit(marketSlug, Signal.NO_TRADE, null, 0, gate.reason, ts, snapshot, feat);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Step D1: identify dominant side ─────────────────────────────────
|
||||
// The dominant side is whichever token the market prices higher.
|
||||
|
||||
const upMid = snapshot.up.mid;
|
||||
const downMid = snapshot.down.mid;
|
||||
const midGap = Math.abs(upMid - downMid);
|
||||
|
||||
if (midGap < MIN_MID_GAP) {
|
||||
if (logThis) {
|
||||
dbg('SCORE',
|
||||
`${marketSlug} | NO_DOMINANT | upMid=${upMid.toFixed(3)} dnMid=${downMid.toFixed(3)} ` +
|
||||
`gap=${midGap.toFixed(3)} < ${MIN_MID_GAP}`,
|
||||
);
|
||||
}
|
||||
this._emit(marketSlug, Signal.NO_TRADE, null, 0, ReasonCode.SIG_NO_DOMINANT, ts, snapshot, feat);
|
||||
return;
|
||||
}
|
||||
|
||||
const isDominantUp = upMid > downMid;
|
||||
const dominantMid = isDominantUp ? upMid : downMid;
|
||||
const dominantBook = isDominantUp ? snapshot.up : snapshot.down;
|
||||
const dominantFeat = isDominantUp ? feat.up : feat.down;
|
||||
const signal = isDominantUp ? Signal.ENTER_LONG : Signal.ENTER_SHORT;
|
||||
const side = isDominantUp ? 'up' : 'down';
|
||||
const slope = dominantFeat?.midSlope6s ?? 0;
|
||||
|
||||
// ── Step D2: minimum probability gate ───────────────────────────────
|
||||
|
||||
if (dominantMid < this._minDominantMid) {
|
||||
if (logThis) {
|
||||
dbg('SCORE',
|
||||
`${marketSlug} | ${side.toUpperCase()} | LOW_DOMINANT | ` +
|
||||
`mid=${dominantMid.toFixed(3)} < ${this._minDominantMid}`,
|
||||
);
|
||||
}
|
||||
this._emit(marketSlug, Signal.NO_TRADE, null, 0, ReasonCode.SIG_LOW_DOMINANT, ts, snapshot, feat);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Step D3: momentum gate ───────────────────────────────────────────
|
||||
// If the dominant side's price is actively falling, the market may be
|
||||
// reversing. A fading dominant is more dangerous than a weak dominant.
|
||||
|
||||
if (slope < SLOPE_CANCEL) {
|
||||
if (logThis) {
|
||||
dbg('SCORE',
|
||||
`${marketSlug} | ${side.toUpperCase()} | FADING | ` +
|
||||
`slope=${slope.toFixed(5)} < ${SLOPE_CANCEL} (reversal risk)`,
|
||||
);
|
||||
}
|
||||
this._emit(marketSlug, Signal.NO_TRADE, null, 0, ReasonCode.SIG_FADING_DOMINANT, ts, snapshot, feat);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Step D4: composite score ─────────────────────────────────────────
|
||||
|
||||
const midScore = this._scoreMid(dominantMid);
|
||||
const momentumScore = this._scoreMomentum(slope);
|
||||
const imbalanceScore = this._scoreImbalance(dominantFeat?.imbalance ?? 0);
|
||||
const spreadScore = this._scoreSpread(dominantBook.spread);
|
||||
|
||||
const score =
|
||||
W_MID * midScore +
|
||||
W_MOMENTUM * momentumScore +
|
||||
W_IMBALANCE * imbalanceScore +
|
||||
W_SPREAD * spreadScore;
|
||||
|
||||
if (logThis) {
|
||||
dbg('SCORE',
|
||||
`${marketSlug} | ${side.toUpperCase()} dominant | ` +
|
||||
`mid=${dominantMid.toFixed(3)} gap=${midGap.toFixed(3)} slope=${slope.toFixed(5)} | ` +
|
||||
`midS=${midScore.toFixed(2)} momS=${momentumScore.toFixed(2)} ` +
|
||||
`imbS=${imbalanceScore.toFixed(2)} sprdS=${spreadScore.toFixed(2)} ` +
|
||||
`→ score=${score.toFixed(3)} (need ${this._scoreThreshold})`,
|
||||
);
|
||||
}
|
||||
|
||||
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} | ` +
|
||||
`mid=${dominantMid.toFixed(3)} slope=${slope.toFixed(5)} ` +
|
||||
`score=${score.toFixed(3)} tte=${tteSec}s`,
|
||||
);
|
||||
|
||||
this._emit(marketSlug, signal, side, score, null, ts, snapshot, feat);
|
||||
}
|
||||
|
||||
// ── Hard gates ────────────────────────────────────────────────────────────
|
||||
|
||||
_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 };
|
||||
|
||||
// Use the dominant side's spread only — underdog's spread is irrelevant
|
||||
// since we never buy the underdog.
|
||||
const dominantSpread = Math.min(snapshot.up.spread, snapshot.down.spread);
|
||||
if (dominantSpread > SPREAD_MAX)
|
||||
return { pass: false, reason: ReasonCode.GATE_SPREAD_WIDE };
|
||||
|
||||
// Require adequate depth on at least one side (dominant side check happens after)
|
||||
const thinUp = snapshot.up.bestBidSize < this._minTopSize
|
||||
|| snapshot.up.bestAskSize < this._minTopSize;
|
||||
const thinDown = snapshot.down.bestBidSize < this._minTopSize
|
||||
|| snapshot.down.bestAskSize < this._minTopSize;
|
||||
|
||||
if (thinUp && thinDown)
|
||||
return { pass: false, reason: ReasonCode.GATE_DEPTH_THIN };
|
||||
|
||||
return { pass: true, reason: null };
|
||||
}
|
||||
|
||||
// ── Scoring helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Score market confidence in the dominant side.
|
||||
* Higher mid price = market is more certain = higher score.
|
||||
* Entry "sweet spot" is 0.60–0.80 (clear direction, still worth holding).
|
||||
*/
|
||||
_scoreMid(mid) {
|
||||
if (mid >= 0.85) return 1.00;
|
||||
if (mid >= 0.75) return 0.85;
|
||||
if (mid >= 0.65) return 0.65;
|
||||
if (mid >= 0.58) return 0.40;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Score the momentum (direction) of the dominant side's price movement.
|
||||
* This is the "follow where the odds are moving" factor.
|
||||
*
|
||||
* Positive slope = dominant side is getting more expensive = conviction increasing.
|
||||
* Flat slope = direction held, acceptable.
|
||||
* Mild negative = slight give-back, cautious but still allowed.
|
||||
* SLOPE_CANCEL = actively fading = blocked by momentum gate before reaching here.
|
||||
*/
|
||||
_scoreMomentum(slope) {
|
||||
if (slope >= SLOPE_STRONG) return 1.00; // Strong, fast move in dominant direction
|
||||
if (slope >= SLOPE_MILD) return 0.75; // Steady climb
|
||||
if (slope >= 0) return 0.50; // Flat / holding
|
||||
if (slope >= -0.0005) return 0.20; // Slight give-back — cautious
|
||||
return 0.05; // Between -0.0005 and SLOPE_CANCEL — marginal
|
||||
}
|
||||
|
||||
/**
|
||||
* Score order-book imbalance for the dominant side.
|
||||
* Positive = more buy depth on dominant side = confirms direction.
|
||||
* Mildly negative = tolerated (sellers exist on winner too, normal).
|
||||
*/
|
||||
_scoreImbalance(imb) {
|
||||
if (imb >= IMB_STRONG) return 1.00;
|
||||
if (imb >= IMB_WEAK) return 0.70;
|
||||
if (imb >= -0.10) return 0.40; // Neutral to slight sell pressure
|
||||
if (imb >= -0.25) return 0.10; // Notable sell pressure
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Score execution cost (spread).
|
||||
* For hold-to-expiry the spread is paid once at entry, so wider spreads
|
||||
* are more tolerated than in a scalping strategy — hence 4 tiers up to SPREAD_MAX.
|
||||
*/
|
||||
_scoreSpread(spread) {
|
||||
if (spread <= 0.01) return 1.00;
|
||||
if (spread <= 0.02) return 0.70;
|
||||
if (spread <= 0.03) return 0.40;
|
||||
if (spread <= 0.04) return 0.10;
|
||||
return 0;
|
||||
}
|
||||
|
||||
_emit(marketSlug, signal, side, score, reason, ts, snapshot, features) {
|
||||
this._eventBus.emit('signal', {
|
||||
ts,
|
||||
marketSlug,
|
||||
tteSec: snapshot.tteSec,
|
||||
signal,
|
||||
side,
|
||||
score,
|
||||
reason,
|
||||
snapshot,
|
||||
features,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 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 == null
|
||||
? 'pending(on-chain)'
|
||||
: 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}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 (legacy)
|
||||
SIG_NO_DOMINANT: 'SIG_NO_DOMINANT', // Neither side is clearly dominant (mid gap too small)
|
||||
SIG_LOW_DOMINANT: 'SIG_LOW_DOMINANT', // Dominant side mid below minimum threshold
|
||||
SIG_FADING_DOMINANT: 'SIG_FADING_DOMINANT', // Dominant side mid is actively falling — reversal risk
|
||||
|
||||
// Execution failures
|
||||
EXEC_TIMEOUT_NO_FILL: 'EXEC_TIMEOUT_NO_FILL', // FOK timed out without fill
|
||||
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_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
|
||||
});
|
||||
|
||||
// ── 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]: [],
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* debug.js
|
||||
* Lightweight debug helper for the OneShot engine.
|
||||
*
|
||||
* Enable by setting ONESHOT_DEBUG=true in your .env or environment,
|
||||
* or by passing --debug on the command line:
|
||||
*
|
||||
* ONESHOT_DEBUG=true npm run oneshot
|
||||
* npm run oneshot -- --debug
|
||||
* npm run oneshot-debug (shorthand script)
|
||||
*/
|
||||
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
export const DEBUG = process.env.ONESHOT_DEBUG === 'true'
|
||||
|| process.argv.includes('--debug');
|
||||
|
||||
/**
|
||||
* Log a debug message — no-op when DEBUG is false.
|
||||
* Prefixes every line with a [DBG <tag>] marker so you can grep by component.
|
||||
*
|
||||
* @param {string} tag - Component name, e.g. 'FEED', 'GATE', 'SCORE'
|
||||
* @param {string} msg - Message string
|
||||
*/
|
||||
export function dbg(tag, msg) {
|
||||
if (!DEBUG) return;
|
||||
logger.info(`[DBG:${tag}] ${msg}`);
|
||||
}
|
||||
@@ -409,6 +409,28 @@ export async function cleanupOpenPositions(clobClient) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Single-position redeemer (used by OneShot RedeemEngine) ──────────────────
|
||||
|
||||
/**
|
||||
* Redeem a single resolved position through the Gnosis Safe proxy wallet.
|
||||
* Uses the same execSafeCall path as all other MM on-chain operations,
|
||||
* enforcing the 30 Gwei priority fee floor required by Polygon.
|
||||
*
|
||||
* @param {string} conditionId - bytes32 condition ID
|
||||
* @param {boolean} negRisk - use NegRisk CTF address if true
|
||||
*/
|
||||
export async function redeemPosition(conditionId, negRisk = false) {
|
||||
const ctfAddress = negRisk ? NEG_RISK_EXCHANGE : CTF_ADDRESS;
|
||||
const ctfIface = new ethers.utils.Interface(CTF_ABI);
|
||||
const data = ctfIface.encodeFunctionData('redeemPositions', [
|
||||
USDC_ADDRESS,
|
||||
ethers.constants.HashZero,
|
||||
conditionId,
|
||||
[1, 2],
|
||||
]);
|
||||
await execSafeCall(ctfAddress, data, `redeemPositions ${conditionId.slice(0, 12)}...`);
|
||||
}
|
||||
|
||||
// ── Periodic redeemer ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user