PropValue

Pine Script v6 · v2.0

Day Type Classifier

Calls each index-futures session EXPANSION, ROTATION or MIXED by 10:30 New York, using logistic models tested out of sample on 2019-2026 ES data.

Install

  1. Open a 1-5 minute ES, MES, NQ or MNQ chart with extended trading hours switched on, then open the Pine Editor.
  2. Paste the script, press Save, then Add to chart. It needs 10 completed sessions of history before the first call.
  3. Read the panel at 10:00 (early lean), 10:30 (main call) and 11:00 (update), New York time.
  4. Set alerts for EXPANSION, ROTATION or MIXED calls in the Alerts dialog.

Check sizes by hand with the position sizer.

What it does

The hardest call in intraday trading is often what kind of day it is. On expansion days the move runs and fading it is expensive; on rotation days price stays in its range and chasing breakouts is expensive. This indicator puts a number on it.

At each checkpoint it estimates the probability that the day will add at least one initial-balance width (the first hour's range) beyond the range made so far. The inputs are how large the morning has been relative to recent days, the size of the first hour, the prior day and overnight ranges, the opening gap, and how one-sided and directional trade has been around VWAP.

Tested out of sample on ES 1-minute data from 2019 to 2026 (models trained on 2008-2018), the 10:30 call scored an AUC of 0.71. Days it called EXPANSION added at least one IB width 63% of the time, against 19% for days it called ROTATION. When an expansion came, it went in the morning's direction about 62% of the time, so EXPANSION calls also show a directional lean.

It is a probability, not a promise: about one in five ROTATION days still expanded in testing. Use it to choose between trend-following and range tactics, not as an entry signal.

Script

// ═══════════════════════════════════════════════════════════════════════════════════════════════
// Day Type Classifier · v2.0 · October 2026
// by David J Kaplan · https://propvalue.co/indicators/day-type-classifier/
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// Calls each regular-hours (RTH) session for index futures as one of:
//   EXPANSION  the day is likely to extend well beyond its morning range: trade with the trend / MA
//   ROTATION   the day is likely to stay inside its range: fade the edges
//   MIXED      no edge either way
//
// What the models predict
//   After each checkpoint, will the day add at least 1× the initial balance (IB, the first hour's
//   range) beyond the range made so far? Three logistic regression models give that probability
//   at 10:00, 10:30 and 11:00 New York time.
//
// How they were tested (ES 1-minute data)
//   Models trained on 2008-2018 and scored out of sample on 2019-2026:
//     10:00  early lean   AUC 0.66
//     10:30  main call    AUC 0.71   EXPANSION days added ≥1 IB 63% of the time, ROTATION days 19%
//     11:00  update       AUC 0.70
//   The coefficients below were then fitted on the full 2008-2026 sample.
//   A pre-open model (overnight range, gap, prior day) showed no edge and is not used.
//   When an expansion came, it went in the morning's direction about 62% of the time, so the
//   EXPANSION call also shows a directional bias.
//
// Use
//   Best on 1-5 minute ES / MES / NQ / MNQ charts with extended hours on (the overnight range is a
//   model input; on an RTH-only chart it falls back to its long-run average). Needs 10 completed
//   sessions of history before the first call. The IB lines, +1× extensions and RTH VWAP are drawn
//   for reference.
//
// A probability, not a promise: one in five ROTATION days still expanded in testing.
// Original work, free to use and modify. Not financial advice.
// ═══════════════════════════════════════════════════════════════════════════════════════════════

//@version=6
indicator("Day Type Classifier", shorttitle = "DayType", overlay = true, max_labels_count = 200)

// ---------------------------------------------------------------- Inputs
grpS = "Session"
sessInput  = input.session("0930-1600", "RTH session", group = grpS,
                 tooltip = "The models were fitted on 09:30-16:00 New York. The overnight window used as a model input is fixed at 18:00-09:30 New York.")
tzInput    = input.string("America/New_York", "Session timezone", group = grpS)

grpC = "Call thresholds (probability of expansion)"
useCustom = input.bool(false, "Override fitted thresholds (10:30 call)", group = grpC,
                tooltip = "The fitted cut-offs are the bottom and top quartile of predicted probability at each checkpoint. The override applies to the 10:30 main call only.")
cRot      = input.float(0.275, "ROTATION at or below", step = 0.005, group = grpC)
cExp      = input.float(0.53, "EXPANSION at or above", step = 0.005, group = grpC)

grpD = "Display"
showIB    = input.bool(true, "Draw IB high/low and 1x extensions", group = grpD)
showVWAP  = input.bool(true, "Plot RTH-anchored VWAP", group = grpD)
showBg    = input.bool(false, "Tint background by day type", group = grpD)
showMark  = input.bool(true, "Mark checkpoint calls on chart", group = grpD)
showTable = input.bool(true, "Show panel", group = grpD)
tablePos  = input.string("Top Right", "Panel position", options = ["Top Right", "Top Left", "Bottom Right", "Bottom Left"], group = grpD)

// ---------------------------------------------------------------- Model constants (fitted on 2008-2026)
// Features (ADR20 = average of the last 20 full RTH ranges; IB = first-hour range):
//   lMorn  = ln(range so far / ADR20)              how big the morning has been
//   lIB    = ln(IB width / average IB of last 20)  how wide the first hour was (10:30 and 11:00 only)
//   lPrev  = ln(previous RTH range / ADR20)
//   lON    = ln(overnight range / ADR20)
//   gapAbs = |RTH open - previous RTH close| / ADR20
//   sSide  = how one-sided trade has been around VWAP: share of bars above minus share below,
//            rescaled so 20% imbalance → 0 and 80% → 1
//   sNet   = |close - open| / range so far, rescaled so 25% → 0 and 70% → 1 (directional progress)
//   sExt   = how far price has extended beyond the IB on one side only, in IB widths, capped at 1
//            and reduced when both sides have been extended (11:00 only)
//   posMid = distance of close from the middle of the range so far (0 = middle, 1 = at an edge)
LON_MEAN = -0.3879   // used when the chart has no overnight bars

f_sig(float x) => 1.0 / (1.0 + math.exp(-x))

f_p1000(float lMorn, float lPrev, float lON, float gapAbs, float sSide, float sNet, float posMid) =>
    f_sig(-1.42753 - 1.50074 * lMorn + 0.38780 * lPrev + 0.35914 * lON + 0.14659 * gapAbs - 0.09354 * sSide + 0.07417 * sNet - 0.59841 * posMid)

f_p1030(float lMorn, float lIB, float lPrev, float lON, float gapAbs, float sSide, float sNet, float posMid) =>
    f_sig(-2.28132 - 3.03941 * lMorn + 0.94771 * lIB + 0.50648 * lPrev + 0.66992 * lON + 0.08345 * gapAbs - 0.46057 * sSide + 0.25364 * sNet + 0.16819 * posMid)

f_p1100(float lMorn, float lIB, float lPrev, float lON, float gapAbs, float sSide, float sNet, float sExt, float posMid) =>
    f_sig(-2.45635 - 2.48687 * lMorn + 0.58048 * lIB + 0.58924 * lPrev + 0.50191 * lON + 0.07034 * gapAbs - 0.34484 * sSide + 0.14931 * sNet + 2.02626 * sExt + 0.29997 * posMid)

// Fitted cut-offs per checkpoint: bottom and top quartile of predicted probability.
f_lo(int cp) => useCustom and cp == 2 ? cRot : cp == 1 ? 0.309 : cp == 2 ? 0.275 : 0.209
f_hi(int cp) => useCustom and cp == 2 ? cExp : cp == 1 ? 0.503 : cp == 2 ? 0.530 : 0.418

f_clamp(float x) => math.max(0.0, math.min(1.0, x))

f_pos(string s) =>
    s == "Top Left" ? position.top_left : s == "Bottom Right" ? position.bottom_right : s == "Bottom Left" ? position.bottom_left : position.top_right

// ---------------------------------------------------------------- Session detection
inSess  = not na(time(timeframe.period, sessInput, tzInput))
newDay  = dayofmonth(time, tzInput) != dayofmonth(time[1], tzInput)
newSess = inSess and (not inSess[1] or newDay)
mod     = hour(time, tzInput) * 60 + minute(time, tzInput)

// ---------------------------------------------------------------- Overnight range (18:00 to 09:29 ET)
// mod = minutes after midnight in the session timezone; 1080 = 18:00, 570 = 09:30.
var float onHi = na
var float onLo = na
if mod >= 1080 and (mod[1] < 1080 or na(mod[1]))
    onHi := high
    onLo := low
else if (mod >= 1080 or mod < 570) and not inSess and not na(onHi)
    onHi := math.max(onHi, high)
    onLo := math.min(onLo, low)

// ---------------------------------------------------------------- State
// All per-session values reset on the first RTH bar. Range and IB histories keep the last 20 sessions.
var float sessOpen  = na
var int   sessStart = na
var float sessHi    = na
var float sessLo    = na
var float lastClose = na
var float lastMins  = na
var float prevRange = na
var float prevClose = na
var float sessOnR   = na
var float ibHi      = na
var float ibLo      = na
var bool  ibDone    = false
var float ibAvg     = na
var ibHist          = array.new_float()
var rngHist         = array.new_float()

var float cumPV     = 0.0
var float cumV      = 0.0
var float rthVwap   = na
var int   barsIn    = 0
var int   barsAbove = 0
var int   barsBelow = 0
var float minsIn    = 0.0
var float sSide     = 0.0
var float sNet      = 0.0
var float sExt      = 0.0
var float upExt     = 0.0
var float dnExt     = 0.0
var int   mDir      = 0

var float p1 = na
var float p2 = na
var float p3 = na
var int   cpDone = 0      // highest checkpoint evaluated today
var int   dayCall = 0        // 0 none/mixed, 1 expansion, 2 rotation
var int   callDir = 0

// ---------------------------------------------------------------- New session
// Close out yesterday (only full sessions of 385+ minutes count toward ADR, so half days are skipped),
// then reset everything for today.
if newSess
    if not na(sessHi)
        prevRange := sessHi - sessLo
        prevClose := lastClose
        if lastMins >= 385
            array.push(rngHist, sessHi - sessLo)
            if array.size(rngHist) > 20
                array.shift(rngHist)
    sessOnR   := na(onHi) ? na : onHi - onLo
    sessOpen  := open
    sessStart := time
    sessHi    := high
    sessLo    := low
    ibHi      := high
    ibLo      := low
    ibDone    := false
    cumPV     := 0.0
    cumV      := 0.0
    barsIn    := 0
    barsAbove := 0
    barsBelow := 0
    upExt     := 0.0
    dnExt     := 0.0
    p1        := na
    p2        := na
    p3        := na
    cpDone    := 0
    dayCall   := 0
    callDir   := 0

adr = array.size(rngHist) >= 10 ? array.avg(rngHist) : na

// ---------------------------------------------------------------- Per-bar update
// Track the range, IB, RTH VWAP and the shape features, then score the model at each checkpoint.
bool cpFired = false
if inSess and not na(sessOpen)
    sessHi := math.max(sessHi, high)
    sessLo := math.min(sessLo, low)
    minsIn := (time - sessStart) / 60000.0
    lastClose := close
    lastMins  := minsIn

    if not ibDone
        if minsIn < 60
            ibHi := math.max(ibHi, high)
            ibLo := math.min(ibLo, low)
        else
            ibDone := true
            ibAvg  := array.size(ibHist) > 0 ? array.avg(ibHist) : na
            array.push(ibHist, ibHi - ibLo)
            if array.size(ibHist) > 20
                array.shift(ibHist)

    v = nz(volume)
    cumPV += hlc3 * v
    cumV  += v
    rthVwap := cumV > 0 ? cumPV / cumV : hlc3

    side = close > rthVwap ? 1 : close < rthVwap ? -1 : 0
    barsIn += 1
    if side == 1
        barsAbove += 1
    else if side == -1
        barsBelow += 1

    rng = sessHi - sessLo
    net = rng > 0 ? math.abs(close - sessOpen) / rng : 0.0
    sNet  := f_clamp((net - 0.25) / 0.45)
    sSide := f_clamp((float(math.abs(barsAbove - barsBelow)) / barsIn - 0.2) / 0.6)
    ibW = ibHi - ibLo
    upExt := ibDone and ibW > 0 ? math.max(0.0, sessHi - ibHi) / ibW : 0.0
    dnExt := ibDone and ibW > 0 ? math.max(0.0, ibLo - sessLo) / ibW : 0.0
    dom = math.max(upExt, dnExt)
    sExt := dom > 0 ? f_clamp(dom) * (1.0 - f_clamp(math.min(upExt, dnExt) / dom)) : 0.0

    d0 = close > sessOpen and close > rthVwap ? 1 : close < sessOpen and close < rthVwap ? -1 : 0
    mDir := d0 != 0 ? d0 : upExt > dnExt ? 1 : upExt < dnExt ? -1 : 0

    // Checkpoints: first bar at or after 30 / 60 / 90 minutes (10:00 / 10:30 / 11:00).
    // On a realtime bar the call can still change until that bar closes; alerts fire on bar close.
    int cp = minsIn >= 90 ? 3 : minsIn >= 60 ? 2 : minsIn >= 30 ? 1 : 0
    if cp > cpDone and not na(adr) and rng > 0 and adr > 0
        lMorn  = math.log(rng / adr)
        lPrev  = na(prevRange) or prevRange <= 0 ? 0.0 : math.log(prevRange / adr)
        lON    = na(sessOnR) or sessOnR <= 0 ? LON_MEAN : math.log(sessOnR / adr)
        gapAbs = na(prevClose) ? 0.0 : math.abs(sessOpen - prevClose) / adr
        posMid = math.abs((close - sessLo) / rng - 0.5) * 2
        lIB    = not na(ibAvg) and ibAvg > 0 and ibW > 0 ? math.log(ibW / ibAvg) : 0.0
        float p = na
        if cp == 1
            p1 := f_p1000(lMorn, lPrev, lON, gapAbs, sSide, sNet, posMid)
            p := p1
        else if cp == 2
            p2 := f_p1030(lMorn, lIB, lPrev, lON, gapAbs, sSide, sNet, posMid)
            p := p2
        else
            p3 := f_p1100(lMorn, lIB, lPrev, lON, gapAbs, sSide, sNet, sExt, posMid)
            p := p3
        dayCall := p >= f_hi(cp) ? 1 : p <= f_lo(cp) ? 2 : 0
        callDir := mDir
        cpDone  := cp
        cpFired := true

// ---------------------------------------------------------------- Label text
string dayTxt = "Waiting for RTH"
color  dayCol = color.gray
string cpName = cpDone == 1 ? "10:00 lean" : cpDone == 2 ? "10:30 call" : cpDone == 3 ? "11:00 update" : ""
if not na(sessOpen)
    if na(adr)
        dayTxt := "Warming up (needs 10 sessions)"
    else if cpDone == 0
        dayTxt := "Opening: no read until 10:00"
        dayCol := color.silver
    else if dayCall == 1
        dayTxt := "EXPANSION" + (callDir > 0 ? " (bias up)" : callDir < 0 ? " (bias down)" : "") + ": trade with MA"
        dayCol := callDir > 0 ? color.lime : callDir < 0 ? color.red : color.aqua
    else if dayCall == 2
        dayTxt := "ROTATION: fade the edges"
        dayCol := color.orange
    else
        dayTxt := "MIXED: no edge"
        dayCol := color.yellow
    if not inSess
        dayTxt := dayTxt + " (closed)"

// ---------------------------------------------------------------- Plots
ibVis = showIB and inSess and ibDone
ibWg  = ibHi - ibLo
plot(ibVis ? ibHi : na, "IB high", color.new(color.teal, 0), 1, plot.style_linebr)
plot(ibVis ? ibLo : na, "IB low", color.new(color.teal, 0), 1, plot.style_linebr)
plot(ibVis ? ibHi + ibWg : na, "IB +1x", color.new(color.teal, 65), 1, plot.style_linebr)
plot(ibVis ? ibLo - ibWg : na, "IB -1x", color.new(color.teal, 65), 1, plot.style_linebr)
plot(showVWAP and inSess ? rthVwap : na, "RTH VWAP", color.new(color.purple, 0), 2, plot.style_linebr)
bgcolor(showBg and inSess and cpDone > 0 ? (dayCall == 1 ? color.new(dayCol, 90) : dayCall == 2 ? color.new(color.orange, 90) : na) : na)

if showMark and cpFired
    pTxt = str.tostring((cpDone == 1 ? p1 : cpDone == 2 ? p2 : p3) * 100, "#") + "%"
    tag  = dayCall == 1 ? "EXP" : dayCall == 2 ? "ROT" : "MIX"
    label.new(bar_index, dayCall == 2 ? low : high, cpName + "\n" + tag + " " + pTxt,
              style = dayCall == 2 ? label.style_label_up : label.style_label_down,
              color = color.new(dayCol, 20), textcolor = color.black, size = size.small)

// ---------------------------------------------------------------- Panel
var table panel = table.new(f_pos(tablePos), 2, 8, bgcolor = color.new(color.black, 20), frame_color = color.new(color.gray, 50), frame_width = 1, border_width = 1)

f_row(int r, string k, string val, color c) =>
    table.cell(panel, 0, r, k, text_color = color.gray, text_size = size.small, text_halign = text.align_left)
    table.cell(panel, 1, r, val, text_color = c, text_size = size.small, text_halign = text.align_right)

f_ptxt(float p, int cp) =>
    na(p) ? "-" : str.tostring(p * 100, "#") + "%" + (p >= f_hi(cp) ? "  EXP" : p <= f_lo(cp) ? "  ROT" : "  mix")

if showTable and barstate.islast
    f_row(0, "Day type", dayTxt, dayCol)
    f_row(1, "P(expand) 10:00 lean", f_ptxt(p1, 1), color.white)
    f_row(2, "P(expand) 10:30 call", f_ptxt(p2, 2), color.white)
    f_row(3, "P(expand) 11:00 update", f_ptxt(p3, 3), color.white)
    f_row(4, "Range so far / ADR20", na(adr) ? "-" : str.tostring((sessHi - sessLo) / adr * 100, "#") + "%", color.white)
    f_row(5, "IB width vs avg", ibDone and not na(ibAvg) and ibAvg > 0 ? str.tostring((ibHi - ibLo) / ibAvg, "#.##") + "x" : "-", color.white)
    f_row(6, "IB ext up / down", ibDone ? str.tostring(upExt, "#.##") + "x / " + str.tostring(dnExt, "#.##") + "x" : "-", color.white)
    f_row(7, "Overnight range", na(sessOnR) ? "none (RTH chart)" : na(adr) ? "-" : str.tostring(sessOnR / adr * 100, "#") + "% of ADR", color.white)

// ---------------------------------------------------------------- Alerts
alertcondition(cpFired and dayCall == 1, "Day type: EXPANSION", "Day type: EXPANSION")
alertcondition(cpFired and dayCall == 2, "Day type: ROTATION", "Day type: ROTATION")
alertcondition(cpFired and dayCall == 0, "Day type: MIXED", "Day type: MIXED")
if cpFired
    alert(cpName + ": " + dayTxt, alert.freq_once_per_bar_close)