{
  "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,
    "min_daily_atr_pct": 3.0,
    "volatility_lookback_days": 30,
    "_note_min_daily_atr_pct": "[FEATURE 2026-09-15] Structural volatility floor -- catches BDCs/SPACs and similar low-beta vehicles that pass every other filter (right price band, real name, even a real RVOL blip) but structurally never move enough to day-trade. exclude_etfs_and_funds can't catch these: they're incorporated as ordinary corporations (e.g. 'WhiteHorse Finance, Inc.'), not named anything fund/trust-like. Calibrated against real cases found 2026-09-15: WHF (a BDC) measured 2.23% avg daily ATR over the trailing 30 days, APXT (a pre-merger SPAC parked at NAV) measured 0.09%, vs. 4.3%-21.9% for genuine same-day momentum candidates (IIIV, JOBY, RDW, SNAP, MARA, RCAT, VEEA, TNON). 3.0% sits with margin below every real candidate tested and above both dead cases. Uses DAILY bars specifically -- premarket_scanner.py found that per-1-minute-bar ATR% does NOT discriminate (WHF's intraday minute-bar ATR reads statistically identical to real candidates); only the multi-day range history reveals the difference. A symbol with too little daily history to measure (e.g. a very fresh IPO) is never rejected on this alone -- 'unknown' fails open, same conservative pattern as prev_day_high's fail-soft contract."
  },
  "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,
    "vwap_lookback_bars": 12,
    "momentum_lookback_snapshots": 5,
    "acceleration_min_ratio": 1.15,
    "coil_max_retrace_pct": 50.0,
    "_note_2026_09_15": "max_distance_from_pm_high_pct and extension_risk_pct_threshold (both flat, VWAP-only thresholds) were removed -- scorer.py's resistance and extension scoring now reuse fast_prediction_engine.py's own ATR-normalized/breakout-aware classifiers directly (see scorer.py's imports from fast_prediction_engine), so those two concepts are governed by config.json's fast_prediction section instead, keeping scanner ranking and entry-gate reality the same question. coil_max_retrace_pct is new: max % retracement of the prior up-move that indicators.pullback_then_continuation() will still call a healthy 'coil back' rather than a broken structure."
  },
  "rvol_starvation_fallback": {
    "_note": "[FEATURE 2026-09-16] premarket_scanner.scan()'s meets_min_rvol gate (bugfixed 2026-09-15 into a hard reject) has no fallback of its own -- on 2026-09-16 premarket-wide activity was thin enough (the single highest-RVOL real stock in the whole universe read 0.92x; everything else under 0.5x) that it rejected all 540 prefiltered symbols and the scanner produced ZERO candidates for the entire session. Investigated whether min_rvol itself (premarket_scoring.min_rvol, currently 1.5) was just miscalibrated -- checked thresholds down to 0.1 against that day's real data and still found only ~10 names, dominated by RVOL-baseline artifacts (see prefilter_by_snapshot's now-fixed near-zero-baseline bug) -- so the honest conclusion was that no static threshold fixes a day with genuinely no elevated premarket volume anywhere, while every normal day so far clears 1.5x comfortably with room to spare (2026-09-15's own selected list ranged 1.51-5.35). min_rvol is left at 1.5 per explicit instruction rather than loosened. Instead: when fewer than min_candidates clear the real gate, backfill from the next-best-scoring symbols that already passed every OTHER hard check (volume/volatility/spread) but fell short only on RVOL, up to min_candidates. Backfilled entries are flagged flags.rvol_floor_waived=True so they're always distinguishable from a genuine RVOL-qualified candidate in logs/review -- this only widens the MONITORING pool for the day; fast_entry_gate.py's live persistence/hard-disqualifier checks still gate any actual trade regardless of how a symbol entered premarket_20.",
    "enabled": true,
    "min_candidates": 5
  },
  "breakout_scanner": {
    "_note": "[FEATURE 2026-09-16] Config for breakout_scanner.py -- a shadow/comparison scanner implementing the candidate-scoring model from docs/breakout_bot_design_conversation.pdf (Part II section 3), run once alongside sip_bot's own 9:29 premarket scan so the two approaches can be compared on the exact same morning. See breakout_scanner.py's module docstring for the full design (why universe-building is shared with premarket_scanner.py but scoring is independent, why there are no hard reject gates here unlike premarket_scoring.min_rvol/etc). weights are the PDF's own explicit split (30/20/20/15/15). The four *_full_credit* thresholds below are this module's own first-cut, unvalidated placeholders (the PDF itself never finished defining this math) -- tune once there's a few days of real scanner.json output to compare against sip_bot's picks and real outcomes. [BUGFIX 2026-09-16] gap_full_credit_pct and price_strength_full_credit_pct are now used SYMMETRICALLY (score_breakout_candidate() clamps to [-1, 1], not [0, 1]) -- a decline of this same magnitude now earns the full NEGATIVE weight instead of merely zero credit. Found the same day via a real cross-reference against Yahoo Finance: 7 of a real 30-candidate scan were actively declining at evaluation time (including ALHC at -10.8% on 1.8x above-average volume -- a real selloff, not noise), yet the old [0,1] clamp let their RVOL/volume/resistance sub-scores carry them into the top 30 anyway since a decline scored identically to dead-flat. No new config keys needed -- same thresholds, just applied to both sides of zero.",
    "enabled": true,
    "top_candidate_count": 30,
    "weights": {
      "relative_volume": 30,
      "premarket_volume": 20,
      "gap_or_price_movement": 20,
      "premarket_price_strength": 15,
      "distance_to_resistance": 15
    },
    "rvol_full_credit_multiple": 3.0,
    "premarket_volume_full_credit": 100000,
    "gap_full_credit_pct": 10.0,
    "price_strength_full_credit_pct": 5.0,
    "resistance_full_credit_distance_pct": 8.0
  },
  "intraday_health": {
    "_note": "[FEATURE 2026-08-17] Continuous health scoring for premarket_20, now the primary intraday candidate pool. Primarily affects eligibility for NEW entries/replacements. [UPDATED 2026-09-11] One exception: exit_on_deterioration_enabled forces a sell on an OPEN position once its health has read STALE-or-worse for exit_confirm_reads consecutive evaluations -- see intraday_health.py module docstring.",
    "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,
    "exit_on_deterioration_enabled": true,
    "exit_confirm_reads": 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."
  },
  "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.4,
      "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."
  },
  "fast_prediction": {
    "_note": "[FEATURE 2026-09-11] Config for fast_prediction_engine.py -- the replacement prediction stage for entry.decision_engine == 'fast_prediction', built after 2026-09-10's zero-trade session (see entry_score._note_full_confirmation_decay above for that day's full diagnosis). This produces one Direction + a separately-computed descriptive Confidence, plus named classifications (momentum/volume/vwap/ema9/resistance/extension) for logging, review, and shortlist ranking. Confidence never gates entry -- fast_entry_gate.py's hard/soft checklist decides BUY/WAIT/REJECT. min_bars=5 is the rolling-window warm-up: fewer than 5 one-minute bars and this returns insufficient_data rather than a guess, same failure-safety pattern as every other engine in this codebase.",
    "_note_direction": "[BUGFIX 2026-09-14] direction=UP originally came from the same blended confidence_weights score (raw >= 0.55) -- with only four inputs, that let one actively-contradicting leg get outvoted by the other three. Confirmed live: ALLT cleared UP with Strong momentum + Bullish vwap/ema9 despite Declining volume (blend 0.659, well above 0.55); TNON's 'Building' momentum label fired despite slope_5m actually negative (that label only requires momentum accelerating, not the price actually rising). Per explicit instruction ('we don't have lots of indicators so we need to make sure that they are all positive before an entry'), direction=UP now requires unanimous agreement on momentum in {Strong, Weakening} (slope_5m > 0, excludes Building), volume in {Expanding, Steady} (not Declining), AND resistance=SAFE (genuinely clear of the nearest overhead level, or a confirmed breakout in progress -- CAUTION no longer passes on its own). vwap and ema9 are DELIBERATELY EXCLUDED from this hard gate (explicit instruction, 'let's disable vwap and ema9 for tomorrow run') -- they still feed confidence_weights' blended score below, used for shortlist ranking/logging, just no longer required to gate entry. This progression was tuned same-day via a full retrospective replay against every real entry opportunity on 2026-09-14 (56 symbols, every logged shortlisted evaluation): the original 5-condition version (incl. vwap=Bullish, ema9=Bullish) collapsed 48 real trades to 1 (Expanding-only volume) then 2 (volume loosened to not-Declining) -- reproducing the same near-zero-trade starvation shape that got the entire prior engine scrapped on 2026-09-10; dropping vwap/ema9 recovered 3 of 48 real trades, net +$5.78 vs. the real day's -$136.85. Still a very small sample (3 trades) -- unvalidated beyond this one retrospective day, revisit after real live results accumulate.",
    "min_bars": 5,
    "min_relative_volume": 0.5,
    "resistance_safe_pct": 0.75,
    "resistance_safe_atr_mult": 0.5,
    "resistance_caution_pct": 0.4,
    "_note_resistance": "Distance to resistance as a % of price AND relative to current volatility (ATR), per explicit instruction -- SAFE if EITHER the flat % or the ATR-relative distance clears its bar, so a calm stock leans on the %, a volatile one on the ATR multiple. Checked against the nearest overhead level in explicit priority order: session high -> premarket high -> previous-day high (premarket_scanner.py's new prev_day_high, fault-tolerant/optional -- its absence just removes it from the priority list, never blocks). breakout_override_volume_accel: if price has touched/pierced the level within the last breakout_override_lookback_bars bars AND volume is accelerating at least this much, the distance penalty is waived entirely -- 'the resistance-distance penalty should disappear once the breakout is confirmed,' per explicit instruction.",
    "breakout_override_lookback_bars": 2,
    "breakout_override_volume_accel": 1.2,
    "extension_atr_extended": 2.0,
    "extension_atr_severe": 3.5,
    "_note_extension": "ATR-normalized distance from EMA9/VWAP (reuses stream_features.py's already-computed price_vs_ema9_atr/vwap_distance_atr). EXTENDED is a confidence penalty only, per explicit instruction ('a stock can be 2.5% above VWAP and still be an excellent breakout... you don't want the old extension gate preventing that trade'). SEVERELY_EXTENDED requires BOTH the large ATR distance AND momentum already Weakening/Deteriorating -- that combination is fast_entry_gate.py's hard reject (chase protection), never distance alone.",
    "confidence_weights": {
      "momentum": 0.3,
      "volume": 0.25,
      "vwap": 0.25,
      "ema": 0.2
    },
    "_note_confidence_weights": "Purely descriptive output, not a gate -- see this section's own top note. Not independently validated against real fills yet; tune here directly once fast_prediction_engine's logged Direction/Confidence has real outcomes to compare against (see data_storage.fast_engine_dir)."
  },
  "fast_prediction_gate": {
    "_note": "[FEATURE 2026-09-11] Config for fast_entry_gate.py -- the ONLY thing that decides BUY/WAIT/REJECT for entry.decision_engine == 'fast_prediction'. persistence_seconds is the short live-confirmation window ('aim for seconds, not minutes... signal persistence should be about 15-20 seconds'), replacing entry_score.py's old 8s/2-reading confirmation_state AND deliberately NOT the 60-90s window originally floated during design. REJECT clears the persistence timer only -- per explicit instruction ('reset and rewatch it in place'), a rejected symbol is evaluated completely fresh on the very next poll, no separate cooldown invented here.",
    "persistence_seconds": 18,
    "max_spread_pct": 1.0,
    "hard_price_acceleration_negative": -0.5,
    "min_volume_pace_ratio": 0.4,
    "max_evaluation_gap_seconds": 15,
    "_note_hard_disqualifiers": "Each of these mirrors one of the explicit REJECT examples from design: price below VWAP, VWAP slope negative, price acceleration sharply negative (this threshold, %/min cross-horizon basis -- not independently validated yet, tune against real sessions), resistance TOO_CLOSE with no breakout in progress (fast_prediction.py's own resistance classification already accounts for the breakout exception), spread over max_spread_pct, SEVERELY_EXTENDED (large ATR distance + deteriorating momentum together), and volume pace below min_volume_pace_ratio (see fast_entry_gate.py's DEFAULT_CONFIG comment -- added 2026-09-14 after TNON entered at a ~0.25x pace and its exit slipped 5.6% on a market order into too-thin a book; 0.4 is a first-cut floor). A SOFT miss on anything else (e.g. EMA9/EMA20 context, momentary volume cooling) never triggers REJECT -- it just means the persistence timer hasn't finished yet (WAIT), per explicit instruction that a minor condition disappearing should not kill the whole attempt.",
    "_note_max_evaluation_gap_seconds": "[BUGFIX 2026-09-14] The persistence 'since' timestamp only ever gets compared as `now - since` -- with no check that the symbol was actually re-evaluated on every poll in between. Confirmed live same day: ALLT bought instantly off a UP-since timestamp that survived a 180s bench with zero re-confirmation in between; JBS did the same across a ~3min health-STALE gap; BTE's since-timestamp survived the ~15s its own prior position took to finish closing, causing 5 back-to-back buy/momentum_fade_exit cycles in under a minute; FUBO's worst case carried a since timestamp across a 2h26m (8804s) health-STALE gap and bought the instant health recovered, then repeated BTE's rapid-refire pattern on top of that. This bounds how long a since timestamp survives NOT being actively polled before it's discarded as stale and persistence restarts from zero -- sized to tolerate one or two missed ~5s poll ticks, not a 180s bench or a multi-minute/hour health gap. Unvalidated beyond this one day's retrospective, tune once there's more live data.",
    "not_yet_validated_against_a_live_session": true
  },
  "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.1,
    "trailing_distance": 0.1,
    "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.\n\n[DISABLED 2026-09-11] Turned off after reviewing 2026-09-11's session log: on 4 of that day's trades (CHPT, EQX, STLA, USDE) the grace budget kept resetting on every partial recovery -- EQX got 7 separate grants over 6 minutes, STLA got 16 over 21 minutes -- because intraday_health's smoothed price_slope/vwap_slope kept reading flat/positive well after price had actually turned down, so 'not confirmed fading' wasn't a reliable noise filter. Net effect matched the 2026-09-02 note above: grace was the real exit mechanism, not the ATR trail, and it was widening realized giveback (e.g. CHPT: nominal $0.111 trail -> $0.21 actual peak-to-exit). position_manager.py's touch handler already fails toward exiting when disabled (see its 'fade-confirmation is disabled' branch), so with enabled=false every stop touch now exits immediately at the ratchet-computed stop, same as before this feature existed. Re-enable (or replace with a raw-price-based fade check instead of the smoothed health slope) once there's a few days of clean immediate-exit data to compare against.",
      "enabled": false,
      "grace_distance": 0.05,
      "max_grace_extensions": 1
    },
    "momentum_fade_exit": {
      "_note": "[FEATURE 2026-09-11] Proactive exit, independent of the trailing stop entirely -- added the same day fade_confirmation above was disabled, after finding its root cause: intraday_health's 8-minute-bar slope regression is too slow to catch a reversal within the first 2-3 minutes of a trade (CHPT's worked example: 8-bar slope was still +0.41%, classified 'positive', the same minute price had already reversed and hit the stop). This reuses stream_features.compute_features() -- the same fast, sub-30s-bucket pipeline fast_entry_gate.py already runs for every entry candidate -- so the exit side reacts on the same timescale entries do, instead of intraday_health's session-level lens. position_manager._check_momentum_fade_exit() fires the moment BOTH velocity_sub (live price vs. the close of the last completed 30s sub-bucket, %/sec) drops to or below velocity_stall_threshold_pct_per_sec (price has stopped climbing) AND pressure.score (blended uptick/downtick ratio + velocity + volume acceleration, -100..+100) is below pressure_threshold (net selling, i.e. buyers are gone) -- both on the SAME poll, no confirm-reads counter, no grace/widening layer: exits immediately once confirmed. Deliberately allowed to fire while price is still above the trailing stop (a stalled, seller-dominated tape is reason enough on its own); same_symbol_reentry_cooldown_minutes=0 already allows re-entering the same symbol if it turns out to be a false alarm and the stock re-firms away from resistance. min_ticks_sub exists because trade_flow.tick_count_sub can be tiny right after a new sub-bucket starts, making that bucket's uptick/downtick ratio (and therefore pressure.score) unreliable -- this holds off the check for that one poll (not a fixed delay) until the bucket has actually seen enough prints to trust. All three thresholds are first-cut placeholders, unvalidated against live fills -- tune here once there's a few days of real MOMENTUM_FADE exits to review.",
      "enabled": true,
      "velocity_stall_threshold_pct_per_sec": 0.0,
      "pressure_threshold": -10,
      "min_ticks_sub": 3
    },
    "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",
    "fast_engine_dir": "data/fast_engine",
    "breakout_scanner_dir": "data/breakout_scanner",
    "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
  }
}
