14 Commits

Author SHA1 Message Date
direkturcrypto 0b28dcc228 fix(oneshot): always clear loss positions from redeem queue regardless of tx result 2026-02-24 16:38:28 +07:00
direkturcrypto 28cba8b0fb fix(oneshot): route redeemPositions through Gnosis Safe with 30gwei gas floor — same as MM 2026-02-24 16:08:37 +07:00
direkturcrypto 0c1e9f114c fix(oneshot): remove gasLimit override in redeemPositions — let ethers auto-estimate gas for Polygon 2026-02-24 15:52:12 +07:00
direkturcrypto bac4e320f5 fix(oneshot): handle null pnl in Telemetry.logExit for expired positions 2026-02-24 15:46:03 +07:00
direkturcrypto dbaba5914f fix(oneshot): emit expired snapshot so positions get queued to RedeemEngine on market close 2026-02-24 15:29:36 +07:00
direkturcrypto 6a2bbfa008 fix(oneshot): remove global position limit — block per-market only via SM state 2026-02-24 15:05:48 +07:00
direkturcrypto 8296c4129b fix(oneshot): add momentum scoring, widen gates to fix rare-entry problem
Root causes identified and fixed:

1. SPREAD_MAX 0.02 → 0.04  (biggest culprit — near-expiry books often have
   0.03 spread, hard gate was blocking all valid entries)

2. tteMax 90s → 150s  (direction is established by TTE=150s on 5m markets;
   previous 90s window was too narrow, skipped the "trend building" phase)

3. Added momentum as scoring factor W_MOMENTUM=0.30  ("follow where odds
   are moving" — midSlope6s from FeatureEngine now drives 30% of entry score)

4. Added SLOPE_CANCEL momentum gate (-0.0020): if dominant side's mid is
   actively falling (reversal risk), block entry regardless of mid level.
   New reason code: SIG_FADING_DOMINANT

5. Revised score weights: MID 45%→35%, IMBALANCE 35%→20%, SPREAD 20%→15%,
   MOMENTUM 0%→30%

6. Score threshold 0.55 → 0.42 (now calibrated for 4-factor scoring)

7. minDominantMid default 0.60 → 0.58, tteMin 20 → 15

8. Spread gate now checks min(up.spread, down.spread) — dominant side only
   needs to be tradeable, not both sides

Updated .env and .env.example defaults to match new parameters.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 14:48:36 +07:00
direkturcrypto 89a7803aa8 feat(oneshot): add auto-redemption via RedeemEngine
When a position's market expires, RedeemEngine automatically polls the
CTF contract and redeems the winning position on-chain — no manual redeem needed.

Flow:
  1. expirePosition() queues the expired position into RedeemEngine
  2. RedeemEngine polls every 30s (ONESHOT_REDEEM_POLL_MS)
  3. Checks Gamma API first, then CTF.payoutDenominator() on-chain
  4. When settled: emits redemption:complete event with final P&L
  5. Orchestrator passes P&L to RiskEngine

DRY_RUN=true: simulates by reading on-chain payouts and logging win/loss
DRY_RUN=false: submits real redeemPositions() tx on Polygon (gasLimit 300k)

Also stores conditionId and negRisk in PositionEngine state so the
expired position has all data needed for redemption without extra lookups.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 14:20:57 +07:00
direkturcrypto 2692694309 fix(oneshot): add global position limit — no new entry while any position is open
Previously the per-market state machine check (sm.is(IDLE)) only blocked
re-entry on the same slug. A fresh market slot (different slug) would get
its own IDLE state machine and could trigger another entry while the previous
market's position was still being held.

Added posEngine.hasAnyPosition() global guard in onSignal so the engine holds
exactly one position at a time across all tracked markets.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 14:07:13 +07:00
direkturcrypto bf09d30376 refactor(oneshot): switch from scalper to Dominant Side Hold strategy
Previous behaviour: enter any side with positive momentum, exit at TP (+1 tick),
cycle back to IDLE — causing rapid buy-sell-buy loops on low-probability tokens.

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 14:05:44 +07:00
direkturcrypto 707d654749 fix(oneshot): rewrite MarketFeedService market discovery to match working detectors
- Use /markets/slug/{slug} direct endpoint (not /markets?slug=...&limit=1)
- Extract token IDs from clobTokenIds field with JSON string parsing fallback
- Read tick size from market.orderPriceMinTickSize (no separate API call)
- Prioritise endDate (full datetime) over endDateIso (date-only) to fix false expiry
- Slot formula matches sniperDetector/mmDetector exactly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 13:40:09 +07:00
direkturcrypto acf3943f80 fix(oneshot): use endDate instead of endDateIso for market expiry check
The Gamma API returns two date fields:
  endDateIso = "2026-02-24"           ← date only, no time
  endDate    = "2026-02-24T06:35:00Z" ← correct close datetime

_parseEndTs() was preferring endDateIso, which parsed to midnight UTC
and was already in the past by the time any market opened during the day.
Both current and next-slot markets were therefore rejected as "already expired".

Fix: prioritise endDate (full ISO datetime) over endDateIso (date-only).

Co-Authored-By: direkturcrypto <direkturcrypto.x@mail3.me>
2026-02-24 13:33:48 +07:00
direkturcrypto 202bf98fe7 feat(oneshot): add ONESHOT_DEBUG verbose logging mode
Adds a debug flag (ONESHOT_DEBUG=true / --debug / npm run oneshot-debug)
that surfaces the engine's internal decision process at every key step.

Debug output tags
─────────────────
  [DBG:FEED]   — Market discovery: every slug probed, API response status,
                 token IDs extracted, tick size fetched.
                 Throttled poll summary every 10 ticks per market showing
                 bid/ask/spread/mid/depth for both UP and DOWN sides.

  [DBG:GATE]   — Hard gate result every 5 evaluations per market:
                 TTE range, spread width, depth thinness, stale flag.
                 Shows exact gate fail reason or PASS confirmation.

  [DBG:FEAT]   — Feature breakdown for each side every 5 evals:
                 slope, imbalance, spread, retrace raw values plus
                 per-component scores and weighted total.

  [DBG:SCORE]  — Per-side qualify check: score vs threshold,
                 trend confirm flag, and QUALIFY / skip verdict.

  [DBG:SIGNAL] — Always logged (no throttle) when an ENTER signal fires.

  [DBG:HEART]  — 5-second heartbeat: active markets, per-market SM state,
                 dailyPnl, consecLosses, cooldownLeft, halted flag.

New script
──────────
  npm run oneshot-debug  →  DRY_RUN=true ONESHOT_DEBUG=true node src/oneshot.js

Co-Authored-By: direkturcrypto <direkturcrypto.x@mail3.me>
2026-02-24 13:29:26 +07:00
direkturcrypto f074ca9ecb feat(oneshot): add Anti-Flip 5m microstructure execution engine
Introduces a complete, event-driven execution engine for 5-minute
Polymarket UP/DOWN markets, implementing the Anti-Flip strategy spec.

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

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

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

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

Co-Authored-By: direkturcrypto <direkturcrypto.x@mail3.me>
2026-02-24 13:00:19 +07:00
16 changed files with 2511 additions and 1 deletions
+75
View File
@@ -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 (200500ms recommended)
ONESHOT_POLL_INTERVAL_MS=300
# USDC risk per trade — size = floor(ONESHOT_BASE_RISK_USDC / entryPrice), min 5 shares
ONESHOT_BASE_RISK_USDC=5
# ── 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.550.65. The momentum gate provides additional conviction filtering.
ONESHOT_MIN_DOMINANT_MID=0.58
# Minimum composite score to trigger entry (01).
# 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 25 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
View File
@@ -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
View File
@@ -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);
});
+22
View File
@@ -0,0 +1,22 @@
/**
* EventBus.js
* Central event bus for the OneShot engine.
* All inter-service communication flows through this singleton.
*
* Event catalogue:
* snapshot MarketFeedService → FeatureEngine, orchestrator
* features FeatureEngine → SignalEngine, orchestrator
* signal SignalEngine → orchestrator
* state:transition StateMachine → orchestrator, Telemetry
*/
import { EventEmitter } from 'events';
class OneShotEventBus extends EventEmitter {}
const bus = new OneShotEventBus();
// Prevent memory-leak warnings for high subscriber counts across many markets
bus.setMaxListeners(50);
export default bus;
+221
View File
@@ -0,0 +1,221 @@
/**
* ExecutionEngine.js
* Steps E & F of the runtime sequence.
*
* Responsibilities:
* - Submit a limit-marketable FOK BUY order at bestAsk
* - Wait up to fillTimeoutMs for an ack/fill response
* - Return structured fill result (filled | partial | cancelled)
* - Submit market-sell (FOK) for exits and cut-losses
* - Place GTC limit-sell for take-profit orders
*
* In dry-run mode all calls short-circuit with simulated successful results.
*/
import { Side, OrderType } from '@polymarket/clob-client';
import logger from '../utils/logger.js';
const FILL_TIMEOUT_MS = 800;
export class ExecutionEngine {
/**
* @param {Object} opts
* @param {import('@polymarket/clob-client').ClobClient} opts.client
* @param {boolean} opts.dryRun
* @param {number} [opts.fillTimeoutMs=800]
*/
constructor({ client, dryRun, fillTimeoutMs = FILL_TIMEOUT_MS }) {
this._client = client;
this._dryRun = dryRun;
this._fillTimeoutMs = fillTimeoutMs;
/** Cache tick sizes to avoid repeated API calls */
this._tickCache = new Map();
}
// ── Buy ───────────────────────────────────────────────────────────────────
/**
* Submit a limit-marketable FOK buy and wait for the fill result.
*
* @param {Object} opts
* @param {string} opts.tokenId - ERC1155 token ID (UP or DOWN)
* @param {number} opts.size - Number of shares to buy (≥ 5)
* @param {number} opts.price - Limit price (bestAsk from snapshot)
* @param {string} opts.marketSlug - For logging
*
* @returns {Promise<FillResult>}
*/
async submitBuy({ tokenId, size, price, marketSlug }) {
if (this._dryRun) {
logger.trade(`[SIM] BUY ${marketSlug} | ${size} shares @ $${price}`);
return {
orderId: `sim_buy_${Date.now()}`,
status: 'filled',
filledSize: size,
avgFillPrice: price,
ackMs: 45,
fillMs: 90,
};
}
const startTs = Date.now();
const { tickSize, negRisk } = await this._getMarketOpts(tokenId);
logger.trade(`BUY ${marketSlug} | ${size} shares @ $${price}`);
const response = await this._withTimeout(
this._client.createAndPostOrder(
{ tokenID: tokenId, price: price.toString(), size, side: Side.BUY },
{ tickSize, negRisk },
OrderType.FOK,
),
this._fillTimeoutMs,
);
const ackMs = Date.now() - startTs;
const fillMs = ackMs;
if (!response?.success) {
logger.warn(`ExecutionEngine: buy not filled — ${response?.errorMsg ?? 'no response'}`);
return { orderId: null, status: 'cancelled', filledSize: 0, ackMs, fillMs };
}
const takingAmt = parseFloat(response.takingAmount || '0');
const makingAmt = parseFloat(response.makingAmount || '0');
if (takingAmt > 0) {
const avgFillPrice = makingAmt > 0 ? makingAmt / takingAmt : price;
logger.success(`ExecutionEngine: filled ${takingAmt.toFixed(2)} shares @ avg $${avgFillPrice.toFixed(4)}`);
return { orderId: response.orderID, status: 'filled', filledSize: takingAmt, avgFillPrice, ackMs, fillMs };
}
// Some CLOB responses indicate fill via status string rather than amounts
const isMatched = /matched|filled/i.test(response.status ?? '');
if (isMatched || response.success) {
return { orderId: response.orderID, status: 'filled', filledSize: size, avgFillPrice: price, ackMs, fillMs };
}
return { orderId: response.orderID, status: 'cancelled', filledSize: 0, ackMs, fillMs };
}
// ── Sell (exit / cut-loss) ────────────────────────────────────────────────
/**
* Submit a market-sell FOK order to exit a position immediately.
*
* @param {Object} opts
* @param {string} opts.tokenId
* @param {number} opts.size - Shares to sell
* @param {number} opts.price - Minimum acceptable sell price (5% slippage floor applied internally)
* @param {string} opts.marketSlug
*/
async submitSell({ tokenId, size, price, marketSlug }) {
if (this._dryRun) {
logger.trade(`[SIM] SELL ${marketSlug} | ${size} shares @ ~$${price}`);
return { orderId: `sim_sell_${Date.now()}`, status: 'filled' };
}
const { tickSize, negRisk } = await this._getMarketOpts(tokenId);
const minPrice = Math.max(price * 0.95, 0.01);
logger.trade(`SELL ${marketSlug} | ${size} shares @ min $${minPrice.toFixed(4)}`);
const response = await this._client.createAndPostMarketOrder(
{ tokenID: tokenId, side: Side.SELL, amount: size, price: minPrice },
{ tickSize, negRisk },
OrderType.FOK,
).catch((err) => {
logger.warn(`ExecutionEngine: sell error — ${err.message}`);
return null;
});
const filled = response?.success ?? false;
if (!filled) logger.warn(`ExecutionEngine: sell not filled — ${response?.errorMsg ?? 'unknown'}`);
return { orderId: response?.orderID ?? null, status: filled ? 'filled' : 'failed' };
}
/**
* Place a GTC limit-sell order for take-profit.
* Returns the order ID so the caller can cancel it if exit conditions change.
*
* @param {Object} opts
* @param {string} opts.tokenId
* @param {number} opts.size - Shares to sell
* @param {number} opts.tpPrice - Exact target sell price (aligned to tick size)
* @param {string} opts.marketSlug
*/
async submitTPOrder({ tokenId, size, tpPrice, marketSlug }) {
if (this._dryRun) {
logger.trade(`[SIM] TP ORDER ${marketSlug} | ${size} shares @ $${tpPrice}`);
return { orderId: `sim_tp_${Date.now()}`, status: 'placed' };
}
const { tickSize, negRisk } = await this._getMarketOpts(tokenId);
const response = await this._client.createAndPostOrder(
{ tokenID: tokenId, price: tpPrice.toString(), size, side: Side.SELL },
{ tickSize, negRisk },
OrderType.GTC,
).catch((err) => {
logger.warn(`ExecutionEngine: TP order error — ${err.message}`);
return null;
});
const placed = response?.success ?? false;
logger.info(`ExecutionEngine: TP order ${placed ? 'placed' : 'failed'} | ${marketSlug} @ $${tpPrice}`);
return { orderId: response?.orderID ?? null, status: placed ? 'placed' : 'failed' };
}
/** Cancel an open order by order ID */
async cancelOrder(orderId) {
if (this._dryRun || !orderId) return;
try {
await this._client.cancelOrder({ orderID: orderId });
} catch (err) {
logger.warn(`ExecutionEngine: cancel failed for ${orderId}${err.message}`);
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
async _getMarketOpts(tokenId) {
if (this._tickCache.has(tokenId)) return this._tickCache.get(tokenId);
let tickSize = '0.01';
let negRisk = false;
try {
tickSize = String(await this._client.getTickSize(tokenId) ?? '0.01');
negRisk = await this._client.getNegRisk(tokenId).catch(() => false) ?? false;
} catch { /* use defaults */ }
const opts = { tickSize, negRisk };
this._tickCache.set(tokenId, opts);
return opts;
}
/**
* Wrap a promise with a hard timeout.
* Resolves to null on timeout rather than rejecting — execution layer
* treats null as a no-fill and transitions back to IDLE cleanly.
*/
_withTimeout(promise, ms) {
return Promise.race([
promise,
new Promise((resolve) => setTimeout(() => resolve(null), ms)),
]);
}
}
/**
* @typedef {Object} FillResult
* @property {string|null} orderId
* @property {'filled'|'partial'|'cancelled'} status
* @property {number} filledSize
* @property {number} avgFillPrice
* @property {number} ackMs
* @property {number} fillMs
*/
+174
View File
@@ -0,0 +1,174 @@
/**
* FeatureEngine.js
* Step B of the runtime sequence.
*
* Maintains a rolling 15-second buffer of market snapshots per market
* and computes the following features on each incoming snapshot:
*
* midSlope6s — Linear regression slope of the mid price over the last 6s
* (positive = upward momentum, unit: price change per second)
* retrace3s — Fractional pullback from the 6s rolling peak to current mid
* (0 = no retrace, 1 = fully retraced to baseline)
* imbalance — (depthBid - depthAsk) / (depthBid + depthAsk)
* (positive = buyers dominate, negative = sellers dominate)
* spread — Current bestAsk - bestBid
* depthTop3 — Sum of the top-3 bid levels (buy-side depth at best prices)
*
* Features are computed independently for both UP and DOWN book sides.
*
* Emits a 'features' event on the event bus with shape:
* { ts, marketSlug, tteSec, up: SideFeatures, down: SideFeatures, snapshot }
*/
const BUFFER_WINDOW_MS = 15_000;
const SLOPE_WINDOW_MS = 6_000;
const RETRACE_PEAK_MS = 6_000; // Look-back window for peak in retrace calc
const DEPTH_TOP_N = 3;
export class FeatureEngine {
/**
* @param {Object} opts
* @param {import('./EventBus.js').default} opts.eventBus
*/
constructor({ eventBus }) {
this._eventBus = eventBus;
/** @type {Map<string, Array<{ts, up_mid, down_mid, up_spread, up_depthBid, up_depthAsk, up_bestBidSize, up_bestAskSize, down_spread, down_depthBid, down_depthAsk, down_bestBidSize, down_bestAskSize}>>} */
this._buffers = new Map();
/** @type {Map<string, Object>} Most recent features per market */
this._latest = new Map();
this._eventBus.on('snapshot', (snap) => this._onSnapshot(snap));
}
/** Retrieve the most recently computed features for a given market */
getLatest(marketSlug) {
return this._latest.get(marketSlug) ?? null;
}
// ── Internal ──────────────────────────────────────────────────────────────
_onSnapshot(snap) {
const { marketSlug, ts, tteSec, up, down } = snap;
// Add to rolling buffer
if (!this._buffers.has(marketSlug)) this._buffers.set(marketSlug, []);
const buf = this._buffers.get(marketSlug);
buf.push({
ts,
up_mid: up.mid,
up_spread: up.spread,
up_depthBid: up.depthBid,
up_depthAsk: up.depthAsk,
up_bestBidSize: up.bestBidSize,
up_bestAskSize: up.bestAskSize,
down_mid: down.mid,
down_spread: down.spread,
down_depthBid: down.depthBid,
down_depthAsk: down.depthAsk,
down_bestBidSize: down.bestBidSize,
down_bestAskSize: down.bestAskSize,
});
// Evict entries older than the buffer window
const cutoff = ts - BUFFER_WINDOW_MS;
while (buf.length > 0 && buf[0].ts < cutoff) buf.shift();
const features = {
ts,
marketSlug,
tteSec,
up: this._computeSideFeatures(buf, 'up', up),
down: this._computeSideFeatures(buf, 'down', down),
snapshot: snap,
};
this._latest.set(marketSlug, features);
this._eventBus.emit('features', features);
}
/**
* Compute all features for one book side using the rolling buffer.
*
* @param {Array} buf - Rolling buffer entries (ascending ts)
* @param {string} side - 'up' or 'down'
* @param {Object} currentBook - Live BookSide from current snapshot
*/
_computeSideFeatures(buf, side, currentBook) {
const now = buf[buf.length - 1]?.ts ?? Date.now();
const midKey = `${side}_mid`;
// Slice for slope window (last 6s)
const slopeBuf = buf.filter((e) => e.ts >= now - SLOPE_WINDOW_MS);
const mids6s = slopeBuf.map((e) => e[midKey]);
// Slice for retrace peak look-back (last 6s)
const retraceBuf = buf.filter((e) => e.ts >= now - RETRACE_PEAK_MS);
const midsRetrace = retraceBuf.map((e) => e[midKey]);
const midSlope6s = this._linearSlope(mids6s);
const retrace3s = this._retrace(midsRetrace, currentBook.mid);
// Imbalance from depth
const totalDepth = currentBook.depthBid + currentBook.depthAsk;
const imbalance = totalDepth > 0
? (currentBook.depthBid - currentBook.depthAsk) / totalDepth
: 0;
// Top-3 bid depth from current book
const depthTop3 = currentBook.bids
.slice(0, DEPTH_TOP_N)
.reduce((s, l) => s + l.size, 0);
return {
midSlope6s,
retrace3s,
imbalance,
spread: currentBook.spread,
depthTop3,
bufLen: slopeBuf.length, // diagnostic
};
}
/**
* Ordinary least-squares slope through an array of mid-price values.
* Returns slope in units of "price change per sample interval".
* Returns 0 if fewer than 2 data points are available.
*/
_linearSlope(values) {
const n = values.length;
if (n < 2) return 0;
const meanX = (n - 1) / 2;
const meanY = values.reduce((a, b) => a + b, 0) / n;
let num = 0;
let den = 0;
for (let i = 0; i < n; i++) {
const dx = i - meanX;
num += dx * (values[i] - meanY);
den += dx * dx;
}
return den === 0 ? 0 : num / den;
}
/**
* Fractional retrace: how far the current mid has pulled back from
* the rolling peak within the look-back window.
*
* 0 = price is at its peak (no retrace)
* 1 = price is at its trough (full retrace)
*/
_retrace(mids, currentMid) {
if (mids.length === 0) return 0;
const peak = Math.max(...mids, currentMid);
const trough = Math.min(...mids, currentMid);
const range = peak - trough;
if (range < 1e-9) return 0;
return Math.max(0, (peak - currentMid) / range);
}
}
+374
View File
@@ -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 (200500)
* @param {import('./EventBus.js').default} opts.eventBus
*/
constructor({ client, assets, duration = '5m', pollIntervalMs = 300, eventBus }) {
this._client = client;
this._assets = assets;
this._duration = duration;
this._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,
};
}
}
+153
View File
@@ -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
*/
+280
View File
@@ -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
*/
+138
View File
@@ -0,0 +1,138 @@
/**
* RiskEngine.js
* Step H of the runtime sequence.
*
* Global risk enforcement across all markets in the same session:
*
* Consecutive loss cap — after N consecutive losses, enter COOLDOWN for
* `cooldownRounds` market opportunities
* Daily loss cap — if total daily P&L drops below -dailyLossCap,
* HALT all trading for the rest of the day
*
* All policy violations are surfaced via canTrade() so the orchestrator
* can gate entries without needing direct access to internal state.
*/
import logger from '../utils/logger.js';
import { ReasonCode } from './constants.js';
export class RiskEngine {
/**
* @param {Object} opts
* @param {number} opts.maxConsecLosses - Consecutive losses before cooldown
* @param {number} opts.cooldownRounds - Market slots to skip during cooldown
* @param {number} opts.dailyLossCap - Max cumulative daily loss in USDC (positive number)
*/
constructor({ maxConsecLosses = 2, cooldownRounds = 3, dailyLossCap = 20 }) {
this._maxConsecLosses = maxConsecLosses;
this._cooldownRounds = cooldownRounds;
this._dailyLossCap = dailyLossCap;
this._dailyPnl = 0;
this._consecLosses = 0;
this._cooldownLeft = 0;
this._halted = false;
this._sessionStart = Date.now();
}
// ── Public API ────────────────────────────────────────────────────────────
/**
* Check whether a new entry is allowed.
* @returns {{ ok: boolean, reason: string|null, halted: boolean }}
*/
canTrade() {
if (this._halted) {
return { ok: false, reason: ReasonCode.RISK_DAILY_CAP, halted: true };
}
if (this._cooldownLeft > 0) {
return { ok: false, reason: ReasonCode.RISK_CONSEC_LOSS, halted: false };
}
return { ok: true, reason: null, halted: false };
}
/** True if the engine is in cooldown (but not halted) */
isCooldown() {
return !this._halted && this._cooldownLeft > 0;
}
/** True if trading has been permanently halted for today */
isHalted() {
return this._halted;
}
/**
* Record the P&L of a closed position and update risk counters.
* @param {number} pnl - Realised P&L in USDC (negative = loss)
*/
recordResult(pnl) {
this._dailyPnl += pnl;
if (pnl < 0) {
this._consecLosses++;
if (this._consecLosses >= this._maxConsecLosses) {
this._cooldownLeft = this._cooldownRounds;
logger.warn(
`RiskEngine: ${this._consecLosses} consecutive losses — ` +
`entering cooldown for ${this._cooldownRounds} rounds`,
);
}
} else {
// Reset consecutive loss streak on any win
this._consecLosses = 0;
}
// Daily cap check
if (this._dailyPnl <= -Math.abs(this._dailyLossCap)) {
this._halted = true;
logger.error(
`RiskEngine: daily loss cap hit ($${this._dailyPnl.toFixed(2)}) — ` +
`trading HALTED for the rest of the session`,
);
}
this._logState(pnl);
}
/**
* Decrement the cooldown counter by one market slot.
* Called by the orchestrator each time a new market opportunity is seen
* while in cooldown mode.
*/
decrementCooldown() {
if (this._cooldownLeft > 0) {
this._cooldownLeft--;
logger.info(`RiskEngine: cooldown rounds remaining: ${this._cooldownLeft}`);
if (this._cooldownLeft === 0) {
this._consecLosses = 0;
logger.success('RiskEngine: cooldown lifted — resuming normal trading');
}
}
}
/** Current session statistics snapshot */
stats() {
return {
dailyPnl: this._dailyPnl,
consecLosses: this._consecLosses,
cooldownLeft: this._cooldownLeft,
halted: this._halted,
};
}
// ── Internal ──────────────────────────────────────────────────────────────
_logState(pnl) {
const sign = pnl >= 0 ? '+' : '';
const stats = this.stats();
logger.info(
`RiskEngine: pnl=${sign}$${pnl.toFixed(4)} | ` +
`daily=$${stats.dailyPnl.toFixed(4)} | ` +
`streak=${stats.consecLosses} | ` +
`cooldown=${stats.cooldownLeft}`,
);
}
}
+304
View File
@@ -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 (01)
* @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.600.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,
});
}
}
+69
View File
@@ -0,0 +1,69 @@
/**
* StateMachine.js
* Explicit per-market state machine with strict transition guards.
*
* Rule: never derive state from floating booleans.
* Every state change must go through transition() to be validated and logged.
*/
import { State, TRANSITIONS } from './constants.js';
export class StateMachine {
/**
* @param {string} marketSlug - Market identifier (used in error messages and logs)
* @param {import('./EventBus.js').default} eventBus
*/
constructor(marketSlug, eventBus) {
this._state = State.IDLE;
this._slug = marketSlug;
this._eventBus = eventBus;
}
/** Current state string */
get state() {
return this._state;
}
/**
* Attempt a state transition.
* Throws if the transition is not in the allowed graph — this is intentional:
* a programming error that bypasses the guard should be loud and traceable.
*
* @param {string} nextState - One of the State enum values
* @param {string} [reason] - Human-readable reason for the transition
* @returns {StateMachine} - Returns `this` for chaining
*/
transition(nextState, reason = '') {
const allowed = TRANSITIONS[this._state] ?? [];
if (!allowed.includes(nextState)) {
throw new Error(
`[StateMachine] Invalid transition: ${this._state}${nextState}` +
` (market: ${this._slug}, reason: ${reason})`,
);
}
const from = this._state;
this._state = nextState;
this._eventBus.emit('state:transition', {
marketSlug: this._slug,
from,
to: nextState,
reason,
ts: Date.now(),
});
return this;
}
/** @param {string} state */
is(state) {
return this._state === state;
}
/** @param {string} state */
canTransitionTo(state) {
return (TRANSITIONS[this._state] ?? []).includes(state);
}
}
+111
View File
@@ -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}`);
});
}
}
+70
View File
@@ -0,0 +1,70 @@
/**
* constants.js
* Shared enums and reason codes for the Anti-Flip 5m OneShot Engine.
* All objects are frozen to prevent accidental mutation at runtime.
*/
// ── State machine states ───────────────────────────────────────────────────────
export const State = Object.freeze({
IDLE: 'IDLE', // Waiting for a qualifying signal
SETUP_READY: 'SETUP_READY', // Signal passed — about to submit order
ORDER_PENDING: 'ORDER_PENDING', // Order submitted, awaiting fill ack
POSITION_OPEN: 'POSITION_OPEN', // Filled — actively managing position
REDUCE_ONLY: 'REDUCE_ONLY', // Time threshold reached — exit only, no new entry
COOLDOWN: 'COOLDOWN', // Short suspension after consecutive losses
HALTED: 'HALTED', // Daily stop-loss hit — no more trading today
});
// ── Reason / decision codes ────────────────────────────────────────────────────
export const ReasonCode = Object.freeze({
// Hard gate failures
GATE_TTE_FAIL: 'GATE_TTE_FAIL', // TTE outside [25, 120] range
GATE_SPREAD_WIDE: 'GATE_SPREAD_WIDE', // Spread exceeds maximum threshold
GATE_DEPTH_THIN: 'GATE_DEPTH_THIN', // Best bid/ask size below minimum
GATE_STALE_BOOK: 'GATE_STALE_BOOK', // Book snapshot is stale or empty
// Signal evaluation failures
SIG_SCORE_LOW: 'SIG_SCORE_LOW', // Composite score below threshold
SIG_NO_CONFIRM: 'SIG_NO_CONFIRM', // Trend confirmation failed (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]: [],
});
+28
View File
@@ -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}`);
}
+22
View File
@@ -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 ─────────────────────────────────────────────────────────
/**