๐Ÿ“… April 5, 2026 python paper-trading gap-detection risk-management

How We Handle Gap Openings in Our Python Paper Trader

April 3rd was a big gap day. MSFT opened +4.6% over the prior close, AAPL +4.5%, V +4.8%. If you're running an overnight position in any of those, you need to know what your bot is going to do with that โ€” before it does it.

Here's how TradeSight's gap detection module works, what it logged that morning, and the reasoning behind the "consider taking partial profit" recommendation rather than an automatic exit.

๐Ÿ“Š Apr 3 Live Paper Trading Results

Portfolio: $544.19 (+8.84% from $500 starting capital) ยท 4 open positions ยท VIX: 23.9 (normal regime)

What the Logs Said

Here's the actual output from the Apr 3 scan, lightly formatted:

[Gap] MSFT gapped UP 4.6%: prev_close=$369.46 โ†’ current=$386.39
[Gap] MSFT gap up โ€” unrealized=$9.17, consider taking partial profit
[Gap] AAPL gapped UP 4.5%: prev_close=$255.43 โ†’ current=$266.91
[Gap] AAPL gap up โ€” unrealized=$4.70, consider taking partial profit
[Gap] V gapped UP 4.8%: prev_close=$298.50 โ†’ current=$312.90
[Gap] V gap up โ€” unrealized=$0.05, consider taking partial profit
[Regime] VIX=23.9 โ†’ normal
[SL/TP/Trail] SL=5.0% TP=12.0% Trail=3.0% (activates at +2%)

The Gap Detection Logic

The gap detector runs at the top of every trading scan. It compares the previous close to the current price for each held position:

def check_gaps(positions, threshold=0.03):
    """
    Flag any held position that gapped significantly from prior close.
    threshold: 0.03 = 3% gap triggers a warning
    """
    alerts = []
    for pos in positions:
        symbol = pos['symbol']
        prev_close = get_prev_close(symbol)  # from daily bar data
        current = float(pos['current_price'])

        if prev_close is None:
            continue

        gap_pct = (current - prev_close) / prev_close

        if abs(gap_pct) >= threshold:
            direction = "UP" if gap_pct > 0 else "DOWN"
            unrealized = float(pos['unrealized_pl'])
            alerts.append({
                'symbol': symbol,
                'direction': direction,
                'gap_pct': gap_pct,
                'prev_close': prev_close,
                'current': current,
                'unrealized_pl': unrealized
            })
            logger.warning(
                f"[Gap] {symbol} gapped {direction} {gap_pct:.1%}: "
                f"prev_close=${prev_close:.2f} โ†’ current=${current:.2f}"
            )
            if gap_pct > 0:
                logger.info(
                    f"[Gap] {symbol} gap up โ€” unrealized=${unrealized:.2f}, "
                    f"consider taking partial profit"
                )
            else:
                logger.warning(
                    f"[Gap] {symbol} gap down โ€” unrealized=${unrealized:.2f}, "
                    f"stop-loss active at {SL_PCT:.1%}"
                )

    return alerts

The threshold is set at 3% by default. A gap under that is noise. Above that, something real happened overnight that your intraday signal didn't see coming.

Why "Consider" Instead of Auto-Exit?

The log says "consider taking partial profit" โ€” not "sell now." That's intentional. Here's the reasoning:

The mental model: Gap detection is an alerting layer, not a decision layer. It tells the system "something unusual happened, check your stops." The stops themselves handle the actual exit.

VIX Regime Changes the Calculation

The regime detector ran in parallel and returned normal (VIX=23.9). That matters because the same 4.6% gap reads differently in different regimes:

VIX RangeRegimeGap Response
<18low_volGaps are rare โ€” treat as signal, tighten stop
18โ€“28normalGaps happen โ€” alert only, let stops run
28โ€“40high_volGaps are frequent โ€” reduce position sizes, widen stops
>40extremeNew entries paused โ€” manage existing only

At VIX=23.9, we're in normal territory. The system correctly classified it and didn't alter stop-loss parameters. Had VIX been above 28, the SL/TP config would have automatically widened to SL=7% / TP=15% to account for bigger intraday swings.

def get_regime(vix: float) -> str:
    if vix < 18:
        return "low_vol"
    elif vix < 28:
        return "normal"
    elif vix < 40:
        return "high_vol"
    else:
        return "extreme"

def get_sl_tp_config(regime: str) -> dict:
    configs = {
        "low_vol":  {"sl": 0.04, "tp": 0.10, "trail": 0.02, "trail_activates": 0.015},
        "normal":   {"sl": 0.05, "tp": 0.12, "trail": 0.03, "trail_activates": 0.020},
        "high_vol": {"sl": 0.07, "tp": 0.15, "trail": 0.04, "trail_activates": 0.030},
        "extreme":  {"sl": 0.08, "tp": 0.18, "trail": 0.05, "trail_activates": 0.040},
    }
    return configs[regime]

What We'd Do Differently

A few things Apr 3 exposed:

  1. V gap was barely worth reporting. Unrealized P&L of $0.05 on a 4.8% gap means the position is tiny. The gap detector doesn't filter by position size โ€” it probably should.
  2. No pre-market check. All three tickers gapped because of macro news (broad market move). We don't currently distinguish between stock-specific gaps vs. broad market gaps. The response should differ.
  3. Gap-down asymmetry. We handle gap-downs more aggressively (stop-loss fires immediately vs. just alerting). That's correct but could be surfaced more clearly in the logs.
One thing we haven't solved: pre-market gap detection. By the time the first scan runs at 9:35 AM ET, the gap has already happened. Ideally you'd check the pre-market print at 9:25 and decide before the open โ€” but that requires a pre-market data subscription on Alpaca (or a separate feed). On the free tier, you work with what opens at 9:30.

The Bigger Picture

After ~5 weeks of paper trading, the portfolio is at +8.84% ($544.19 from $500). That's not spectacular but it's positive and the system is learning โ€” each tournament cycle, strategies compete on real performance metrics and the loser gets its parameters evolved. The MACD Crossover strategy has been the consistent winner; RSI mean reversion has been the consistent underperformer on this particular market regime.

Gap handling is one of the less glamorous parts of a trading bot but it's where you lose money fast if you get it wrong. A system that ignores overnight moves will hold positions into reversals. One that exits on every gap will constantly sell into continuation moves. The answer is almost always: alert, adjust stops, let the position ride unless the stop fires.

If you're building something similar, the TradeSight repo is open source. The gap detection code is in signals/signal_service.py.