/**
 * txocap - SMOOTH strategy runner (Bybit demo)
 *
 * ── Signal ──────────────────────────────────────────────────────────────────
 * Combined calendar/structural score across 13 signals (4yr-calibrated):
 *   DOW (Thu/Wed/Sun/Mon/Fri), Hour (H20/H21/H22/H23), BUYP-60m,
 *   US-gap fade, RSI-60 oversold, 24h reversion.
 * Weights derived from 4yr (Apr 2022-Apr 2026) cross-regime stability analysis.
 *
 * ── Entry ───────────────────────────────────────────────────────────────────
 * Fires when |score| ≥ LONG_ENTRY_THRESH (16) or SHORT_ENTRY_THRESH (8).
 *
 * THREE entry filters (layered):
 *   1. 72h/600bps  - skip if 72h return opposes direction >600 bps (already active)
 *   2. 7d/700bps   - skip if 7-day return opposes >700 bps (crash regime)
 *   3. 14d/800bps  - skip if 14-day return opposes >800 bps (sustained correction)
 *
 * Bid/ask at composite liquidity level (LEVEL_WAIT_BARS=30 min):
 *   LONG:  bid at nearest of {$250/$500/$1000 floor, swing low, PDL, POC}
 *   SHORT: ask at nearest of {$250/$500/$1000 ceil, swing high, PDH, POC}
 *   Only enters when price comes to a real support/resistance level.
 *
 * 4yr validation candidate (L16/S8, cap420, wait30 adaptive-imm, composite L1/L2/L3, MEXC 0%):
 *   n=3637  mean=9.60bps  t=6.36****  moIR=1.14  actIR=1.17  maxDD=28.1%  months+=43/49
 *
 * ── Exits ───────────────────────────────────────────────────────────────────
 * 1. TIME EXIT (maker): deadline, aggressive 1-tick maker, fallback after 10min.
 * 2. EXTENSION: at deadline, if |score| ≥ EXT_THRESH same direction, extend hold.
 * 3. MAKER SL: soft SL at 100 bps - place maker at SL price, wait for fill.
 *    Hard SL at 125 bps - taker if maker never fills.
 *    Backtest: 68.7% fill as maker, 31.3% hard SL.
 *
 * ── Risk ───────────────────────────────────────────────────────────────────
 * 3% equity risk per trade, 100bps stop, 100x leverage. Full compounding.
 * Expected maxDD: ~30% in typical year, ~42% over 4yr including FTX (2022)
 * stopCap=2: max 2 same-direction stops per UTC calendar day.
 *
 * ── Operational ─────────────────────────────────────────────────────────────
 * State: /tmp/txocap-combined/state.json  Lock: /tmp/txocap-combined/runner.lock
 * Restart: kill -SIGINT $(cat /tmp/txocap-combined/pid), wait 4s, re-run.
 * NEVER rm -f runner.lock - always SIGINT.
 *
 * ── Deployment ─────────────────────────────────────────────────────────────
 * Current: Bybit demo (FEE_BPS=2). Target: MEXC live (FEE_BPS=0).
 */

import { loadConfig }              from '../core/config.js'
import { BybitRestClient }         from '../lib/bybit/rest.js'
import { BybitPublicTradeStreamer } from '../lib/bybit/public-stream.js'
import { DemoExecutor }            from '../sim/demo-executor.js'
import type { TapeTrade }          from '../core/types.js'
import fs                          from 'node:fs'

// ═══════════════════════════════════════════════════════════════════════════
// CONFIG
// ═══════════════════════════════════════════════════════════════════════════

/** 3% of equity per trade. Kelly = 12%; 3% is conservative and lets the
 *  account compound without catastrophic drawdown. */
const RISK_PCT  = 0.03

/** Soft SL trigger in bps - used as FALLBACK only when composite level
 *  cannot be computed (e.g. insufficient bar history).
 *  Normal operation: softSlPrice is set to the next composite level (L2)
 *  from the entry level (L1), making the stop structurally meaningful. */
const SOFT_SL_BPS = 100

/** Hard SL backstop: now placed at L3 (next structural level beyond L2).
 *  Replaces the previous fixed 25bps buffer. See executeEntry() for derivation.
 *  This constant is kept only for the fallback case when L2 is out of range. */
const HARD_SL_BUFFER_BPS = 25

/** Minimum structural distance from L1 to L2 before accepting a soft SL level.
 *  If the nearest round/PDH/PDL level is closer than this, skip to the next
 *  structural level. This avoids noise-sized L2 stops (e.g. ~20bps) while
 *  still sizing risk to the resulting L3 hard stop.
 *
 *  4yr tactical validation: 25bps was the only viable minimum-distance patch;
 *  35bps+ degraded actual sized P&L too much. */
const MIN_SOFT_SL_BPS = 25

/** Liquidity moat for obvious round-number stop zones.
 *  The old L2 remains the public stop-liquidity anchor; when it is an exact
 *  $250/$500/$1000 round level, place the soft-SL process on the far side of
 *  that zone instead of exactly on the public level.
 *
 *  Focused validation: round25 had the strongest rolling actual-PnL robustness
 *  among conditional moat variants. PDH/PDL and fallback stops are unchanged. */
const STOP_ZONE_MOAT_USD = 25

/** Extra cost when hard SL fires: MEXC taker fee (5 bps) + slippage (3 bps).
 *  On Bybit demo taker ≈ 5.5 bps + slip; use 8 as conservative estimate. */
const HARD_SL_SLIP_BPS = 8

/** Exchange leverage multiplier. Sizing formula:
 *    notional = equity × RISK_PCT / (SOFT_SL_BPS / 10000)
 *  With RISK_PCT=3% and SL=100bps that equals equity×3 regardless of
 *  leverage setting. Leverage only affects margin requirement. */
const LEVERAGE = 100

/** Round-trip maker fee in bps. 2 bps on Bybit demo.
 *  On MEXC: 0 bps - change this to 0 when deploying live. */
const FEE_BPS = 2

/**
 * 7-day momentum crash halt.
 * Skip entry if the 7-day BTC return opposes the calendar direction by more
 * than this threshold. Targets BTC corrections like Aug 2024 and early-2025.
 *
 * 4yr backtest (composite level + 72h/600 baseline):
 *   Without: t=6.06****  moIR=0.93  compDD=52.7%  maxConsec=14
 *   With:    t=6.42****  moIR=0.95  compDD=45.4%  maxConsec=10
 * t-stat IMPROVES - the filter removes genuinely bad entries, not good ones.
 */
const HALT_7D_BPS = 700

/**
 * 14-day momentum crash halt.
 * Skip entry if the 14-day BTC return opposes the calendar direction by more
 * than this threshold. Catches slower, multi-week corrections.
 *
 * Combined with HALT_7D_BPS (7d/700 + 14d/800):
 *   t=6.42****  moIR=0.94  compDD=42.4%  maxConsec=7  months+=42/49
 *   Worst month: -$102 (vs -$225 without filters)
 */
const HALT_14D_BPS = 800

/** 72-hour momentum crash halt threshold (bps).
 *  Named constant so both the entry gate and the START log use the same value. */
const HALT_72H_BPS = 600

// ── SMOOTH signal parameters ─────────────────────────────────────────────

/** Minimum |score| to open a LONG position.
 *  Raised from 10 → 16 after full long-threshold + walk-forward validation.
 *  Keeps the stronger short side at 8 while filtering weaker long entries. */
const LONG_ENTRY_THRESH = 16

/** Minimum |score| to open a SHORT position.
 *  Kept at 8 after L16/S4–S24 sweep. Shorts are the stronger side;
 *  raising S materially removes too many profitable short trades.
 *  S9 is defensible but only a tiny improvement, so S8 remains conservative. */
const SHORT_ENTRY_THRESH = 8

/** Minimum |score × dir| to keep extending the hold.
 *  Set equal to SHORT_ENTRY_THRESH (8) for symmetry: if the signal was worth
 *  entering at 8, it is worth holding through at 8.
 *  Backtest (4yr): thresh=8 gives t=8.22**** moIR=1.02 vs thresh=10 t=8.04 moIR=0.98. */
const EXT_THRESH = 8

/**
 * Signal confirmation: how many consecutive sealed bars must ALL show score × dir
 * ≥ entry threshold before we act.
 *
 * confirmBars=1 → fire on the first bar the signal appears (old behaviour).
 * confirmBars=4 → signal must be present on bars i, i-1, i-2, i-3 (3 bars wait).
 *
 * Backtest sweep (4yr, full-composite, MEXC 0%):
 *   1 bar (baseline): t=7.43****  moIR=1.14  maxDD=44.6%  minHt=4.75
 *   4 bars (3 wait):  t=7.66****  moIR=1.16  maxDD=30.3%  minHt=5.45 ← CHOSEN
 *   5 bars:           t=7.55****  moIR=1.16  maxDD=33.3%  minHt=5.35
 *
 * Why 4 bars wins:
 *   - Peak t-stat and minHt at exactly 4 bars
 *   - maxDD improvement is dramatic: 44.6% → 30.3% (-32%)
 *   - Removes only 5.7% of trades (calendar signals are persistent by nature)
 *   - Filtered trades average 12.56 bps vs kept 17.39 bps - genuinely worse quality
 *   - Beyond 4 bars: signal count drops faster than quality improves
 */
const CONFIRM_BARS = 4

/** Min/max hold duration in bars (1 bar = 1 minute). Weighted average of
 *  active signal hold periods, clamped to this range. */
const MIN_HOLD_BARS = 60
const MAX_HOLD_BARS = 240

/**
 * Hard cap: maximum bars a position can be held regardless of ongoing signal.
 *
 * The FREEZE/extend loop can keep a position alive indefinitely if score never
 * drops below EXT_THRESH. This cap is the absolute backstop.
 *
 * Original cap sweep under the old threshold profile chose 6h. After the
 * validated L16/S8 threshold change, the focused cap/short OOS grid promoted
 * 420 bars as the conservative risk-path improvement:
 *
 *   L16/S8 cap360: n=3840 mean=8.95bps t=6.34**** moIR=1.02 actIR=1.01 maxDD=35.5%
 *   L16/S8 cap420: n=3638 mean=9.19bps t=6.13**** moIR=1.08 actIR=1.10 maxDD=29.8%
 *
 * S9/cap420 is stronger for fee robustness, but S8/cap420 is the cleaner
 * conservative patch: one cap change, lower drawdown, no extra threshold tweak.
 */
const HARD_CAP_BARS = 420   // 7 hours

/** Max same-direction stops per calendar day. After the cap is reached,
 *  new entries in that direction are blocked for the rest of the day.
 *  Opposite direction is always allowed.
 *  Backtest: cap=2 halves monthly SD ($129→$96) and raises moIR 1.63→2.11. */
const STOP_CAP_PER_DAY = 2

/** Drawdown circuit breaker: halt new entries if realised maxDD exceeds this.
 *  Existing positions are managed normally; the runner goes into observe-only
 *  mode until equity recovers or the run is reset. */
const MAX_ALLOWED_DD_PCT = 0.25

/**
 * Post-stop cooldown: bars to block new entries after a stop fires.
 * Does NOT apply after time exits — only after stops.
 *
 * Rationale: a stop means the market rejected the entry level. Re-entering
 * immediately risks being stopped at the same level again. A 30-minute
 * pause lets the market show whether the level genuinely broke or just spiked.
 *
 * 4yr sweep (stop-only cooldown, all other params constant):
 *   5m  (current): t=4.77****  moIR=0.69  minHt=3.18
 *   30m (chosen):  t=5.04****  moIR=0.74  minHt=3.56  ← +0.27t, +0.05 moIR
 *   60m:           t=4.69****  moIR=0.65  minHt=3.27  (starts degrading)
 *   120m:          t=4.89****  moIR=0.68  minHt=3.48
 *   180m+:         t ↓ moIR ↓ maxDD ↑ (blocks too many legitimate re-entries)
 *
 * Why 30m not longer: calendar signals fire in 60-120 min windows. A 30m
 * cooldown prevents the immediate retry while still allowing the next
 * signal window to fire with fresh CONFIRM_BARS confirmation.
 */
const SL_COOLDOWN_BARS = 30   // 30 minutes after a stop fires

const STARTING_CAPITAL = 500

/** Maker exit: after this many ms without a fill, fall back to market.
 *  Normal time exits use maker aggressively for up to 10 minutes. */
const MAKER_EXIT_TIMEOUT_MS = 10 * 60_000

/** Maker SL: after this many ms waiting for price to return to the soft SL
 *  level, give up and take taker at current price.
 *  In practice the hard SL fires first - this is a safety net. */
const MAKER_SL_TIMEOUT_MS = 30 * 60_000

/** Bybit BTCUSDT tick size = $0.10. Used to position maker orders at the
 *  front of the bid/ask queue without crossing the spread. */
const TICK = 0.10

/** Maximum bars to wait for a composite liquidity level fill.
 *  30 bars = 30 minutes. Promoted after focused entry-wait OOS/walk-forward
 *  validation versus wait20 with L16/S8 cap420 imm10.
 *
 *  Full-period no-fee: moIR 1.08 → 1.14, actIR 1.10 → 1.17,
 *  maxDD 29.8% → 28.1%, actual monthly Δ ≈ +$106.
 *  FeeRT=4: moIR 0.62 → 0.66, actIR 0.68 → 0.74,
 *  maxDD 43.6% → 39.8%, actual monthly Δ ≈ +$106. */
const LEVEL_WAIT_BARS = 30

/** Volatility-normalised immediate-entry threshold.
 *  If current price is within this many bps of L1, enter immediately at/near
 *  mid; otherwise rest an anchored L1 order for LEVEL_WAIT_BARS.
 *
 *  Focused validation with roundMoat25 preferred:
 *    immediateBps = clamp(4, 10, 0.75 × ATR60_bps)
 *  over fixed 10bps and fixed 5bps. It keeps useful mid entries in high vol
 *  while requiring sharper L1 proximity in low/moderate vol. */
const IMMEDIATE_ENTRY_ATR_MULT = 0.75
const MIN_IMMEDIATE_ENTRY_BPS  = 4
const MAX_IMMEDIATE_ENTRY_BPS  = 10

/** Fractal swing detection window. A swing low/high requires this many
 *  consecutive higher-lows/lower-highs on each side to be confirmed.
 *  5 = classic 5-bar fractal used by most institutional order flow tools. */
const SWING_N = 5

/** Lookback window for swing level search (bars). 480 = 8 hours.
 *  Recent swings are more relevant than old ones; beyond 8h they are
 *  less likely to be watched by active market participants. */
const SWING_LOOKBACK = 480

/** Set TXOCAP_FLATTEN=1 to close any open position on SIGINT/SIGTERM.
 *  Default (unset): position is left open on Bybit; state is saved;
 *  the next run resumes the position via state.json reconciliation.
 *
 *  Use the default when deploying code updates so live trades survive
 *  the restart. Set TXOCAP_FLATTEN=1 only when you want a clean stop.
 *
 *  Example:
 *    TXOCAP_FLATTEN=1 node dist/runners/combined-demo.js   ← flatten + exit
 *    node dist/runners/combined-demo.js                    ← resume position
 */
const FLATTEN_ON_EXIT = !!process.env.TXOCAP_FLATTEN
const DURATION_MS = process.env.TXOCAP_BENCH_MS ? Number(process.env.TXOCAP_BENCH_MS) : Infinity

// ═══════════════════════════════════════════════════════════════════════════
// LOGGING
// ═══════════════════════════════════════════════════════════════════════════

function log(tag: string, msg: string): void {
  process.stderr.write(`[${new Date().toISOString().slice(11, 19)}] [${tag}] ${msg}\n`)
}

const STATUS_MSG_LEN = 80 - '[00:00:00] [STATUS] '.length

function fixedStatusLine(msg: string): string {
  return msg.slice(0, STATUS_MSG_LEN).padEnd(STATUS_MSG_LEN, ' ')
}

function fixedUnsigned(n: number, width: number): string {
  if (!Number.isFinite(n)) return ''.padStart(width, '-')
  const max = 10 ** width - 1
  return String(Math.min(max, Math.max(0, Math.round(n)))).padStart(width, '0')
}

function fixedSignedInt(n: number, width: number): string {
  if (!Number.isFinite(n)) return ''.padStart(width, '-')
  const sign = n >= 0 ? '+' : '-'
  const digits = width - 1
  const max = 10 ** digits - 1
  return sign + String(Math.min(max, Math.abs(Math.round(n)))).padStart(digits, '0')
}

function fixedSignedDec(n: number, width: number, decimals: number): string {
  if (!Number.isFinite(n)) return ''.padStart(width, '-')
  const sign = n >= 0 ? '+' : '-'
  const bodyWidth = width - 1
  const intDigits = bodyWidth - decimals - (decimals > 0 ? 1 : 0)
  const max = 10 ** intDigits - 10 ** (-decimals)
  return sign + Math.min(max, Math.abs(n)).toFixed(decimals).padStart(bodyWidth, '0')
}

function shortPrice(n: number): string {
  if (!Number.isFinite(n)) return '-----'
  const r = Math.max(0, Math.round(n))
  return r >= 100_000 ? `${Math.min(999, Math.round(r / 1_000))}k` : String(r)
}

function fixedQty4(q: number): string {
  if (!Number.isFinite(q)) return '----'
  if (q >= 1) return Math.min(9.99, q).toFixed(2)
  return q.toFixed(3).replace(/^0/, '').padStart(4, '0').slice(0, 4)
}

function pct1(n: number): string {
  if (!Number.isFinite(n)) return 'na'
  const v = Math.min(99.9, Math.max(-99.9, n))
  return `${v >= 0 ? '+' : ''}${v.toFixed(1)}`
}

// ═══════════════════════════════════════════════════════════════════════════
// 1-MINUTE BAR AGGREGATOR
// ═══════════════════════════════════════════════════════════════════════════

interface Bar { o: number; h: number; l: number; c: number; ts: number; v?: number }

/** Aggregates a stream of individual trades into sealed 1-minute OHLC bars.
 *  Returns true when a bar is sealed (new minute boundary crossed). */
class BarAgg {
  private bars: Bar[] = []
  private curKey  = -1
  private cur: Bar | null = null

  /** Ingest a single trade tick (price + notional volume in USD). */
  ingest(price: number, ts: number, volUsd = 0): boolean {
    const key = Math.floor(ts / 60_000)
    if (key !== this.curKey) {
      if (this.cur) this.bars.push({ ...this.cur })
      this.curKey = key
      this.cur = { o: price, h: price, l: price, c: price, ts: key * 60_000, v: volUsd }
      return this.bars.length > 0   // true = a bar was just sealed
    }
    if (this.cur) {
      this.cur.h = Math.max(this.cur.h, price)
      this.cur.l = Math.min(this.cur.l, price)
      this.cur.c = price
      this.cur.v = (this.cur.v ?? 0) + volUsd
    }
    return false
  }

  /** Ingest a completed OHLCV bar directly (used by preloader for REST klines). */
  ingestBar(o: number, h: number, l: number, c: number, ts: number, v = 0): void {
    const key = Math.floor(ts / 60_000)
    // Flush any open partial bar for a different minute first
    if (this.cur && this.curKey !== key) {
      this.bars.push({ ...this.cur })
      this.cur = null
    }
    // Push the complete bar directly; don't open a new partial bar
    if (this.curKey !== key) {
      this.bars.push({ o, h, l, c, ts: key * 60_000, v })
      this.curKey = key
      this.cur = null  // no partial bar open
    }
  }

  get():   Bar[]  { return this.bars }
  count(): number { return this.bars.length }
}

// ═══════════════════════════════════════════════════════════════════════════
// SIGNAL HELPERS
// ═══════════════════════════════════════════════════════════════════════════

/** Lookback return in basis points over n bars. Returns 0 if insufficient data. */
function lbRet(bars: Bar[], i: number, n: number): number {
  return i >= n && bars[i - n].c > 0
    ? (bars[i].c - bars[i - n].c) / bars[i - n].c * 10000
    : 0
}

/** Where within the high-low range did the bar close? 1 = at high, 0 = at low. */
function closePos(b: Bar): number {
  return b.h > b.l ? (b.c - b.l) / (b.h - b.l) : 0.5
}

/** Average close position over n bars - proxy for sustained buying pressure. */
function avgCP(bars: Bar[], i: number, n: number): number {
  let s = 0
  for (let j = Math.max(0, i - n + 1); j <= i; j++) s += closePos(bars[j])
  return s / Math.min(n, i + 1)
}

/** Standard RSI over n bars. Returns 50 if insufficient data. */
function rsi(bars: Bar[], i: number, n: number): number {
  if (i < n) return 50
  let u = 0, d = 0
  for (let j = i - n + 1; j <= i; j++) {
    const dd = bars[j].c - bars[j - 1].c
    if (dd > 0) u += dd; else d -= dd
  }
  return d === 0 ? 100 : 100 - 100 / (1 + u / d)
}

function clamp(n: number, lo: number, hi: number): number {
  return Math.max(lo, Math.min(hi, n))
}

/** Average true range in bps over n one-minute bars. */
function atrBps(bars: Bar[], i: number, n: number): number {
  if (i < 1) return 0
  let s = 0, c = 0
  for (let j = Math.max(1, i - n + 1); j <= i; j++) {
    const prev = bars[j - 1].c
    const tr = Math.max(
      bars[j].h - bars[j].l,
      Math.abs(bars[j].h - prev),
      Math.abs(bars[j].l - prev),
    )
    s += tr / bars[j].c * 10000
    c++
  }
  return c > 0 ? s / c : 0
}

function immediateEntryBps(bars: Bar[]): number {
  const atr60 = atrBps(bars, bars.length - 1, 60)
  return clamp(atr60 * IMMEDIATE_ENTRY_ATR_MULT, MIN_IMMEDIATE_ENTRY_BPS, MAX_IMMEDIATE_ENTRY_BPS)
}

/** Average adverse wick ratio over n bars for given trade direction.
 *  LONG: low wick = (close-low)/(high-low); high = bears tested and failed.
 *  SHORT: high wick = (high-close)/(high-low); high = bulls tested and failed. */
function adverseWickRatio(bars: Bar[], i: number, n: number, dir: number): number {
  let s = 0, c = 0
  for (let j = Math.max(0, i - n + 1); j <= i; j++) {
    const b = bars[j]
    const rng = b.h - b.l
    if (rng <= 0) continue
    const wick = dir > 0 ? (b.c - b.l) / rng : (b.h - b.c) / rng
    s += wick
    c++
  }
  return c > 0 ? s / c : 0
}

/** Hold-duration scale factor based on current 60m ATR.
 *  lin12f50: scale = clamp(0.5, 1.0, 12/ATR60).
 *  At ATR≤12: no change. At ATR=18: 0.67x. At ATR≥24: 0.50x. */
function holdScaleForVol(bars: Bar[]): number {
  const atr = atrBps(bars, bars.length - 1, 60)
  return clamp(12 / Math.max(0.01, atr), 0.5, 1)
}

// ═══════════════════════════════════════════════════════════════════════════
// COMPOSITE LIQUIDITY LEVEL
// ═══════════════════════════════════════════════════════════════════════════

/**
 * Returns the nearest real liquidity level in the favourable direction.
 *
 * Candidate pool (all accepted only if on the correct side of current price):
 *   1. $1000 round numbers  - options strikes, round-lot stops, institutional
 *   2. $500  round numbers  - options sub-strikes, common limit-order levels
 *   3. $250  round numbers  - highest density, best t-stat alone (5.53****)
 *   4. Nearest fractal swing low (LONG) or swing high (SHORT) within 8h
 *   5. Previous UTC-day low (LONG) or high (SHORT)
 *   6. Session intraday volume POC ($50-bucket, reset at midnight UTC)
 *
 * 4yr backtest result (MEXC 0% fee):
 *   BASE market:    t=4.40****  moIR=0.73  maxDD=69.2%  months+=39/49
 *   Composite w=20: t=5.38****  moIR=0.98  maxDD=63.9%  months+=43/49
 *
 * Individual sources (PDH/PDL, POC) that are bad in isolation only win the
 * composite when they are already the nearest candidate - price is right at
 * a tested level - which is genuinely useful context, not dangerous.
 */
function compositeLiquidityLevel(bars: Bar[], dir: number, refPrice?: number): number {
  if (bars.length === 0) return 0
  const price = refPrice ?? bars[bars.length - 1].c
  const N     = bars.length
  const candidates: number[] = []

  // 1-3. Round-number levels (always land on the correct side by construction)
  candidates.push(
    dir > 0 ? Math.floor(price / 1000) * 1000 : Math.ceil(price / 1000) * 1000,
    dir > 0 ? Math.floor(price /  500) *  500 : Math.ceil(price /  500) *  500,
    dir > 0 ? Math.floor(price /  250) *  250 : Math.ceil(price /  250) *  250,
  )

  // 4. Nearest confirmed fractal swing within SWING_LOOKBACK bars.
  //    Only use swings at least SWING_N bars old (fully confirmed).
  {
    let bestSwing: number | null = null
    let bestDist = Infinity
    const scanEnd   = N - SWING_N - 1
    const scanStart = Math.max(SWING_N, N - SWING_LOOKBACK - SWING_N)
    for (let j = scanEnd; j >= scanStart; j--) {
      let isSwing = true
      for (let k = 1; k <= SWING_N && isSwing; k++) {
        if (dir > 0) {
          if ((bars[j - k]?.l ?? Infinity) <= bars[j].l) isSwing = false
          if ((bars[j + k]?.l ?? Infinity) <= bars[j].l) isSwing = false
        } else {
          if ((bars[j - k]?.h ?? 0) >= bars[j].h) isSwing = false
          if ((bars[j + k]?.h ?? 0) >= bars[j].h) isSwing = false
        }
      }
      if (!isSwing) continue
      const lvl = dir > 0 ? bars[j].l : bars[j].h
      if (dir > 0 ? lvl >= price : lvl <= price) continue
      const dist = Math.abs(price - lvl)
      if (dist < bestDist) { bestDist = dist; bestSwing = lvl }
    }
    if (bestSwing !== null) candidates.push(bestSwing)
  }

  // 5. Previous UTC-day high (SHORT) or low (LONG)
  {
    const d = new Date(bars[N - 1].ts)
    d.setUTCHours(0, 0, 0, 0)
    const today = d.getTime()
    let pdH = 0, pdL = Infinity
    for (let j = N - 1; j >= 0; j--) {
      const ts = bars[j].ts
      if (ts >= today) continue
      if (ts < today - 86_400_000) break
      if (bars[j].h > pdH) pdH = bars[j].h
      if (bars[j].l < pdL) pdL = bars[j].l
    }
    if (dir > 0 && pdL < price && pdL > 0) candidates.push(pdL)
    if (dir < 0 && pdH > price && pdH > 0) candidates.push(pdH)
  }

  // 6. Session volume POC: highest-volume $50 bucket since midnight UTC.
  //    Volume is tracked via BarAgg.ingest(price, ts, volUsd) and ingestBar().
  //    Preloaded bars from disk/REST now carry real USD turnover in bar.v.
  //    Live bars accumulate trade.notionalUsd per tick.
  {
    const d = new Date(bars[N - 1].ts)
    d.setUTCHours(0, 0, 0, 0)
    const dayStart = d.getTime()
    const volMap   = new Map<number, number>()
    for (let j = N - 1; j >= 0 && bars[j].ts >= dayStart; j--) {
      const bucket = Math.round(bars[j].c / 50) * 50
      volMap.set(bucket, (volMap.get(bucket) ?? 0) + (bars[j].v ?? 0))
    }
    let maxV = 0, poc = 0
    volMap.forEach((v, p) => { if (v > maxV) { maxV = v; poc = p } })
    if (poc > 0 && (dir > 0 ? poc < price : poc > price)) candidates.push(poc)
  }

  // Pick nearest candidate on the correct side of price
  const valid = candidates.filter(l => dir > 0 ? l <= price : l >= price)
  if (valid.length === 0) {
    return dir > 0 ? Math.floor(price / 250) * 250 : Math.ceil(price / 250) * 250
  }
  return valid.reduce((best, c) =>
    Math.abs(price - c) < Math.abs(price - best) ? c : best
  )
}

/**
 * Stop-loss level: next structural level past the entry for the SL placement.
 *
 * Deliberately EXCLUDES fractal swings and session POC.
 *
 * Why the two-function split:
 *   compositeLiquidityLevel (above) uses all 6 sources for ENTRY (L1).
 *   Swings and POC are useful for picking the nearest bid/ask level.
 *   BUT for the STOP (L2): 5-bar fractals over 480 bars produce hundreds of
 *   micro-fractal swing candidates that cluster every 5-20 bps.  73% of L2
 *   candidates from the full composite are micro-swings, and 96% of those are
 *   within 15 bps of the entry → trigger the 100 bps fallback.
 *   Round numbers ($250/$500/$1000) and previous-day extremes are macro-structural
 *   and predictable; they give a 97% structural-stop hit rate and no false-close
 *   candidates.
 *
 * Backtest comparison (4yr, MEXC 0%, exact runner logic):
 *   Full composite L2:       t=5.61****  moIR=0.93  avgSL=77.9bps  struct=36%
 *   Round+PDH/PDL L2 (this): t=7.43****  moIR=1.14  avgSL=51.9bps  struct=97%
 *   Per-year min t: full=1.84  round=2.09 (both across 2022-2026)
 */
function stopLiquidityLevel(bars: Bar[], dir: number, refPrice: number, minDistanceBps = 0): number {
  const N     = bars.length
  const price = refPrice
  const candidates: number[] = []

  // 1-3. Round-number levels
  candidates.push(
    dir > 0 ? Math.floor(price / 1000) * 1000 : Math.ceil(price / 1000) * 1000,
    dir > 0 ? Math.floor(price /  500) *  500 : Math.ceil(price /  500) *  500,
    dir > 0 ? Math.floor(price /  250) *  250 : Math.ceil(price /  250) *  250,
  )

  // 4. Previous UTC-day high (SHORT) or low (LONG) - macro daily structure
  {
    const d = new Date(bars[N - 1].ts)
    d.setUTCHours(0, 0, 0, 0)
    const today = d.getTime()
    let pdH = 0, pdL = Infinity
    for (let j = N - 1; j >= 0; j--) {
      const ts = bars[j].ts
      if (ts >= today)  continue
      if (ts < today - 86_400_000) break
      if (bars[j].h > pdH) pdH = bars[j].h
      if (bars[j].l < pdL) pdL = bars[j].l
    }
    if (dir > 0 && pdL < price && pdL > 0) candidates.push(pdL)
    if (dir < 0 && pdH > price && pdH > 0) candidates.push(pdH)
  }

  // NO swings: 5-bar fractals over 480 bars produce micro-structural noise for stops.
  // NO POC:    session POC shifts throughout the day and is unstable for stop placement.

  const valid = candidates
    .filter(l => dir > 0 ? l <= price : l >= price)
    .sort((a, b) => Math.abs(price - a) - Math.abs(price - b))
  if (valid.length === 0)
    return dir > 0 ? Math.floor(price / 250) * 250 : Math.ceil(price / 250) * 250

  if (minDistanceBps > 0) {
    const wideEnough = valid.find(l => Math.abs(price - l) / price * 10000 >= minDistanceBps)
    if (wideEnough !== undefined) return wideEnough
    // If all candidates are too close, use the farthest available structural
    // level rather than falling back to an arbitrary fixed 100bps stop.
    return valid[valid.length - 1]
  }

  return valid[0]
}

function isRoundStopZone(level: number): boolean {
  if (!Number.isFinite(level)) return false
  // Round stop levels used by stopLiquidityLevel are exact $250 increments.
  // Keep a tiny tolerance for floating-point / exchange decimal artefacts.
  return Math.abs(level / 250 - Math.round(level / 250)) * 250 <= 0.05
}

function applyStopZoneMoat(level: number, dir: number, moatUsd: number): number {
  return dir > 0 ? level - moatUsd : level + moatUsd
}

// ═══════════════════════════════════════════════════════════════════════════
// SCORING
// ═══════════════════════════════════════════════════════════════════════════

interface ScoreResult {
  /** Signed weighted sum of active signals. Positive → LONG, negative → SHORT. */
  score:    number
  /** Weighted-average hold period across active signals, clamped to [60, 240] min. */
  holdBars: number
  /** Human-readable list of contributing signals for logging. */
  signals:  string[]
}

/**
 * Computes the combined structural score at bar i.
 *
 * Weights updated April 2026 from 4-year (Apr 2022-Apr 2026) cross-regime
 * stability analysis. Each signal was tested individually on each of the 4 years;
 * "posYrs" = how many of the 4 years showed positive returns for that signal.
 *
 * Changes vs original 1yr-derived weights:
 *   Thu   34.41 → 20.00  posYrs=2/4 - strongest signal but regime-dependent;
 *                         reduced to cut concentration risk (fails 2022, 2024)
 *   Fri   10.84 → 6.00   posYrs=2/4 - pre-weekend effect inconsistent in bull runs
 *   H22   0 → +2.86      posYrs=4/4 - NEW signal; 22:00 UTC LONG (Asian pre-open
 *                         bid / post-US-close positioning), t=2.86** on 4yr
 *   H21   hold 60→120m   4yr analysis: 120m hold gives t=3.1** vs 60m t=1.5;
 *                         the bid persists longer than previously modelled
 *   BUYP↓ 15.49 → 8.00   posYrs=2/4 - selling-pressure signal fails when macro bullish
 *   USgap 6.87 → 15.00   posYrs=4/4, t=21.3*** - most stable signal in the full
 *                         dataset; was severely underweighted
 *   RSI↓  4.63 → REMOVED posYrs=1/4 - overbought RSI ≠ reversal in trending bull markets
 *
 * Retained signals (posYrs=3-4/4):
 *   Wed (+19.05/4yr), H21 (+17.90/4yr), BUYP↑ (+15.49/4yr), Sun (+15.05/4yr),
 *   Mon (+10.61/4yr), H20 (+9.49/4yr), H23 (-8.94/4yr), RSI↑ (+4.63/4yr),
 *   Rev24h (±5.00/4yr)
 *
 * 4yr backtest comparison (MEXC 0% fee, SMOOTH params):
 *   1yr-weights: t=4.42**** moIR=0.67   4yr-informed: t=4.24**** moIR=0.71
 *   Per-year: 2023 +0.60▲  2024 +0.86▲  2022 -1.06▼  2025 -0.87▼
 *   Trade-off accepted: better cross-regime robustness vs 2025-period performance
 */
function computeScore(bars: Bar[], i: number): ScoreResult {
  let w = 0, hW = 0, tW = 0
  const sigs: string[] = []

  const d   = new Date(bars[i].ts)
  const h   = d.getUTCHours()
  const m   = d.getUTCMinutes()
  const dow = d.getUTCDay()

  /** Add a signal: direction ±1, t-stat weight, suggested hold in bars. */
  function add(name: string, dir: number, wt: number, hold: number): void {
    w  += dir * wt
    hW += hold * wt
    tW += wt
    sigs.push(`${name}(${dir > 0 ? '+' : ''}${(dir * wt).toFixed(0)})`)
  }

  // Day-of-week structural signals
  // Thu 34.41→20: posYrs=2/4 - reduced, was over-concentrated (fails 2022, 2024)
  // Fri 10.84→6:  posYrs=2/4 - pre-weekend risk-off only works in range-bound regimes
  if (dow === 4) add('Thu',  -1, 20.00, 240)
  if (dow === 3) add('Wed',  +1, 19.05, 240)
  if (dow === 0) add('Sun',  +1, 15.05, 240)
  if (dow === 1) add('Mon',  +1, 10.61, 240)
  if (dow === 5) add('Fri',  -1,  6.00, 240)

  // Hour-of-day structural signals
  // H22 NEW: posYrs=4/4, t=2.86** - 22:00 UTC LONG (Asian session pre-open positioning)
  // H21 hold 60→120m: 120m hold gives t=3.1** on 4yr dataset vs t=1.5 at 60m
  if (h === 22) add('H22',  +1,  2.86, 120)
  if (h === 21) add('H21',  +1, 17.90, 120)
  if (h === 20) add('H20',  +1,  9.49,  60)
  if (h === 23) add('H23',  -1,  8.94,  30)

  // BUYP: sustained buying/selling pressure via 60-bar avg close position
  // BUYP↓ 15.49→8: posYrs=2/4 - selling pressure signal fails when market is fundamentally bullish
  if (i >= 60) {
    const bp = avgCP(bars, i, 60)
    if (bp > 0.58) add('BUYP↑', +1, 15.49, 240)
    if (bp < 0.42) add('BUYP↓', -1,  8.00, 240)
  }

  // US gap fade: opening-range gap fade at 13:00 UTC and 14:00 UTC
  // The condition fires at h=13 m=0..15 and h=14 m=0..15 - 32 min/day total,
  // NOT the full 75-min window originally commented. This restricted window is
  // what was validated in the 4yr backtest (t=21.3**** on the faded signal);
  // expanding to the full 13:00-14:15 range has NOT been validated and could
  // dilute the edge. Keep as-is and track against the validated config.
  // USgap 6.87→15: posYrs=4/4 - most stable signal in the 4yr dataset
  if (i >= 60 && (h === 13 || (h === 14 && m <= 15)) && m <= 15) {
    const gap = lbRet(bars, i, 60)
    if (Math.abs(gap) > 5) add('USgap', gap > 0 ? -1 : +1, 15.00, 120)
  }

  // RSI mean-reversion
  // RSI↑ KEPT: posYrs=4/4 - oversold conditions reliably predict bounces across all regimes
  // RSI↓ REMOVED: was -4.63, posYrs=1/4 - overbought does not predict reversal in bull markets
  if (i >= 60) {
    const r = rsi(bars, i, 60)
    if (r < 30) add('RSI↑', +1, 4.63, 120)
  }

  // 24h reversion: large daily moves partially fade
  if (i >= 1440) {
    const dy = lbRet(bars, i, 1440)
    if (Math.abs(dy) > 30) add('Rev24h', dy > 0 ? -1 : +1, 5.0, 240)
  }

  return {
    score:    w,
    holdBars: tW > 0 ? Math.max(MIN_HOLD_BARS, Math.min(MAX_HOLD_BARS, Math.round(hW / tW))) : 120,
    signals:  sigs
  }
}

// ═══════════════════════════════════════════════════════════════════════════
// TYPES
// ═══════════════════════════════════════════════════════════════════════════

interface Position {
  side:           'long' | 'short'
  entryPrice:     number
  sizeBtc:        number
  entryBar:       number    // bar index when entered
  entryTs:        number    // Unix ms
  exitDeadlineBar: number   // bar index of next time-exit deadline
  /** Price at which the soft SL fires - set at entry, never changes. */
  softSlPrice:    number
  /** Price at which hard SL fires - set at entry, never changes. */
  hardSlPrice:    number
  /** Tracks peak favourable P&L in bps for status display. */
  peakFavBps:     number
  /** True while waiting for a maker limit at softSlPrice to fill. */
  inMakerSL:      boolean
  /** Wall-clock time when the maker SL was triggered (for timeout). */
  makerSLStartMs: number
  /** Active reduce-only maker SL order resting at softSlPrice, if any. */
  makerSLOrderId?: string | null
  makerSLOrderPx?: number
  /** Score + signal list + ATR at entry, for attribution. */
  entryScore?:    number
  entrySignals?:  string[]
  entryATR60?:    number
  entryImmBps?:   number
}

interface ClosedTrade {
  side:     string
  grossBps: number
  netBps:   number
  netUsd:   number
  reason:   string     // 'time' | 'soft_sl' | 'hard_sl'
}

interface SavedState {
  /** Baseline capital for full-run P&L/DD. Must survive restarts. */
  initialCapital?: number
  equity:        number
  peak:          number
  /** Lowest mark-to-market equity seen; used for return range display. */
  trough?:       number
  maxDdPct:      number
  totalFees:     number
  closedTrades:  ClosedTrade[]
  position:      Position | null
  /** Per-UTC-day stop counts, keyed 'YYYY-MM-DD_long' or 'YYYY-MM-DD_short'. */
  stopCounts:    Record<string, number>
  /** Wall-clock timestamp when the engine became flat, if flat. */
  flatSinceMs?:  number | null
  savedAt:       number
}

// ═══════════════════════════════════════════════════════════════════════════
// MAIN
// ═══════════════════════════════════════════════════════════════════════════

async function main(): Promise<void> {
  const config = loadConfig()
  if (!config.demoApiKey || !config.demoApiSecret) {
    throw new Error('Missing BYBIT_DEMO_API_KEY / BYBIT_DEMO_API_SECRET in environment')
  }

  const startedAt = Date.now()

  const rest = new BybitRestClient({
    demoApiKey:    config.demoApiKey,
    demoApiSecret: config.demoApiSecret,
    testnetApiKey: '', testnetApiSecret: '',
    useTestnet:    false,
    recvWindow:    config.recvWindow
  })
  const demo = new DemoExecutor(
    config.demoApiKey,
    config.demoApiSecret,
    config.recvWindow
  )

  const DIR = '/tmp/txocap-combined'
  const STATE_FILE = `${DIR}/state.json`
  const LOCK_FILE  = `${DIR}/runner.lock`
  fs.mkdirSync(DIR, { recursive: true })

  // ── Exclusive process lock ───────────────────────────────────────────────
  // Prevents two instances from managing the same Bybit position simultaneously.
  // Lock file contains PID + timestamp. Released on every exit path.
  // IMPORTANT: never `rm -f runner.lock` manually - always `kill -SIGINT <pid>`.
  if (fs.existsSync(LOCK_FILE)) {
    const pid = parseInt(fs.readFileSync(LOCK_FILE, 'utf8').split('\n')[0].trim())
    try {
      process.kill(pid, 0)   // signal 0 = existence check only
      log('ABORT', `Another instance running (PID ${pid}). Stop it first: kill -SIGINT ${pid}`)
      process.exit(1)
    } catch (e: any) {
      if (e.code === 'ESRCH') {
        log('LOCK', `Stale lock from PID ${pid} (dead process) - taking over`)
      } else { throw e }
    }
  }
  fs.writeFileSync(LOCK_FILE, `${process.pid}\n${new Date().toISOString()}\n`)
  const releaseLock = (): void => { try { fs.unlinkSync(LOCK_FILE) } catch {} }
  process.on('exit',             releaseLock)
  process.on('SIGINT',           () => { releaseLock(); process.exit(0) })
  process.on('SIGTERM',          () => { releaseLock(); process.exit(0) })
  process.on('uncaughtException',  e => { log('FATAL', e.stack ?? e.message); releaseLock(); process.exit(1) })
  process.on('unhandledRejection', (r) => { log('FATAL', r instanceof Error ? (r.stack ?? r.message) : String(r)); releaseLock(); process.exit(1) })

  // ── State persistence ────────────────────────────────────────────────────
  // Written after every fill, exit, and status tick.
  // On restart: position is re-verified against Bybit before being adopted.

  let savedState: SavedState | null = null
  try {
    if (fs.existsSync(STATE_FILE)) {
      savedState = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')) as SavedState
      const ageMin = Math.round((Date.now() - (savedState.savedAt ?? 0)) / 60_000)
      log('RESUME', `State file found (${ageMin}min old) - verifying Bybit position...`)
    }
  } catch { savedState = null }

  // ── Engine state ─────────────────────────────────────────────────────────
  const barAgg     = new BarAgg()
  let position:       Position | null      = savedState?.position ?? null
  let initialCapital: number               = savedState?.initialCapital ?? STARTING_CAPITAL
  let equity:         number               = savedState?.equity ?? STARTING_CAPITAL
  let peak:           number               = Math.max(savedState?.peak ?? initialCapital, initialCapital, equity)
  let maxDdPct:       number               = savedState?.maxDdPct ?? 0
  const savedTrough                         = savedState?.trough
  let trough:        number                = Number.isFinite(savedTrough)
    ? savedTrough as number
    : Math.min(initialCapital, equity, peak * (1 - maxDdPct))
  let flatSinceMs:   number | null         = position
    ? null
    : (savedState?.flatSinceMs ?? savedState?.savedAt ?? Date.now())
  let totalFees:    number               = savedState?.totalFees ?? 0
  let closedTrades: ClosedTrade[]        = savedState?.closedTrades ?? []
  /** stopCounts[dayKey] = number of stops for that day+direction.
   *  dayKey = 'YYYY-MM-DD_long' | 'YYYY-MM-DD_short' */
  const stopCounts: Record<string, number> = savedState?.stopCounts ?? {}
  let busy          = false
  let stopping      = false
  let lastCdUntil   = -1          // per-bar cooldown after a MISS
  let statusTick    = 0           // increments each status timer tick
  let lastBal       = equity
  let lastBalAt     = 0
  let lastBalWarnAt = 0

  function saveState(): void {
    try {
      const s: SavedState = {
        initialCapital, equity, peak, trough, maxDdPct, totalFees,
        closedTrades, position, stopCounts, flatSinceMs,
        savedAt: Date.now()
      }
      fs.writeFileSync(STATE_FILE, JSON.stringify(s, null, 2))
    } catch {}
  }

  function noteEquityRange(value: number): void {
    if (!Number.isFinite(value)) return
    if (value > peak) peak = value
    if (value < trough) trough = value
    const dd = peak > 0 ? (peak - value) / peak : 0
    if (dd > maxDdPct) maxDdPct = dd
  }

  function returnPctFromInitial(value: number): number {
    return Number.isFinite(value) && initialCapital > 0
      ? (value - initialCapital) / initialCapital * 100
      : NaN
  }

  function markFlat(ts = Date.now()): void {
    flatSinceMs = ts
  }

  function markOpen(): void {
    flatSinceMs = null
  }

  function balanceRejectReason(bal: number, reference: number, allowLargeJump = false): string | null {
    if (!Number.isFinite(bal) || bal < 0) return 'non-finite/negative'
    if (reference > 10 && bal < 1) return `near-zero reading vs reference $${reference.toFixed(2)}`
    if (!allowLargeJump && reference > 10) {
      const rel = Math.abs(bal - reference) / reference
      if (rel > 0.75) return `implausible ${(rel * 100).toFixed(0)}% jump vs reference $${reference.toFixed(2)}`
    }
    return null
  }

  async function readVerifiedBalance(reason: string, allowLargeJump = false): Promise<number | null> {
    const reference = lastBal > 0 ? lastBal : (equity > 0 ? equity : STARTING_CAPITAL)
    for (let attempt = 1; attempt <= 3; attempt++) {
      try {
        const bal = await demo.getBalance()
        const reject = balanceRejectReason(bal, reference, allowLargeJump)
        if (!reject) return bal
        const now = Date.now()
        if (now - lastBalWarnAt > 60_000) {
          log('WARN', `Ignoring suspicious Bybit balance for ${reason}: $${bal.toFixed(2)} (${reject}); keeping $${lastBal.toFixed(2)}`)
          lastBalWarnAt = now
        }
      } catch (e: unknown) {
        const now = Date.now()
        if (now - lastBalWarnAt > 60_000) {
          log('WARN', `Balance read failed for ${reason}: ${(e as Error).message}; keeping $${lastBal.toFixed(2)}`)
          lastBalWarnAt = now
        }
      }
      await sleep(250 * attempt)
    }
    return null
  }

  /** Throttled, guarded balance fetch - never accepts transient zero/invalid readings. */
  async function fetchBal(allowLargeJump = false): Promise<number> {
    const now = Date.now()
    if (now - lastBalAt < 5_000) return lastBal
    lastBalAt = now
    const verified = await readVerifiedBalance('status', allowLargeJump)
    if (verified !== null) lastBal = verified
    return lastBal
  }

  /** Force a fresh guarded balance read after exits/reconciliation. */
  async function fetchBalFresh(allowLargeJump = false): Promise<number> {
    lastBalAt = 0
    return fetchBal(allowLargeJump)
  }

  /** Returns the live mid price from the most recent sealed bar. */
  function liveMid(): number {
    const bars = barAgg.get()
    return bars.length > 0 ? bars[bars.length - 1].c : 0
  }

  // ── Startup reconciliation ───────────────────────────────────────────────
  // Four cases: (state+Bybit), (state only), (Bybit only), (neither)
  const bybitPosCurrent = await demo.getPosition().catch(() => null)

  if (savedState?.position && bybitPosCurrent) {
    // Both sides agree - verify they match before resuming
    const sp        = savedState.position
    const sizeMatch = Math.abs(bybitPosCurrent.size - sp.sizeBtc) < 0.002
    const sideMatch = (bybitPosCurrent.side === 'Buy') === (sp.side === 'long')
    if (sizeMatch && sideMatch) {
      log('RESUME', `✓ Resuming ${sp.side.toUpperCase()} ${sp.sizeBtc}btc @${sp.entryPrice.toFixed(1)}`)
      // Re-arm the exchange SL at L3 - it may have been cleared on the
      // previous shutdown or was never set (pre-upgrade positions).
      try {
        await rest.setStopLoss('BTCUSDT', sp.hardSlPrice)
        log('RESUME', `Exchange SL re-armed at L3=$${sp.hardSlPrice.toFixed(0)}`)
      } catch (e: unknown) {
        log('WARN', `Failed to re-arm exchange SL: ${(e as Error).message}`)
      }
    } else {
      log('RESUME', `⚠ State/Bybit mismatch - closing Bybit and starting fresh`)
      await rest.setStopLoss('BTCUSDT', 0).catch(() => {})  // clear any exchange SL before closing
      await demo.placeMarketOrder((bybitPosCurrent.side === 'Buy' ? 'Sell' : 'Buy'), bybitPosCurrent.size, true)
      await sleep(1500)
      savedState = null; position = null; markFlat()
    }
  } else if (savedState?.position && !bybitPosCurrent) {
    // State thinks we have a position but Bybit has none.
    // Most likely: time exit or SL filled on Bybit but runner crashed/restarted
    // before _recordExit could run. Reconcile equity from Bybit balance.
    const sp = savedState.position
    const actualBal = await fetchBalFresh().catch(() => null)
    if (actualBal !== null && Math.abs(actualBal - equity) > 1) {
      // The Bybit balance reflects the closed position P&L.
      // Update equity to match, estimate the fill at hardSlPrice as worst case.
      const dir = sp.side === 'long' ? +1 : -1
      const estimatedFill = actualBal > equity
        ? (equity + actualBal) / 2   // won - can't know exact fill, use midpoint as proxy
        : sp.hardSlPrice              // lost - assume hard SL fired
      const grossBps = dir * (estimatedFill - sp.entryPrice) / sp.entryPrice * 10000
      const netUsd   = actualBal - equity  // ground truth: balance delta
      log('RESUME', `⚠ Position closed externally. Bybit balance=$${actualBal.toFixed(2)} saved=$${equity.toFixed(2)} delta=$${netUsd.toFixed(2)}`)
      equity = actualBal
      peak   = Math.max(peak, equity)
      closedTrades.push({ side: sp.side, grossBps, netBps: grossBps - FEE_BPS * 2, netUsd, reason: 'external_close' })
    } else {
      log('RESUME', `⚠ State has position but Bybit has none - cleared externally, equity unchanged`)
    }
    position = null; markFlat()
  } else if (!savedState?.position && bybitPosCurrent) {
    // Orphan Bybit position (e.g. from a crash before state was written)
    log('RESUME', `⚠ Orphan Bybit position - closing`)
    await demo.placeMarketOrder((bybitPosCurrent.side === 'Buy' ? 'Sell' : 'Buy'), bybitPosCurrent.size, true)
    await sleep(1500)
    markFlat()
    // Fall through: if no state at all, still reset balance below
  }
  if (!savedState) {
    // First ever run - reset demo account to STARTING_CAPITAL
    const curBal = await fetchBalFresh(true)
    const diff   = Math.round(curBal - STARTING_CAPITAL)
    if (Math.abs(diff) > 1) {
      await rest.applyDemoFunds([{ coin: 'USDT', amountStr: String(Math.abs(diff)) }], diff > 0 ? 1 : 0)
      log('RESET', `Balance reset: $${curBal.toFixed(2)} → $${STARTING_CAPITAL}`)
      await sleep(1500)
      lastBalAt = 0
    }
  } else if (!position && !bybitPosCurrent) {
    // Flat restart: Bybit wallet is the source of truth. This repairs any
    // previous theoretical/demo accounting drift while preserving the trade log.
    const actualBal = await fetchBalFresh().catch(() => null)
    if (actualBal !== null && Math.abs(actualBal - equity) > 0.50) {
      log('RESUME', `Flat equity sync: state=$${equity.toFixed(2)} → Bybit=$${actualBal.toFixed(2)} (delta=$${(actualBal - equity).toFixed(2)})`)
      equity = actualBal
      noteEquityRange(equity)
      saveState()
    }
  }

  if (!savedState?.position) await rest.cancelAll({ category: 'linear', symbol: 'BTCUSDT' })
  await demo.setLeverage(LEVERAGE)

  const startBal = initialCapital
  if (!savedState) equity = await fetchBalFresh(true)
  peak = Math.max(peak, initialCapital, equity)
  noteEquityRange(equity)
  log('START', `initial=$${startBal.toFixed(2)} equity=$${equity.toFixed(2)} | risk=${RISK_PCT*100}% structSL=L1→L2→L3 hardSL=L3 fee=${FEE_BPS}bps lev=${LEVERAGE}x`)
  log('START', `SMOOTH params: longEntry=${LONG_ENTRY_THRESH} shortEntry=${SHORT_ENTRY_THRESH} ext=${EXT_THRESH} hold=${MIN_HOLD_BARS}-${MAX_HOLD_BARS}m hardCap=${HARD_CAP_BARS}m minSoftSL=${MIN_SOFT_SL_BPS}bps sweepMoat=$${STOP_ZONE_MOAT_USD} stopCap=${STOP_CAP_PER_DAY}/day`)
  log('START', `Momentum halts: 72h/${HALT_72H_BPS}bps + 7d/${HALT_7D_BPS}bps + 14d/${HALT_14D_BPS}bps | Entry: composite level wait=${LEVEL_WAIT_BARS}min imm=clamp(${MIN_IMMEDIATE_ENTRY_BPS},${MAX_IMMEDIATE_ENTRY_BPS},${IMMEDIATE_ENTRY_ATR_MULT}×ATR60) | Circuit=${(MAX_ALLOWED_DD_PCT*100).toFixed(0)}%DD holdScale=lin12f50`)
  log('START', `Runs until SIGINT`)

  // ═══════════════════════════════════════════════════════════════════════
  // STOP-COUNT HELPERS (daily stop cap)
  // ═══════════════════════════════════════════════════════════════════════

  function stopCountKey(side: 'long' | 'short'): string {
    return `${new Date().toISOString().slice(0, 10)}_${side}`
  }

  function stopsToday(side: 'long' | 'short'): number {
    return stopCounts[stopCountKey(side)] ?? 0
  }

  function recordStop(side: 'long' | 'short'): void {
    const key = stopCountKey(side)
    stopCounts[key] = (stopCounts[key] ?? 0) + 1
  }

  // ═══════════════════════════════════════════════════════════════════════
  // ENTRY EXECUTION
  // ═══════════════════════════════════════════════════════════════════════

  /**
   * Bid/ask at the nearest composite liquidity level (L1).
   *
   * L1 = nearest of {$250/$500/$1000 round numbers, fractal swing, PDL/PDH, session POC}
   * in the favourable direction. If already within adaptive ATR threshold: enter at mid immediately.
   * Otherwise: anchor limit at L1, wait up to LEVEL_WAIT_BARS (30 min) for fill.
   *
   * Why it works: price is drawn to round numbers, tested swing levels, and yesterday's
   * extremes. Entering exactly at L1 means entering where structural support/resistance
   * exists. If price never reaches L1, the LEVEL_MISS is itself useful information:
   * the market is moving away from where we want to buy, not toward it.
   *
   * SL levels computed at signal time:
   *   L2 (soft SL):  next round-number/PDH/PDL beyond L1
   *   L3 (hard SL):  next round-number/PDH/PDL beyond L2, set as exchange stop-loss
   * Position sized to 3% equity risk at L3 (worst case = exchange SL fires at L3).
   */
  async function executeEntry(
    side:     'long' | 'short',
    holdBars: number,
    score:    number,
    signals:  string[],
    barIdx:   number
  ): Promise<void> {
    if (busy || stopping || position) return
    busy = true

    // ── Entry level L1 and structural stop level L2 ───────────────────────
    // L1: the composite level we bid at (nearest support/resistance).
    // L2: the NEXT composite level past L1 in the same direction.
    //     When price breaks L2, L1 has genuinely failed - not noise.
    //
    // Backtest (4yr, MEXC 0%):
    //   Fixed 100bps SL: t=5.62  moIR=0.85  avgSL=100bps
    //   L1→L2 composite: t=6.82  moIR=1.02  avgSL=57bps  (moIR first time >1.0)
    //
    // Position sizing scales with actual stop distance: closer levels →
    // larger size for the same 3% equity risk - gains 1.5-2× on winning trades.
    const dir    = side === 'long' ? +1 : -1
    const bySide = side === 'long' ? 'Buy' : 'Sell'
    const mid    = liveMid()
    const bars   = barAgg.get()
    const target = compositeLiquidityLevel(bars, dir)         // L1: entry (all 6 sources)
    // L2: soft SL - round numbers + PDH/PDL only (no micro-fractal swings)
    const slRef  = dir > 0 ? target - 0.01 : target + 0.01
    const baseSlL2 = stopLiquidityLevel(bars, dir, slRef, MIN_SOFT_SL_BPS) // public L2 stop-zone anchor
    const baseSlBps = Math.abs(target - baseSlL2) / target * 10000
    const isStructSL = baseSlBps >= 15 && baseSlBps <= 200
    const stopZoneMoatUsd = isStructSL && isRoundStopZone(baseSlL2)
      && adverseWickRatio(bars, barAgg.count() - 1, 15, dir) >= 0.55
      ? STOP_ZONE_MOAT_USD : 0
    const slL2 = stopZoneMoatUsd > 0
      ? applyStopZoneMoat(baseSlL2, dir, stopZoneMoatUsd)
      : baseSlL2
    const slBps = Math.abs(target - slL2) / target * 10000    // distance L1→moated L2

    // L3: hard SL - next structural level beyond L2.
    //
    // Previous: hardSL = L2 - 25bps (fixed, arbitrary buffer).
    // Now:      hardSL = L3 (next round-number/PDH/PDL level past L2).
    //
    // Backtest comparison (4yr, final config):
    //   Fixed 25bps: moIR=1.00  maxDD=43.6%  hard SL rate=16%
    //   Fixed 50bps: moIR=1.09  maxDD=44.6%  hard SL rate=5%
    //   L3 struct:   moIR=1.08  maxDD=37.2%  hard SL rate=9%  ← chosen
    //
    // Why L3 wins on maxDD despite ~same moIR as fixed 50bps:
    //   L3 gap adapts to volatility regime: wider in crashes ($500/$1000 step
    //   dominates when PDH/PDL are far away), tighter in calm markets ($250 step).
    //   In June 2022: L3 triggered 4 hard SL events vs Fixed25's 14 (-1/3 the cost).
    //
    // L3 gap capped at [10, 100] bps to prevent degenerate cases.
    const l3ref  = dir > 0 ? slL2 - 0.01 : slL2 + 0.01
    const slL3   = stopLiquidityLevel(bars, dir, l3ref)         // L3: hard SL level
    const l3Gap  = Math.max(10, Math.min(100, Math.abs(slL2 - slL3) / slL2 * 10000))

    // Apply structural SL range guard: if L2 is out of [15, 200] bps, fall back to fixed
    const effectiveSoftSlPx = isStructSL
      ? slL2
      : (dir > 0 ? target * (1 - SOFT_SL_BPS / 10000) : target * (1 + SOFT_SL_BPS / 10000))
    // Hard SL: L3 structural when L2 is structural, else fixed fallback (100 + 25bps)
    const effectiveHardSlPx = isStructSL
      ? (dir > 0 ? slL2 * (1 - l3Gap / 10000) : slL2 * (1 + l3Gap / 10000))
      : (dir > 0 ? target * (1 - (SOFT_SL_BPS + HARD_SL_BUFFER_BPS) / 10000)
                 : target * (1 + (SOFT_SL_BPS + HARD_SL_BUFFER_BPS) / 10000))

    // Position sizing: risk 3% at L3 (hard SL), not L2 (soft SL).
    //
    // Rationale: when the exchange SL fires at L3, the actual loss is L1→L3.
    // Sizing to L2 would mean the worst-case loss is L1→L3/L1→L2 × 3%,
    // which can be 6%+ when L3 is twice L2 away. Sizing to L3 guarantees
    // every stop - whether at L2 (soft, maker) or L3 (hard, exchange) -
    // costs at most 3% of equity.
    //
    // Cost: ~5% lower moIR (winners are proportionally smaller).
    // Benefit: hard stop is always exactly 3% max loss. No surprise 6% stops.
    //
    // Structural:  sizingBps = L1→L2 + L2→L3  (= L1→L3 total distance)
    // Fallback:    sizingBps = SOFT_SL_BPS + HARD_SL_BUFFER_BPS = 100 + 25 = 125bps
    const effectiveSlBps = isStructSL
      ? slBps + l3Gap
      : SOFT_SL_BPS + HARD_SL_BUFFER_BPS

    const distBps = Math.abs(mid - target) / mid * 10000
    const immBps  = immediateEntryBps(bars)

    const fillRef = distBps > immBps ? target : mid
    const notional = Math.min(
      equity * RISK_PCT / (effectiveSlBps / 10000),   // 3% risk at L3
      equity * LEVERAGE
    )
    let sizeBtc = Math.max(0.001, Math.round(notional / fillRef * 1000) / 1000)

    const moatStr = stopZoneMoatUsd > 0 ? ` moat=$${stopZoneMoatUsd}` : ''
    log('SIGNAL', `${side.toUpperCase()} score=${score.toFixed(0)} [${signals.join(' ')}] ${sizeBtc}btc hold=${holdBars}m L1=$${target.toFixed(0)} L2=$${effectiveSoftSlPx.toFixed(0)} L3=$${effectiveHardSlPx.toFixed(0)} (${effectiveSlBps.toFixed(0)}bps to L3) imm=${immBps.toFixed(1)}bps${moatStr}`)

    let result: Awaited<ReturnType<typeof demo.chaseLimitOrder>>

    if (distBps <= immBps) {
      // ── Already close enough to L1: enter immediately as aggressive maker ──
      result = await demo.chaseLimitOrder(bySide, sizeBtc, liveMid, 6, 3_000, false)
    } else {
      // ── Rest one anchored order at composite liquidity level ──────────────
      // Do NOT cancel/recreate at the same L1 each minute. There is no edge in
      // surrendering queue priority just to place the identical PostOnly order.
      // We place once, poll it, and cancel only on fill/partial/timeout.
      log('LEVEL_BID', `${side.toUpperCase()} resting at $${target.toFixed(0)} for ${LEVEL_WAIT_BARS}min (${distBps.toFixed(1)}bps > imm${immBps.toFixed(1)} - composite level)`)

      const started = Date.now()
      const order = await demo.placeLimitOrder(bySide, sizeBtc, target, false)
      result = { filled: false, avgPrice: 0, orderId: order?.orderId ?? null, attempts: 1, elapsedMs: 0 }

      if (order) {
        for (let attempt = 1; attempt <= LEVEL_WAIT_BARS; attempt++) {
          await sleep(60_000)
          const status = await demo.getOrderStatus(order.orderId).catch(() => null)
          if (status?.status === 'Filled') {
            result = { filled: true, avgPrice: status.avgPrice || target, orderId: order.orderId, attempts: attempt, elapsedMs: Date.now() - started }
            break
          }
          if ((status?.cumExecQty ?? 0) > 0) {
            // Partial fill — accept actual executed size and cancel the rest so
            // engine state matches Bybit. This avoids pretending full size filled.
            await demo.cancelOrder(order.orderId).catch(() => {})
            sizeBtc = Math.max(0.001, Math.round((status?.cumExecQty ?? sizeBtc) * 1000) / 1000)
            result = { filled: true, avgPrice: status?.avgPrice || target, orderId: order.orderId, attempts: attempt, elapsedMs: Date.now() - started }
            log('LEVEL_FILL', `Partial fill accepted: ${sizeBtc}btc @${result.avgPrice.toFixed(1)}`)
            break
          }
          if (status === null || status.status === 'Cancelled' || status.status === 'Rejected') {
            // Order disappeared without execution. Do not recreate at the same
            // level; let the next bar/signal decide whether to try again.
            break
          }
        }
        if (!result.filled) await demo.cancelOrder(order.orderId).catch(() => {})
      }

      if (!result.filled) {
        // Level not reached - signal window expired, skip this instance
        log('LEVEL_MISS', `$${target.toFixed(0)} not filled in ${LEVEL_WAIT_BARS}min - signal expired (score=${score.toFixed(0)})`)
        lastCdUntil = barIdx + 1
        busy = false
        return
      }
    }

    if (result.filled && result.avgPrice) {
      const entryFeeUsd = result.avgPrice * sizeBtc * (FEE_BPS / 10000)
      equity    -= entryFeeUsd
      totalFees += entryFeeUsd

      markOpen()
      position = {
        side, entryPrice: result.avgPrice, sizeBtc,
        entryBar: barIdx, entryTs: Date.now(),
        exitDeadlineBar: barIdx + holdBars,
        softSlPrice: effectiveSoftSlPx,  // L2: next composite level past entry
        hardSlPrice: effectiveHardSlPx,  // L3: structural taker backstop
        peakFavBps: 0,
        inMakerSL:      false,
        makerSLStartMs: 0,
        makerSLOrderId: null,
        makerSLOrderPx: 0,
        entryScore:  score,
        entrySignals: [...signals],
        entryATR60:  atrBps(bars, barAgg.count() - 1, 60),
        entryImmBps: immBps,
      }

      const entryMode = distBps > immBps ? `L1@$${target.toFixed(0)}` : `mid<=${immBps.toFixed(1)}bps`
      log('FILL', `${side.toUpperCase()} ${sizeBtc}btc @${result.avgPrice.toFixed(0)} [${entryMode}] L2(softSL)=$${effectiveSoftSlPx.toFixed(0)} L3(hardSL)=$${effectiveHardSlPx.toFixed(0)} stop=${effectiveSlBps.toFixed(0)}bps+${l3Gap.toFixed(0)}bps${moatStr} (${result.attempts}att ${(result.elapsedMs/1000).toFixed(1)}s)`)
      saveState()

      // ── Set exchange-managed stop-loss at L3 ────────────────────────────
      // This is the backstop: Bybit closes the position automatically if
      // price reaches L3, regardless of whether the runner is running or
      // the maker SL at L2 has been triggered. Protects against:
      //   - Runner crashes / network outages
      //   - Flash crashes that gap through L2 before bar-close detection
      //   - Intrabar moves that recover by bar close (bar-close SL misses them)
      try {
        await rest.setStopLoss('BTCUSDT', effectiveHardSlPx)
        log('SL_SET', `Exchange SL set at L3=$${effectiveHardSlPx.toFixed(0)} (${effectiveSlBps.toFixed(0)}bps from entry)`)
      } catch (e: unknown) {
        log('WARN', `Failed to set exchange SL: ${(e as Error).message} - runner SL still active`)
      }

      // Verify Bybit position matches expected size - close any excess
      // Wrapped in try/finally to ensure busy is released even if the
      // excess close throws (e.g. network error during verification).
      try {
        await sleep(1_000)
        const actual = await demo.getPosition().catch(() => null)
        if (!actual) {
          // A fill callback arrived but the exchange is already flat. Treat
          // Bybit as source-of-truth and clear the engine position to avoid a
          // later phantom exchange_sl record.
          const actualBal = await fetchBalFresh().catch(() => NaN)
          log('WARN', `Entry fill reported but Bybit has zero position - clearing engine position${Number.isFinite(actualBal) ? `, bal=$${actualBal.toFixed(2)}` : ''}`)
          if (Number.isFinite(actualBal)) {
            equity = actualBal
            noteEquityRange(equity)
          }
          position = null; markFlat()
          saveState()
          busy = false
          return
        }
        if (Math.abs(actual.size - sizeBtc) > 0.001) {
          const excess = Math.round((actual.size - sizeBtc) * 1000) / 1000
          if (excess > 0) {
            log('WARN', `Bybit shows ${actual.size}btc, expected ${sizeBtc}btc - closing ${excess}btc excess`)
            await demo.placeMarketOrder((side === 'long' ? 'Sell' : 'Buy'), excess, true)
            log('FIX', `Excess closed`)
          }
        }
      } catch (e: unknown) {
        log('WARN', `Position size verification failed: ${(e as Error).message}`)
      }
    } else {
      // Immediate entry miss: 1-bar cooldown then retry
      lastCdUntil = barIdx + 1
      log('MISS', `No fill - retry next bar (score=${score.toFixed(0)})`)
    }

    busy = false
  }

  // ═══════════════════════════════════════════════════════════════════════
  // EXIT EXECUTION
  // ═══════════════════════════════════════════════════════════════════════

  /**
   * Execute a time-based exit.
   *
   * Places a maker limit 1 tick inside the spread. Keeps the same order alive
   * as long as price hasn’t moved — no cancel/replace on every poll cycle.
   * Only replaces when mid has shifted by more than 1 tick from the order price.
   *
   * Falls back to market after MAKER_EXIT_TIMEOUT_MS if no fill.
   */
  async function executeTimeExit(barIdx: number): Promise<void> {
    if (busy || stopping || !position) return
    busy = true
    const pos      = position
    const exitSide = pos.side === 'long' ? 'Sell' : 'Buy'

    let fillPrice  = 0
    let wasTaker   = false
    const giveUpAt = Date.now() + MAKER_EXIT_TIMEOUT_MS

    let activeOrderId: string | null = null
    let activeOrderPx = 0

    while (Date.now() < giveUpAt) {
      // Target: 1 tick inside the spread (front of queue as maker)
      const targetPx = exitSide === 'Sell'
        ? Math.round((liveMid() - TICK) * 10) / 10
        : Math.round((liveMid() + TICK) * 10) / 10

      if (activeOrderId) {
        // Price hasn’t moved enough — poll the existing order instead of replacing
        if (Math.abs(targetPx - activeOrderPx) <= TICK) {
          const status = await demo.getOrderStatus(activeOrderId).catch(() => null)
          if (status?.status === 'Filled') {
            fillPrice = status.avgPrice
            log('EXIT_FILL', `maker @${fillPrice.toFixed(1)}`)
            activeOrderId = null
            break
          }
          if (status === null || status.cumExecQty > 0) {
            // Order gone from open-order list (filled and moved to history)
            // or partially filled — verify via position
            const bybitPos = await demo.getPosition().catch(() => null)
            if (!bybitPos || bybitPos.size < 0.001) {
              fillPrice = liveMid()
              log('EXIT_FILL', `maker confirmed via position check @${fillPrice.toFixed(1)}`)
              activeOrderId = null
              break
            }
            // Still open but order gone — replace
            activeOrderId = null
          } else {
            // Order still live at the right price — just wait
            await sleep(1_000)
            continue
          }
        } else {
          // Price shifted — cancel and replace at new level
          const oldPx = activeOrderPx
          await demo.cancelOrder(activeOrderId).catch(() => {})
          activeOrderId = null
          log('EXIT_REPRICE', `${exitSide} repricing ${oldPx.toFixed(1)} → ${targetPx.toFixed(1)}`)
        }
      }

      // Place fresh order
      const order = await demo.placeLimitOrder(exitSide, pos.sizeBtc, targetPx, true)
      if (order) {
        activeOrderId = order.orderId
        activeOrderPx = targetPx
      }
      await sleep(3_000)

      // Check if position already gone (exchange SL fired or order filled
      // faster than the 3s sleep)
      const bybitPos = await demo.getPosition().catch(() => null)
      if (!bybitPos || bybitPos.size < 0.001) {
        fillPrice = liveMid()
        log('EXIT_FILL', `position cleared during time exit @${fillPrice.toFixed(1)}`)
        if (activeOrderId) { await demo.cancelOrder(activeOrderId).catch(() => {}); activeOrderId = null }
        break
      }
    }

    // Clean up any remaining open order
    if (activeOrderId) await demo.cancelOrder(activeOrderId).catch(() => {})

    if (!fillPrice) {
      wasTaker  = true
      log('MKTFB', `time exit - maker failed ${MAKER_EXIT_TIMEOUT_MS / 60_000}min, market fallback`)
      await demo.placeMarketOrder(exitSide, pos.sizeBtc, true)
      await sleep(1_500)
      fillPrice = liveMid()
    }

    await _recordExit(pos, fillPrice, wasTaker, 'time', barIdx)
  }

  async function cancelMakerSlOrder(pos: Position): Promise<void> {
    if (!pos.makerSLOrderId) return
    await demo.cancelOrder(pos.makerSLOrderId).catch(() => {})
    pos.makerSLOrderId = null
    pos.makerSLOrderPx = 0
  }

  /**
   * Maker SL exit.
   *
   * Phase 1 (NORMAL HOLD → MAKER SL):
   *   Triggered when bar close crosses softSlPrice.
   *   Place a real reduce-only PostOnly order at softSlPrice and leave it
   *   resting while price is between L2 and L3.
   *
   * Phase 2 (MAKER SL ACTIVE, called each bar):
   *   - Poll the real order / Bybit position. Only record a maker exit after
   *     verified order fill or verified zero exchange position.
   *   - If bar close crosses hardSlPrice: cancel maker order and taker exit
   *     unless the exchange SL already closed the position.
   *   - If MAKER_SL_TIMEOUT_MS elapsed: cancel maker order and taker exit.
   *
   * Backtest result (SMOOTH + MEXC, 263 SL events):
   *   68.7% maker fill at -100 bps (0 extra fee)
   *   31.3% hard SL at -125 bps + 8 bps (taker + slip)
   *   vs naive taker: all 263 events at -100 - 8 = -108 bps
   */
  async function executeMakerSL(barIdx: number): Promise<void> {
    if (busy || stopping || !position) return
    busy = true
    const pos = position
    const exitSide = pos.side === 'long' ? 'Sell' : 'Buy'

    try {
      if (!pos.inMakerSL) {
        // First bar crossing softSL: enter maker SL phase and place a real
        // reduce-only PostOnly order. Do not record a theoretical maker fill;
        // only a verified order fill or zero Bybit position can close the trade.
        pos.inMakerSL      = true
        pos.makerSLStartMs = Date.now()
        pos.makerSLOrderId = pos.makerSLOrderId ?? null
        pos.makerSLOrderPx = pos.makerSLOrderPx ?? 0
        log('MAKER_SL', `Triggered at softSL=${pos.softSlPrice.toFixed(0)} - resting maker, waiting for fill or hardSL=${pos.hardSlPrice.toFixed(0)}`)
        saveState()
      }

      const pollMakerOrder = async (): Promise<boolean> => {
        if (!pos.makerSLOrderId) return false
        const status = await demo.getOrderStatus(pos.makerSLOrderId).catch(() => null)
        const bybitPos = await demo.getPosition().catch(() => null)

        if (status?.status === 'Filled') {
          const fillPx = status.avgPrice && status.avgPrice > 0 ? status.avgPrice : pos.softSlPrice
          log('MAKER_SL', `Verified maker SL fill @${fillPx.toFixed(1)} (order=${pos.makerSLOrderId})`)
          pos.makerSLOrderId = null
          pos.makerSLOrderPx = 0
          await _recordExit(pos, fillPx, false, 'soft_sl', barIdx)
          return true
        }

        if ((status?.cumExecQty ?? 0) > 0 && (!bybitPos || bybitPos.size < 0.001)) {
          const fillPx = status?.avgPrice && status.avgPrice > 0 ? status.avgPrice : pos.softSlPrice
          log('MAKER_SL', `Verified maker SL fill via cumExecQty=${status?.cumExecQty} @${fillPx.toFixed(1)}`)
          pos.makerSLOrderId = null
          pos.makerSLOrderPx = 0
          await _recordExit(pos, fillPx, false, 'soft_sl', barIdx)
          return true
        }

        if ((status?.cumExecQty ?? 0) > 0 && bybitPos) {
          // Partial reduce-only fill. Close the remainder immediately; the
          // position has already failed L2, and partial state accounting is not
          // worth carrying in the demo runner.
          log('MAKER_SL', `Partial maker SL fill (${status?.cumExecQty}btc) - closing remainder ${bybitPos.size}btc at market`)
          await cancelMakerSlOrder(pos)
          await demo.placeMarketOrder(exitSide, bybitPos.size, true)
          await sleep(1_500)
          await _recordExit(pos, status?.avgPrice && status.avgPrice > 0 ? status.avgPrice : liveMid(), true, 'soft_sl_timeout', barIdx, 3)
          return true
        }

        if (!bybitPos || bybitPos.size < 0.001) {
          // Position vanished but the maker order is not verified filled. Treat
          // as exchange/backstop closure, not as a theoretical maker fill.
          log('MAKER_SL', `Bybit flat while maker order not filled (status=${status?.status ?? 'unknown'}) - recording hard/backstop exit`)
          pos.makerSLOrderId = null
          pos.makerSLOrderPx = 0
          await _recordExit(pos, pos.hardSlPrice, true, 'hard_sl', barIdx, 0)
          return true
        }

        if (status === null || status.status === 'Cancelled' || status.status === 'Rejected') {
          // Order disappeared/rejected but position is still open. Clear the id
          // and try to place a fresh resting order below.
          pos.makerSLOrderId = null
          pos.makerSLOrderPx = 0
          saveState()
        }
        return false
      }

      if (await pollMakerOrder()) return

      const mid = liveMid()
      const elapsed = Date.now() - pos.makerSLStartMs

      // Hard SL: price has moved significantly past the structural L3 level.
      const crossedHard = pos.side === 'long'
        ? mid <= pos.hardSlPrice
        : mid >= pos.hardSlPrice
      if (crossedHard) {
        log('HARD_SL', `Price at ${mid.toFixed(0)} crossed hard SL ${pos.hardSlPrice.toFixed(0)} - taker exit`)
        await cancelMakerSlOrder(pos)
        const bybitPos = await demo.getPosition().catch(() => null)
        if (!bybitPos || bybitPos.size < 0.001) {
          log('HARD_SL', `Bybit already closed position (exchange SL fired) - recording at $${pos.hardSlPrice.toFixed(0)}`)
          await _recordExit(pos, pos.hardSlPrice, true, 'hard_sl', barIdx, 0)
          return
        }
        const r = await demo.chaseLimitOrder(exitSide, pos.sizeBtc, () => pos.hardSlPrice, 2, 2_000, true)
        const fillPx = (r.filled && r.avgPrice) ? r.avgPrice : pos.hardSlPrice
        if (!r.filled) {
          await demo.placeMarketOrder(exitSide, bybitPos.size, true)
          await sleep(1_500)
        }
        await _recordExit(pos, fillPx, true, 'hard_sl', barIdx, HARD_SL_SLIP_BPS)
        return
      }

      // Timeout check: maker never filled and hard SL wasn't reached.
      if (elapsed >= MAKER_SL_TIMEOUT_MS) {
        log('MAKER_SL', `Timeout - ${MAKER_SL_TIMEOUT_MS / 60_000}min passed, taking taker at market`)
        await cancelMakerSlOrder(pos)
        const bybitPos = await demo.getPosition().catch(() => null)
        if (bybitPos && bybitPos.size >= 0.001) {
          await demo.placeMarketOrder(exitSide, bybitPos.size, true)
          await sleep(1_500)
        }
        await _recordExit(pos, liveMid(), true, 'soft_sl_timeout', barIdx, 3)
        return
      }

      // Place/keep a real resting maker order at L2. The order is maker-safe
      // only while price remains beyond L2. If price has already returned and
      // no resting order caught it, use a taker fallback rather than recording
      // a fake maker fill.
      const makerSafe = pos.side === 'long'
        ? mid < pos.softSlPrice      // sell limit at L2 is above market
        : mid > pos.softSlPrice      // buy limit at L2 is below market

      if (!pos.makerSLOrderId) {
        if (!makerSafe) {
          log('MAKER_SL', `Price returned through softSL=${pos.softSlPrice.toFixed(0)} before maker order filled - taker fallback`)
          const bybitPos = await demo.getPosition().catch(() => null)
          if (bybitPos && bybitPos.size >= 0.001) {
            await demo.placeMarketOrder(exitSide, bybitPos.size, true)
            await sleep(1_500)
          }
          await _recordExit(pos, liveMid(), true, 'soft_sl_timeout', barIdx, 3)
          return
        }

        const order = await demo.placeLimitOrder(exitSide, pos.sizeBtc, pos.softSlPrice, true)
        if (order) {
          pos.makerSLOrderId = order.orderId
          pos.makerSLOrderPx = pos.softSlPrice
          saveState()
          log('MAKER_SL', `Resting reduce-only maker ${exitSide} ${pos.sizeBtc}btc @${pos.softSlPrice.toFixed(0)} (order=${order.orderId})`)
        } else {
          log('MAKER_SL', `Could not place maker SL order @${pos.softSlPrice.toFixed(0)} - will retry next bar`)
        }
      } else {
        log('MAKER_SL', `Waiting (${Math.round(elapsed/1000)}s) - order=${pos.makerSLOrderId} price=${mid.toFixed(0)}, soft=${pos.softSlPrice.toFixed(0)} hard=${pos.hardSlPrice.toFixed(0)}`)
      }
    } finally {
      if (position === pos) busy = false
    }
  }

  /**
   * Shared exit accounting - updates equity, records the trade, clears position.
   * extraSlipBps is charged on hard SL / taker exits to reflect real execution cost.
   */
  async function _recordExit(
    pos:          Position,
    fillPrice:    number,
    wasTaker:     boolean,
    reason:       string,
    barIdx:       number,
    extraSlipBps  = 0
  ): Promise<void> {
    // Idempotency guard: RECON, maker-SL polling, and exchange order-history
    // confirmation can all observe the same flat Bybit position on adjacent
    // async ticks. Only the live engine position may be booked as an exit.
    if (position !== pos) {
      log('EXIT_DUP', `Ignored duplicate ${reason} for ${pos.side.toUpperCase()} @${pos.entryPrice.toFixed(0)} - position already cleared/changed`)
      busy = false
      return
    }

    // Clear the engine position before any await so a concurrent verifier cannot
    // double-book the same trade while we cancel stale orders or sync balance.
    busy = true
    position = null
    markFlat()

    const dir         = pos.side === 'long' ? +1 : -1
    const grossBps    = dir * (fillPrice - pos.entryPrice) / pos.entryPrice * 10000
    const exitFeeBps  = wasTaker ? (FEE_BPS + extraSlipBps) : FEE_BPS
    const exitFeeUsd  = fillPrice * pos.sizeBtc * (exitFeeBps / 10000)
    const grossUsd    = pos.sizeBtc * pos.entryPrice * (grossBps / 10000)
    const netUsd      = grossUsd - exitFeeUsd
    // netBps = gross P&L minus both entry fee and exit fee
    const netBps      = grossBps - (FEE_BPS + exitFeeBps)

    equity    += grossUsd   // add gross P&L (entry fee was already subtracted at fill)
    equity    -= exitFeeUsd // subtract exit fee
    totalFees += exitFeeUsd
    noteEquityRange(equity)

    closedTrades.push({ side: pos.side, grossBps, netBps, netUsd, reason })

    // Record stop for the daily cap
    if (reason === 'soft_sl' || reason === 'hard_sl' || reason === 'soft_sl_timeout') {
      recordStop(pos.side)
    }

    // Cooldown after exit:
    //   - Stop: SL_COOLDOWN_BARS (30m) — market rejected the level, let it settle
    //   - Time exit: 5 bars — normal re-entry window, no structural reason to pause
    lastCdUntil = barIdx + (reason === 'soft_sl' || reason === 'hard_sl' || reason === 'soft_sl_timeout' || reason === 'exchange_sl' ? SL_COOLDOWN_BARS : 5)

    await cancelMakerSlOrder(pos)
    saveState()
    busy = false   // release before async I/O so bars aren't missed during log fetch

    // ── Clear exchange SL now that position is closed ───────────────────────
    // Setting stopLoss=0 removes the exchange SL from the now-closed position.
    // Silently ignore failures (exchange may have already cleared it if it fired).
    rest.setStopLoss('BTCUSDT', 0).catch(() => {})

    const bal      = await fetchBalFresh()
    if (Number.isFinite(bal) && Math.abs(bal - equity) > 0.50) {
      log('RECON', `Post-exit equity sync: model=$${equity.toFixed(2)} → Bybit=$${bal.toFixed(2)} (delta=$${(bal - equity).toFixed(2)})`)
      equity = bal
      noteEquityRange(equity)
      saveState()
    }
    const entryT   = new Date(pos.entryTs).toISOString().slice(11, 16)
    const exitT    = new Date().toISOString().slice(11, 16)
    const holdMin  = Math.round((Date.now() - pos.entryTs) / 60_000)
    const modeStr  = wasTaker ? 'taker' : 'maker'
    const es = pos.entryScore
    const ea = pos.entryATR60
    const ei = pos.entryImmBps
    const sigs = pos.entrySignals?.join(' ') ?? '?'
    const attrStr = `score=${es?.toFixed(0) ?? '?'} sigs=[${sigs}] atr=${ea?.toFixed(1) ?? '?'} imm=${ei?.toFixed(1) ?? '?'}`
    log('EXIT', `${reason.toUpperCase()} ${pos.side.toUpperCase()} ${entryT}→${exitT} (${holdMin}m) @${pos.entryPrice.toFixed(0)}→${fillPrice.toFixed(0)} net=${netBps >= 0 ? '+' : ''}${netBps.toFixed(1)}bps $${netUsd.toFixed(2)} ${modeStr} bal=$${bal.toFixed(0)} | ${attrStr}`)
  }

  // ═══════════════════════════════════════════════════════════════════════
  // TRADE STREAM → BARS → SIGNAL LOOP
  // ═══════════════════════════════════════════════════════════════════════

  const stream = new BybitPublicTradeStreamer(rest, {
    categories: ['linear'], symbolPrefix: 'BTC', subscriptionChunkSize: 1
  })

  stream.on('trade', async (trade: TapeTrade): Promise<void> => {
    if (trade.symbol !== 'BTCUSDT') return
    const newBar = barAgg.ingest(trade.price, trade.timestamp, trade.notionalUsd ?? 0)
    if (!newBar || busy || stopping) return

    const bars = barAgg.get()
    const i    = bars.length - 1
    const mid  = bars[i].c

    // ── Check existing position ──────────────────────────────────────────
    if (position && !busy) {
      const pos = position
      const dir = pos.side === 'long' ? +1 : -1
      const pnlBps = dir * (mid - pos.entryPrice) / pos.entryPrice * 10000
      if (pnlBps > pos.peakFavBps) pos.peakFavBps = pnlBps

      // (A) Already in maker SL phase - continue trying to fill
      if (pos.inMakerSL) {
        void executeMakerSL(i)
        return
      }

      // (B) Structural SL trigger: price has crossed the L2 composite level.
      // This means L1 (our entry support/resistance) has genuinely failed.
      // The check is on bar close, same as the soft SL was previously.
      const crossedL2 = pos.side === 'long'
        ? mid <= pos.softSlPrice    // LONG: price fell to/below L2
        : mid >= pos.softSlPrice    // SHORT: price rose to/above L2
      if (crossedL2) {
        void executeMakerSL(i)
        return
      }

      // (C) Per-bar score validity - reconsider hold on every bar after MIN_HOLD_BARS.
      //
      // Instead of checking at the deadline only, we check on every sealed bar.
      //   score × dir ≥ EXT_THRESH : signal still active → push deadline forward,
      //                                 keep holding (no-op, wait for next bar)
      //   score × dir < EXT_THRESH : signal has expired/decayed → exit via maker
      //                                 immediately, don't wait for the deadline
      //
      // The hard deadline becomes a safety cap (HARD_CAP_BARS = 7h)
      // rather than the primary exit trigger.
      if (i >= pos.entryBar + MIN_HOLD_BARS) {
        const { score: curScore, holdBars: extHold } = computeScore(bars, i)
        // scoreInDir: positive = signal agrees with position, negative = signal has flipped
        // e.g. SHORT (dir=-1): score=-15 → scoreInDir=+15 (agree), score=+5 → scoreInDir=-5 (flipped)
        const scoreInDir = curScore * dir
        if (scoreInDir >= EXT_THRESH) {
          // Signal still active: push hard deadline forward so we keep holding.
          // Cap at HARD_CAP_BARS to prevent indefinite holds.
          const hardCap = pos.entryBar + HARD_CAP_BARS
          const proposed = Math.min(i + extHold, hardCap)
          if (proposed > pos.exitDeadlineBar) {
            const prevDeadline = pos.exitDeadlineBar   // save BEFORE overwriting
            pos.exitDeadlineBar = proposed
            // Log only when the deadline meaningfully advances (>30 bars / 30 min)
            if (proposed > prevDeadline + 30)
              log('HOLD', `${pos.side.toUpperCase()} scoreInDir=+${scoreInDir.toFixed(0)} active - deadline +${proposed - prevDeadline}m → bar ${proposed}`)
          }
        } else {
          // Score below threshold (0 ≤ scoreInDir < EXT_THRESH) OR flipped (scoreInDir < 0).
          // FREEZE in both cases: let the deadline count down.
          //
          // Why we don't exit on flip (scoreInDir < 0):
          //   Backtest showed floor=0 (exit on flip) gives moIR=0.68 vs
          //   floor=-99 (pure freeze) moIR=0.79. Score oscillates near zero
          //   as individual signals expire - H21 at 22:00, BUYP flickering,
          //   hour transitions. Exiting at these momentary flips locks in
          //   incomplete P&L and triggers re-entry fees.
          //   84% of freeze events: score recovers above EXT_THRESH before deadline.
          //
          // score=-1 SHORT example: scoreInDir = (-1)*(-1) = +1 (barely same direction)
          // score=+5 SHORT example: scoreInDir = 5*(-1) = -5 (technically flipped)
          // Both → FREEZE: hold to deadline, let the position work.
          if (i % 60 === 0)
            log('FREEZE', `${pos.side.toUpperCase()} scoreInDir=${scoreInDir >= 0 ? '+' : ''}${scoreInDir.toFixed(0)} - holding to deadline in ${pos.exitDeadlineBar - i}m`)
        }
      }

      // (D) Hard deadline: exit regardless of score at the safety cap
      if (i >= pos.exitDeadlineBar) {
        void executeTimeExit(i)
        return
      }

      return   // position alive, next bar will re-evaluate
    }

    // ── Check entry ──────────────────────────────────────────────────────
    if (position || busy) return
    if (i < lastCdUntil) return
    // Require at least 1440 bars of history so Rev24h can compute a full 24h lookback.
    // H22 warmup (120 bars) and BUYP (60 bars) are satisfied long before this.
    if (barAgg.count() < 1440) return

    const { score, holdBars, signals } = computeScore(bars, i)
    const holdScale = holdScaleForVol(bars)
    const scaledHoldBars = Math.round(holdBars * holdScale)

    // Side-specific entry thresholds (SMOOTH: shorts slightly easier)
    const dir      = score > 0 ? +1 : -1
    const side     = score > 0 ? 'long' : 'short'
    const thresh   = side === 'long' ? LONG_ENTRY_THRESH : SHORT_ENTRY_THRESH
    if (Math.abs(score) < thresh) return

    // ── Signal confirmation: require CONFIRM_BARS consecutive bars above threshold ──
    // Calendar signals are persistent (DOW lasts all day, hour signals 30-120 min)
    // so this rarely fires on legitimate setups. It filters fleeting threshold-crossings
    // from BUYP/RSI oscillation and prevents entering on the first bar of a new signal
    // before it has been confirmed as stable.
    //
    // Backtest: 3-bar wait (CONFIRM_BARS=4) removes 5.7% of trades but improves
    //   t 7.43→7.66, maxDD 44.6→30.3%, minHt 4.75→5.45. Peak at exactly 4 bars.
    if (CONFIRM_BARS > 1) {
      for (let k = 1; k < CONFIRM_BARS; k++) {
        const { score: sk } = computeScore(bars, i - k)
        if (sk * dir < thresh) return  // not confirmed - skip without logging
      }
    }

    // Daily stop cap: block direction if STOP_CAP_PER_DAY stops already hit today
    if (stopsToday(side) >= STOP_CAP_PER_DAY) {
      if (i % 30 === 0)
        log('CAP', `${side.toUpperCase()} capped today (${stopsToday(side)} stops) - opposite OK`)
      return
    }

    // ── Drawdown circuit breaker ───────────────────────────────────────────
    if (maxDdPct >= MAX_ALLOWED_DD_PCT) {
      if (i % 60 === 0)
        log('CIRCUIT', `DD ${(maxDdPct*100).toFixed(1)}% >= ${(MAX_ALLOWED_DD_PCT*100).toFixed(0)}% - no new entries`)
      return
    }

    // ── Momentum crash halts (72h + 7d + 14d) ──────────────────────────
    // Skip entries when short/medium-term momentum is strongly opposed to the
    // calendar signal direction - indicates an active crash/squeeze regime
    // that overwhelms calendar patterns. All three tiers are enforced here
    // so the level-bid is never placed during a crash regime.
    //
    // lbRet returns 0 when insufficient bars - safe fallback (no false halts).
    // LONG suppressed when return < -threshold (BTC crashing downward).
    // SHORT suppressed when return > +threshold (BTC squeezing upward).
    {
      const r72h = lbRet(bars, i,  4320)   // 72-hour return in bps
      const r7d  = lbRet(bars, i, 10080)   // 7-day return in bps
      const r14d = lbRet(bars, i, 20160)   // 14-day return in bps
      if (dir * r72h < -HALT_72H_BPS) {
        if (i % 60 === 0)
          log('HALT', `${side.toUpperCase()} blocked - 72h momentum ${(r72h*dir).toFixed(0)}bps vs threshold -${HALT_72H_BPS}`)
        return
      }
      if (dir * r7d  < -HALT_7D_BPS) {
        if (i % 60 === 0)
          log('HALT', `${side.toUpperCase()} blocked - 7d momentum ${(r7d*dir).toFixed(0)}bps vs threshold -${HALT_7D_BPS}`)
        return
      }
      if (dir * r14d < -HALT_14D_BPS) {
        if (i % 60 === 0)
          log('HALT', `${side.toUpperCase()} blocked - 14d momentum ${(r14d*dir).toFixed(0)}bps vs threshold -${HALT_14D_BPS}`)
        return
      }
    }

    void executeEntry(side, scaledHoldBars, score, signals, i)
  })

  // ═══════════════════════════════════════════════════════════════════════
  // STARTUP: preload bars so Rev24h and BUYP fire immediately
  // ═══════════════════════════════════════════════════════════════════════

  log('PRELOAD', 'Loading historical bars...')
  await preloadBars(barAgg)
  log('PRELOAD', `${barAgg.count()} bars loaded - strategy fires immediately`)

  stream.on('error', () => {})
  await stream.start()
  log('STREAM', 'Bybit BTCUSDT stream connected - live')

  // ═══════════════════════════════════════════════════════════════════════
  // STATUS TICKER (every 60s) + EXCHANGE RECONCILIATION
  // ═══════════════════════════════════════════════════════════════════════

  const statusTimer = setInterval(async (): Promise<void> => {
    let bal       = await fetchBal()
    const elapsed = Math.round((Date.now() - startedAt) / 60_000)
    const hh      = Math.floor(elapsed / 60)
    const mm      = elapsed % 60

    // Reconcile with Bybit - detect external changes
    const exPos = await demo.getPosition().catch(() => null)
    const pos   = position
    if (pos && !exPos) {
      // Guard: if an exit is already in progress (busy=true), the position may have
      // just been closed by executeTimeExit / executeMakerSL. Don't double-record.
      if (busy) {
        // Exit in progress - RECON will catch it next cycle if still outstanding.
        void 0
      } else {
        const recordExternalClose = async (): Promise<void> => {
          if (position !== pos) return
          busy = true
          position = null
          markFlat()
          log('RECON', `⚠ Engine has ${pos.side} position but Bybit has none - exchange SL fired or closed externally`)
          await cancelMakerSlOrder(pos)
          // Balance is source-of-truth. If this was a genuine exchange SL, the
          // wallet already contains the realised P&L/fees. If this was a race,
          // this avoids booking a phantom hard-stop loss.
          const actualBal = await fetchBalFresh().catch(() => NaN)
          const estimatedFill = pos.hardSlPrice
          const dir = pos.side === 'long' ? +1 : -1
          const grossBps = dir * (estimatedFill - pos.entryPrice) / pos.entryPrice * 10000
          const netUsd = Number.isFinite(actualBal) ? actualBal - equity : pos.sizeBtc * pos.entryPrice * (grossBps / 10000)
          equity = Number.isFinite(actualBal) ? actualBal : equity + netUsd
          bal = Number.isFinite(actualBal) ? actualBal : bal
          noteEquityRange(equity)
          closedTrades.push({ side: pos.side, grossBps, netBps: grossBps - FEE_BPS * 2, netUsd, reason: 'exchange_sl' })
          recordStop(pos.side)
          lastCdUntil = barAgg.count() + SL_COOLDOWN_BARS  // exchange SL = stop, apply full cooldown
          rest.setStopLoss('BTCUSDT', 0).catch(() => {})
          saveState()
          busy = false
          log('RECON', `Recorded exchange_sl: ${pos.side.toUpperCase()} @${pos.entryPrice.toFixed(0)}→${estimatedFill.toFixed(0)} net=$${netUsd.toFixed(2)} bal=$${equity.toFixed(2)}`)
        }

        // If a maker-SL order was active, first check order history. A filled
        // reduce-only maker order also makes Bybit flat; do not misclassify it
        // as an exchange hard stop.
        if (pos.inMakerSL && pos.makerSLOrderId) {
          const status = await demo.getOrderStatus(pos.makerSLOrderId).catch(() => null)
          if (position !== pos) {
            void 0
          } else if (status?.status === 'Filled' || (status?.cumExecQty ?? 0) > 0) {
            const fillPx = status?.avgPrice && status.avgPrice > 0 ? status.avgPrice : pos.softSlPrice
            log('RECON', `Verified active maker-SL fill while Bybit flat @${fillPx.toFixed(1)} (order=${pos.makerSLOrderId})`)
            pos.makerSLOrderId = null
            pos.makerSLOrderPx = 0
            await _recordExit(pos, fillPx, false, 'soft_sl', barAgg.count())
            bal = await fetchBal()
          } else {
            await recordExternalClose()
          }
        } else {
          await recordExternalClose()
        }
      }
    } else if (pos && exPos) {
      const ok = (exPos.side === 'Buy') === (pos.side === 'long')
              && Math.abs(exPos.size - pos.sizeBtc) < 0.002
      if (!ok) {
        log('RECON', `⚠ Mismatch: engine=${pos.side} ${pos.sizeBtc}btc  bybit=${exPos.side} ${exPos.size}btc - closing and resetting`)
        await cancelMakerSlOrder(pos)
        await rest.setStopLoss('BTCUSDT', 0).catch(() => {})  // clear exchange SL before closing
        await demo.placeMarketOrder((exPos.side === 'Buy' ? 'Sell' : 'Buy'), exPos.size, true)
        await sleep(1_500)
        bal = await fetchBalFresh()
        equity = bal
        noteEquityRange(equity)
        position = null; markFlat(); saveState()
      }
    } else if (!pos && exPos) {
      // Orphan: Bybit has a position the engine doesn't know about. Never close
      // it while an entry/exit operation is in flight; a fresh fill can arrive
      // before executeEntry has assigned position.
      if (busy) {
        log('RECON', `Pending operation; not closing apparent orphan ${exPos.side} ${exPos.size}btc`)
      } else {
        log('RECON', `⚠ Orphan Bybit position ${exPos.side} ${exPos.size}btc @avg${exPos.entry?.toFixed(0) ?? '?'} - closing`)
        const closeSide = exPos.side === 'Buy' ? 'Sell' : 'Buy'
        const closeResult = await demo.placeMarketOrder(closeSide, exPos.size, true)
        if (closeResult && exPos.entry) {
          await sleep(1_500)
          const midNow   = liveMid()
          const dir      = exPos.side === 'Buy' ? +1 : -1
          const grossBps = dir * (midNow - exPos.entry) / exPos.entry * 10000
          const actualBal = await fetchBalFresh().catch(() => NaN)
          const netUsd   = Number.isFinite(actualBal) ? actualBal - equity : exPos.size * exPos.entry * (grossBps / 10000)
          equity = Number.isFinite(actualBal) ? actualBal : equity + netUsd
          bal = Number.isFinite(actualBal) ? actualBal : bal
          noteEquityRange(equity)
          closedTrades.push({ side: exPos.side === 'Buy' ? 'long' : 'short', grossBps, netBps: grossBps - FEE_BPS * 2, netUsd, reason: 'orphan_close' })
          log('RECON', `Orphan closed: net=$${netUsd.toFixed(2)} bal=$${equity.toFixed(2)} (entry~${exPos.entry.toFixed(0)} exit~${midNow.toFixed(0)})`)
          saveState()
        }
      }
    } else if (!pos && !exPos && !busy && Number.isFinite(bal) && Math.abs(bal - equity) > 0.50) {
      log('RECON', `Flat equity sync: model=$${equity.toFixed(2)} → Bybit=$${bal.toFixed(2)} (delta=$${(bal - equity).toFixed(2)})`)
      equity = bal
      noteEquityRange(equity)
      saveState()
    }

    const mid = liveMid()
    const { score: curScore, signals: curSigs } = barAgg.count() > 1
      ? computeScore(barAgg.get(), barAgg.count() - 1)
      : { score: 0, signals: [] as string[], holdBars: 120 } as ReturnType<typeof computeScore>
    // Display and DD should be full-run and mark-to-market. Wallet balance
    // excludes open-position uPnL, so add Bybit uPnL while a verified position
    // is open. Do not write this into realised equity; only peak/DD/status.
    const accountEquity = Number.isFinite(bal) ? bal + (position && exPos ? exPos.uPnL : 0) : bal
    noteEquityRange(accountEquity)
    const elapsedStr = `m${fixedUnsigned(elapsed, 4)}`
    const topStr = `hi${pct1(returnPctFromInitial(peak))}`
    const curStr = `now${pct1(returnPctFromInitial(accountEquity))}`
    const botStr = `lo${pct1(returnPctFromInitial(trough))}`
    const baseStatus = `${elapsedStr} px${shortPrice(mid)} ${topStr} ${curStr} ${botStr}`
    const scoreStr = `s${fixedSignedInt(curScore, 3)}`

    if (position) {
      const p       = position
      const exitIn  = Math.max(0, p.exitDeadlineBar - barAgg.count())
      const posStr  = `${p.side === 'long' ? 'L' : 'S'}${fixedQty4(p.sizeBtc)}@${shortPrice(p.entryPrice)}`
      const exitStr = p.inMakerSL ? 'xMSL' : `x${Math.min(999, exitIn)}`
      log('STATUS', fixedStatusLine(`${baseStatus} ${posStr} ${exitStr} ${scoreStr}`))
    } else {
      // Check if a halt filter is blocking entries right now
      const bars    = barAgg.get()
      const i       = bars.length - 1
      const dir     = curScore > 0 ? +1 : -1
      const r72     = i >= 4320  ? (bars[i].c - bars[i-4320].c)  / bars[i-4320].c  * 10000 : 0
      const r7d     = i >= 10080 ? (bars[i].c - bars[i-10080].c) / bars[i-10080].c * 10000 : 0
      const r14d    = i >= 20160 ? (bars[i].c - bars[i-20160].c) / bars[i-20160].c * 10000 : 0
      const haltBy  = dir * r72  < -HALT_72H_BPS ? 'h72'
                    : dir * r7d  < -700          ? 'h7d'
                    : dir * r14d < -800          ? 'h14'
                    : null
      if (flatSinceMs === null) markFlat()
      const flatMin = Math.max(0, Math.round((Date.now() - (flatSinceMs ?? Date.now())) / 60_000))
      const modeStr = haltBy ?? 'flat'
      log('STATUS', fixedStatusLine(`${baseStatus} ${modeStr} f${Math.min(999, flatMin)}m ${scoreStr}`))
    }

    saveState()

    // Signal distribution log every ~60min (60 status ticks)
    statusTick++
    if (statusTick % 60 === 0) {
      const bars = barAgg.get()
      const i = bars.length - 1
      const d = computeScore(bars, i)
      log('SIGDIST', `score=${d.score.toFixed(0)} signals=[${d.signals.join(' ')}] hold=${d.holdBars}m | ATR60=${atrBps(bars,i,60).toFixed(1)}bps`)
    }
  }, 60_000)

  // ═══════════════════════════════════════════════════════════════════════
  // GRACEFUL SHUTDOWN
  // ═══════════════════════════════════════════════════════════════════════

  const endTimer = isFinite(DURATION_MS)
    ? setTimeout(() => void shutdown('timer'), DURATION_MS)
    : null

  async function shutdown(reason: string): Promise<void> {
    if (stopping) return
    stopping = true
    log('STOP', reason)
    clearInterval(statusTimer)
    if (endTimer) clearTimeout(endTimer)
    // Pending orders (level bids, maker SL) always cancelled on any exit
    try { await rest.cancelAll({ category: 'linear', symbol: 'BTCUSDT' }) } catch {}

    const exPos = await demo.getPosition().catch(() => null)
    if (exPos && exPos.size > 0) {
      if (FLATTEN_ON_EXIT) {
        // TXOCAP_FLATTEN=1: close the position before exiting
        // Clear exchange SL first so it doesn't race with the market order
        await rest.setStopLoss('BTCUSDT', 0).catch(() => {})
        const side = exPos.side === 'Buy' ? 'Sell' : 'Buy'
        await demo.placeMarketOrder(side as 'Buy' | 'Sell', exPos.size, true)
        position = null; markFlat()
        log('FLAT', `Market flatten: ${exPos.side} ${exPos.size}btc`)
      } else {
        // Default: leave position open, save state, resume on next start
        log('WARN', [
          `Position still open on Bybit: ${exPos.side} ${exPos.size}btc`,
          position ? `@${position.entryPrice.toFixed(0)} softSL=${position.softSlPrice.toFixed(0)}` : '',
          `State saved - next start will resume automatically.`,
          `Run with TXOCAP_FLATTEN=1 to close on exit.`,
        ].filter(Boolean).join(' | '))
      }
    }
    stream.stop()

    if (reason === 'timer') {
      // Clean timer shutdown: delete state so next run starts fresh
      try { fs.unlinkSync(STATE_FILE) } catch {}
    } else {
      // SIGINT/SIGTERM restart: preserve state for seamless resume
      saveState()
      process.stderr.write(`State saved → ${STATE_FILE}. Restart resumes automatically.\n`)
    }

    const endBal = await fetchBalFresh().catch(() => NaN)
    const wins   = closedTrades.filter(t => t.netUsd > 0)
    const pnlStr = Number.isFinite(endBal) ? `$${(endBal - startBal).toFixed(2)}` : 'n/a'
    process.stderr.write('\n' + '═'.repeat(60) + '\n')
    process.stderr.write('TXOCAP - SMOOTH STRATEGY REPORT\n')
    process.stderr.write('═'.repeat(60) + '\n')
    process.stderr.write(`Duration:   ${Math.round((Date.now()-startedAt)/60_000)}min\n`)
    process.stderr.write(`Balance:    $${startBal.toFixed(2)} → ${Number.isFinite(endBal)?'$'+endBal.toFixed(2):'n/a'} (${pnlStr})\n`)
    process.stderr.write(`Trades:     ${closedTrades.length} (${wins.length}W/${closedTrades.length-wins.length}L  WR=${closedTrades.length?((wins.length/closedTrades.length)*100).toFixed(0):0}%)\n`)
    process.stderr.write(`Fees:       $${totalFees.toFixed(2)}\n`)
    process.stderr.write(`Max DD:     ${(maxDdPct*100).toFixed(1)}%\n`)
    process.stderr.write(`Bars:       ${barAgg.count()} (${(barAgg.count()/1440).toFixed(1)} days)\n`)
    if (closedTrades.length) {
      process.stderr.write('\nTrades:\n')
      for (const t of closedTrades) {
        process.stderr.write(`  ${t.reason.padEnd(8)} ${t.side.toUpperCase()} gross=${t.grossBps.toFixed(1)}bps net=${t.netBps.toFixed(1)}bps $${t.netUsd.toFixed(2)}\n`)
      }
    }
    process.stderr.write('═'.repeat(60) + '\n')
    process.exit(0)
  }

  // Override the simple lock-release handlers set at startup
  process.removeAllListeners('SIGINT')
  process.removeAllListeners('SIGTERM')
  process.on('SIGINT',  () => void shutdown('sigint'))
  process.on('SIGTERM', () => void shutdown('sigterm'))
}

// ═══════════════════════════════════════════════════════════════════════════
// BAR PRELOADER
// ═══════════════════════════════════════════════════════════════════════════

/**
 * Warm up the bar aggregator so all signals (including Rev24h which needs
 * 1440 bars = 24h) can fire on the first live tick after connecting.
 *
 * Step 1: Load the last 2000 bars from the local JSONL file.
 * Step 2: Bridge any gap between the file's last bar and the present
 *         using the Bybit public kline REST endpoint.
 *
 * Rate limit: 250ms between requests ≈ 240 req/min (Bybit limit: 120/min
 * for authenticated; public endpoint is more lenient but we stay safe).
 */
async function preloadBars(barAgg: BarAgg): Promise<void> {
  const fsm      = await import('node:fs')
  const rl_mod   = await import('node:readline')
  const https    = await import('node:https')

  function httpGet(url: string): Promise<unknown> {
    return new Promise((res, rej) => {
      https.default.get(url, { headers: { 'User-Agent': 'txocap/1.0' } }, r => {
        let d = ''; r.on('data', (c: Buffer) => d += c)
        r.on('end', () => { try { res(JSON.parse(d)) } catch { rej(new Error(d.slice(0, 100))) } })
        r.on('error', rej)
      }).on('error', rej)
    })
  }

  // Step 1: disk - JSONL files have full OHLCV; use ingestBar for accuracy
  const KLINE_FILE = 'data/klines/BTCUSDT-1m.jsonl'
  if (fsm.default.existsSync(KLINE_FILE)) {
    const lines: string[] = []
    const rl = rl_mod.default.createInterface({ input: fsm.default.createReadStream(KLINE_FILE) })
    for await (const line of rl) { if (line.trim()) lines.push(line) }
    const recent = lines.slice(-2000)
    for (const line of recent) {
      const b = JSON.parse(line)
      // JSONL format: { ts, o, h, l, c, v (BTC), usd }
      // Use ingestBar to preserve full OHLC and volume (enables live POC once re-enabled)
      barAgg.ingestBar(b.o ?? b.c, b.h ?? b.c, b.l ?? b.c, b.c, b.ts, b.usd ?? b.v ?? 0)
    }
    process.stderr.write(`[PRELOAD] ${recent.length} bars from disk, last=${new Date(JSON.parse(recent[recent.length-1]).ts).toISOString().slice(0,16)}\n`)
  }

  // Step 2: REST bridge
  const bars = barAgg.get()
  if (bars.length === 0) return
  let cursor = bars[bars.length - 1].ts + 60_000
  const now  = Date.now() - 60_000

  if (cursor >= now) return

  process.stderr.write(`[PRELOAD] Bridging ${Math.round((now-cursor)/60_000)}min gap via REST...\n`)
  let fetched = 0
  while (cursor < now) {
    try {
      const url  = `https://api.bybit.com/v5/market/kline?category=linear&symbol=BTCUSDT&interval=1&start=${cursor}&limit=1000`
      const resp = await httpGet(url) as any
      const list: string[][] = (resp?.result?.list ?? []).reverse()
      if (list.length === 0) break
      for (const k of list) {
        const ts = Number(k[0])
        if (ts >= now) continue
        // Bybit kline format: [ts, open, high, low, close, volume, turnover]
        // Use ingestBar with full OHLCV so PDH/PDL and POC are accurate
        barAgg.ingestBar(
          Number(k[1]),  // open
          Number(k[2]),  // high
          Number(k[3]),  // low
          Number(k[4]),  // close
          ts,
          Number(k[6])   // turnover (USD volume) - more useful than base volume k[5]
        )
        fetched++
      }
      cursor = Number(list[list.length - 1][0]) + 60_000
    } catch (e: any) {
      process.stderr.write(`[PRELOAD] REST error: ${e.message}\n`)
      break
    }
    await sleep(250)
  }
  process.stderr.write(`[PRELOAD] ${fetched} bars from REST. Total: ${barAgg.count()} bars\n`)
}

// ═══════════════════════════════════════════════════════════════════════════
// UTILITIES
// ═══════════════════════════════════════════════════════════════════════════

function sleep(ms: number): Promise<void> {
  return new Promise(r => setTimeout(r, ms))
}

// ═══════════════════════════════════════════════════════════════════════════
// ENTRYPOINT
// ═══════════════════════════════════════════════════════════════════════════

main().catch(e => {
  process.stderr.write(`[FATAL] ${e instanceof Error ? e.stack ?? e.message : String(e)}\n`)
  process.exit(1)
})
