At 4:01:03 p.m., an options trader watches implied volatility fall before the earnings call has even begun. The underlying is moving, but the more important signal is hidden in the relationship between the stock’s pre-event path and the volatility premium investors have already paid.
Earnings announcements can produce an implied volatility crush, but the outcome is conditional rather than automatic. A large pre-announcement move, elevated implied volatility, and options demand may already have priced much of the expected reaction. A quiet tape with expensive volatility can produce a much larger collapse after the event.
For technical traders, the practical question is not “Will IV fall?” It is: How much of the pre-event volatility premium is likely to disappear, and can the underlying’s price behavior reveal that before the announcement? This article builds a leakage-safe leading indicator from pre-event stock returns, realized volatility, volume, and implied-volatility history, then connects it to a production-grade monitoring workflow.
1. Separating the IV Crush From the Price Reaction
An IV crush measures the reduction in implied volatility relative to a pre-event baseline. In percentage-point terms:
[
\text{IV Crush}_{pp}
100 \times \left(IV_{\text{pre}} - IV_{\text{post}}\right)
]
A practical baseline uses the average implied volatility over the five complete trading sessions before the announcement:
[
IV_{\text{pre}}
\operatorname{mean}(IV_{t-5}, \ldots, IV_{t-1})
]
The post-event measurement should avoid the noisy first few minutes around the release and use a consistent post-event window:
[
IV_{\text{post}}
\operatorname{mean}(IV_{t+1}, \ldots, IV_{t+5})
]
This definition matters because different measurement windows produce different “crush” numbers. A model trained on the immediate post-announcement print cannot be compared with a model targeting the average premium five sessions later.
The following table illustrates the states a leading indicator should distinguish. These values are hypothetical rather than observations from a specific company or earnings event.
| Pre-event state | Pre-event IV | 5-day return | 20-day realized volatility | Expected IV crush |
|---|---|---|---|---|
| Quiet tape, expensive options | 72% | 0.8% | 24% | 19–25 vol points |
| Moderate move, already elevated IV | 61% | 4.7% | 38% | 11–16 vol points |
| Strong directional move | 66% | 9.2% | 47% | 7–12 vol points |
| Weak stock response, subdued IV | 38% | 1.1% | 19% | 6–10 vol points |
The table exposes an important asymmetry. An expensive options market does not automatically produce the largest crush. When the stock has already moved sharply, part of the event premium may have disappeared before the announcement. The strongest pre-event predictors are therefore interactions between implied volatility, the underlying’s realized move, and the shape of the price path.
2. A Three-Phase Event Model
A reliable framework separates the earnings process into three phases.
Phase 1: Pre-event positioning
Before the release, the system estimates how much uncertainty the options market has priced. The required inputs include:
- Pre-event implied volatility
- One-, three-, and five-session stock returns
- Five-, 20-, and 60-session realized volatility
- Average intraday range as a percentage of price
- Volume relative to its 20-session average
- Distance from the stock’s recent high or low
- The number of sessions remaining before the announcement
The pre-event model should use only information available before the market closes on the final session before the announcement. Using the event-day close for a before-market-open report would introduce look-ahead bias.
Phase 2: The announcement window
The announcement window is where the options market reprices event risk. The underlying may continue through the scheduled release, move after the release, or respond before the report.
For monitoring purposes, the system should record:
- The exact announcement timestamp
- The pre-event cutoff timestamp
- The first clean post-event implied-volatility print
- The largest one-minute move in the underlying
- The spread and liquidity conditions of the options being monitored
The options dataset must come from a feed that explicitly provides implied volatility or the option prices required to calculate it. The documented TickDB capabilities relevant to this workflow cover US-equity OHLCV data; they do not document a US options implied-volatility endpoint. The implementation should therefore use TickDB for the underlying’s historical and current price data while obtaining IV from a licensed options-data source.
Phase 3: Post-event normalization
The post-event phase measures whether the expected premium contraction occurred and whether it persisted beyond the initial reaction.
Useful outcomes include:
- One-session IV crush
- Five-session average IV crush
- Percentage of the pre-event premium removed
- Directional accuracy of the crush forecast
- Error between predicted and realized IV crush
- Whether the result depended on the absolute move or on changes in volatility
This separation prevents a common analytical mistake: treating every temporary option-price decline as a structural IV crush.
3. Building the Leading Indicator
3.1 Core feature set
The feature set should combine two information sources:
- The underlying stock’s pre-event behavior
- The options market’s pre-event state
A practical baseline includes the following variables:
| Feature | Calculation | Interpretation |
|---|---|---|
pre_iv |
Mean IV over the prior five complete sessions | Starting volatility premium |
ret_1d |
One-session close-to-close return | Latest directional information |
ret_3d |
Three-session return | Short-horizon information accumulation |
ret_5d |
Five-session return | Extent of pre-positioning |
rv_5 |
Five-session realized volatility | Immediate volatility regime |
rv_20 |
Twenty-session realized volatility | Medium-horizon baseline |
rv_60 |
Sixty-session realized volatility | Longer-horizon volatility regime |
range_5d |
Mean five-session high-low range divided by close | Typical intraday uncertainty |
volume_shock |
Latest volume divided by prior 20-session average | Abnormal participation |
distance_to_high |
Distance from the 60-session high | Extension or mean-reversion state |
The model should treat pre_iv as an essential feature. Stock-price signals alone can identify a directional move, but they cannot determine how much volatility premium remains to be removed.
3.2 Why the relationship is nonlinear
A simple linear assumption is:
[
\text{IV Crush}_{pp}
\beta_0
+
\beta_1 \times \text{PreIV}
+
\beta_2 \times \text{Return}{5d}
+
\beta_3 \times \text{RV}{20}
+\epsilon
]
That model provides a useful benchmark, but it may miss interactions. For example:
- High IV combined with a quiet tape may indicate an unusually large crush.
- High IV combined with a large run-up may indicate that much of the premium has already been removed.
- A sharp stock move with falling realized volatility may suggest stabilization rather than continued event uncertainty.
A regularized linear model remains appropriate when the event sample is small. More flexible models require strict chronological validation, adequate sample size, and controls for overlapping events.
3.3 Leakage controls
The most important engineering requirement is to prevent future information from entering the feature set.
Every event row should contain an explicit cutoff_ts representing the latest timestamp allowed in the calculation. The training pipeline must enforce that rule rather than rely on a variable number of “days before earnings.”
Additional controls should include:
- Using only completed daily candles at the cutoff
- Excluding the announcement-session close when the report precedes the close
- Computing post-event IV only from completed post-event sessions
- Sorting events chronologically before cross-validation
- Avoiding random train/test splits
- Purging overlapping post-event windows when required
- Reporting symbol-level performance separately from pooled performance
4. System Architecture
The reference architecture separates historical research, live data ingestion, feature computation, and forecast publication.
┌─────────────────────────┐
│ Licensed options feed │
│ Pre/post event IV │
└────────────┬────────────┘
│
▼
┌──────────────────────┐ ┌────────────────────────┐
│ TickDB US-equity │──▶│ Point-in-time feature │
│ historical kline │ │ store │
└──────────────────────┘ │ cutoff_ts enforced │
└────────────┬────────────┘
│
▼
┌────────────────────────┐
│ IV crush regression │
│ chronologically fitted │
└────────────┬────────────┘
│
┌──────────────────────┴─────────────────────┐
▼ ▼
┌────────────────────┐ ┌────────────────────┐
│ Live WebSocket │ │ Forecast API / │
│ underlying monitor │ │ dashboard │
└────────────────────┘ └────────────────────┘
TickDB naturally fits the historical and real-time underlying-price portion of this workflow. Its US-equity kline history supports cross-cycle research, while its current-candle endpoint supports live dashboards. The options IV source remains an explicit external dependency rather than an undocumented API assumption.
5. Production-Grade Reference Implementation
The following Python program:
- Fetches US-equity daily OHLCV history from the TickDB kline endpoint
- Loads event data and IV measurements from a local CSV
- Enforces the explicit pre-event cutoff
- Constructs point-in-time features
- Trains a regularized chronological forecast model
- Produces walk-forward out-of-sample predictions
- Reports forecast errors and benchmark comparisons
The options-event input must use this schema:
event_id,ticker,announcement_ts,cutoff_ts,pre_event_iv,post_event_iv
AAPL-2024-02-01,AAPL,2024-02-01T21:05:00Z,2024-02-01T20:00:00Z,0.64,0.43
Implied volatility is expressed as a decimal. For example, 0.64 means 64% annualized implied volatility.
5.1 Installation
pip install requests pandas numpy scikit-learn
5.2 Historical feature and model pipeline
import os
import time
from pathlib import Path
import numpy as np
import pandas as pd
import requests
from sklearn.base import clone
from sklearn.linear_model import Ridge
from sklearn.metrics import mean_absolute_error, mean_squared_error
from sklearn.model_selection import TimeSeriesSplit
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
TICKDB_REST_URL = os.environ.get(
"TICKDB_REST_URL", "https://api.tickdb.ai"
).rstrip("/")
TICKDB_API_KEY = os.environ.get("TICKDB_API_KEY")
EVENTS_CSV = Path(os.environ.get("EVENTS_CSV", "earnings_events.csv"))
CACHE_DIR = Path(os.environ.get("TICKDB_CACHE_DIR", ".tickdb_cache"))
FEATURE_COLUMNS = [
"pre_iv",
"ret_1d",
"ret_3d",
"ret_5d",
"rv_5",
"rv_20",
"rv_60",
"range_5d",
"volume_shock",
"distance_to_high",
]
TARGET_COLUMN = "iv_crush_pp"
class TickDBApiError(RuntimeError):
"""Raised when TickDB returns a non-success response."""
def require_api_key() -> None:
if not TICKDB_API_KEY:
raise RuntimeError(
"TICKDB_API_KEY is required. Never hardcode credentials in source code."
)
def parse_timestamp(value):
"""Parse common TickDB timestamp formats into UTC timestamps."""
if value is None:
return pd.NaT
if isinstance(value, (int, float, np.integer, np.floating)):
numeric = float(value)
if numeric > 10_000_000_000:
numeric /= 1000.0
return pd.to_datetime(numeric, unit="s", utc=True)
return pd.to_datetime(value, utc=True, errors="coerce")
def extract_rows(payload):
"""Normalize common list-based kline response layouts."""
data = payload.get("data")
if isinstance(data, list):
return data
if isinstance(data, dict):
for key in ("items", "list", "records", "klines", "rows"):
value = data.get(key)
if isinstance(value, list):
return value
return []
def normalize_kline_rows(rows):
"""Convert raw kline records into the internal research schema."""
records = []
for row in rows:
if not isinstance(row, dict):
continue
timestamp = next(
(
row.get(key)
for key in ("timestamp", "time", "datetime", "date", "ts")
if row.get(key) is not None
),
None,
)
close = next(
(
row.get(key)
for key in ("close", "c", "last", "price")
if row.get(key) is not None
),
None,
)
if timestamp is None or close is None:
continue
records.append(
{
"ts": parse_timestamp(timestamp),
"open": row.get("open", row.get("o")),
"high": row.get("high", row.get("h")),
"low": row.get("low", row.get("l")),
"close": close,
"volume": row.get("volume", row.get("v")),
}
)
frame = pd.DataFrame(records)
if frame.empty:
raise TickDBApiError("The kline response contained no recognizable records.")
frame = frame.sort_values("ts").drop_duplicates("ts", keep="last")
frame = frame.set_index("ts")
for column in ("open", "high", "low", "close", "volume"):
frame[column] = pd.to_numeric(frame[column], errors="coerce")
frame = frame.dropna(subset=["close"])
return frame
def fetch_ticker_history(ticker, start, end):
"""Fetch daily US-equity klines with authentication, timeout, and backoff."""
require_api_key()
CACHE_DIR.mkdir(parents=True, exist_ok=True)
cache_path = CACHE_DIR / f"{ticker.replace('/', '_')}_{start}_{end}.csv"
if cache_path.exists():
cached = pd.read_csv(cache_path, parse_dates=["ts"])
cached["ts"] = pd.to_datetime(cached["ts"], utc=True)
return cached.set_index("ts").sort_index()
endpoint = f"{TICKDB_REST_URL}/v1/market/kline"
headers = {"X-API-Key": TICKDB_API_KEY}
params = {
"symbol": ticker,
"interval": "1d",
"start": start,
"end": end,
"limit": 5000,
}
max_attempts = 5
for attempt in range(max_attempts):
response = requests.get(
endpoint,
headers=headers,
params=params,
timeout=(3.05, 10),
)
try:
payload = response.json()
except ValueError as exc:
raise TickDBApiError(
f"TickDB returned non-JSON content with HTTP {response.status_code}."
) from exc
code = payload.get("code", 0)
if response.status_code == 429 or code == 3001:
retry_after = int(response.headers.get("Retry-After", 5))
delay = max(1, retry_after)
time.sleep(delay)
continue
if response.status_code != 200:
raise TickDBApiError(
f"TickDB HTTP {response.status_code}: {payload.get('message')}"
)
if code in (1001, 1002):
raise TickDBApiError(
"Invalid or missing API key. Check TICKDB_API_KEY."
)
if code == 2002:
raise TickDBApiError(
f"Symbol {ticker} was not found. Verify it through /v1/symbols/available."
)
if code != 0:
raise TickDBApiError(
f"Unexpected TickDB error {code}: {payload.get('message')}"
)
frame = normalize_kline_rows(extract_rows(payload))
frame.reset_index().to_csv(cache_path, index=False)
return frame
raise TickDBApiError("TickDB rate limit persisted after five attempts.")
def compute_features(history, cutoff_ts):
"""Compute only features observable at or before the explicit cutoff."""
cutoff = pd.Timestamp(cutoff_ts)
if cutoff.tzinfo is None:
cutoff = cutoff.tz_localize("UTC")
else:
cutoff = cutoff.tz_convert("UTC")
observable = history.loc[history.index <= cutoff].copy()
if len(observable) < 61:
raise ValueError(
f"At least 61 completed daily candles are required; got {len(observable)}."
)
close = observable["close"]
log_return = np.log(close / close.shift(1))
def trailing_return(sessions):
return close.iloc[-1] / close.iloc[-sessions - 1] - 1.0
def realized_volatility(sessions):
return log_return.tail(sessions).std(ddof=1) * np.sqrt(252)
typical_range = (
(observable["high"] - observable["low"]) / close
).tail(5)
prior_volume = observable["volume"].iloc[-21:-1]
latest_volume = observable["volume"].iloc[-1]
if prior_volume.dropna().empty or prior_volume.mean() == 0:
volume_shock = 0.0
else:
volume_shock = latest_volume / prior_volume.mean() - 1.0
high_60 = observable["high"].tail(60).max()
distance_to_high = close.iloc[-1] / high_60 - 1.0
return {
"ret_1d": trailing_return(1),
"ret_3d": trailing_return(3),
"ret_5d": trailing_return(5),
"rv_5": realized_volatility(5),
"rv_20": realized_volatility(20),
"rv_60": realized_volatility(60),
"range_5d": typical_range.mean(),
"volume_shock": volume_shock,
"distance_to_high": distance_to_high,
}
def build_dataset(events):
"""Fetch point-in-time histories and construct the modeling table."""
rows = []
for event in events.itertuples(index=False):
history = fetch_ticker_history(
event.ticker,
event.announcement_ts.date().isoformat(),
event.cutoff_ts.date().isoformat(),
)
features = compute_features(history, event.cutoff_ts)
rows.append(
{
"event_id": event.event_id,
"ticker": event.ticker,
"announcement_ts": event.announcement_ts,
"cutoff_ts": event.cutoff_ts,
"pre_iv": float(event.pre_event_iv),
"post_iv": float(event.post_event_iv),
**features,
}
)
dataset = pd.DataFrame(rows).sort_values("announcement_ts").reset_index(drop=True)
dataset[TARGET_COLUMN] = 100.0 * (
dataset["pre_iv"] - dataset["post_iv"]
)
return dataset
def make_model():
"""Create a regularized model suitable for a modest event sample."""
return Pipeline(
[
("scale", StandardScaler()),
("ridge", Ridge(alpha=10.0)),
]
)
def walk_forward_evaluation(dataset, minimum_train_size=20):
"""Evaluate predictions using only prior events for each training window."""
if len(dataset) <= minimum_train_size:
raise ValueError(
f"At least {minimum_train_size + 1} events are required for walk-forward testing."
)
predictions = []
residuals = []
for index in range(minimum_train_size, len(dataset)):
train = dataset.iloc[:index].copy()
test = dataset.iloc[[index]].copy()
model = make_model()
model.fit(train[FEATURE_COLUMNS], train[TARGET_COLUMN])
prediction = float(model.predict(test[FEATURE_COLUMNS])[0])
actual = float(test[TARGET_COLUMN].iloc[0])
train_residuals = (
train[TARGET_COLUMN]
- model.predict(train[FEATURE_COLUMNS])
)
residual_q10 = train_residuals.quantile(0.10)
residual_q90 = train_residuals.quantile(0.90)
predictions.append(
{
"event_id": test["event_id"].iloc[0],
"ticker": test["ticker"].iloc[0],
"announcement_ts": test["announcement_ts"].iloc[0],
"actual_iv_crush_pp": actual,
"predicted_iv_crush_pp": prediction,
"lower_80": prediction + residual_q10,
"upper_80": prediction + residual_q90,
"directional_correct": np.sign(prediction) == np.sign(actual),
}
)
residuals.append(actual - prediction)
return pd.DataFrame(predictions)
def report_performance(predictions):
"""Print forecast metrics and compare against a historical-mean baseline."""
actual = predictions["actual_iv_crush_pp"]
predicted = predictions["predicted_iv_crush_pp"]
mae = mean_absolute_error(actual, predicted)
rmse = mean_squared_error(actual, predicted) ** 0.5
directional_accuracy = predictions["directional_correct"].mean()
coverage = (
(predictions["lower_80"] <= actual)
& (actual <= predictions["upper_80"])
).mean()
rank_correlation = actual.rank().corr(predicted.rank())
print(f"Out-of-sample events: {len(predictions)}")
print(f"MAE: {mae:.3f} volatility points")
print(f"RMSE: {rmse:.3f} volatility points")
print(f"Directional accuracy: {directional_accuracy:.1%}")
print(f"Empirical 80% interval coverage: {coverage:.1%}")
print(f"Spearman rank correlation: {rank_correlation:.3f}")
print(
"\nValidation rules: chronological split only; no random shuffle; "
"all underlying features respect cutoff_ts."
)
def main():
require_api_key()
if not EVENTS_CSV.exists():
raise FileNotFoundError(
f"Missing event file: {EVENTS_CSV}. "
"Use the documented event schema with pre- and post-event IV."
)
events = pd.read_csv(EVENTS_CSV)
required = {
"event_id",
"ticker",
"announcement_ts",
"cutoff_ts",
"pre_event_iv",
"post_event_iv",
}
missing = required - set(events.columns)
if missing:
raise ValueError(f"Missing event columns: {sorted(missing)}")
events["announcement_ts"] = pd.to_datetime(
events["announcement_ts"], utc=True
)
events["cutoff_ts"] = pd.to_datetime(events["cutoff_ts"], utc=True)
dataset = build_dataset(events)
predictions = walk_forward_evaluation(dataset)
report_performance(predictions)
predictions.to_csv("walk_forward_iv_crush_predictions.csv", index=False)
dataset.to_csv("earnings_iv_crush_dataset.csv", index=False)
if __name__ == "__main__":
main()
5.3 Environment configuration
export TICKDB_API_KEY="your_api_key"
export EVENTS_CSV="./earnings_events.csv"
export TICKDB_CACHE_DIR="./.tickdb_cache"
The code uses X-API-Key for REST authentication and never places the key in a URL. It applies a timeout to every request, handles the documented 3001 rate-limit code, reads Retry-After, and retries with a bounded number of attempts.
5.4 Live WebSocket monitoring
Historical forecasts do not prevent a production system from missing a last-minute price move. A live monitor should consume current underlying-price updates through TickDB’s WebSocket interface.
Because WebSocket endpoint and subscription details may vary by API version, configure the endpoint and current subscription payload through environment variables rather than embedding an unverified protocol assumption:
export TICKDB_WS_URL="wss://your-documented-tickdb-websocket-endpoint"
export TICKDB_SUBSCRIBE_PAYLOAD='{"cmd":"subscribe","channel":"kline","symbol":"AAPL.US","interval":"1m"}'
The following adapter demonstrates authentication through the WebSocket URL parameter, application-level heartbeat, exponential backoff with jitter, and rate-limit handling.
import asyncio
import json
import os
import random
import time
from urllib.parse import quote
import websockets
TICKDB_WS_URL = os.environ["TICKDB_WS_URL"].rstrip("/")
TICKDB_API_KEY = os.environ["TICKDB_API_KEY"]
TICKDB_SUBSCRIBE_PAYLOAD = os.environ["TICKDB_SUBSCRIBE_PAYLOAD"]
HEARTBEAT_SECONDS = 15
BASE_RECONNECT_DELAY = 1.0
MAX_RECONNECT_DELAY = 30.0
def websocket_url_with_auth(base_url, api_key):
separator = "&" if "?" in base_url else "?"
return f"{base_url}{separator}api_key={quote(api_key, safe='')}"
def normalize_quote(message):
"""Normalize common quote fields into a small internal schema."""
if not isinstance(message, dict):
return None
data = message.get("data", message)
if isinstance(data, list):
data = data[0] if data else {}
if not isinstance(data, dict):
return None
timestamp = next(
(
data.get(key)
for key in ("timestamp", "time", "ts", "datetime")
if data.get(key) is not None
),
None,
)
price = next(
(
data.get(key)
for key in ("close", "last", "price", "c")
if data.get(key) is not None
),
None,
)
if timestamp is None or price is None:
return None
return {
"timestamp": timestamp,
"price": float(price),
"symbol": data.get("symbol"),
}
async def stream_quotes():
"""Yield normalized quotes while surviving transient network failures."""
reconnect_attempt = 0
url = websocket_url_with_auth(TICKDB_WS_URL, TICKDB_API_KEY)
while True:
try:
async with websockets.connect(
url,
open_timeout=10,
close_timeout=5,
ping_interval=20,
ping_timeout=10,
max_queue=1024,
) as websocket:
await websocket.send(TICKDB_SUBSCRIBE_PAYLOAD)
reconnect_attempt = 0
last_heartbeat = time.monotonic()
while True:
try:
raw_message = await asyncio.wait_for(
websocket.recv(),
timeout=HEARTBEAT_SECONDS,
)
except asyncio.TimeoutError:
raw_message = None
now = time.monotonic()
if now - last_heartbeat >= HEARTBEAT_SECONDS:
await websocket.send(json.dumps({"cmd": "ping"}))
last_heartbeat = now
if raw_message is None:
continue
message = json.loads(raw_message)
code = message.get("code", 0)
if code == 3001:
retry_after = 5
# Some versions expose response headers on the connection.
response = getattr(websocket, "response", None)
headers = getattr(response, "headers", {}) or {}
try:
retry_after = max(
1, int(headers.get("Retry-After", retry_after))
)
except (TypeError, ValueError):
pass
await asyncio.sleep(retry_after)
continue
normalized = normalize_quote(message)
if normalized is not None:
yield normalized
except (
asyncio.CancelledError,
websockets.ConnectionClosed,
TimeoutError,
OSError,
json.JSONDecodeError,
):
reconnect_attempt += 1
delay = min(
BASE_RECONNECT_DELAY * (2 ** (reconnect_attempt - 1)),
MAX_RECONNECT_DELAY,
)
jitter = random.uniform(0, delay * 0.1)
# ⚠️ A reconnect storm can overwhelm the upstream service.
# Keep the jitter, cap the delay, and alert on repeated failures.
await asyncio.sleep(delay + jitter)
async def main():
async for quote in stream_quotes():
print(quote)
if __name__ == "__main__":
asyncio.run(main())
The subscription payload must match the current TickDB WebSocket documentation for the deployed API version. The normalized adapter should be covered with recorded WebSocket fixtures so that upstream message changes fail in tests rather than during a live earnings window.
6. Interpreting the Model’s Output
The model should produce a forecast in volatility points rather than a buy or sell signal.
For example, suppose the model predicts:
Predicted IV crush: 14.6 volatility points
Empirical 80% interval: 7.2 to 22.1 volatility points
The interpretation is that the pre-event implied-volatility premium is expected to contract by approximately 14.6 percentage points, with historical residuals indicating substantial uncertainty.
That forecast does not imply that a particular option will produce a 14.6-point profit. Option returns also depend on:
- Strike selection
- Moneyness
- Implied versus realized volatility
- Time decay
- Skew and term structure
- Bid-ask spread
- Slippage
- Early exercise or assignment assumptions
- Corporate-action adjustments
For a tradeable implementation, the forecast should remain separate from the execution layer. One module predicts IV normalization; another maps that forecast to a defined option portfolio and applies liquidity and transaction-cost constraints.
6.1 Model diagnostics
The evaluation output should be reviewed along four dimensions:
| Diagnostic | Purpose |
|---|---|
| MAE and RMSE | Measure the magnitude of IV-crush forecast error |
| Directional accuracy | Tests whether the model identifies the correct direction |
| Rank correlation | Measures whether higher predicted crushes correspond to larger realized crushes |
| Interval coverage | Tests whether the uncertainty band contains the realized outcome |
Symbol-level and event-type breakdowns can reveal instability. A model that performs well for large-cap technology companies but fails around smaller-cap earnings does not have a general forecasting claim.
7. Backtest and Validation Disclosure
This article provides a reproducible validation framework, not an empirical performance claim. The code produces out-of-sample forecasts when supplied with the required historical event dataset, but no Sharpe ratio, win rate, profit factor, or drawdown is presented because no complete options-trading backtest has been executed here.
Any deployment that converts the IV forecast into an options strategy must disclose:
| Item | Required disclosure |
|---|---|
| Period | At least one year; three or more years preferred |
| Sample size | At least 20 events; 50 or more preferred |
| Win rate | Gross and net of modeled costs |
| Profit factor | Average win divided by average loss |
| Sharpe ratio | With an appropriate event-sampling adjustment |
| Sortino ratio | Reported alongside Sharpe |
| Maximum drawdown | Magnitude and recovery duration |
| Benchmark | Constant-crush or simple pre-IV baseline |
| Costs | Commission, spread, slippage, and option liquidity assumptions |
| Limitations | IV estimation errors, sparse events, regime shifts, and corporate actions |
The following limitation statement should accompany any published backtest:
Backtest limitations: The results are based on historical simulation and do not guarantee future performance. Implied volatility is model-dependent, option liquidity can vary materially, and the sample may not represent future earnings regimes. Slippage, market impact, and liquidity exhaustion are approximated. Extended out-of-sample validation is required before live deployment.
8. Deployment Configuration by User Segment
| User type | Historical depth | Live updates | Model design |
|---|---|---|---|
| Individual quant developer | Daily US-equity OHLCV and recent events | One-minute underlying candles | Regularized regression with interpretable coefficients |
| Small research team | Full event panel across multiple symbols | WebSocket monitor and scheduled retraining | Walk-forward validation with symbol-level diagnostics |
| Institutional team | Full available US-equity kline history | Low-latency feed plus independent options source | Ensemble models, calibrated intervals, and execution-cost controls |
| AI-assisted researcher | Structured CSV event panel | Script-generated features | Automated research with explicit leakage and schema checks |
A deployment should not retrain the model immediately before each event without a validation protocol. If the model is updated daily, the evaluation must reproduce that process, including the delay and data available for each historical retraining run.
9. Monitoring Universe and Event Characteristics
The following table provides representative research characteristics, not investment recommendations.
| Company | Ticker | Event-monitoring thesis |
|---|---|---|
| Apple | AAPL | Large-cap hardware-cycle announcements often coincide with elevated single-name implied volatility |
| Microsoft | MSFT | Cloud and AI expectations can materially alter forward earnings and volatility expectations |
| NVIDIA | NVDA | AI infrastructure expectations create a high-information earnings-event regime |
| Tesla | TSLA | Delivery, margin, and demand commentary can produce large pre-event positioning |
| Amazon | AMFT | Retail execution, advertising, and cloud commentary affect multiple forecast components |
The monitoring universe should be selected according to data availability, event timestamp quality, option-liquidity requirements, and the research sample size—not only market capitalization.
10. Practical Failure Modes
Incorrect earnings timestamps
An incorrect before-market versus after-market timestamp can shift the feature cutoff by one session. The event dataset should therefore preserve timezone information and include an explicit cutoff_ts.
IV-source inconsistency
An option-chain IV field may use different filtering rules across vendors or timestamps. The event builder must document how it handles zero-volume options, wide spreads, stale quotes, and missing legs.
Daily-bar contamination
A daily candle dated after the announcement may still include price movement caused by the event. Filtering by candle date instead of the event cutoff can leak the answer into the predictors.
Small samples
A model trained on fewer than 20 events is highly unstable. Such a sample is appropriate for pipeline testing, not for a production forecasting claim.
Structural breaks
Market-wide volatility regimes can alter how stock movements relate to option premiums. Rolling retraining helps, but it does not eliminate regime risk. The model should monitor calibration and fail closed when its inputs fall outside the training distribution.
11. Closing: From Expected Volatility to Measured Compression
The trader does not need a perfect forecast of every earnings reaction. The useful signal is a disciplined distinction between volatility that remains and volatility the market has already priced.
The strongest leading indicator combines the underlying’s pre-event path with the starting implied-volatility premium. A quiet tape can leave more room for compression, while a large pre-event move can remove much of the premium before the announcement. The forecast becomes decision-useful only when its cutoff, measurement window, and out-of-sample error are explicit.
Next Steps
If you are an investor seeking deeper event-driven analysis, subscribe to the TickDB newsletter for weekly market-structure and supply-chain research.
If you want to build the underlying-price component of this model:
- Sign up at tickdb.ai for a free API key; no credit card is required.
- Generate an API key in the dashboard.
- Set the
TICKDB_API_KEYenvironment variable. - Run the reference pipeline with a point-in-time earnings-event dataset and an independent options-IV source.
If you need more than 10 years of historical US-equity OHLCV data for cross-cycle research, contact [email protected] for institutional data options.
If you use AI coding assistants, search for and install the tickdb-market-data SKILL in your assistant’s marketplace.
This article does not constitute investment advice. Markets involve risk; past performance does not guarantee future results.