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)