The close of the regular trading session is not the end of the trade — it is the beginning of the next one.
Between 4:00 PM ET and 9:30 AM ET, passive forces reshape the order book in ways that silently determine the opening print, the first fill, and the trajectory of the first hour. Institutional algorithms post limit orders overnight. Market makers adjust quotes based on earnings revisions, futures pricing, and cross-venue arbitrage windows. Dark pool midpoint prices drift away from the last sale, establishing overnight reference levels that the opening auction will either validate or rupture.
For quantitative traders, this twelve-hour window is not downtime. It is an active engineering problem: what signals can be computed from end-of-day data, how should those signals inform pre-market positioning, and how does the opening auction behavior validate or invalidate the overnight thesis?
This article builds a complete pre-market signal framework using production-grade Python. We dissect overnight order book mechanics, implement pre-market liquidity estimation, and construct an opening strategy preparation pipeline that runs before the first auction bell rings.
The Overnight Liquidity Problem
Every trader who has been stopped out at the open — "gapped up and immediately reversed" — has experienced the consequence of overnight signal blindness. The problem is not that the market moved. The problem is that the information architecture available at 4:00 PM ET is fundamentally incomplete for predicting 9:30 AM ET behavior.
Three structural forces reshape liquidity between sessions:
1. Overnight index arbitrage pressure. Futures on the underlying index (e.g., /ES or /NQ) trade continuously. When the futures premium widens beyond fair value, arbitrageurs post limit orders at the opening auction, creating directional pressure that is invisible to anyone watching only the equity order book.
2. End-of-day order queue reconstruction. Large institutional orders that were cancelled at or near the close (to avoid the closing auction impact fee) are re-posted overnight via algorithms. These orders sit in the pre-market book, invisible to real-time depth feeds until the pre-market session opens.
3. Dark pool midpoint drift. A significant portion of overnight volume transpires in dark pools. The reported closing price reflects only the last print on lit venues. Dark pool activity can shift the effective midpoint by several basis points, creating an overnight reference level that diverges from the official close.
The consequence is measurable: opening gaps occur in approximately 60–65% of trading days for actively traded US equities. Of those gaps, roughly 35% are filled within the first 30 minutes, while 25% extend in the direction of the gap through the morning session. The difference between a tradable gap-fill and an extended gap is determined by overnight signal quality.
End-of-Day Snapshot: What to Capture at 4:00 PM ET
The foundation of any pre-market signal framework is the end-of-day order book snapshot. At the close, you want to capture three layers of data: the top-of-book state, the volume-weighted average price for the session, and the order flow imbalance at the close.
The Five-Minute Close Imbalance
The closing auction imbalance (CAI) is the single most predictive end-of-day signal. It measures whether closing orders are skewed toward the bid or the ask in the final minutes before the closing print. A CAI skewed toward the ask (more sell volume queued at the offer) indicates that the closing print will occur at a lower price than the prevailing midpoint — often a precursor to overnight weakness.
The calculation requires aggregating sell-initiated trades and buy-initiated trades separately during a defined window (e.g., the last 5 minutes of the regular session). A clean implementation pulls trade data from TickDB's kline endpoint with a 1-minute interval, then applies the tick rule to classify each trade.
import os
import requests
import numpy as np
import pandas as pd
from datetime import datetime, timedelta, timezone
# Load TickDB credentials from environment
API_KEY = os.environ.get("TICKDB_API_KEY")
BASE_URL = "https://api.tickdb.ai/v1"
def fetch_trades(symbol, start_time, end_time, interval="1m", limit=500):
"""
Fetch intraday trade aggregates from TickDB.
Returns a DataFrame with timestamp, volume, and buy/sell classified ticks.
"""
headers = {"X-API-Key": API_KEY}
# Convert times to milliseconds for TickDB API
start_ms = int(start_time.timestamp() * 1000)
end_ms = int(end_time.timestamp() * 1000)
response = requests.get(
f"{BASE_URL}/market/kline",
headers=headers,
params={
"symbol": symbol,
"interval": interval,
"start": start_ms,
"end": end_ms,
"limit": limit
},
timeout=(3.05, 10)
)
response.raise_for_status()
data = response.json()
if data.get("code") != 0:
raise ValueError(f"TickDB error {data.get('code')}: {data.get('message')}")
klines = data["data"]["klines"]
df = pd.DataFrame(klines)
df["open_time"] = pd.to_datetime(df["open_time"], unit="ms", utc=True)
return df
def compute_close_imbalance(df, lookback_minutes=5):
"""
Compute the closing auction imbalance (CAI) for the last N minutes.
Returns a signed ratio: positive = buy pressure, negative = sell pressure.
"""
cutoff = df["open_time"].max() - timedelta(minutes=lookback_minutes)
window = df[df["open_time"] >= cutoff]
if window.empty:
return 0.0
# Approximate buy/sell split using tick rule:
# If close > open: buy-initiated. If close < open: sell-initiated.
# Equal: proportionally split (midpoint rule)
window = window.copy()
window["buy_volume"] = np.where(
window["close"] > window["open"],
window["volume"],
np.where(
window["close"] < window["open"],
0,
window["volume"] / 2
)
)
window["sell_volume"] = np.where(
window["close"] < window["open"],
window["volume"],
np.where(
window["close"] > window["open"],
0,
window["volume"] / 2
)
)
total_buy = window["buy_volume"].sum()
total_sell = window["sell_volume"].sum()
total = total_buy + total_sell
if total == 0:
return 0.0
imbalance = (total_buy - total_sell) / total
return imbalance
# Example: capture closing imbalance for AAPL
symbol = "AAPL.US"
session_close = datetime.now(timezone.utc).replace(hour=21, minute=0, second=0, microsecond=0)
session_open = session_close - timedelta(hours=9, minutes=30)
df_trades = fetch_trades(symbol, session_open, session_close)
imbalance = compute_close_imbalance(df_trades, lookback_minutes=5)
print(f"AAPL close imbalance: {imbalance:.3f}")
print(f"Interpretation: {'Buy pressure' if imbalance > 0.1 else 'Sell pressure' if imbalance < -0.1 else 'Balanced'}")
Engineering note: The tick rule is a first-order approximation. For high-precision applications, cross-reference against real-time quote data (bid vs. offer at the moment of the trade) when available via the depth channel. The tick rule introduces a classification error that is typically within 5–8% for liquid large-cap equities but can exceed 20% in thin markets.
Pre-Market Volume Profile Estimation
Pre-market trading on US equity exchanges runs from 4:00 AM ET to 9:15 AM ET. The liquidity available during this window is typically 3–8% of regular session volume, but the profile is highly non-uniform. The first hour of pre-market (4:00–5:00 AM ET) sees negligible volume. Activity ramps between 7:00 and 8:00 AM ET as European markets open and US futures price discovery begins. The final 15 minutes before the opening auction (9:15–9:30 AM ET) typically account for 60–70% of pre-market volume.
This non-uniform profile matters for entry strategy: a limit order placed at 7:30 AM ET with the expectation of pre-market fills will frequently sit unfilled until the auction rush, at which point the order book has already shifted.
To model the pre-market volume profile, track the volume distribution across time buckets for the same symbol across the last 20 trading days. Compute the median volume share per 15-minute bucket. Then compare the current day's pre-market cumulative volume against the historical median for the same time bucket.
def estimate_premarket_volume_profile(symbol, days_back=20, bucket_minutes=15):
"""
Build a historical pre-market volume profile for a symbol.
Returns a DataFrame of time buckets with median volume share.
"""
headers = {"X-API-Key": API_KEY}
bucket_count = int((9 * 60 + 15) / bucket_minutes) # 37 buckets for 4:00-9:15 ET
profiles = []
for day_offset in range(1, days_back + 1):
trading_day = datetime.now(timezone.utc).date() - timedelta(days=day_offset)
# Fetch today's pre-market volume bucket by bucket
start_time = datetime.combine(trading_day, datetime.min.time()).replace(
hour=8, minute=0, second=0, microsecond=0
) - timedelta(days=1) # approximate prior day structure
end_time = start_time + timedelta(hours=9, minutes=15)
response = requests.get(
f"{BASE_URL}/market/kline",
headers=headers,
params={
"symbol": symbol,
"interval": f"{bucket_minutes}m",
"start": int(start_time.timestamp() * 1000),
"end": int(end_time.timestamp() * 1000),
"limit": 200
},
timeout=(3.05, 10)
)
response.raise_for_status()
data = response.json()
if data.get("code") != 0 or not data["data"].get("klines"):
continue
klines = data["data"]["klines"]
df = pd.DataFrame(klines)
df["volume"] = df["volume"].astype(float)
total_volume = df["volume"].sum()
if total_volume == 0:
continue
df["volume_share"] = df["volume"] / total_volume
profiles.append(df[["volume_share"]])
if not profiles:
return pd.DataFrame()
# Pad shorter profiles to the maximum bucket count
max_len = max(len(p) for p in profiles)
padded = []
for p in profiles:
if len(p) < max_len:
pad_rows = max_len - len(p)
p = pd.concat([p, pd.DataFrame({"volume_share": [0.0] * pad_rows})], ignore_index=True)
padded.append(p)
combined = pd.concat(padded, axis=1)
median_profile = combined.median(axis=1)
result = median_profile.to_frame(name="median_volume_share")
result.index.name = "bucket"
return result
def compare_today_premarket(symbol, today_premarket_df, historical_profile):
"""
Compare today's cumulative pre-market volume against the historical median.
Returns a dictionary of excess/deficit ratios per bucket.
"""
if historical_profile.empty:
return {}
today_cumulative = today_premarket_df["volume"].cumsum()
today_total = today_cumulative.iloc[-1]
historical_cumulative = historical_profile["median_volume_share"].cumsum()
comparison = {}
for idx in range(len(today_cumulative)):
hist_share = historical_cumulative.iloc[idx] if idx < len(historical_cumulative) else 1.0
today_share = today_cumulative.iloc[idx] / today_total if today_total > 0 else 0.0
ratio = today_share / hist_share if hist_share > 0 else 0.0
comparison[idx] = {
"today_share": today_share,
"historical_share": hist_share,
"ratio": ratio # >1 = above median volume, <1 = below
}
return comparison
# Example usage
profile = estimate_premarket_volume_profile("AAPL.US", days_back=20)
print("Pre-market volume profile (15-min buckets):")
print(profile.head(10))
Engineering note: This estimation method assumes a stable historical profile. During earnings seasons, option expiry weeks, or Federal Reserve meeting dates, the pre-market volume profile deviates materially from the median. Tag these dates in your calendar and flag the estimation confidence accordingly.
Overnight Implied Move from Options Data
For equities with liquid options markets, the overnight implied move (OIM) derived from at-the-money straddle pricing provides a statistically useful bound on the opening gap. The OIM is computed as:
OIM (%) = Straddle Price / Stock Price * 100
A straddle priced at $3.00 on a $100 stock implies a ±3% move (one standard deviation) overnight. Opening prints beyond this range tend to be associated with information-driven moves that sustain through the morning session; prints within this range are more likely to mean-revert.
The practical use case is signal calibration: if the overnight OIM is 2.5% and your end-of-day order book signals suggest a 1.8% gap in either direction, you can set your pre-market alert threshold at 2.5% and prepare a strategy for both the sustained-move and mean-reversion scenarios.
def estimate_overnight_implied_move(current_price, atm_straddle_price):
"""
Compute the overnight implied move (OIM) as a percentage.
"""
if current_price <= 0 or atm_straddle_price <= 0:
return None
return (atm_straddle_price / current_price) * 100
def build_open_strategy_scenario(
current_price,
atm_straddle_price,
close_imbalance,
premarket_excess_ratio,
gap_threshold_pct=1.5
):
"""
Build a scenario matrix for the opening range.
Returns scenarios with recommended actions.
"""
oim = estimate_overnight_implied_move(current_price, atm_straddle_price)
scenarios = []
if oim is not None:
scenarios.append({
"type": "sustained_move",
"direction": "up",
"trigger": f"Open > {current_price * (1 + oim/100):.2f} (+{oim:.2f}% OIM)",
"action": "Avoid chasing. Wait for first pullback retest of the open print.",
"target": f"Let position size decay 30%. Re-enter on volume confirmation above OIM."
})
scenarios.append({
"type": "sustained_move",
"direction": "down",
"trigger": f"Open < {current_price * (1 - oim/100):.2f} (-{oim:.2f}% OIM)",
"action": "Short covers likely in first 10 minutes. Do not initiate new shorts.",
"target": "Wait for afternoon re-test of the overnight low."
})
# Near-open mean reversion scenario
if abs(close_imbalance) > 0.3:
direction = "buy" if close_imbalance < 0 else "sell"
scenarios.append({
"type": "mean_reversion",
"direction": direction,
"trigger": f"Open within ±{gap_threshold_pct}% of previous close",
"action": f"Close imbalance suggests {direction} pressure at close may partially reverse.",
"target": f"Target first 30-min close at prior day's VWAP."
})
# Pre-market volume signal
if premarket_excess_ratio and premarket_excess_ratio > 1.5:
scenarios.append({
"type": "liquidity_confirmation",
"direction": "neutral",
"trigger": "Pre-market volume > 150% of historical median",
"action": "Elevated pre-market volume signals institutional positioning. Follow the flow.",
"target": "Confirm direction with first 5-minute candle direction."
})
return scenarios
# Example scenario matrix
scenarios = build_open_strategy_scenario(
current_price=185.50,
atm_straddle_price=4.20, # ~2.26% OIM
close_imbalance=-0.22, # net sell imbalance at close
premarket_excess_ratio=1.8,
gap_threshold_pct=1.5
)
print("Opening strategy scenario matrix:")
for s in scenarios:
print(f"\n Type: {s['type']} ({s['direction']})")
print(f" Trigger: {s['trigger']}")
print(f" Action: {s['action']}")
print(f" Target: {s['target']}")
Opening Auction Prediction Model
The opening auction on US equity exchanges (9:30 AM ET, with the print occurring between 9:29:55 and 9:30:05) matches incoming orders against the aggregate book. The auction price maximizes executed volume — it is the price at which the maximum quantity clears.
To predict the auction print, build a three-factor model:
| Factor | Weight | Data source |
|---|---|---|
| End-of-day imbalance (CAI) | 35% | Closing 5-min trade flow |
| Overnight futures deviation | 30% | /ES or /NQ overnight change |
| Dark pool midpoint drift | 35% | NMS alternative trading system data or proprietary MIDP feed |
Each factor is normalized to a z-score relative to its 20-day rolling distribution, then combined with the assigned weights to produce a directional bias score. A score above +0.5 predicts an up-gap; below −0.5 predicts a down-gap.
class OpeningAuctionPredictor:
"""
Three-factor model for predicting the direction of the opening auction print.
"""
def __init__(self, symbol):
self.symbol = symbol
self.history = {"caim": [], "futures_dev": [], "dp_mid": []}
self.weights = {"caim": 0.35, "futures_dev": 0.30, "dp_mid": 0.35}
def update_history(self, factor_name, value):
"""Maintain a rolling 20-day history for z-score normalization."""
if factor_name in self.history:
self.history[factor_name].append(value)
if len(self.history[factor_name]) > 20:
self.history[factor_name].pop(0)
def zscore(self, values, current):
"""Compute z-score for a factor given current value."""
if len(values) < 5:
return 0.0
mean = np.mean(values)
std = np.std(values)
if std == 0:
return 0.0
return (current - mean) / std
def compute_score(self, close_imbalance, futures_overnight_change, darkpool_midpoint_drift):
"""
Compute the combined directional score.
Returns a signed float: positive = up-gap, negative = down-gap.
"""
# Update histories
self.update_history("caim", close_imbalance)
self.update_history("futures_dev", futures_overnight_change)
self.update_history("dp_mid", darkpool_midpoint_drift)
z_caim = self.zscore(self.history["caim"], close_imbalance)
z_futures = self.zscore(self.history["futures_dev"], futures_overnight_change)
z_dp = self.zscore(self.history["dp_mid"], darkpool_midpoint_drift)
score = (
self.weights["caim"] * z_caim +
self.weights["futures_dev"] * z_futures +
self.weights["dp_mid"] * z_dp
)
return score
def interpret_score(self, score, threshold=0.5):
"""
Interpret the directional score into a readable signal.
"""
if abs(score) < threshold:
return {
"signal": "neutral",
"interpretation": f"Score {score:.2f} is within the neutral band ±{threshold}.",
"recommended_action": "Do not pre-position. Confirm direction with first 5-minute candle."
}
elif score >= threshold:
return {
"signal": "up_gap",
"interpretation": f"Score {score:.2f} suggests directional upside at the open.",
"recommended_action": "Set limit buys 1-2% below the previous close for gap-fill scenarios."
}
else:
return {
"signal": "down_gap",
"interpretation": f"Score {score:.2f} suggests directional downside at the open.",
"recommended_action": "Position defensively. Avoid long entries until first 15-minute candle confirms support."
}
# Example prediction
predictor = OpeningAuctionPredictor("AAPL.US")
score = predictor.compute_score(
close_imbalance=-0.22,
futures_overnight_change=0.85, # /ES up 0.85%
darkpool_midpoint_drift=-0.12 # Dark pool midpoint down 12 bps from close
)
signal = predictor.interpret_score(score)
print(signal)
Engineering note: Dark pool midpoint data is not universally accessible via low-cost APIs. If you do not have a MIDP feed, substitute with the NBBO (National Best Bid and Offer) midpoint from the previous close — it is a noisier proxy but maintains directional correlation with dark pool activity for liquid equities.
Live Pre-Market Monitoring with WebSocket
With the overnight signal framework computed and the opening auction prediction calibrated, the final component is real-time monitoring of the pre-market order book as 9:15 AM ET approaches. The pre-market depth feed (available via TickDB's depth channel for applicable markets) provides live top-of-book updates that can be used to detect:
- Accelerating imbalance: When ask depth at L1 exceeds bid depth at L1 by a widening margin in the final 10 minutes, the auction print is likely to occur below the last sale.
- Invisible large orders: Sudden jumps in L1 depth without corresponding trade activity indicate institutional limit orders entering the book — a high-confidence directional signal.
- Cross-venue arbitrage: When the pre-market quote on one venue diverges from another by more than the typical spread, arbitrage pressure will resolve at the auction print.
import json
import time
import threading
import websocket
class PreMarketDepthMonitor:
"""
WebSocket monitor for pre-market order book depth.
Alerts on accelerating imbalance and large order entries.
"""
def __init__(self, symbol, api_key, alert_callback=None):
self.symbol = symbol
self.api_key = api_key
self.alert_callback = alert_callback
self.ws = None
self.running = False
self.heartbeat_interval = 30
self.last_ping_time = time.time()
self.reconnect_delay = 1.0
self.max_reconnect_delay = 30.0
# Rolling window for depth snapshot comparison
self.depth_history = []
self.history_window = 20 # snapshots
# Alert thresholds
self.imbalance_threshold = 2.5
self.large_order_threshold = 5000 # shares
def on_message(self, ws, message):
"""Handle incoming depth updates."""
try:
data = json.loads(message)
# Handle ping/pong heartbeat
if data.get("type") == "ping":
ws.send(json.dumps({"type": "pong"}))
return
if data.get("type") != "depth_snapshot":
return
bids = data["data"]["bids"]
asks = data["data"]["asks"]
if not bids or not asks:
return
# Extract L1 size
bid_l1_size = float(bids[0]["size"]) if bids else 0.0
ask_l1_size = float(asks[0]["size"]) if asks else 0.0
snapshot = {
"timestamp": time.time(),
"bid_l1_size": bid_l1_size,
"ask_l1_size": ask_l1_size,
"imbalance": bid_l1_size / ask_l1_size if ask_l1_size > 0 else 1.0,
"spread": float(bids[0]["price"]) - float(asks[0]["price"]) if bids and asks else 0.0
}
self.depth_history.append(snapshot)
if len(self.depth_history) > self.history_window:
self.depth_history.pop(0)
self.check_alerts(snapshot)
except (json.JSONDecodeError, KeyError, ValueError) as e:
# ⚠️ Malformed messages should not crash the connection
pass
def check_alerts(self, snapshot):
"""Evaluate current snapshot against rolling history for alert conditions."""
if len(self.depth_history) < 5:
return
avg_imbalance = np.mean([s["imbalance"] for s in self.depth_history])
current_imbalance = snapshot["imbalance"]
# Alert: Imbalance accelerating toward one side
if abs(current_imbalance - avg_imbalance) > 0.5:
direction = "bid" if current_imbalance > avg_imbalance else "ask"
self.trigger_alert(
"imbalance_acceleration",
f"{direction.capitalize()} pressure accelerating. "
f"Current ratio: {current_imbalance:.2f} vs avg: {avg_imbalance:.2f}"
)
# Alert: Large L1 order detected
if snapshot["bid_l1_size"] > self.large_order_threshold or snapshot["ask_l1_size"] > self.large_order_threshold:
side = "bid" if snapshot["bid_l1_size"] > self.large_order_threshold else "ask"
self.trigger_alert(
"large_order_entry",
f"Large {side} order detected at L1: {snapshot[f'{side}_l1_size']:,.0f} shares"
)
def trigger_alert(self, alert_type, message):
"""Dispatch alert to callback if registered."""
if self.alert_callback:
self.alert_callback(alert_type, message)
print(f"[ALERT {alert_type.upper()}] {message}")
def on_error(self, ws, error):
print(f"[WS ERROR] {error}")
def on_close(self, ws, close_code, close_reason):
print(f"[WS CLOSED] {close_code}: {close_reason}")
self.running = False
def on_open(self, ws):
print("[WS OPENED] Subscribing to depth channel")
self.running = True
self.reconnect_delay = 1.0 # Reset backoff on successful connection
subscribe_msg = {
"cmd": "subscribe",
"channel": "depth",
"symbol": self.symbol,
"params": {"depth": 5}
}
ws.send(json.dumps(subscribe_msg))
def connect(self):
"""Establish WebSocket connection with authentication."""
ws_url = f"wss://stream.tickdb.ai/ws?api_key={self.api_key}"
self.ws = websocket.WebSocketApp(
ws_url,
on_message=self.on_message,
on_error=self.on_error,
on_close=self.on_close
)
self.ws.on_open = self.on_open
thread = threading.Thread(target=self.ws.run_forever, daemon=True)
thread.start()
def heartbeat_loop(self):
"""Send periodic ping to keep connection alive."""
while self.running:
time.sleep(self.heartbeat_interval)
if self.running and self.ws:
try:
self.ws.send(json.dumps({"cmd": "ping"}))
except Exception:
pass
def start(self):
"""Start the monitor with automatic reconnection logic."""
self.connect()
# Heartbeat thread
heartbeat_thread = threading.Thread(target=self.heartbeat_loop, daemon=True)
heartbeat_thread.start()
print(f"[Monitor started] Watching {self.symbol} pre-market depth")
try:
while True:
time.sleep(5)
if not self.running:
# Exponential backoff + jitter on reconnect
jitter = np.random.uniform(0, self.reconnect_delay * 0.1)
wait_time = self.reconnect_delay + jitter
print(f"[Reconnecting in {wait_time:.2f}s]")
time.sleep(wait_time)
self.reconnect_delay = min(
self.reconnect_delay * 2,
self.max_reconnect_delay
)
self.connect()
except KeyboardInterrupt:
print("[Monitor stopped]")
if self.ws:
self.ws.close()
# Example: start the pre-market monitor
def my_alert_handler(alert_type, message):
"""Custom alert handler — integrate with your trading system."""
print(f"[HANDLER] {alert_type}: {message}")
monitor = PreMarketDepthMonitor(
symbol="AAPL.US",
api_key=os.environ.get("TICKDB_API_KEY"),
alert_callback=my_alert_handler
)
monitor.start()
Engineering warning: WebSocket connections for US equity depth data are subject to exchange licensing terms. Verify that your data subscription includes real-time pre-market depth. Historical depth snapshots are retrievable via the REST /depth endpoint for backtesting purposes.
Order Flow Signal Dashboard
With all the components in place, the final deliverable is a consolidated pre-market signal dashboard that outputs a single-page summary before the opening bell. The dashboard aggregates the close imbalance, the OIM, the auction prediction score, the pre-market volume profile comparison, and the live depth alerts into a single decision matrix.
def generate_premarket_dashboard(
symbol,
current_price,
atm_straddle_price,
close_imbalance,
futures_overnight_change,
darkpool_midpoint_drift,
premarket_excess_ratio,
live_depth_alerts=None
):
"""
Generate a complete pre-market signal dashboard.
"""
print(f"\n{'='*60}")
print(f" PRE-MARKET SIGNAL DASHBOARD — {symbol}")
print(f" Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S ET')}")
print(f"{'='*60}")
oim = estimate_overnight_implied_move(current_price, atm_straddle_price)
print(f"\n Current Price: ${current_price:.2f}")
print(f" OIM (ATM Straddle): ±{oim:.2f}%" if oim else " OIM: N/A")
print(f" Futures Overnight: {futures_overnight_change:+.2f}%")
print(f"\n --- Closing Session Signals ---")
print(f" Close Imbalance: {close_imbalance:+.3f} ({'buy' if close_imbalance > 0 else 'sell'} pressure)")
print(f" Dark Pool Drift: {darkpool_midpoint_drift:+.4f}%")
print(f" Pre-Mkt Volume: {premarket_excess_ratio:.2f}x historical median")
predictor = OpeningAuctionPredictor(symbol)
score = predictor.compute_score(
close_imbalance, futures_overnight_change, darkpool_midpoint_drift
)
signal = predictor.interpret_score(score)
print(f"\n --- Auction Prediction ---")
print(f" Directional Score: {score:.3f}")
print(f" Signal: {signal['signal']}")
print(f" Interpretation: {signal['interpretation']}")
print(f" Action: {signal['recommended_action']}")
scenarios = build_open_strategy_scenario(
current_price, atm_straddle_price, close_imbalance, premarket_excess_ratio
)
print(f"\n --- Scenario Matrix ({len(scenarios)} scenarios) ---")
for i, s in enumerate(scenarios, 1):
print(f" Scenario {i}: {s['type']} / {s['direction']}")
print(f" Trigger: {s['trigger']}")
print(f" Action: {s['action']}")
if live_depth_alerts:
print(f"\n --- Live Pre-Market Alerts ---")
for alert_type, message in live_depth_alerts.items():
print(f" [{alert_type.upper()}] {message}")
print(f"\n{'='*60}")
print(" Risk reminder: Pre-market trading carries elevated risks.")
print(" Low liquidity, wide spreads, and information asymmetry")
print(" can cause fills significantly different from quotes.")
print(f"{'='*60}\n")
return {
"symbol": symbol,
"oim": oim,
"futures_change": futures_overnight_change,
"direction_score": score,
"signal": signal["signal"],
"scenarios": scenarios
}
# Example dashboard generation
result = generate_premarket_dashboard(
symbol="AAPL.US",
current_price=185.50,
atm_straddle_price=4.20,
close_imbalance=-0.22,
futures_overnight_change=0.85,
darkpool_midpoint_drift=-0.12,
premarket_excess_ratio=1.8,
live_depth_alerts={
"imbalance_acceleration": "Bid pressure accelerating. Ratio: 2.85 vs avg: 2.10",
"large_order_entry": "Large bid order detected at L1: 12,500 shares"
}
)
Pre-Market Signal Reference Table
The table below summarizes the signals, their data sources, and the typical confidence levels for opening range strategies.
| Signal | Data source | Confidence | Update frequency | Notes |
|---|---|---|---|---|
| Close imbalance (CAI) | Intraday trade flow (TickDB kline 1m) |
Medium | End-of-day only | Tick rule introduces 5–8% classification error for liquid names |
| Overnight implied move (OIM) | ATM straddle price / current price | High | End-of-day | OIM sets the statistical boundary; actual gaps extend beyond 1σ approximately 32% of the time |
| Auction prediction score | CAI + futures + dark pool midpoint | Medium-High | Pre-market | Requires 20-day history for z-score normalization; initial estimates are noisy |
| Pre-market volume profile | Historical 15-min buckets | Medium | Real-time during pre-mkt | Deviates materially during earnings and FOMC weeks |
| Live depth imbalance | WebSocket depth channel |
High (real-time) | Sub-second | Most actionable signal 5–15 min before open; requires live feed access |
| Large order detection | WebSocket depth channel |
Very High | Sub-second | Institutional orders entering the book 5 min pre-open are strong directional predictors |
Closing
The hours between the closing bell and the opening auction are not idle — they are a quiet negotiation between information, capital, and risk appetite that plays out in futures markets, dark pools, and the pre-market order book. The trader's edge does not begin at 9:30 AM ET. It begins at 4:00 PM ET, when the end-of-day snapshot is captured and the signal pre-computation pipeline is set in motion.
The framework presented here — closing imbalance, overnight implied move, auction prediction score, pre-market volume profile analysis, and live depth monitoring — is designed to run as an automated pipeline. Every signal feeds into the scenario matrix before market open. The result is not a prediction of the exact opening print — that is an impossible target — but a calibrated expectation space within which the opening print is interpretable against a structured prior.
What you do with that prior depends on your risk tolerance, your position size, and the confidence level of the signal. The framework does not remove uncertainty. It converts uncertainty from noise into structured probability — which is, ultimately, the only thing quantitative trading can honestly promise.
Next Steps
If you are an individual quant developer looking to implement this framework, sign up at tickdb.ai for a free API key and begin pulling historical kline data to validate the closing imbalance signal against your own backtest.
If you need real-time depth feeds for pre-market monitoring, explore the Professional plan at tickdb.ai, which includes WebSocket access to live order book data for supported markets.
If you are building an automated trading system that runs the entire overnight-to-open pipeline, reach out to enterprise@tickdb.ai for data infrastructure packages covering historical backtest data and real-time streaming across multiple asset classes.
If you use AI coding assistants, search for and install the tickdb-market-data SKILL in your AI tool's marketplace to get native TickDB API integration within your existing workflows.
This article does not constitute investment advice. Market data is provided for informational purposes only. Pre-market trading involves significant risks including low liquidity, wide bid-ask spreads, and price volatility that can result in material losses. Past signal performance does not guarantee future results.