← Back to Blog
STRATEGY

Event-Driven Volatility Strategy for SPCX-PERP: A Python Implementation

June 22, 2026 · 9 min read · LMEX.AI

SPCX-PERP trades unlike most crypto perpetuals. Most of the price movement comes from discrete events — Starship launches, government contracts, secondary market valuations, Musk announcements. Between events, price drifts in a tight range. Around events, volatility explodes.


This article walks through a strategy that detects the early stages of volatility expansion and trades the breakout, with working Python code targeting LMEX.


Why this strategy fits SPCX-PERP


Standard momentum strategies need sustained trends. Standard mean-reversion strategies need stable ranges. SPCX-PERP, at least in the early months of trading, will have neither — instead, it'll have quiet ranges punctuated by sharp event-driven moves.


The natural strategy for this profile is **volatility expansion breakout**: do nothing during quiet periods, then trade in the direction of the first major breakout when volatility spikes. The thesis is simple — a sudden volatility increase usually means new information has hit the market, and the initial price move often continues for several hours as the information gets digested by more participants.


This strategy makes most of its money on a small number of trades. Win rate is typically 40-55%; profitability comes from larger winners than losers when an event move continues.


Strategy mechanics


**Setup**:

  • Timeframe: 1-hour bars
  • Universe: SPCX-PERP (could extend to other private company perps as they list)
  • Lookback for baseline: 30 days (720 bars)

  • **Indicators**:

  • 20-period Bollinger Band width as the volatility measure
  • 14-period ATR for sizing and stops
  • 4-hour rolling volume vs 30-day average for confirmation

  • **Entry conditions** (all must be true):

    1. Current Bollinger Band width > 1.5× its 30-day rolling average → volatility has expanded

    2. Latest 4-hour volume > 2× the 30-day average 4-hour volume → real money is moving, not just bots widening quotes

    3. Closing price outside the Bollinger Band (above upper for long, below lower for short)

    4. No existing position in the same direction opened within last 24 hours → avoid stacking


    **Position sizing**:

  • Risk 0.5% of account per trade
  • Initial stop: 1× ATR from entry
  • Position size = (account_value × 0.005) / (entry_price × 0.01 × atr_pct)

  • **Exits**:

  • Trailing stop: 1.5× ATR from highest favourable price since entry
  • Time stop: exit at the bar where volatility returns to baseline (BB width back below 1.0× its rolling average)
  • Hard time stop: exit 48 hours after entry regardless of conditions

  • Python implementation


    import ccxt
    import pandas as pd
    import numpy as np
    import time
    from datetime import datetime, timedelta
    from dataclasses import dataclass
    from typing import Optional
    
    exchange = ccxt.lmex({
        'apiKey': os.getenv('LMEX_API_KEY'),
        'secret': os.getenv('LMEX_API_SECRET'),
    })
    
    @dataclass
    class Position:
        side: str
        entry_price: float
        qty: float
        entry_time: datetime
        atr_at_entry: float
        highest_favorable: float
        order_id: str
    
    class SpcxVolatilityBot:
        def __init__(self, symbol='SPCX-PERP', account_value=10000, risk_pct=0.005):
            self.symbol = symbol
            self.account_value = account_value
            self.risk_pct = risk_pct
            self.position: Optional[Position] = None
            self.last_entry_time = {}  # side -> datetime
        
        def fetch_data(self, limit=720):
            candles = exchange.fetch_ohlcv(self.symbol, '1h', limit=limit)
            df = pd.DataFrame(candles, columns=['ts', 'o', 'h', 'l', 'c', 'v'])
            df['ts'] = pd.to_datetime(df['ts'], unit='ms')
            return df
        
        def calculate_indicators(self, df):
            # Bollinger Bands
            df['ma'] = df['c'].rolling(20).mean()
            df['std'] = df['c'].rolling(20).std()
            df['upper'] = df['ma'] + 2 * df['std']
            df['lower'] = df['ma'] - 2 * df['std']
            df['bb_width'] = (df['upper'] - df['lower']) / df['ma']
            df['bb_width_avg'] = df['bb_width'].rolling(720).mean()
            df['bb_expansion'] = df['bb_width'] / df['bb_width_avg']
            
            # ATR
            high_low = df['h'] - df['l']
            high_close = abs(df['h'] - df['c'].shift())
            low_close = abs(df['l'] - df['c'].shift())
            tr = pd.concat([high_low, high_close, low_close], axis=1).max(axis=1)
            df['atr'] = tr.ewm(span=14).mean()
            df['atr_pct'] = df['atr'] / df['c']
            
            # Volume
            df['vol_4h'] = df['v'].rolling(4).sum()
            df['vol_4h_avg'] = df['vol_4h'].rolling(720).mean()
            df['vol_ratio'] = df['vol_4h'] / df['vol_4h_avg']
            
            return df
        
        def check_entry(self, df):
            latest = df.iloc[-1]
            prev = df.iloc[-2]
            
            # Required conditions
            vol_expanded = latest['bb_expansion'] > 1.5
            volume_confirmed = latest['vol_ratio'] > 2.0
            
            if not (vol_expanded and volume_confirmed):
                return None
            
            # Direction from breakout
            if latest['c'] > latest['upper']:
                direction = 'buy'
            elif latest['c'] < latest['lower']:
                direction = 'sell'
            else:
                return None
            
            # Skip if we entered the same direction recently
            last_entry = self.last_entry_time.get(direction)
            if last_entry and (datetime.utcnow() - last_entry) < timedelta(hours=24):
                return None
            
            return direction
        
        def calculate_size(self, entry_price, atr_pct):
            stop_distance_pct = atr_pct  # 1 ATR
            risk_dollars = self.account_value * self.risk_pct
            size_in_quote = risk_dollars / stop_distance_pct
            size_in_base = size_in_quote / entry_price
            return float(exchange.amount_to_precision(self.symbol, size_in_base))
        
        def enter_position(self, direction, df):
            latest = df.iloc[-1]
            entry_price = latest['c']
            atr = latest['atr']
            atr_pct = latest['atr_pct']
            
            qty = self.calculate_size(entry_price, atr_pct)
            if qty <= 0:
                return False
            
            try:
                order = exchange.create_order(self.symbol, 'market', direction, qty)
                self.position = Position(
                    side=direction,
                    entry_price=entry_price,
                    qty=qty,
                    entry_time=datetime.utcnow(),
                    atr_at_entry=atr,
                    highest_favorable=entry_price,
                    order_id=order['id']
                )
                self.last_entry_time[direction] = datetime.utcnow()
                print(f"{datetime.utcnow().isoformat()} | ENTER {direction} {qty} @ {entry_price}")
                return True
            except Exception as e:
                print(f"Entry failed: {e}")
                return False
        
        def check_exit(self, df):
            if not self.position:
                return None
            
            latest = df.iloc[-1]
            current_price = latest['c']
            pos = self.position
            
            # Update highest favorable
            if pos.side == 'buy':
                pos.highest_favorable = max(pos.highest_favorable, current_price)
                trail_stop = pos.highest_favorable - 1.5 * pos.atr_at_entry
                if current_price < trail_stop:
                    return 'trailing_stop'
            else:
                pos.highest_favorable = min(pos.highest_favorable, current_price)
                trail_stop = pos.highest_favorable + 1.5 * pos.atr_at_entry
                if current_price > trail_stop:
                    return 'trailing_stop'
            
            # Volatility contraction exit
            if latest['bb_expansion'] < 1.0:
                return 'vol_contraction'
            
            # Hard time stop
            if (datetime.utcnow() - pos.entry_time) > timedelta(hours=48):
                return 'time_stop'
            
            return None
        
        def exit_position(self, reason):
            if not self.position:
                return
            
            pos = self.position
            exit_side = 'sell' if pos.side == 'buy' else 'buy'
            
            try:
                exchange.create_order(
                    self.symbol, 'market', exit_side, pos.qty,
                    None, {'reduceOnly': True}
                )
                print(f"{datetime.utcnow().isoformat()} | EXIT {pos.side} {pos.qty} | reason: {reason}")
                self.position = None
            except Exception as e:
                print(f"Exit failed: {e}")
        
        def step(self):
            df = self.fetch_data()
            df = self.calculate_indicators(df)
            
            # Check exit first if in position
            if self.position:
                reason = self.check_exit(df)
                if reason:
                    self.exit_position(reason)
            
            # Check entry if flat
            if not self.position:
                direction = self.check_entry(df)
                if direction:
                    self.enter_position(direction, df)
        
        def run(self, interval_seconds=3600):
            while True:
                try:
                    self.step()
                except Exception as e:
                    print(f"Step error: {e}")
                time.sleep(interval_seconds)
    
    if __name__ == '__main__':
        bot = SpcxVolatilityBot(symbol='SPCX-PERP', account_value=10000)
        bot.run()

    Run this on a cron schedule (hourly) or as a long-running process on a VPS. Each cycle checks for entry/exit signals and acts accordingly.


    Backtesting considerations


    The standard caveats apply, plus some specific to SPCX-PERP:


    **Limited history.** SPCX-PERP just launched. There's only days of data, not the months needed for confident parameter selection. Use related markets as proxies for early backtests — TSLA pre-2020 had similar private-market characteristics before liquidity matured.


    **Reference index isn't tradable.** Even if you can backtest against the reference index price history, real fills will differ. The perp can drift meaningfully from the index, especially during volatile periods. Real performance will likely be 20-40% worse than ideal backtest.


    **Event clustering matters.** Starship launches happen in bursts. Public market reactions create cascading volatility. Strategies that assume independent volatility events overestimate sample size and underestimate drawdown.


    What goes wrong


    A few predictable failure modes:


    **False breakouts during low-liquidity hours.** Thin order books mean a single 10-contract order can push price outside the Bollinger Band, triggering an entry that immediately reverses when the market returns to fair value. Solution: add a liquidity filter — skip signals during the lowest-volume hours (typically Asia overnight).


    **Funding rate decay on winners.** Holding a long position through positive funding periods erodes returns. For positions held > 24 hours, factor expected funding into your target exit. The strategy's 48-hour time stop helps but doesn't eliminate the cost.


    **Stop hunts during major events.** Around expected events (Starship launches, government announcements), volatility expands but order books also widen. Tight stops get hit on noise before the real move resolves. Consider wider stops (1.5× ATR instead of 1×) for trades opened immediately before known events.


    **Strategy drift in normal regime.** If SPCX-PERP enters an extended quiet period, the strategy goes flat for weeks. That's fine — but if your portfolio expects steady returns, the irregular cadence is a problem. Pair this strategy with others that work in trending or ranging conditions.


    Variations worth considering


    A few extensions if the basic strategy works:


    **Event calendar integration.** Maintain a list of expected event dates (Starship launch schedule, expected Starlink reports). Pre-position before known events with smaller size, taking advantage of the predictable volatility expansion.


    **Cross-asset signals.** SPCX correlates with aerospace ETFs (XAR, ITA). When those move sharply, the SPCX move often follows by 1-4 hours. Trading the cross-asset lag is a separate strategy worth running in parallel.


    **Funding rate filter.** If funding is extremely positive (>0.05% per 8h), skip long signals — the market is already overcrowded long. If extremely negative, skip shorts. This prevents fighting the dominant positioning.


    Frequently Asked Questions


    Q: How much capital does this strategy need to be viable?

    \$5,000-10,000 minimum. Below that, position sizes hit minimum order limits and slippage dominates. \$25,000+ becomes comfortable. \$1M+ starts to face liquidity ceilings until SPCX-PERP volume scales further.


    Q: How frequently does this strategy trade?

    In the early months of SPCX-PERP, expect 2-8 trades per month. During event-heavy periods (Starship test campaigns, quarterly Starlink updates), expect more. During quiet stretches, zero trades for weeks. This is normal for event-driven strategies.


    Q: Can I run the same strategy on BTC-PERP or ETH-PERP?

    Yes, with parameter retuning. Crypto has much higher base volatility, so the 1.5× expansion threshold becomes less meaningful. Use 2.0× or 2.5× for crypto majors. Volume threshold needs adjusting too — crypto volume profiles differ from private-market perps.


    Q: What's the realistic Sharpe ratio expectation?

    0.8-1.4 in honest backtests; live trading typically delivers 40-60% of backtest Sharpe. Don't expect 3.0+ from any volatility breakout strategy regardless of how good the backtest looks — those numbers indicate overfit.


    Related Articles


    → SPCX-PERP Goes Live on LMEX: Trade SpaceX as a 24/7 Perpetual
    → Bollinger Band Trading Strategy: Mean Reversion on LMEX Perpetuals
    → Backtesting Your LMEX Trading Bot in Python: A Practical Guide
    ← All ArticlesBuild a Bot →