← Back to list

vol_control_atr_filter VALIDATED FAIL

Auto-discovered strategy

Symbol: BTC | Exchange: Bitfinex | Role: vol_control

5/6
Profitable Years
+7.6%
Total Return
45.9%
Avg Win Rate
0.44
Avg Sharpe

Monthly Results

Click a period to view chart

Period Return Win Rate Trades Max DD Sharpe
2020 +2.8% 44.9% 176 2.0% 1.23
2021 +3.2% 43.3% 178 2.1% 1.10
2022 +0.5% 42.9% 177 2.2% 0.25
2023 -1.8% 47.7% 149 2.0% -1.31
2024 +1.0% 49.1% 161 1.6% 0.50
2025 +1.9% 47.3% 148 1.4% 0.88

Performance Chart

Loading chart...

Walk-Forward Validation FAIL

0/1 Windows Profitable
-0.3% OOS Return
0.00 Median Sharpe
0.000 Score
Window Train Period Val Period Val Return Val Test Period Test Return Status
WF-1 2025-07→2025-11 2025-12→2025-12 -0.6% FAIL 2026-01→ongoing +0.2% FAIL

AI Review Score: 35/100

cost_sensitive execution inconsistent overfitting
## Critical Issues ### 1. Same-Bar Entry/Exit Logic (Execution Risk) The strategy detects range touches and enters **on the same bar** where `current_low <= range_low + tolerance`. At 1-minute resolution, this is unrealistic: ```python if current_low <= range_low + tolerance and current_close > range_low: # Opens position immediately using current_close ``` **Problem**: You're checking if the low touched the range boundary AND the close bounced higher, then entering at the close price of that same candle. In live execution: - By the time you detect the touch + bounce, the bar is closed - Next bar's open may gap away from your assumed entry price - The "bounce" might not continue next bar This creates **optimistic backtest fills** that won't occur in live trading. The strategy needs +1 bar delay for realistic execution. ### 2. Extreme Cost Sensitivity With 176-178 trades in early periods and declining to 148-161 later: - **Total cost at 0.1% round-trip**: ~17.6% to 17.8% on highest trade counts - **Strategy return**: 7.62% total - **After costs**: Deeply negative (-10% to -11%) The validation period shows -0.56% before realistic costs. This edge is too thin for 1-minute execution. ### 3. Inconsistent Performance Pattern Monthly returns show erratic behavior: - Early periods (2020-2022): +2.8%, +3.2%, +0.5% - Mid period (2023): **-1.8%** (negative) - Late periods: +1.0%, +1.9% The sharp negative drawdown in one period followed by recovery suggests the strategy may be **regime-dependent**. The overall Sharpe of 0.44 masks this instability. Validation Sharpe of -1.41 confirms this doesn't generalize. ### 4. Parameter Specificity (Overfitting Signals) Several parameters raise concerns: - `range_period: 60` - exactly 1 hour, reasonable - `atr_lookback: 120` - exactly 2 hours, reasonable - `min_bars_between: 2880` - exactly 48 hours (very specific) - `vol_entry_max: 0.0` - precise zero threshold - `vol_spike_disable: 2.0` - round number (good) - `vol_exit_high: 1.5` - round number (good) The **48-hour cooldown** (2880 bars) is suspiciously specific and severely limits trade frequency. This feels optimized to avoid certain losing trade clusters rather than derived from theory. ### 5. Range Detection Logic Weakness The strategy uses `highest(highs, 60)` and `lowest(lows, 60)` for range boundaries, but: - No validation that a **range actually exists** (could be trending) - No minimum range width check (tight ranges → whipsaws) - 2% tolerance for "touching" is arbitrary - Assumes low vol = range, but low vol can also occur in directional grinds ### 6. Stop Loss Ineffectiveness The 0.15% stop loss is extremely tight (1.5 bps on BTC price), but: - At 1-minute bars, normal bid-ask bounce can trigger stops - With slippage, a 0.15% stop becomes ~0.20% realized loss - The strategy exits after 10 bars anyway (max hold) - Stop is more likely to get hit by noise than protect capital ## What This Strategy Gets Right 1. **Vol spike disable** (ATR z > 2) is excellent - hard stops during volatility regime shifts 2. **Time-based exits** (10 bars) prevent runaway losses 3. **Vol exit** (ATR z > 1.5) is smart for exiting before regime change 4. **Round number parameters** for vol thresholds show good design ## Why The Edge Fails The core hypothesis is: "Low vol = range = mean reversion opportunity." **But**: - The entry logic assumes you can **reactively** catch range bounces at 1-minute scale - By the time you see "touched low and closed higher," the bounce is mostly over - The 10-bar holding period is correct for microstructure, but entry timing is too late - 48-hour cooldown suggests the strategy knows it needs to be very selective, but this creates **sparse, fragile** edge ## Microstructure Reality Check 1-minute range reversion strategies need: - **Anticipatory entries** (approach boundaries, not reaction to touches) - **Sub-tick execution** (maker fills at boundaries, not taker fills after bounce) - **Higher frequency** (dozens of small trades, not 150-180 large cooldown trades) This strategy has the structure backwards: it waits for confirmation (bounce) then enters, when it should enter **into** the boundary touch with tight risk. ## Cost Death Spiral At 176 trades: - Gross return: 7.62% - Cost (0.1% RT): 17.6% - **Net: -10%** Even with perfect execution (no delay), this strategy cannot survive realistic 1-minute costs. The average trade of ~0.04% gross is **smaller than slippage**. ## Validation Failure Analysis Validation Sharpe of -1.41 (vs training Sharpe 0.44) indicates: - Parameters are optimized to training period - The 48-hour cooldown likely avoids specific bad periods in training - Low vol regime didn't persist in validation period - Range detection doesn't adapt to changing market structure ## Verdict This strategy has **conceptually sound elements** (vol filtering, quick exits, regime awareness) but fails on: 1. **Execution realism** - same-bar entry on detected bounces 2. **Cost structure** - edge too small for trade frequency 3. **Generalization** - works in specific training regimes, fails validation 4. **Parameter brittleness** - 48-hour cooldown is a red flag The score of 35/100 reflects: reasonable concept, poor execution model, fatal cost sensitivity.
Reviewed: 2026-01-14T13:42:45.724432

Source Code

#!/usr/bin/env python3
"""
Vol Control Strategy: Low Volatility Range Trade
=================================================

Role: VOL_CONTROL - "Trade range boundaries in calm markets"

Concept:
When volatility is low (ATR z-score < 0), price tends to oscillate in a range.
This strategy:
1. Detects range bounds (highest high / lowest low over N bars)
2. Trades reversions from range extremes when vol is low
3. NEVER trades when ATR z-score > 2 (vol spike disable)

WHY THIS WORKS on 1-minute:
- Low vol = price mean-reverts within a range
- Range boundaries act as support/resistance
- Quick time-based exits prevent range breakout losses

Entry:
1. ATR z-score < 0 (calm market - below average vol)
2. Price touches range extreme (highest high or lowest low of 60 bars)
3. Long cooldown between trades (quality over quantity)

Exit:
- Time-based (10 bars / 10 minutes)
- OR vol spike (ATR z > 1.5)
- OR stop loss hit

Vol Control requirements: sharpe >= 0.2, DD < 12%, trades >= 10, loss < -3%
"""

import sys
sys.path.insert(0, '/root/trade_1m')
from lib import load_data, ema, sma, rsi, atr, highest, lowest
from strategy import backtest_strategy, run_strategy


def init_strategy():
    return {
        'name': 'vol_control_atr_filter',
        'role': 'vol_control',  # CRITICAL: vol_control role
        'warmup': 300,  # ~5 hours warmup
        'subscriptions': [
            {'symbol': 'tBTCUSD', 'exchange': 'bitfinex', 'timeframe': '1m'},
        ],
        'parameters': {
            # ATR parameters
            'atr_period': 20,
            'atr_lookback': 120,         # 2 hour lookback for z-score stats
            # Volatility gates
            'vol_entry_max': 0.0,        # Entry when vol at or below average
            'vol_spike_disable': 2.0,    # HARD STOP - no trades above this
            'vol_exit_high': 1.5,        # Exit when vol rises
            # Range parameters
            'range_period': 60,          # 60 bars (1 hour) for range detection
            # Trade management
            'max_hold_bars': 10,         # Quick exit - 10 minutes max
            'min_bars_between': 2880,    # ~48 hours cooldown
            'stop_loss_pct': 0.15,       # Very tight stop (range should hold)
        }
    }


def process_time_step(ctx):
    """
    Vol control strategy: Range reversion in calm markets.

    CRITICAL: ATR z-score > 2 = NO TRADES (volatility spike disable)

    Entry:
    1. ATR z-score < 0 (calm market)
    2. Price at range extreme (highest high or lowest low of N bars)
    3. Cooldown satisfied

    Exit:
    - Time-based (10 bars - quick exit)
    - OR vol spike (ATR z > 1.5)
    - OR stop loss hit
    """
    key = ('tBTCUSD', 'bitfinex')
    bars = ctx['bars'][key]
    i = ctx['i']
    positions = ctx['positions']
    params = ctx['parameters']
    state = ctx['state']

    atr_period = params['atr_period']
    atr_lookback = params['atr_lookback']
    vol_entry_max = params['vol_entry_max']
    vol_spike = params['vol_spike_disable']
    vol_exit = params['vol_exit_high']
    range_period = params['range_period']
    max_hold = params['max_hold_bars']
    min_bars_between = params['min_bars_between']

    # Need enough data
    required = max(atr_lookback + atr_period, range_period + 10)
    if i < required:
        return []

    # Lazy-compute indicators
    if 'atr_values' not in state or len(state['atr_values']) < i + 1:
        highs = [b.high for b in bars]
        lows = [b.low for b in bars]
        closes = [b.close for b in bars]
        state['atr_values'] = atr(highs, lows, closes, atr_period)
        state['highs'] = highs
        state['lows'] = lows
        state['closes'] = closes
        state['last_trade_bar'] = -min_bars_between

    atr_values = state['atr_values']
    highs = state['highs']
    lows = state['lows']
    closes = state['closes']

    # Ensure current values exist
    current_atr = atr_values[i] if i < len(atr_values) else None
    if current_atr is None:
        return []

    # Calculate ATR z-score
    recent_atrs = [atr_values[j] for j in range(max(0, i - atr_lookback), i)
                   if j < len(atr_values) and atr_values[j] is not None]
    if len(recent_atrs) < 30:
        return []

    atr_mean = sum(recent_atrs) / len(recent_atrs)
    atr_variance = sum((x - atr_mean) ** 2 for x in recent_atrs) / len(recent_atrs)
    atr_std = atr_variance ** 0.5 if atr_variance > 0 else 0.0001
    atr_zscore = (current_atr - atr_mean) / atr_std

    actions = []

    # === HARD VOL SPIKE DISABLE ===
    if atr_zscore > vol_spike:
        if key in positions:
            pos = positions[key]
            action_type = 'close_long' if pos.side == 'long' else 'close_short'
            state['last_trade_bar'] = i
            return [{
                'action': action_type,
                'symbol': 'tBTCUSD',
                'exchange': 'bitfinex',
            }]
        return []

    # Calculate range bounds
    range_high = highest(highs, range_period, i)
    range_low = lowest(lows, range_period, i)

    if range_high is None or range_low is None:
        return []

    current_high = highs[i]
    current_low = lows[i]
    current_close = closes[i]

    # === EXIT LOGIC ===
    if key in positions:
        pos = positions[key]
        bars_held = i - pos.entry_bar

        should_exit = False

        # Time-based exit
        if bars_held >= max_hold:
            should_exit = True

        # Vol spike exit
        if atr_zscore > vol_exit:
            should_exit = True

        if should_exit:
            state['last_trade_bar'] = i
            action_type = 'close_long' if pos.side == 'long' else 'close_short'
            actions.append({
                'action': action_type,
                'symbol': 'tBTCUSD',
                'exchange': 'bitfinex',
            })
        return actions

    # === ENTRY LOGIC ===
    # Check cooldown
    bars_since_last = i - state.get('last_trade_bar', -min_bars_between)
    if bars_since_last < min_bars_between:
        return []

    # Check volatility is below average (calm market)
    if atr_zscore > vol_entry_max:
        return []

    # Check for range extreme touches
    # Small tolerance for "touching" the range boundary
    tolerance = (range_high - range_low) * 0.02  # 2% of range

    # Long: price touched range low and bounced (close above low)
    if current_low <= range_low + tolerance and current_close > range_low:
        state['last_trade_bar'] = i
        actions.append({
            'action': 'open_long',
            'symbol': 'tBTCUSD',
            'exchange': 'bitfinex',
            'size': 1.0,
            'stop_loss_pct': params['stop_loss_pct'],
        })

    # Short: price touched range high and bounced (close below high)
    elif current_high >= range_high - tolerance and current_close < range_high:
        state['last_trade_bar'] = i
        actions.append({
            'action': 'open_short',
            'symbol': 'tBTCUSD',
            'exchange': 'bitfinex',
            'size': 1.0,
            'stop_loss_pct': params['stop_loss_pct'],
        })

    return actions


if __name__ == '__main__':
    from lib import calc_metrics, Trade

    print("\n" + "="*60)
    print("Testing: vol_control_atr_filter")
    print("Role: vol_control")
    print("="*60)

    # Run backtest on training data
    results, profitable, _ = backtest_strategy(init_strategy, process_time_step)

    # Get all trades for overall metrics
    test_periods = [
        ('2025-07-01', '2025-07-31'),
        ('2025-08-01', '2025-08-31'),
        ('2025-09-01', '2025-09-30'),
        ('2025-10-01', '2025-10-31'),
        ('2025-11-01', '2025-11-30'),
    ]

    all_trades = []
    monthly_returns = []
    for start, end in test_periods:
        trades, _, _ = run_strategy(init_strategy, process_time_step, start, end)
        std_trades = [
            Trade(
                entry_time=t.entry_time,
                entry_price=t.entry_price,
                exit_time=t.exit_time,
                exit_price=t.exit_price,
                side=t.side,
                pnl_pct=t.pnl_pct
            )
            for t in trades
        ]
        all_trades.extend(std_trades)
        m = calc_metrics(std_trades)
        monthly_returns.append(m.get('return', 0))

    overall = calc_metrics(all_trades)

    print(f"\n  {'='*50}")
    print(f"  OVERALL METRICS:")
    print(f"  Total Return: {overall.get('return', 0):+.2f}%")
    print(f"  Trades: {overall.get('trades', 0)}")
    print(f"  Sharpe: {overall.get('sharpe', 0):.2f}")
    print(f"  Max DD: {overall.get('max_dd', 0):.1f}%")
    print(f"  Win Rate: {overall.get('win_rate', 0):.0f}%")
    print(f"  Avg Trade: {overall.get('avg_trade', 0):.3f}%")

    # Monthly consistency check
    positive_months = sum(1 for r in monthly_returns if r > 0)
    total_return = overall.get('return', 0)
    if total_return > 0 and max(monthly_returns) > 0:
        max_month_contribution = max(monthly_returns) / total_return
    else:
        max_month_contribution = 1.0
    print(f"\n  MONTHLY CONSISTENCY:")
    print(f"  Positive months: {positive_months}/5")
    print(f"  Max month contribution: {max_month_contribution:.0%}")

    # Stress tests
    print(f"\n  STRESS TESTS:")
    trades_count = overall.get('trades', 0)
    SLIPPAGE = 0.05  # 5bp per side = 0.1% round trip
    total_cost = trades_count * 2 * SLIPPAGE
    after_slippage = overall.get('return', 0) - total_cost
    print(f"  After slippage ({SLIPPAGE*2:.2f}% round trip): {after_slippage:+.2f}%")

    delay_factor = 0.7  # Assume 30% edge loss from +1 bar delay
    after_delay = overall.get('return', 0) * delay_factor
    print(f"  After +1 bar delay (~30% edge loss): {after_delay:+.2f}%")

    worst_case = after_delay - total_cost
    print(f"  Worst case (both): {worst_case:+.2f}%")

    # Vol control validation requirements
    print(f"\n  VOL CONTROL VALIDATION:")
    sharpe_pass = overall.get('sharpe', 0) >= 0.2
    dd_pass = overall.get('max_dd', 0) < 12
    trades_pass = overall.get('trades', 0) >= 10
    loss_pass = overall.get('return', 0) > -3

    print(f"  - Sharpe >= 0.2: {'PASS' if sharpe_pass else 'FAIL'} ({overall.get('sharpe', 0):.2f})")
    print(f"  - DD < 12%: {'PASS' if dd_pass else 'FAIL'} ({overall.get('max_dd', 0):.1f}%)")
    print(f"  - Trades >= 10: {'PASS' if trades_pass else 'FAIL'} ({overall.get('trades', 0)})")
    print(f"  - Return > -3%: {'PASS' if loss_pass else 'FAIL'} ({overall.get('return', 0):+.2f}%)")

    all_pass = sharpe_pass and dd_pass and trades_pass and loss_pass
    print(f"\n  OVERALL: {'PASS' if all_pass else 'FAIL'}")