{
  "mode": {
    "execution_mode": "paper",
    "_execution_mode_options": ["simulation", "paper", "live"],
    "_note": "simulation = no orders sent anywhere, uses Alpaca paper account only for market data. paper = real orders sent to Alpaca paper trading account. live = real money (not recommended until fully validated)."
  },

  "universe": {
    "price_min": 5.0,
    "price_max": 15.0,
    "exclude_symbols": [],
    "include_only_symbols": [],
    "min_avg_daily_volume": 500000,
    "exclude_otc": true,
    "exclude_etfs_and_funds": true,
    "exclude_leveraged_inverse": true,
    "max_name_syllables": 5
  },

  "schedule": {
    "timezone": "America/New_York",
    "_note_premarket_scan_time": "[UPDATED 2026-09-01] Moved from 09:00:00 to 09:29:00 per explicit request: a single full-universe scan run right before the open instead of a 09:00 scan followed by a 09:00-09:25 development-monitoring window and a 09:25 final-scoring review (both eliminated -- see removed development_monitoring section and monitor.py). Real measured duration of a full scan on 2026-09-01 was ~41s for 549 prefiltered symbols (09:00:05-09:00:45) -- that leaves very little buffer before market_open_time if the universe is larger or the API is slower on a given day. Watch actual scan-completion timestamps in the log after this change; move earlier (e.g. 09:28:00 or 09:27:00) if the scan is ever still running at or after 09:30:00.",
    "premarket_scan_time": "09:29:00",
    "market_open_time": "09:30:00",
    "no_new_entries_after": "15:15:00",
    "force_liquidate_time": "15:55:00",
    "market_close_time": "16:00:00",
    "eod_liquidation_max_wait_seconds": 120,
    "poll_interval_seconds_intraday": 5,
    "_note_reconcile_interval_seconds": "[BUGFIX 2026-09-02] Added because reconcile_with_broker() was calling the Alpaca positions API on every single poll_interval_seconds_intraday (5s) cycle with no throttle of its own -- unlike _update_intraday_health() and _run_intraday_full_rescan(), which both already gate on an interval. Measured cost: 4,514 RECONCILE log lines on 2026-09-02 alone (42% of that day's entire log volume), almost all of them the same expected one-cycle settlement lag right after an entry rather than a real mismatch. 30s keeps the safety net (a real orphan is still caught within half a minute) while cutting the noise ~6x.",
    "reconcile_interval_seconds": 30
  },

  "candidates": {
    "premarket_candidate_count": 30,
    "final_candidate_count": 15
  },

  "premarket_scoring": {
    "weights": {
      "price_quality": 5,
      "volume_quality": 12,
      "rvol": 12,
      "volume_acceleration": 10,
      "price_momentum": 12,
      "price_structure": 12,
      "vwap_structure": 8,
      "vwap_slope": 8,
      "consolidation_quality": 8,
      "distance_from_pm_high": 6,
      "spread_liquidity": 5,
      "momentum_consistency": 8,
      "extension_risk_penalty": 10
    },
    "min_premarket_volume": 20000,
    "min_rvol": 1.5,
    "max_spread_pct": 1.5,
    "max_distance_from_pm_high_pct": 8.0,
    "extension_risk_pct_threshold": 25.0,
    "vwap_lookback_bars": 12,
    "momentum_lookback_snapshots": 5,
    "acceleration_min_ratio": 1.15
  },

  "intraday_health": {
    "_note": "[FEATURE 2026-08-17] Continuous health scoring for premarket_20, now the primary intraday candidate pool. Never triggers an exit on an open position -- only affects eligibility for NEW entries/replacements.",
    "enabled": true,
    "eval_interval_seconds": 60,
    "slope_lookback_bars": 8,
    "structure_lookback_bars": 4,
    "flat_slope_threshold_pct": 0.05,
    "severe_negative_slope_pct": 0.6,
    "stale_volume_decline_ratio": 0.7,
    "stale_range_contraction_pct": 35,
    "healthy_confirm_reads": 2,
    "unhealthy_confirm_reads": 3,
    "remove_after_consecutive_unhealthy": 3,
    "atr_period": 14,
    "volume_participation_rvol_target": 1.5,
    "volume_declining_score_multiplier": 0.4,
    "range_contracting_score_multiplier": 0.2,
    "max_distance_from_recent_high_pct": 8.0,
    "no_new_highs_threshold_pct": 0.05,
    "price_slope_flat_credit": 0.35,
    "price_slope_negative_credit": 0.0,
    "vwap_support_partial_credit": 0.6,
    "vwap_support_weak_credit": 0.1,
    "structure_mixed_credit": 0.5,
    "weights": {
      "price_slope": 20,
      "vwap_support": 20,
      "volume_participation": 20,
      "structure": 20,
      "range_expansion": 10,
      "distance_from_recent_high": 10
    },
    "full_rescan_interval_minutes": 30,
    "full_rescan_pool_size": 20,
    "full_rescan_lookback_hours": 1,
    "entry_shortlist_size": 5,
    "max_consecutive_confirmation_failures": 5,
    "confirmation_failure_cooldown_seconds": 180,
    "_note_confirmation_failure_cooldown": "[FEATURE 2026-09-01] Fixes a real starvation bug found in 2026-09-01's session review: monitor.py's _scan_for_entries() ranks premarket_20 by health_score and only evaluate_entry()-checks the top entry_shortlist_size (5) each cycle. A symbol that ranks highly on health_score but keeps failing entry_engine's confirmation (e.g. a hard momentum/health disqualifier that doesn't change bar to bar) can occupy one of those 5 slots on every single 5-second poll indefinitely, crowding out lower-ranked but potentially viable candidates -- confirmed live: F occupied a shortlist slot continuously from 09:32 to 09:56 (~24 min) failing every attempt on the same 'momentum fading' hard disqualifier, while CRML (health-eligible, briefly in the pool at a competitive score) never got a single evaluate_entry() call before rotating back out at the next 30-min rescan. After max_consecutive_confirmation_failures (5) straight not-confirmed results for the same symbol, it's benched from the shortlist for confirmation_failure_cooldown_seconds (180s = 3min) so the next-best candidates get a turn; the bench clears immediately the moment that symbol IS confirmed, and also just decays on its own after the cooldown. This does not loosen or tighten any entry criterion itself -- it only changes WHICH candidates get a chance to be evaluated against those same criteria."
  },

  "regime_detection": {
    "_note": "[SIMULATION-ONLY 2026-09-04] Session-shape classifier (regime_detector.py) built for offline strategy simulation against data/snapshots/<date>/. NOT YET consulted anywhere in the live bot (monitor.py, entry_engine.py, intraday_health.py are all untouched) -- enabled stays false until per-regime entry strategies are designed and backtested. When that work starts, this is the config surface entry_engine.py would read from to pick a regime-specific rule set instead of the current one-size-fits-all confirmation logic.",
    "enabled": false,
    "session_start": "09:30",
    "session_end": "16:00",
    "gap_momentum_min_pct": 3.0,
    "catalyst_gap_min_pct": 8.0,
    "catalyst_range_min_pct": 15.0,
    "chop_max_range_pct": 6.0,
    "chop_max_net_change_pct": 2.0,
    "breakout_lookback_bars": 5,
    "breakout_buffer_pct": 0.3,
    "breakout_hold_bars": 2,
    "failed_breakout_retrace_pct": 50.0,
    "reclaim_min_dip_pct": 3.0,
    "reclaim_hold_bars": 5,
    "atr_period": 14,
    "min_bars_required": 10,
    "whipsaw_spike_lookback_bars": 10,
    "whipsaw_min_spike_pct": 4.0,
    "whipsaw_reversal_window_bars": 10,
    "whipsaw_min_retrace_pct": 60.0,
    "_note_whipsaw": "[SIMULATION-ONLY 2026-09-04, added retroactively] REGIME_7_INTRADAY_SPIKE_NO_GAP_WHIPSAW -- added after confirming CHPT's real 2026-09-04 entry (-$54.87, the day's worst trade) fell through every one of the original six regimes: it isn't a gap regime (gap_pct=2.2%, below gap_momentum_min_pct), and REGIME_5_FAILED_BREAKOUT's opening-range-high check structurally cannot see it because CHPT's spike (open $9.28 -> $10.20 by minute 2) happens INSIDE breakout_lookback_bars=5's own reference window, so the spike gets baked into the resistance level instead of breaking it. This regime instead checks the raw first whipsaw_spike_lookback_bars minutes directly against the OPEN price: a spike of at least whipsaw_min_spike_pct%, followed by giving back at least whipsaw_min_retrace_pct% of that move within the next whipsaw_reversal_window_bars. Verified against CHPT's real bars: spike_pct ~9.9%, retrace_pct ~95% -- correctly detected."
  },

  "regime_strategies": {
    "_note": "[SIMULATION-ONLY 2026-09-04] Independent entry-rule overrides per regime_detector.py label, applied via entry_engine.evaluate_entry()'s cfg_override param (regime_strategies.py's evaluate_entry_for_regime() is the only caller) -- same shallow-merge mechanism as entry.time_based_overrides, just keyed by regime instead of time-of-day, and applied AFTER any time-window override so a regime's rules win on overlapping keys. Only the two regimes with real 2026-09-04 trade evidence are populated so far. NOT consulted by monitor.py/entry_engine.py's live call site -- enabled stays false until these are backtested with simulate_regime_strategies.py and shown to help.",
    "enabled": false,
    "REGIME_4_RANGE_CHOP": {
      "_note": "8 of 2026-09-04's 13 real trades landed in this regime (ONDS, NOK, BTG, SMR, EQX, INFQ, EXK, AGNC) for a combined -$105.31 -- the single worst-performing bucket, and 7 of those 8 losers showed near-zero MFE (mfe <= $0.90 in ONDS/NOK/SMR/RDW-adjacent cases), meaning price reversed almost immediately after entry with no favorable move captured at all. That's the signature of a breakout-confirmation firing on noise inside a range that was never going anywhere -- the base entry rules (tuned for genuine breakouts) are too permissive here. Every override below tightens toward 'only take this if it's unusually convincing for a chop name', not toward a different strategy shape.",
      "overrides": {
        "min_confirmation_score": 85,
        "opening_volume_expansion_ratio": 2.2,
        "min_extension_from_vwap_pct": 0.6,
        "max_extension_from_vwap_pct": 2.0,
        "breakout_hold_bars": 4,
        "pullback_max_retrace_pct": 20,
        "require_rsi_not_overbought": true,
        "max_rsi_at_entry": 60
      }
    },
    "REGIME_5_FAILED_BREAKOUT": {
      "_note": "RDW's 2026-09-04 09:57:33 entry (-$25.25) broke its opening-range high, held only the base-config minimum of 2 bars, then gave the entire move back by the time the bot's classification window closed (retrace_pct=194.9%, i.e. it round-tripped past its own starting point) -- see data/snapshots/2026-09-04/regimes.json. That's exactly the 'spiked through and immediately failed' case breakout_confirmed()'s docstring warns about, just with a hold count too short to filter it out. Overrides below demand a much more decisive, longer-held breakout before trusting one in a symbol whose session already carries this shape, and remove the easier pullback-continuation side door entirely rather than just tightening it.",
      "overrides": {
        "min_confirmation_score": 85,
        "breakout_buffer_pct": 0.5,
        "breakout_hold_bars": 5,
        "allow_pullback_continuation_entry": false,
        "max_extension_from_vwap_pct": 2.5,
        "min_extension_from_vwap_pct": 0.5
      }
    },
    "REGIME_7_INTRADAY_SPIKE_NO_GAP_WHIPSAW": {
      "_note": "CHPT's 2026-09-04 09:32:01 entry (-$54.87, the day's single worst trade) opened flat (gap_pct=2.2%), spiked from $9.28 to $10.20 within the first two minutes, and the bot bought at $9.83 already riding that spike's reversal down -- every existing check passed cleanly (VWAP above/rising, breakout of $9.50 held, momentum positive, RSI a neutral 50 from too little history) because none of them measure distance from a RECENT high, only from VWAP or a resistance level established before the spike happened. The core override here is the new require_no_recent_spike_extension check (entry_engine.py, off by default everywhere else): block any entry already more than 2% off the highest high in the last 10 bars, i.e. don't chase a move that's already fading. Other overrides tighten the same way as the other two regimes for consistency.",
      "overrides": {
        "min_confirmation_score": 85,
        "require_no_recent_spike_extension": true,
        "recent_spike_lookback_bars": 10,
        "max_pullback_from_recent_high_pct": 2.0,
        "max_extension_from_vwap_pct": 2.5
      }
    }
  },

  "streaming": {
    "feed": "sip",
    "reconnect_backoff_seconds": [2, 5, 10, 20, 30],
    "max_reconnect_attempts": 20,
    "stale_data_seconds": 15,
    "bar_aggregation_seconds": 60,
    "sub_minute_bucket_seconds": 30,
    "sub_minute_buffer_minutes": 15,
    "_note_sub_minute": "[SIMULATION-ONLY 2026-09-05] Feeds a SECOND, parallel rolling buffer (SymbolBuffer.bars_sub in stream.py) alongside the existing 1-minute self.bars -- built for the prediction-engine work's stream_features.py (velocity/slope at finer-than-1-minute resolution) so it doesn't have to wait for a minute bar to close to see a move accelerating or stalling. Purely additive: fed by the same on_trade() ticks already arriving over the existing SIP subscription (no new API calls, no new network cost -- see session discussion), and get_bars_sub() is a new accessor nothing currently calls. The existing 1-minute self.bars / get_bars() path (used by entry_engine, intraday_health, position_manager, risk_manager) is completely untouched by this. sub_minute_bucket_seconds is the bucket width (started at 30s as a smoothed middle ground between full tick-level noise and the 1-minute bars' up-to-60s detection lag; configurable here rather than hard-coded per explicit request). sub_minute_buffer_minutes bounds retention (15 min of 30s buckets = 30 buckets kept). Each sub-bucket also now carries tick_count/upticks/downticks (O(1) counters, no raw ticks stored) feeding stream_features.py's trade_flow.*."
  },

  "stream_features": {
    "_note": "[SIMULATION-ONLY 2026-09-05] Config for stream_features.py -- the prediction-engine work's pure feature-computation layer over stream.py's rolling buffers. [UPDATED 2026-09-05] Now conditionally consumed: monitor.py._scan_for_entries() calls into this module (via prediction_pipeline.evaluate_entry_pipeline()) only when entry.decision_engine == 'prediction_pipeline' (default 'legacy' -- unused). The `enabled` flag below is informational only and not read by any code; entry.decision_engine is the real live switch. min_bars_1m/min_bars_sub gate 'insufficient data -> do not trade' per the project's failure-safety requirement -- below these thresholds compute_features() returns insufficient_data=True rather than manufacturing values from too little history.",
    "enabled": false,
    "min_bars_1m": 3,
    "min_bars_sub": 2,
    "slope_sub_lookback_buckets": 4,
    "vwap_time_window_bars": 20,
    "ema9_period": 9,
    "ema20_period": 20,
    "ema_slope_lookback_bars": 5,
    "rsi_period": 14,
    "atr_period": 14,
    "range_expansion_lookback_bars": 6,
    "pressure_velocity_scale_pct_per_sec": 0.05,
    "pressure_weights": {"uptick": 0.40, "velocity": 0.35, "volume": 0.25},
    "_note_pressure": "[FEATURE 2026-09-05] pressure.score/-slope added alongside the other stream-derived features -- see the project's original design brief's section 13, which describes this as its own 'engine' conceptually but the recommended-module list has no separate pressure_engine.py file, so it lives here instead of as a duplicate module."
  },

  "trend_engine": {
    "_note": "[SIMULATION-ONLY 2026-09-05] Config for trend_engine.py -- combines stream_features.py's multi-horizon slopes into one STRONG_UPTREND..STRONG_DOWNTREND classification, a 0-100 direction-agnostic strength score, and a strengthening/weakening trend_slope (reused from stream_features' momentum_acceleration). [UPDATED 2026-09-05] Now conditionally consumed -- see stream_features._note; live switch is entry.decision_engine, not the `enabled` flag below. Weights determine how much each horizon contributes to the agreement score -- tune here, never hard-code in trend_engine.py.",
    "enabled": false,
    "flat_slope_threshold_pct": 0.05,
    "strong_trend_score_threshold": 70,
    "weights": {
      "slope_sub": 15,
      "slope_1m": 20,
      "slope_3m": 20,
      "slope_5m": 20,
      "vwap_slope": 15,
      "ema9_slope": 10
    }
  },

  "structure_engine": {
    "_note": "[SIMULATION-ONLY 2026-09-05] Config for structure_engine.py -- causal swing-point (HH/HL/LH/LL) detection and the failure patterns built on it (structure_break, failed_higher_low, failed_breakout, support_failure, resistance_rejection). Different from indicators.is_higher_highs_higher_lows()/is_lower_highs_lower_lows(), which check a fixed bar window for a majority pattern rather than identifying discrete pivots -- both stay, they answer different questions. [UPDATED 2026-09-05] Now conditionally consumed -- see stream_features._note; live switch is entry.decision_engine, not the `enabled` flag below. pivot_lookback_bars/pivot_confirm_bars control how many bars on each side of a candidate pivot must agree before it's confirmed -- larger values are less noisy but slower to confirm (inherent, honest lag; no look-ahead is ever used).",
    "enabled": false,
    "pivot_lookback_bars": 2,
    "pivot_confirm_bars": 2,
    "min_swings_for_structure": 2,
    "resistance_rejection_tolerance_pct": 0.3,
    "structure_score_lookback_swings": 4
  },

  "regime_engine": {
    "_note": "[SIMULATION-ONLY 2026-09-05] Config for regime_engine.py -- the LIVE, continuously-re-evaluated regime classifier, NOT the same thing as regime_detection above (that one is a retrospective whole-session classifier for offline strategy simulation -- see regime_engine.py's module docstring for the full distinction). [UPDATED 2026-09-05] Now conditionally consumed -- see stream_features._note; live switch is entry.decision_engine, not the `enabled` flag below. allowed_setups maps each live regime to which setup_engine.py setup types are permitted -- config-driven per the project's explicit 'the regime determines which entry patterns are allowed' requirement. [UPDATED 2026-09-08, Phase 4] Extended taxonomy: STRUCTURE_BREAKDOWN, ACCUMULATION, PRESSURE_BUILDING, BREAKOUT_ATTEMPT/CONFIRMED, EXPANSION, CONTINUATION added (replacing the old bare BREAKOUT label, which is now unreachable -- verified nothing else in this codebase checks for it by exact string). STRUCTURE_BREAKDOWN and the BREAKOUT progression are real, live-relevant classification changes (see regime_engine.py's classify_regime() docstring); ACCUMULATION/PRESSURE_BUILDING only ever fire when the caller passes a compression_engine reading in, which prediction_pipeline.py does not do yet as of this note -- so live behavior is unchanged until that wiring lands.",
    "enabled": false,
    "opening_volatility_window_minutes": 30,
    "opening_volatility_min_atr_pct": 1.0,
    "pullback_trend_slope_threshold": -0.5,
    "distribution_min_volume_acceleration": 1.3,
    "distribution_max_trend_slope": -0.2,
    "vwap_reclaim_min_time_below_pct": 30.0,
    "chop_min_atr_pct": 0.8,
    "range_max_atr_pct": 0.8,
    "structure_breakdown_hard_block": true,
    "accumulation_min_compression_score": 55,
    "pressure_building_min_breakout_pressure_score": 70,
    "breakout_confirm_bars": 2,
    "expansion_min_bars": 3,
    "continuation_min_bars": 5,
    "breakout_volume_confirm_ratio": 1.0,
    "allowed_setups": {
      "STRONG_UPTREND": ["PULLBACK_CONTINUATION", "BREAKOUT_RETEST", "VWAP_RECLAIM"],
      "WEAK_UPTREND": ["PULLBACK_CONTINUATION", "VWAP_RECLAIM"],
      "BREAKOUT": ["BREAKOUT_RETEST"],
      "PULLBACK": ["PULLBACK_CONTINUATION"],
      "VWAP_RECLAIM": ["VWAP_RECLAIM"],
      "OPENING_VOLATILITY": ["BREAKOUT_RETEST"],
      "RANGE": ["COMPRESSION_EXPANSION"],
      "CHOP": [],
      "DISTRIBUTION": [],
      "DOWNTREND": [],
      "FAILED_BREAKOUT": [],
      "RECOVERY": ["HIGHER_LOW_REVERSAL"],
      "STRUCTURE_BREAKDOWN": [],
      "ACCUMULATION": ["COMPRESSION_EXPANSION"],
      "PRESSURE_BUILDING": ["COMPRESSION_EXPANSION", "BREAKOUT_RETEST"],
      "BREAKOUT_ATTEMPT": ["BREAKOUT_RETEST"],
      "BREAKOUT_CONFIRMED": ["BREAKOUT_RETEST", "PULLBACK_CONTINUATION"],
      "EXPANSION": ["BREAKOUT_RETEST", "PULLBACK_CONTINUATION"],
      "CONTINUATION": ["PULLBACK_CONTINUATION", "BREAKOUT_RETEST"]
    }
  },

  "setup_engine": {
    "_note": "[SIMULATION-ONLY 2026-09-05] Config for setup_engine.py -- the five concrete entry-pattern detectors (PULLBACK_CONTINUATION, VWAP_RECLAIM, BREAKOUT_RETEST, HIGHER_LOW_REVERSAL, COMPRESSION_EXPANSION) regime_engine.py's allowed_setups already references by name. Each is a stateless checklist against the current trend/structure/stream_features snapshot -- see setup_engine.py's module docstring for why this is a deliberate simplification vs. a true multi-call temporal tracker, and why that's fine for now. [UPDATED 2026-09-05] Now conditionally consumed -- see stream_features._note; live switch is entry.decision_engine, not the `enabled` flag below.",
    "enabled": false,
    "vwap_reclaim_min_time_below_pct": 30.0,
    "retest_buffer_pct": 0.5,
    "min_volume_acceleration": 1.0,
    "compression_expansion_min_range_expansion_pct": 40.0
  },

  "compression_engine": {
    "_note": "[SIMULATION-ONLY 2026-09-08, Phase 3] Config for compression_engine.py -- range/ATR/close-to-close contraction detection ('coiling') plus a directional BREAKOUT_PRESSURE_SCORE (compression scaled up only in a bullish context: VWAP rising, higher lows holding, price near resistance). Not consumed anywhere in the live bot yet -- compression is an optional param to regime_engine.classify_regime() and stays inert until prediction_pipeline.py is wired to compute and pass it.",
    "enabled": false,
    "lookback_bars": 8,
    "weights": {
      "range_contraction": 30,
      "atr_contraction": 25,
      "close_to_close_contraction": 15,
      "holding_near_highs": 15,
      "volume_not_collapsing": 15
    },
    "near_high_max_distance_pct": 3.0,
    "volume_not_collapsing_min_ratio": 0.7,
    "pressure_bonus_vwap_slope": 0.15,
    "pressure_bonus_higher_lows": 0.20,
    "pressure_bonus_near_resistance": 0.15,
    "pressure_max_multiplier": 1.5,
    "near_resistance_max_distance_pct": 2.0
  },

  "relative_strength_engine": {
    "_note": "[SIMULATION-ONLY 2026-09-08, Phase 3] Config for relative_strength_engine.py -- RS vs SPY/QQQ across 1m/3m/5m/15m/premarket windows, plus an optional sector/peer confirmation bonus. sector_map is a STATIC, HAND-MAINTAINED mapping -- there is no dynamic sector-classification data source anywhere in this codebase (see prediction_engine.py's own relative_strength docstring for the same no-manufactured-data principle applied to the individual-symbol case). A symbol with no entry here simply skips the sector layer; rs_score vs SPY/QQQ is computed regardless. Not consumed anywhere in the live bot yet -- requires monitor.py to fetch/stream SPY/QQQ (and any mapped peers) and thread their bars through, which hasn't been wired in yet.",
    "enabled": false,
    "windows": {"1m": 1, "3m": 3, "5m": 5, "15m": 15},
    "window_weights": {"1m": 0.15, "3m": 0.25, "5m": 0.30, "15m": 0.20, "premarket": 0.10},
    "rs_scale_pct": 2.0,
    "sector_breadth_min_for_bonus": 0.6,
    "sector_confirmation_bonus": 0.15,
    "sector_peer_window": "5m",
    "sector_map": {
      "SMR": {"sector": "nuclear", "peers": ["OKLO", "LEU", "CCJ"], "sector_etf": "URA"},
      "MARA": {"sector": "crypto_miners", "peers": ["RIOT", "CLSK", "CORZ"], "sector_etf": null}
    }
  },

  "prediction_engine": {
    "_note": "[SIMULATION-ONLY 2026-09-05] Config for prediction_engine.py -- combines stream_features/trend_engine/structure_engine (+ optionally regime_engine/setup_engine) into a 0-100 PREDICTION_SCORE, a prediction_slope, and deterministic next-move probabilities for 1m/3m/5m. DETERMINISTIC HEURISTIC, not ML or statistically calibrated -- see prediction_engine.py's module docstring. relative_strength is NOT implemented (needs cross-symbol data this module doesn't have) -- its weight below is present for documentation/future use but is excluded from the live weighted sum until a real cross-symbol data source exists. [UPDATED 2026-09-05] Now conditionally consumed -- see stream_features._note; live switch is entry.decision_engine, not the `enabled` flag below.",
    "enabled": false,
    "weights": {
      "trend": 15, "structure": 15, "momentum": 12, "acceleration": 10,
      "vwap": 10, "volume": 12, "pressure": 10, "relative_strength": 6,
      "breakout_quality": 5, "risk_quality": 5
    },
    "momentum_scale": 2.0,
    "acceleration_scale": 3.0,
    "max_vwap_atr": 3.0,
    "max_ema9_atr": 3.0,
    "extension_penalty_weight": 20,
    "exhaustion_min_bars_since_pullback": 6,
    "exhaustion_penalty_weight": 15,
    "max_spread_pct": 1.0,
    "spread_penalty_weight": 10,
    "chop_penalty_weight": 15,
    "min_relative_volume": 0.5,
    "poor_liquidity_penalty_weight": 10,
    "horizon_confidence_decay": {"1m": 1.0, "3m": 0.8, "5m": 0.6},
    "expected_move_confidence_factor": 0.5
  },

  "entry_score": {
    "_note": "[SIMULATION-ONLY 2026-09-05] Config for entry_score.py -- combines trend/structure/prediction/entry/risk into FIVE SEPARATE scores plus a WAIT/CONFIRMING/READY action, deliberately not one blended number (section 24's explicit requirement). confirmation_seconds/confirmation_min_readings implement the 'never enter from one tick' confirmation window (section 25) -- a caller-held confirmation_state dict must be passed in and its returned value fed back on the next call for this to work across cycles; a fresh {} on every call means it can never reach READY. [UPDATED 2026-09-05] Now conditionally consumed -- monitor.py owns confirmation_state per symbol (Monitor._pipeline_state) exactly as this note requires. Live switch is entry.decision_engine, not the `enabled` flag below.",
    "enabled": false,
    "min_trend_score": 60,
    "min_structure_score": 50,
    "min_prediction_score": 60,
    "min_entry_score": 65,
    "min_risk_score": 60,
    "max_spread_pct": 1.0,
    "confirmation_seconds": 8,
    "confirmation_min_readings": 2,
    "min_reward_risk_ratio": 1.5,
    "entry_weights": {"setup_confidence": 0.5, "regime_allows": 0.3, "prediction_momentum": 0.2},
    "risk_weights": {"stop_quality": 0.4, "liquidity": 0.3, "spread": 0.3},
    "min_relative_volume": 0.5
  },

  "opportunity_ranker": {
    "_note": "[SIMULATION-ONLY 2026-09-05] Config for opportunity_ranker.py -- ranks ALL currently-eligible symbols by risk-adjusted opportunity quality (section 26), reusing prediction_engine.py's already-penalized score rather than re-applying the same extension/exhaustion/spread/chop/liquidity penalties a second time. resistance_min_room_pct is the one genuinely new comparative penalty (overhead-resistance proximity), distinct from prediction_engine's VWAP/EMA9-based extension penalty. Not consumed by monitor.py/entry_engine.py yet.",
    "enabled": false,
    "weights": {
      "prediction": 30, "entry_quality": 25, "trend": 15,
      "pressure": 10, "structure": 10, "risk_quality": 10
    },
    "resistance_penalty_weight": 10,
    "resistance_min_room_pct": 1.0
  },

  "outcome_engine": {
    "_note": "[SIMULATION-ONLY 2026-09-05] Config for outcome_engine.py -- the design brief's section 33 learning-dataset/outcome-recording piece. Records every prediction_engine.py reading, then resolves it against real forward bars (1m/3m/5m) and reports how well the deterministic next-move probabilities and expected-move estimates matched reality. NOT ML -- this module records and measures calibration, it does not train or adjust prediction_engine.py's weights itself (that remains a future step, once this dataset exists). Not consumed by monitor.py/entry_engine.py yet.",
    "enabled": false,
    "sideways_band_pct": 0.15,
    "pullback_min_retrace_pct": 0.10,
    "reversal_min_down_pct": 0.15,
    "calibration_bins": 5
  },

  "entry": {
    "decision_engine": "prediction_pipeline",
    "_decision_engine_options": ["legacy", "prediction_pipeline"],
    "_note_decision_engine": "[FEATURE 2026-09-05] Selects which system monitor.py._scan_for_entries() calls to decide should_enter: 'legacy' (default) is entry_engine.evaluate_entry(), unchanged. 'prediction_pipeline' is this session's new stream_features/trend/structure/regime/setup/prediction/entry_score chain (prediction_pipeline.evaluate_entry_pipeline()) -- see that module's docstring. Only the entry TRIGGER changes; risk_manager.py/position_manager.py (sizing, stops, exits) are completely untouched either way, per this session's own 'do not replace the existing risk engine' design constraint. Four days of offline replay (2026-09-01 through 09-04) found the prediction_pipeline path fires extremely rarely (3 entries / 4 days / 405 symbol-days, prediction_score is the bottleneck ~96% of the time) -- expect few or zero entries from a single live day. Set to 'prediction_pipeline' only for a deliberate paper-mode comparison test, and only while mode.execution_mode is 'paper' or 'simulation', never 'live', until it has real paper-trading days behind it.",
    "require_price_above_vwap": true,
    "require_vwap_rising": true,
    "vwap_rising_lookback_bars": 3,
    "require_opening_volume_expansion": true,
    "opening_volume_expansion_ratio": 1.4,
    "require_breakout_of_pm_high_or_resistance": true,
    "breakout_buffer_pct": 0.1,
    "breakout_hold_bars": 2,
    "require_higher_lows": true,
    "higher_lows_lookback_bars": 4,
    "max_spread_pct_entry": 1.2,
    "max_extension_from_vwap_pct": 4.0,
    "min_extension_from_vwap_pct": 0.3,
    "allow_pullback_continuation_entry": true,
    "pullback_max_retrace_pct": 40,
    "min_confirmation_score": 65,
    "require_min_health_score": true,
    "min_health_score": 65,
    "min_health_score_by_state": {
      "_note": "[UPDATED 2026-09-01] Set to HEALTHY=65, WATCH=70 per explicit request. The flat min_health_score above is the fallback for any raw_state not listed in this dict (currently only HEALTHY and WATCH exist as entry-eligible states, so that fallback shouldn't normally be hit).",
      "HEALTHY": 65,
      "WATCH": 70
    },
    "require_momentum_not_fading": true,
    "require_momentum_not_fading_strict": true,
    "_note_momentum": "[FEATURE 2026-08-25, revised] require_momentum_not_fading (the soft vote) is superseded by require_momentum_not_fading_strict below -- confirmed on real 2026-08-24 data that the soft vote alone never has enough weight to block anything (losing 1 vote among 7 total checks only ever dropped the worst case to 71.4%, still above min_confirmation_score's 65% floor -- real case: MARA's 10:33:54 entry, WATCH/score=92 with momentum_slope=negative, still passed at 5/6=83.3% under the vote). require_momentum_not_fading_strict makes negative momentum an unconditional disqualifier instead, same tier as the VWAP-extension and fresh-health checks -- set to false to fall back to the soft-vote behavior, or set both false to disable momentum-based gating entirely.",
    "require_volume_not_declining": true,
    "require_rsi_not_overbought": true,
    "rsi_period": 14,
    "max_rsi_at_entry": 70,
    "_note_rsi": "[FEATURE 2026-08-26] Disabled by default -- flip require_rsi_not_overbought to true to activate. When active, any entry with RSI(rsi_period) above max_rsi_at_entry is disqualified outright, same tier as the VWAP-extension ceiling. rsi_period and max_rsi_at_entry are both freely tunable here; no code change or redeploy needed to adjust the threshold or turn this off again. Not yet validated against real trade data (added in response to a request to check whether CLSK's three 2026-08-26 entries, which all lost, were RSI-overextended -- that specific question couldn't be answered retroactively since RSI wasn't being computed or logged that day; this makes it checkable going forward).",
    "require_no_recent_spike_extension": false,
  "recent_spike_lookback_bars": 10,
  "max_pullback_from_recent_high_pct": 2.0,
  "_note_spike_extension": "[FEATURE 2026-09-05, SIMULATION-ONLY -- disabled by default] Disqualifies an entry whose current price is already more than max_pullback_from_recent_high_pct off the highest high seen in the last recent_spike_lookback_bars bars -- i.e. don't chase a move that's already fading. Added for regime_strategies.py's REGIME_7_INTRADAY_SPIKE_NO_GAP_WHIPSAW override after CHPT's real 2026-09-04 entry (-$54.87) passed every other check while already riding a reversal off a spike one minute earlier. Off at this base level -- flip require_no_recent_spike_extension per-regime via regime_strategies, not here, unless a future review shows it should apply broadly.",
  "require_no_extended_downtrend": true,
    "downtrend_min_bars": 20,
    "downtrend_min_decline_pct": 1.5,
    "downtrend_recovery_threshold_pct": 1.5,
    "_note_downtrend": "[FEATURE 2026-09-03] Hard disqualifier, same tier as the VWAP-extension/fresh-health checks. Added because every existing trend/structure check (vwap_rising_lookback_bars=3, higher_lows_lookback_bars=4, intraday_health's slope_lookback_bars=8) only ever looks at the last 4-8 minutes -- blind to a decline that took hours. Real case: ACHR on 2026-09-03 went $5.96 (09:35 ET) -> $5.70 (13:02 ET), a genuine lower-highs/lower-lows decline over 3.5 hours, then bounced to $5.79 by 14:01 ET when the bot entered -- every short-window check read cleanly positive because the last few minutes WERE ticking up, with nothing evaluating whether that bounce had undone the prior decline (it hadn't: still 2.85% off the session high). This instead fits a regression line across ALL bars since open (indicators.regression_fit_pct_change(), not a fixed short window) and requires BOTH that fit to be down at least downtrend_min_decline_pct AND current price to remain at least downtrend_recovery_threshold_pct below the session high -- so a name that's already reclaimed its highs is never flagged just because the whole-day regression is still net negative. downtrend_min_bars gates this off entirely until that many bars of session history exist, so it can't fire on a fresh candidate a few minutes off the open (every one of 2026-09-03's actual winners -- CHPT, RKT, HAFN -- entered within 7 minutes of the open and would never reach this check at all). Verified against 2026-09-03's real bars before shipping: correctly flags ACHR's 14:01 entry (fit=-2.48%, dist_from_high=2.85%) and does NOT flag CRML's 10:59 entry, a genuine fresh breakout at its highs (fit=+3.05%, dist_from_high=0.09%). Not yet validated across many more days of live data -- tune the two thresholds directly here if it proves too strict or too loose.",
    "entry_buffer_pct": 0.05,
    "require_full_confirmation_on_reentry": true,
    "reentry_min_confirmation_score": 90,
    "_note_reentry": "[FEATURE 2026-08-27] Replaces the earlier time-based same_symbol_reentry_cooldown_minutes (trading block, now defaulted to 0/disabled below) as the primary re-entry control, per explicit request: 'instead a cooldown on a stock, I want it to score 100% to be allowed a re-enter.' A symbol with a closed position already this session must clear reentry_min_confirmation_score (100% by default -- every check passing cleanly) to be bought again, regardless of the normal min_confirmation_score above. A fresh symbol never traded today is unaffected.",
    "time_based_overrides": {
      "enabled": true,
      "_note": "[FEATURE 2026-08-27] Disabled by default -- flip enabled to true to activate. Each window's 'overrides' dict is shallow-merged on top of this entry block's base values for any evaluate_entry() call whose current time (America/New_York) falls in [start, end). Only keys present in a window's overrides change; every other setting falls through to the base values above unchanged. Windows are checked in listed order; the first match wins. If no window matches the current time, the unmodified base config is used -- a gap or typo fails safe to normal behavior. The three example windows below are a STARTING POINT reflecting one plausible read of the day (tighter at the volatile open, a bit looser midday, moderate in the afternoon) -- not validated against real multi-window data yet, since this project has always run one flat config per session so far. Edit start/end/overrides freely; add or remove windows as needed.",
      "windows": [
        {
          "label": "morning_open",
          "start": "09:30:00",
          "end": "11:00:00",
          "overrides": {
            "min_confirmation_score": 65,
            "max_extension_from_vwap_pct": 4.0,
            "require_rsi_not_overbought": true,
            "max_rsi_at_entry": 70
          }
        },
        {
          "label": "midday",
          "start": "11:00:00",
          "end": "13:00:00",
          "overrides": {
            "min_confirmation_score": 65,
            "max_extension_from_vwap_pct": 4.0,
            "require_rsi_not_overbought": true,
            "max_rsi_at_entry": 60
          }
        },
        {
          "label": "afternoon",
          "start": "13:00:00",
          "end": "16:00:00",
          "overrides": {
            "min_confirmation_score": 65,
            "max_extension_from_vwap_pct": 4.0,
            "require_rsi_not_overbought": true,
            "max_rsi_at_entry": 70
          }
        }
      ]
    },
    "cancel_unfilled_after_seconds": 20
  },

  "risk": {
    "account_risk_pct_per_trade": 1.0,
    "max_position_notional_pct_of_equity": 20,
    "min_shares": 1,
    "symbol_cooldown_minutes": 10,
    "max_daily_loss_pct": 3.0,
    "max_consecutive_losses_pause": 4,
    "consecutive_loss_pause_minutes": 20
  },

  "stop": {
    "method": "atr",
    "_method_options": ["fixed_cents", "percentage", "atr", "volatility_adjusted"],
    "_note_method": "[UPDATED 2026-09-01] Switched from fixed_cents to atr per explicit request ('trail stop loss based on the ATR x0.25 instead of the fixed 10 cents'). This one 'method' setting drives BOTH compute_initial_stop() and update_trailing_stop() in risk_manager.py -- there is no separate initial-vs-trailing method switch, only separate multipliers (atr_multiplier_initial/atr_multiplier_trailing below) once atr is selected. initial_distance/trailing_distance (fixed_cents) and percentage_initial_pct/percentage_trailing_pct (percentage) are kept here, unused while method=atr, purely so switching back is a one-line change with no data loss. If bars are unavailable at stop-computation time (e.g. compute_initial_stop() called with bars=None), risk_manager.py falls back to initial_distance/trailing_distance automatically -- see its atr branch.",
    "initial_distance": 0.10,
    "trailing_distance": 0.10,
    "percentage_initial_pct": 1.0,
    "percentage_trailing_pct": 1.0,
    "atr_period": 14,
    "atr_multiplier_initial": 1.2,
    "atr_multiplier_trailing": 0.4,
    "_note_atr_multiplier_trailing": "[UPDATED 2026-09-02] Was 0.25 (set 2026-09-01, see prior note below), raised to 0.4 -- the lower end of that note's own contingency range -- after one full day's evidence that the contingency triggered: on 2026-09-02, 14/14 trades (100%) touched the trailing stop and needed the fade_confirmation grace extension just to survive the first touch (170 grace events total), effectively making the grace layer -- meant as a rare noise filter -- the real exit mechanism on every single trade instead of the ATR trail itself. Two of that day's six losers had near-zero MFE (ONDS $0.00, SLS $0.64), consistent with a trail tighter than this cohort's normal noise band; several of the six losers were also high-beta names (ONDS beta ~2.75, JOBY ~2.70, SLS ~2.49) where a flat 0.25x multiplier bites hardest. Re-evaluate after a few more days at 0.4 -- prior note's original request for 0.25 stands as the floor to revisit if this proves too loose.\n\n[UPDATED 2026-09-01, superseded above] Was 1.0 (effectively ~1x the average 1-minute true range). Changed to 0.25x per explicit request. This makes the trailing stop noticeably TIGHTER than before on most of this project's $5-15 candidates -- on 2026-09-01's real bars a 14-period 1-min ATR on these names ran roughly $0.15-$0.40, so 0.25x ATR lands near $0.04-$0.10 (versus the old flat $0.10), while a quieter stock now gets a tighter stop than a volatile one instead of the same $0.10 for both. min_stop_distance_cents below still applies as a floor, so an extremely quiet stock's ATR-derived stop can never go below $0.03. Watch real fills after this change -- if whipsaw stop-outs increase noticeably on quiet stocks, raise this back toward 0.4-0.5x; if winners are still giving back too much before the stop catches them, lower it further.",
    "min_stop_distance_cents": 0.03,
    "min_stop_distance_pct": 1.0,
    "_note_min_stop_distance_pct": "[FEATURE 2026-09-03] Added because min_stop_distance_cents alone is a flat dollar amount, so it means something completely different on a $5 stock than a $50 one -- and on this project's $5-15 cohort, several real 2026-09-03 entries landed EXACTLY on or barely above that $0.03 floor (ACHR $0.03 = 0.4% of its $5.79 entry, CRML $0.045 = 0.6%, HAFN $0.0484 = 0.5%, JOBY $0.0522 = 0.7%), meaning the ATR-derived distance was so small at entry time that the flat-cents floor was doing all the work of setting the stop, with essentially no room for normal noise. risk_manager.py now takes the initial/trailing stop distance as max(min_stop_distance_cents, current_price * min_stop_distance_pct / 100) -- the flat-cents value remains a hard floor-of-the-floor for very low-priced names where 1% would be a fraction of a cent, but for this project's normal $5-15 range the percentage floor is what actually governs, raising ACHR's example from $0.03 to $0.058, CRML's from $0.045 to $0.076, HAFN's from $0.0484 to $0.089, JOBY's from $0.0522 to $0.071. Still capped by the existing max_stop_distance_pct=3.0 ceiling on the other side. Not yet validated against live fills at this new floor -- tune directly here if 1.0% still proves too tight or turns out too loose.",
    "max_stop_distance_pct": 3.0,
    "fade_confirmation": {
      "_note": "[FEATURE 2026-08-18] When the trailing stop is touched, instead of exiting immediately, checks intraday_health.compute_health() for a CONFIRMED fade (negative price slope AND VWAP lost AND deteriorating structure -- the same 'raw_state == UNHEALTHY' classification used everywhere else). If confirmed, exits immediately as before. If NOT confirmed (i.e. this looks like ordinary noise, not real deterioration), the exit is forgiven and the effective stop is widened by grace_distance -- up to max_grace_extensions times per dip. The grace window resets the moment price recovers back above the real (ratchet-computed) stop, so a later, unrelated dip always gets a fresh budget. Once max_grace_extensions is used up without price recovering, any further touch exits unconditionally regardless of health, as a capital-protection fail-safe. This NEVER changes the underlying trailing-stop computation itself (risk_manager.py's 'only ever moves up' rule is untouched) -- it only adds a bounded, health-gated forgiveness layer on top of the exit check. grace_distance is a placeholder value to be tuned once you're running this live -- see the request that added it.",
      "enabled": true,
      "grace_distance": 0.05,
      "max_grace_extensions": 1
    },
    "exit_retry": {
      "_note": "[BUGFIX 2026-08-24] Distinct from fade_confirmation above -- this governs what happens when close_position() itself is REJECTED by the broker with 'position not found' (code 40410000), which real logs show happening 4-8 times in a row when a stop is touched moments after entry, before the entry order has finished settling at the broker. position_not_found_backoff_seconds is how long exit_position() waits before retrying in that specific case, instead of resubmitting every single poll cycle.",
      "position_not_found_backoff_seconds": 15
    }
  },

  "trading": {
    "max_positions": 5,
    "allow_multiple_entries_same_symbol": false,
    "simulated_equity_default": 100000.0,
    "same_symbol_reentry_cooldown_minutes": 0,
    "min_replacement_health_score": 65
  },

  "data_storage": {
    "premarket_dir": "data/premarket",
    "intraday_dir": "data/intraday",
    "trades_dir": "data/trades",
    "state_dir": "state",
    "retention_days": 30,
    "write_intraday_snapshot_every_n_polls": 1
  },

  "logging": {
    "log_dir": "logs",
    "level": "INFO",
    "log_rejections": true,
    "max_log_file_mb": 20,
    "backup_count": 10,
    "_note": "[FEATURE 2026-08-20] max_log_file_mb/backup_count are no longer used -- logs are now one dated file per trading day (logs/{filename_prefix}_YYYY-MM-DD.log) instead of a single size-rotated file. filename_prefix sets that file's base name; retention_days controls how many days of dated log files are kept before old ones are pruned on startup.",
    "filename_prefix": "sip_bot",
    "retention_days": 30
  }
}