Skip to main content

Overview

The IntervalTracker is a state machine that orchestrates the lifecycle of 5-minute trading intervals. It detects epoch transitions, captures prediction snapshots at strategic moments, and closes completed intervals with comprehensive result data.

Core Concepts

Epoch Boundaries

Intervals align to 5-minute epoch boundaries (300-second divisions of Unix time):
// Example: 1678901400 = 2023-03-15 14:30:00 UTC
const epochTimestamp = Math.floor(Date.now() / 1000 / 300) * 300
When the epoch changes, the tracker automatically:
  1. Closes the previous interval with a result
  2. Starts a new interval with a fresh index

Prediction Snapshots

The tracker captures two prediction snapshots during each interval:
SnapshotTimingPurpose
Early Prediction60s remainingRisk/EV capture for trading decisions
Final Prediction30s remainingLast-moment model confidence
Why two snapshots? The early prediction (60s) includes full trading context (bet size, EV, drawdown state) for position sizing decisions. The final prediction (30s) captures the model’s latest confidence before the interval closes.

IntervalRecord Structure

Each interval generates a comprehensive record with 40+ data points:
{
  // ── Identity ──
  index: 42,                    // Sequential interval number
  epochTimestamp: 1678901400,   // 5-min boundary (seconds)
  
  // ── Outcome ──
  strikePrice: 24150.00,        // Price at interval start
  finalPrice: 24175.50,         // Price at interval end
  result: 'UP',                 // 'UP' | 'DOWN' | 'ACTIVE'
  priceDelta: 25.50,            // finalPrice - strikePrice
  priceMovePct: 0.106,          // % price change
  closedAt: '2023-03-15T14:35:00.123Z',

  // ── Predictions ──
  earlyPrediction: {
    probability: 0.6843,
    direction: 'UP'
  },
  earlyPredictionCorrect: true,
  
  prediction: {
    probability: 0.7021,
    direction: 'UP'
  },
  predictionCorrect: true,

  // ── Model Factors (at 60s) ──
  volatility: 0.00042,          // EWMA sigma per-second
  momentum: 0.0031,             // ROC momentum factor
  reversion: -0.0012,           // Mean reversion factor
  calibrated: true,             // Platt calibration active?

  // ── Market & EV (at 60s) ──
  qMarket: 0.52,                // Polymarket UP token price
  evAtCapture: 0.0843,          // Expected value
  edge: 0.1643,                 // p - q (model advantage)
  margin: 0.05,                 // Safety margin
  evSide: 'YES',                // Best EV side

  // ── Position Sizing (at 60s) ──
  betSize: 125.00,              // USD bet amount
  fullKelly: 0.3286,            // Full Kelly fraction
  alpha: 0.25,                  // Kelly fraction (1/4 Kelly)
  betSide: 'YES',               // 'YES' | 'NO'
  betCapped: false,             // Hit max bet limit?

  // ── Risk State (at 60s) ──
  drawdownLevel: 'green',       // 'green'|'yellow'|'red'|'critical'
  bankroll: 5000.00,            // Current bankroll USD
  drawdownPct: 2.3,             // % drawdown from peak
  coldStreak: 0,                // Consecutive misses

  // ── Abstention ──
  abstentionReason: null,       // Why no trade (null = traded)
  
  // ── Timing ──
  timeRemainingAtCapture: 59    // Seconds remaining at capture
}
All market/trading fields are captured at the early prediction moment (60s remaining) to reflect the exact state when position sizing decisions are made.

State Machine Flow

Transition Logic

The tracker detects epoch changes on every tick:
async tick({ currentPrice, strikePrice, prediction, epochTimestamp, ... }) {
  // Transition detection
  if (epochTimestamp !== this._currentEpoch) {
    
    // CLOSE previous interval
    if (this._currentInterval && currentPrice !== null) {
      const closing = this._currentInterval
      closing.finalPrice = currentPrice
      closing.result = currentPrice > closing.strikePrice ? 'UP' : 'DOWN'
      closing.closedAt = new Date().toISOString()
      
      // Evaluate prediction accuracy
      if (closing.earlyPrediction) {
        const predictedUp = closing.earlyPrediction.direction === 'UP'
        const actualUp = currentPrice > closing.strikePrice
        closing.earlyPredictionCorrect = predictedUp === actualUp
      }
      
      this._history.push(closing)
      await this._onIntervalClose(closing)
    }
    
    // START new interval
    this._currentEpoch = epochTimestamp
    this._currentInterval = {
      index: this._nextIndex++,
      epochTimestamp,
      result: 'ACTIVE',
      // ... initialize all fields to null
    }
    
    await this._onIntervalStart(epochTimestamp, this._currentInterval.index)
  }
  
  // Update current interval with latest data
  // ...
}
// CLOSE previous interval
if (this._currentInterval && currentPrice !== null) {
  const closing = this._currentInterval
  closing.finalPrice = currentPrice

  // Result is determined against the CLOSING interval's own strikePrice
  if (closing.strikePrice !== null) {
    closing.result = currentPrice > closing.strikePrice ? 'UP' : 'DOWN'
  } else {
    // Cannot determine result without a strikePrice — mark as FAIL
    closing.result = 'DOWN'
  }

  // Evaluate whether the prediction was correct
  if (closing.prediction) {
    const predictedUp = closing.prediction.direction === 'UP'
    const actualUp = currentPrice > closing.strikePrice
    closing.predictionCorrect = predictedUp === actualUp
  } else {
    closing.predictionCorrect = null
  }

  // Evaluate early prediction accuracy
  if (closing.earlyPrediction && closing.strikePrice !== null) {
    const earlyPredictedUp = closing.earlyPrediction.direction === 'UP'
    const actualUp = currentPrice > closing.strikePrice
    closing.earlyPredictionCorrect = earlyPredictedUp === actualUp
  } else {
    closing.earlyPredictionCorrect = null
  }

  // Price dynamics
  if (closing.strikePrice !== null) {
    closing.priceDelta = currentPrice - closing.strikePrice
    closing.priceMovePct = (closing.priceDelta / closing.strikePrice) * 100
  }

  closing.closedAt = new Date().toISOString()

  this._history.push(closing)
  await this._onIntervalClose(closing)
}

Snapshot Capture

Early Prediction (60s Remaining)

Captures full trading context for position sizing:
// Capture early prediction snapshot ONCE at ~1 min mark (60s remaining)
if (
  modelPred &&
  !this._currentInterval.earlyPrediction &&
  timeRemainingSeconds != null &&
  timeRemainingSeconds <= 60
) {
  this._currentInterval.earlyPrediction = {
    probability: modelPred.probability,
    direction: modelPred.direction,
  }

  // Model factors from prediction result
  this._currentInterval.volatility = modelPred.volatility ?? null
  this._currentInterval.momentum = modelPred.momentum ?? null
  this._currentInterval.reversion = modelPred.reversion ?? null
  this._currentInterval.calibrated = modelPred.calibrated ?? null

  // Market & EV
  if (qMarket != null) this._currentInterval.qMarket = qMarket
  if (evResult != null) {
    this._currentInterval.evAtCapture = evResult.ev ?? null
    this._currentInterval.edge = evResult.edge ?? null
    this._currentInterval.margin = evResult.margin ?? null
    this._currentInterval.evSide = evResult.bestSide ?? null
  }

  // Bet details (only when trade-eligible)
  if (prediction && betResult != null) {
    this._currentInterval.betSize = betResult.bet ?? null
    this._currentInterval.fullKelly = betResult.fullKelly ?? null
    this._currentInterval.alpha = betResult.alpha ?? null
    this._currentInterval.betSide = betResult.side ?? null
    this._currentInterval.betCapped = betResult.capped ?? null
  }

  // Risk state
  if (drawdownLevel != null) this._currentInterval.drawdownLevel = drawdownLevel
  if (drawdownState != null) {
    this._currentInterval.bankroll = drawdownState.bankroll ?? null
    this._currentInterval.drawdownPct = drawdownState.drawdownPct ?? null
    this._currentInterval.coldStreak = drawdownState.coldStreak ?? null
  }

  // Abstention reason at capture moment
  this._currentInterval.abstentionReason = abstentionReason ?? null

  // Exact capture timing
  this._currentInterval.timeRemainingAtCapture = timeRemainingSeconds
}
// Capture early prediction snapshot ONCE at ~1 min mark (60s remaining)
// Uses raw model prediction so recording isn't blocked by trading filters
if (
  modelPred &&
  !this._currentInterval.earlyPrediction &&
  timeRemainingSeconds != null &&
  timeRemainingSeconds <= 60
) {
  this._currentInterval.earlyPrediction = {
    probability: modelPred.probability,
    direction: modelPred.direction,
  }

  // Model factors from prediction result
  this._currentInterval.volatility = modelPred.volatility ?? null
  this._currentInterval.momentum = modelPred.momentum ?? null
  this._currentInterval.reversion = modelPred.reversion ?? null
  this._currentInterval.calibrated = modelPred.calibrated ?? null

  // Market & EV
  if (qMarket != null) this._currentInterval.qMarket = qMarket
  if (evResult != null) {
    this._currentInterval.evAtCapture = evResult.ev ?? null
    this._currentInterval.edge = evResult.edge ?? null
    this._currentInterval.margin = evResult.margin ?? null
    this._currentInterval.evSide = evResult.bestSide ?? null
  }

  // Bet details (only when trade-eligible)
  if (prediction && betResult != null) {
    this._currentInterval.betSize = betResult.bet ?? null
    this._currentInterval.fullKelly = betResult.fullKelly ?? null
    this._currentInterval.alpha = betResult.alpha ?? null
    this._currentInterval.betSide = betResult.side ?? null
    this._currentInterval.betCapped = betResult.capped ?? null
  }

  // Risk state
  if (drawdownLevel != null) this._currentInterval.drawdownLevel = drawdownLevel
  if (drawdownState != null) {
    this._currentInterval.bankroll = drawdownState.bankroll ?? null
    this._currentInterval.drawdownPct = drawdownState.drawdownPct ?? null
    this._currentInterval.coldStreak = drawdownState.coldStreak ?? null
  }

  // Abstention reason at capture moment
  this._currentInterval.abstentionReason = abstentionReason ?? null

  // Exact capture timing
  this._currentInterval.timeRemainingAtCapture = timeRemainingSeconds
}

Final Prediction (30s Remaining)

Captures last-moment model confidence:
// Capture final prediction snapshot ONCE at ~30s remaining
if (
  modelPred &&
  !this._currentInterval.prediction &&
  timeRemainingSeconds != null &&
  timeRemainingSeconds <= 30
) {
  this._currentInterval.prediction = {
    probability: modelPred.probability,
    direction: modelPred.direction,
  }
}
// Capture final prediction snapshot ONCE at ~30s remaining
if (
  modelPred &&
  !this._currentInterval.prediction &&
  timeRemainingSeconds != null &&
  timeRemainingSeconds <= 30
) {
  this._currentInterval.prediction = {
    probability: modelPred.probability,
    direction: modelPred.direction,
  }
}

Usage Example

Integration with the main bot loop:
import { IntervalTracker } from './tracker/interval.js'
import { HistoryStore } from './tracker/history.js'

const history = new HistoryStore({ filePath: 'data/history.json' })

const tracker = new IntervalTracker({
  onIntervalClose: async (record) => {
    console.log(`Interval ${record.index} closed: ${record.result}`)
    await history.append(record)
  },
  onIntervalStart: async (epoch, index) => {
    console.log(`Interval ${index} started at epoch ${epoch}`)
  }
})

// Load previous history to continue interval numbering
const previousRecords = await history.load()
tracker.loadHistory(previousRecords)

// Main loop (runs every second)
setInterval(async () => {
  const currentPrice = await priceSource.getPrice()
  const strikePrice = await vaticAPI.getStrikePrice()
  const prediction = await model.predict()
  const epochTimestamp = Math.floor(Date.now() / 1000 / 300) * 300
  const timeRemainingSeconds = 300 - (Math.floor(Date.now() / 1000) % 300)
  
  await tracker.tick({
    currentPrice,
    strikePrice,
    prediction,
    epochTimestamp,
    timeRemainingSeconds,
    qMarket: await polymarket.getPrice(),
    evResult: await evCalculator.compute(),
    betResult: await positionSizer.size(),
    drawdownLevel: drawdownTracker.getLevel(),
    drawdownState: drawdownTracker.getState(),
  })
}, 1000)

API Reference

Constructor

new IntervalTracker({ onIntervalClose, onIntervalStart })
Parameters:
  • onIntervalClose (function): Called with the closed IntervalRecord when an interval ends
  • onIntervalStart (function): Called with (epochTimestamp, index) when a new interval begins

Methods

tick(params)

Processes one second of interval lifecycle. Detects epoch transitions, captures snapshots, and updates state. Parameters:
  • currentPrice (number|null): Latest BTC price
  • strikePrice (number|null): Strike from Vatic API
  • prediction (object|null): Post-filter prediction { probability, direction }
  • rawPrediction (object|null): Raw model output before trading filters
  • epochTimestamp (number): Current 5-min epoch boundary (seconds)
  • timeRemainingSeconds (number): Seconds left in interval
  • qMarket (number|null): Polymarket price
  • evResult (object|null): Expected value calculation result
  • betResult (object|null): Position sizing result
  • drawdownLevel (string|null): Current drawdown level
  • drawdownState (object|null): Full drawdown state
  • abstentionReason (string|null): Why no trade was made

getCurrentInterval()

Returns the currently active IntervalRecord, or null if no tick has been processed yet.

getHistory()

Returns an array of all closed IntervalRecord objects accumulated this session.

loadHistory(records)

Restores previously persisted records so that interval indices continue from where they left off. Parameters:
  • records (array): Array of IntervalRecord objects loaded from disk

Edge Cases

Missing Strike Price

If the Vatic API fails and no strike price is available:
if (closing.strikePrice !== null) {
  closing.result = currentPrice > closing.strikePrice ? 'UP' : 'DOWN'
} else {
  // Cannot determine result without a strikePrice — mark as FAIL
  closing.result = 'DOWN'
}

Late Strike Price Arrival

The tracker back-fills the strike price when it arrives late:
// Back-fill strikePrice when it arrives late
if (strikePrice && !this._currentInterval.strikePrice) {
  this._currentInterval.strikePrice = strikePrice
}

Abstention Tracking

When the bot abstains from trading:
// Abstention reason at capture moment
this._currentInterval.abstentionReason = abstentionReason ?? null

// Examples:
// "low_confidence"      - Model probability < min threshold
// "negative_ev"         - Expected value < 0
// "drawdown_red"        - In red/critical drawdown state
// "cold_streak"         - Too many consecutive misses
// "insufficient_edge"   - Edge too small vs market

History Store

JSON persistence for interval records

Metrics

Scoring rules and performance analysis

Logging

Structured logs and tick data

Build docs developers (and LLMs) love