Skip to main content

nq momentum signal: adaptive lookback after the tariff vol test

2:30 AM wednesday. A. finished something around 1 and went to bed still holding her coffee mug. found it on the counter half-full when I went for water. she’s like that when she’s in flow — stops the world when she figures it out.

been staring at NQ data since about 11. this is what happens when tariff week settles: you go back through the wreckage and find the things that technically held but for the wrong reasons.

the event risk throttle worked during tariff week. the SQS held. options side held. all covered in the postmortem two weeks ago. but there was a line in that post I wrote quickly and moved on from:

ES futures: flat exposure the whole week. the throttle was right to keep sizing minimal there. realized vol on NQ was 4x historical average for two days straight — no edge in that for my strategies.

that’s true. but it glosses over why there was no edge. the throttle scaled position size down. that’s correct and necessary. but the momentum signal itself was still running — generating scores, flagging entries, doing its thing — and those scores were essentially noise during the vol spike. the signal quality didn’t degrade gracefully. it hit a cliff.

that’s the problem I’ve been thinking about. and tonight I started fixing it.


the nq momentum strategy, briefly
#

NQ is ~10% of my book. it’s the smallest allocation, but it’s the one I built earliest and has the most history.

the strategy is directional momentum on NQ futures (emini NASDAQ-100). long on confirmed upside momentum, short on confirmed downside momentum, flat when signal is ambiguous or confidence is low. no mean reversion, no delta-neutral — pure directional. holding period is 2 hours to 3 days depending on signal strength.

inputs:

  • 30-minute OHLC via Polygon futures API
  • 20-period momentum score (rate of change smoothed with EMA)
  • volume confirmation (volume z-score relative to trailing 20 bars)
  • realized vol (20-day, annualized) from TimescaleDB

entry logic:

entry = (
    momentum_score > ENTRY_THRESHOLD and  # directional
    volume_zscore > 0.5 and               # volume confirms
    rvol_20d < 35.0                        # not extreme vol
)

that hard cutoff at rvol_20d < 35 was my manual patch for high-vol periods. blunt instrument. works in the sense that “don’t trade when vol is insane” is correct. doesn’t work because 35 is arbitrary, and between 20 and 35 there’s a huge range where signal quality is degrading but still technically above cutoff.


why fixed lookback breaks in high vol
#

momentum signals use a lookback window. classic 20-period momentum: current price vs price N periods ago. if the window is fixed, your signal is implicitly assuming the market’s mean-reversion timescale stays constant.

it doesn’t.

in low vol, a 20-period lookback on 30-minute bars (10 hours of data) is appropriate. trends develop over hours, noise oscillates at a shorter scale, the lookback filters enough noise to identify real direction.

in high vol, everything speeds up. the market can move from bullish to bearish and back in 90 minutes. a fixed 20-period lookback during tariff week was smoothing over genuine direction changes because those changes were happening faster than the lookback could track.

the signal was saying “momentum is moderately bullish” while NQ was in the middle of a 4% intraday reversal. technically not wrong for the 10-hour window. totally wrong for what was actually happening.


the fix: vol-adjusted lookback
#

the core insight is simple: shorter lookback when vol is high, longer lookback when vol is low.

high vol = faster price changes = shorter lookback needed to track direction low vol = slower price changes = longer lookback needed to filter noise

from dataclasses import dataclass
import numpy as np
from typing import Final


# realized vol breakpoints for adaptive lookback
VOL_BREAKPOINTS: Final[list[tuple[float, int]]] = [
    (12.0,  28),   # CALM:     28-period lookback (~14 hours on 30-min bars)
    (18.0,  22),   # NORMAL:   22-period lookback
    (28.0,  15),   # ELEVATED: 15-period lookback (~7.5 hours)
    (45.0,  10),   # STRESSED: 10-period lookback (~5 hours)
    (float('inf'), 6),  # EXTREME: 6-period lookback (~3 hours)
]


def adaptive_lookback(rvol_20d: float) -> int:
    """
    Select momentum lookback period based on current realized vol.

    Higher vol → shorter lookback (faster signal, less smoothing)
    Lower vol  → longer lookback (more smoothing, trend-following)
    """
    for threshold, periods in VOL_BREAKPOINTS:
        if rvol_20d < threshold:
            return periods
    return VOL_BREAKPOINTS[-1][1]  # fallback to shortest


@dataclass
class MomentumSignal:
    score: float       # -1.0 to 1.0
    lookback: int      # actual lookback used
    rvol: float        # realized vol at signal time
    confidence: float  # 0.0 to 1.0 (vol-adjusted)


def compute_adaptive_momentum(
    closes: np.ndarray,
    volumes: np.ndarray,
    rvol_20d: float,
) -> MomentumSignal:
    """
    Compute momentum score with adaptive lookback based on realized vol.

    Args:
        closes:    Array of recent 30-minute close prices (at least 30 bars)
        volumes:   Array of recent 30-minute volumes (same length)
        rvol_20d:  Current 20-day realized vol (annualized)

    Returns:
        MomentumSignal with score in [-1.0, 1.0]
    """
    lookback = adaptive_lookback(rvol_20d)

    if len(closes) < lookback + 1:
        return MomentumSignal(score=0.0, lookback=lookback, rvol=rvol_20d, confidence=0.0)

    # rate of change over adaptive window
    roc = (closes[-1] - closes[-lookback]) / closes[-lookback]

    # normalize by historical range to get [-1, 1] score
    # use 3x current rvol as denominator to scale
    expected_move = (rvol_20d / 100) * np.sqrt(lookback / 252 * (6.5 / 0.5))
    normalized_roc = np.clip(roc / max(expected_move, 0.001), -1.0, 1.0)

    # volume confirmation: z-score of most recent bar vs trailing mean
    vol_mean = np.mean(volumes[-20:])
    vol_std  = np.std(volumes[-20:]) + 1e-10
    vol_zscore = (volumes[-1] - vol_mean) / vol_std
    volume_conf = np.clip(vol_zscore / 2.0, 0.0, 1.0)  # 0 to 1

    # confidence degrades with higher vol (signal is noisier)
    vol_penalty = min(rvol_20d / 60.0, 1.0)   # 0 at rvol=0, 1.0 at rvol>=60
    confidence = volume_conf * (1.0 - 0.5 * vol_penalty)

    return MomentumSignal(
        score=float(normalized_roc),
        lookback=lookback,
        rvol=rvol_20d,
        confidence=float(confidence),
    )

the expected_move normalization is doing something important: it’s scaling the raw rate-of-change by what a “big” move looks like at the current vol level. a 1% move in NQ is large when rvol is 12, ordinary when rvol is 45. the momentum score [-1, 1] now means something consistent across different vol environments.


signal degradation during tariff week
#

ran the new signal computation against the tariff week data. this chart shows what the old (fixed 20-period) vs new (adaptive) momentum score was doing during the worst two days — april 8-9:

fixed 20-period lookback was still reading slightly bullish at 12:00 PM on April 8 when NQ was mid-reversal. adaptive signal had already flipped to -0.61. on April 9, the adaptive signal catches the tariff-pause rip at 12:30 PM (score 0.72) two bars faster than fixed (0.28). both examples are entries the fixed signal would have missed or gotten wrong.


lookback profile by vol state
#

this is what the adaptive lookback table looks like across the current vol regime classification, plotted against how often each lookback fires in my historical data:

most trading happens in the NORMAL bucket (42% of days) with a 22-period lookback. EXTREME (rvol > 45) accounts for 3% of days historically — that’s about 7-8 sessions per year. the April tariff week generated most of those. the 6-period lookback in EXTREME conditions is aggressive by design: in a market moving 3-4% intraday, you need to track direction on the scale of hours, not days.


infrastructure: reading vol state from redis
#

the adaptive lookback decision needs current realized vol. i don’t compute rvol inside the momentum strategy — that would be duplicating work that already runs in the vol state manager (wrote about that system last week in the replay post context).

instead, the momentum signal reads from Redis:

import redis.asyncio as aioredis
import json
import asyncio


REDIS_VOL_INPUTS_KEY = "system:vol_state:last_inputs"


async def get_current_rvol(redis_client: aioredis.Redis) -> float | None:
    """
    Fetch current realized vol from Redis (maintained by vol state manager).
    Returns None if the key is stale or missing.
    """
    raw = await redis_client.get(REDIS_VOL_INPUTS_KEY)
    if raw is None:
        return None

    data = json.loads(raw)

    # staleness check: reject if last update > 10 minutes ago
    age_ms = int(asyncio.get_event_loop().time() * 1000) - data.get("timestamp_ms", 0)
    if age_ms > 600_000:
        return None

    return data["inputs"].get("realized_vol_20d")


class NQMomentumStrategy:
    def __init__(self, redis_client: aioredis.Redis):
        self._redis = redis_client
        self._default_rvol = 18.0  # fallback when Redis is unavailable

    async def compute_signal(
        self,
        closes: list[float],
        volumes: list[float]
    ) -> MomentumSignal:
        import numpy as np

        rvol = await get_current_rvol(self._redis)
        if rvol is None:
            rvol = self._default_rvol  # conservative fallback

        return compute_adaptive_momentum(
            np.array(closes, dtype=float),
            np.array(volumes, dtype=float),
            rvol_20d=rvol,
        )

    async def should_enter_long(self, signal: MomentumSignal) -> bool:
        """Gate: signal score + confidence must both meet threshold."""
        return signal.score > 0.55 and signal.confidence > 0.35

    async def should_enter_short(self, signal: MomentumSignal) -> bool:
        return signal.score < -0.55 and signal.confidence > 0.35

    async def should_exit(self, signal: MomentumSignal) -> bool:
        """Exit when signal crosses flat zone."""
        return abs(signal.score) < 0.15

clean separation: vol state manager owns rvol computation, momentum strategy consumes it. no circular dependencies.

the confidence > 0.35 exit threshold is the part I’m still tuning. during tariff week, confidence was dropping to 0.1-0.15 which would gate all entries, which is… probably the right call. but I want to backtest the threshold more carefully before committing.


colocation latency context
#

this matters for NQ specifically because futures execution is time-sensitive in a way options aren’t. my Chicago colo (where the execution engine runs) gets fills on NQ in 1.1–1.8 ms average on normal days. during tariff week, that jumped to 4–6 ms — congestion at the CME matching engine.

the adaptive signal doesn’t directly address latency, but there’s an indirect relationship: longer lookback in normal conditions = fewer trades = fewer fills = less latency pressure. shorter lookback in stressed conditions = more trades, but in stressed conditions you’re already sized down (the vol state manager handles this), so per-trade impact is lower.

I run the NQ strategy from the colo directly. the strategy reads market data from Polygon’s Chicago datacenter endpoint, reads Redis from the container side via latency-tolerant poll (5s), executes via IB’s TWS API at the colo. the 5-second Redis poll doesn’t matter because momentum signals on 30-minute bars don’t need sub-second rvol updates.


where this is going
#

a few things still to build:

cross-asset momentum filter: NQ and ES have different momentum characteristics but are highly correlated. I want to look at whether ES momentum confirms or diverges from NQ signal — divergence might be a filter or an amplifier depending on the regime. haven’t touched this yet.

backtest across vol events: tariff week was one event. I have yen carry unwind (aug 2024) and COVID vol (march 2020) in TimescaleDB for NQ. running the adaptive signal back through those is on the list. the replay infrastructure I built last week makes this straightforward in theory.

confidence threshold optimization: the 0.35 confidence gate is arbitrary. running an Optuna sweep on the threshold across 2022-2025 NQ data to see where the P&L curve bends.

live since earlier tonight on the colo. first signal: FLAT. rvol is 16.1, lookback is 22, momentum score is +0.08. market is quiet at 2 AM, which is correct. watching the first morning session in a few hours.


I read a thread years ago on NexusFi — one of those algo journals where someone was running NinjaTrader bots and logging everything. the thing that stuck with me wasn’t the code. it was how they tracked why the signal was wrong during specific events and built from there. started doing the same in 2024. the Attack of the Robots algo journal thread is still in my bookmarks — 254k views, legitimately useful community around systematic trading. different stack, same problems.


the thing about building infra you’d never finish in time to show anyone: dad always said the only system that matters is the one that works when things go wrong. been thinking about that a lot this month. pretty sure he’d have hated watching the tariff week charts. would’ve loved that the throttle held.


-AK

Related

tariff week post-mortem: what the data actually showed
2:30 AM monday. week one of what i’m calling “the post-tariff-chaos era” starts in a few hours. last week was one of those that splits into a clear before and after. monday and tuesday felt like freefall — VIX went from 20 to 32 in about 36 hours, SPX dropped hard, options spreads blew out 3-4x, and my event risk throttle (which I built the week prior and wrote about here) was earning every line of code it took to build. then wednesday happened. whoever made the tariff pause call did it at 1:07 PM eastern and watching the S&P rip 8% in ninety minutes while running algorithms was… a lot.
replaying the yen carry unwind: validating sqs against a real vol event
2:15 AM monday. system’s been clean since the websocket IV fix went live friday. heartbeat healthy, colo latency normal, no stale data flags. spent most of sunday going deep on something i’ve been meaning to do since the tariff postmortem.
signal quality scoring: building a market-aware trade gate
2:15 AM wednesday. apartment quiet. A. went to bed around midnight — she had a client deadline today so it was a long one. checked the colo heartbeat before sitting down to write this. normal. algos running clean for the first time since last monday.
event risk throttle: dynamic exposure scaling based on vol regime
2:30 AM monday. Q2 week 2 starts in a few hours. Been sitting with something since Thursday when I posted the Q2 week 1 numbers. Said we were running at 60% position size - waiting for the health scoring system to validate before going full deployment.
fixing the stale iv problem: thetadata websocket streaming for real-time greeks
2:30 AM friday. been at this since 9 PM. promised myself two weeks ago, right in the middle of the tariff chaos, that i’d actually fix the IV rank staleness issue. the signal quality scoring work was the band-aid — a composite gate that tells the system “this signal isn’t reliable right now.” it worked. it’s in production. but the underlying problem was unchanged: during the spike, my IV rank was being computed from options data that was 10-14 minutes old. the signal wasn’t wrong, technically. it was just answering a question about a market that no longer existed.
real-time portfolio Greeks: aggregating delta, gamma, theta, vega at scale
2:15 AM friday. couldn’t sleep after the week we just had. VIX ripped to 28 monday, calmed down midweek, then did that whipsaw thing thursday afternoon where you think it’s done but it’s absolutely not done.