Skip to Main Content
Back to website
Indicator Disclaimer | Report this indicator

CascadeQuant Options Suite

TSBuild26

Options positioning, trade ideas and a structure calculator in one panel, built from a single options chain request.

WHAT IT SHOWS

  • Gamma levels: King and Queen strikes, call and put walls, gamma flip, max pain and the expected move, drawn on the chart.
  • Gamma regime: long or short gamma, above or below the flip.
  • Trade ideas: up to three defined-risk structures for your bias (verticals, iron condors, debit spreads, butterflies, long options), each with strikes, expiry, contracts, credit or debit, max loss, probability of profit and expected value.
  • Calculator: legs, payoff, breakevens, reward/risk, probabilities (market vs model), Greeks and a PL ladder for any idea or for your own trade.
  • Context: next earnings, ex-dividend date, IV vs forecast volatility, skew, term structure, unusual options flow.

HOW IDEAS ARE CHOSEN

  • Expiries 21–60 DTE; expiries that span earnings are skipped by default.
  • Every candidate is priced at Natural fills (buy at the ask, sell at the bid) and filtered for liquidity (open interest, bid-ask slippage) and your risk per trade.
  • Ideas are ranked by expected value per dollar at risk.
  • Idea Mode: Open shows the best-priced structures; Restrictive also requires at least 2:1 reward/risk on debit trades and a credit of at least 33% of the spread width on credit trades.
  • When nothing qualifies, the panel says so and why. Waiting is a valid outcome.

KEY SETTINGS

  • Bias (Bullish, Neutral, Bearish, Auto), Account Size and Risk per Trade %, Idea Mode, DTE range, Take Profit % and Close at DTE.
  • Panel View: Compact, Standard or Extended. Panel layout: Single, Split or Off. Panels can be moved with TrendSpider's panel editor.

SIGNALS FOR SCANS AND ALERTS

  • Long-Short Gamma, Above-Below Gamma Flip, Backwardation, Trade Idea Available, Top Pick Credit-Debit, Earnings Inside Window, IV Rich-Cheap vs Forecast, and more.

NOTES

  • Built for liquid US stocks and ETFs with listed options. Requires TrendSpider options data.
  • Probabilities use the option market's own volatility smile and the stock's historical return pattern. They are estimates, not forecasts.
  • Open interest-based levels update once per trading day. Quotes are as current as the last recalculation.
  • The indicator does not predict direction. It structures and prices the view you bring.

RISK DISCLOSURE
For informational and educational purposes only; not investment advice or a recommendation to buy or sell any security. Options involve substantial risk and are not suitable for all investors. Leveraged positions can lose all of their value quickly, and some positions can lose more than the premium received. Displayed values are model estimates based on assumptions and current quotes; spreads, slippage, fees and data delays can materially change results. Verify all prices with your broker before trading. You are solely responsible for your trading decisions.

describe_indicator('CascadeQuant Options Suite', 'price', {
  shortName: 'CQO-S', version: '3.11',
  description: 'Gamma positioning + options structure calculator in one chain fetch'
});

// ============================================================================
// INPUTS
// ============================================================================
// v2.3.2: inputs grouped; labels unchanged so saved values carry over. Fixed (no longer inputs):
// GEX strikes/side 15, strike range 15%, rank top expiries by gross GEX, label offsets 60 / 25 bars,
// label stagger on (gap 5% of range, 18 bars), panel wrap width 440 px, trade ±1σ lines removed.
const gEng = input.group('Engine');
const engineOn   = gEng.boolean('Trade Ideas', true);
const biasIn     = gEng.select('Bias', 'Auto', ['Auto', 'Bullish', 'Neutral', 'Bearish']);
const calcSource = gEng.select('Calculator Uses', 'Idea 1', ['Idea 1', 'Idea 2', 'Idea 3', 'Manual']);
// v3.7 GAMMA LEVELS: 'γ ZG' is the zero-gamma SPOT — every selected strike's gamma repriced (own IV, own expiry)
//   at hypothetical spots ±15% and the nearest crossing of total net GEX found. Replaces the v3.6 strike flip (where
//   per-strike GEX changed sign), which disagreed with the LONG/SHORT γ regime on 22% of name-dates. New line:
//   'Hedge / 1% move' = assumed dealer hedge for a 1% move (unweighted net GEX at spot) as % of 20-day average
//   daily dollar volume. Sign convention (unverifiable): dealers long calls, short puts. ZG and hedge use every
//   fetched strike within ±15% (Chain Strikes/Side; 60 recommended on SPY / QQQ), not only the 15 GEX strikes.
//   Tested on CascadeQuant chains (39 names, Jul 2024 – Sep 2026; rebuilt per name-date at CQO-S scope):
//   · Short γ → next-5-day realised ÷ IV +6% overall (t 1.6, name effects, forecast control, date-block SEs),
//     +3.6% at 21 days (t 1.4); strong in H1 (t 2.4–2.7), zero / negative in H2 → not a stand-alone signal.
//   · SPY / QQQ / IWM: +17% at 5 days (t 2.9), same sign in both halves (+27% / +6%); 21 days not significant.
//     Index short-γ readings are the most credible use; single names are context.
//   · No gradient by hedge ÷ ADV tercile; median hedge ÷ ADV 1.0% single names, 3.7% index ETFs.
//   · ZG moves 0.8% of spot on a median day (p90 3.5%); regime flips ~2 times a month per name.
//   · Engine: regime-fit and 'ZG between spot and breakeven' score terms removed (see engine notes).
// v3.6 DTE Window: preset expiry ranges for the idea engine; 'Custom' uses Engine Min / Max DTE.
// Validation covered 21–60 DTE only; shorter windows run the same model and are flagged on the panel.
const dteWindow  = gEng.select('DTE Window', '21-60 DTE', ['0DTE', '1-21 DTE', '21-60 DTE', 'Custom']);
const engDteMinIn = gEng.number('Engine Min DTE', 21, { min: 0, max: 180, step: 1 });
const engDteMaxIn = gEng.number('Engine Max DTE', 60, { min: 0, max: 365, step: 1 });
const engDteMin = dteWindow === '0DTE' ? 0 : dteWindow === '1-21 DTE' ? 1 : dteWindow === '21-60 DTE' ? 21 : engDteMinIn;
const engDteMax = dteWindow === '0DTE' ? 0 : dteWindow === '1-21 DTE' ? 21 : dteWindow === '21-60 DTE' ? 60 : engDteMaxIn;
const SHORT_DTE = engDteMax < 21;
const engMinOI   = gEng.number('Engine Min Open Interest', 100, { min: 0, max: 10000, step: 10 });
const engMaxBA   = gEng.number('Engine Max Slippage %', 20, { min: 1, max: 100, step: 1 });   // Natural vs Mid cost, % of the mid credit/debit
const avoidEarn  = gEng.boolean('Skip Earnings Expiries', true);
const regimeMode = gEng.select('Regime Filter', 'Warn', ['Off', 'Warn', 'Filter']);   // v3.10 market regime
// v3.5 Idea Mode: 'Open' = no payoff floors (v3.1 behaviour); 'Restrictive' = the two floors below apply and the
// debit structure for each bias is always searched. The floor inputs keep their values while Open is selected.
const ideaMode   = gEng.select('Idea Mode', 'Open', ['Open', 'Restrictive']);
const RESTRICT = ideaMode === 'Restrictive';
const engMinRRIn = gEng.number('Min Debit Reward/Risk', 2, { min: 0, max: 10, step: 0.25 });   // Restrictive: debit & long structures, max profit ÷ max loss ≥ x (0 = off)
const engMinCrWIn = gEng.number('Min Credit % of Width', 33, { min: 0, max: 90, step: 1 });    // Restrictive: credit ≥ x% of the widest wing (0 = off)
const engMinRR = RESTRICT ? engMinRRIn : 0, engMinCrW = RESTRICT ? engMinCrWIn : 0;
const engWalls   = gEng.boolean('Credit Shorts Beyond Walls', false);   // untested gamma filter: off by default (v2.3.2)
const chainStrikes = gEng.number('Chain Strikes/Side', 30, { min: 15, max: 60, step: 5 });
const riskFreeRate = gEng.number('Risk-Free %', 4.2, { min: 0, max: 20, step: 0.1 }) / 100;

const gRisk = input.group('Account & Risk');
const acctSize   = gRisk.number('Account Size $', 25000, { min: 1000, max: 10000000, step: 1000 });
const riskPctIn  = gRisk.number('Risk per Trade %', 1, { min: 0.1, max: 10, step: 0.1 });
const engMaxCon  = gRisk.number('Max Contracts', 10, { min: 1, max: 500, step: 1 });

const gMgmt = input.group('Trade Management');
const tpPct      = gMgmt.number('Take Profit % (0=off)', 0, { min: 0, max: 95, step: 5 });   // tested: no benefit → default off
const closeDTE   = gMgmt.number('Close at DTE (0=hold)', 0, { min: 0, max: 90, step: 1 });

const gMan = input.group('Manual Calculator');   // used only when Calculator Uses = Manual
const STRATS = ['Long Call', 'Long Put', 'Bull Call Debit', 'Bear Put Debit', 'Bull Put Credit',
    'Bear Call Credit', 'Iron Condor', 'Long Straddle', 'Long Strangle', 'Call Butterfly'];
const strat      = gMan.select('Strategy', 'Bull Put Credit', STRATS);
const targetDTE  = gMan.number('Target DTE', 30, { min: 0, max: 180, step: 1 });
const shortDelta = gMan.number('Short Leg Delta', 30, { min: 5, max: 60, step: 1 });
const longDelta  = gMan.number('Long Leg Delta', 50, { min: 5, max: 90, step: 1 });
const strgDelta  = gMan.number('Strangle Leg Delta', 25, { min: 5, max: 45, step: 1 });
const wingW      = gMan.number('Wing Width (strikes)', 2, { min: 1, max: 10, step: 1 });
const k1Over     = gMan.number('Strike 1 Override (0=auto)', 0, { min: 0, max: 100000, step: 0.5 });
const k2Over     = gMan.number('Strike 2 Override (0=auto)', 0, { min: 0, max: 100000, step: 0.5 });
const fillMode   = gMan.select('Fill Price', 'Mid', ['Mid', 'Natural']);
const contracts  = gMan.number('Contracts', 1, { min: 1, max: 100, step: 1 });

const gGam = input.group('Gamma Levels');
const nearExpiries = gGam.number('Near Expiries',    3,  { min: 1, max: 4,   step: 1 });
const topExpiries  = gGam.number('Top GEX Expiries', 3,  { min: 0, max: 4,   step: 1 });
const maxDTE       = gGam.number('Max DTE',          45, { min: 7, max: 120, step: 1 });
const minOI        = gGam.number('Min OI',           10, { min: 0, max: 500, step: 10 });
const matrixRows   = gGam.number('Matrix Rows',      8,  { min: 4, max: 16,  step: 1 });
const showMatrix   = gGam.boolean('Show Matrix', true);
const strikesPerSide = 15, strikeRangePct = 15, rankMode = 'Gross GEX';

const gDisp = input.group('Display');
const panelMode    = gDisp.select('Panel', 'Single', ['Single', 'Split', 'Off']);
// Panel position / order / offsets: use TrendSpider's native Edit button next to each panel
// in the indicator settings ('CQO-S Panel', 'CQO-S Calc Panel'). Positions here are only the defaults.
const panelView    = gDisp.select('Panel View', 'Standard', ['Compact', 'Standard', 'Extended']);
// Compact ≈ 20 rows: regime, key levels, rec #1, trade summary · Standard ≈ 45: + all recs, context, legs,
// probabilities, Greeks · Extended ≈ 75: + GEX matrix, P/L ladder, walls check, flow / seasonality, footer.
// Only the panel text changes: lines, signals and the engine are identical in every view. Warnings always show.
const LV = panelView === 'Compact' ? 1 : panelView === 'Standard' ? 2 : 3;
const panelFont    = gDisp.number('Panel Font Size', 10, { min: 7, max: 14, step: 1 });
const showGLines   = gDisp.boolean('Gamma Lines', true);
const showTLines   = gDisp.boolean('Trade Lines', true);
const SINGLE = panelMode === 'Single', showPanel = panelMode !== 'Off', showCalcPnl = panelMode === 'Split';
const panelMaxW = 440, gLabelBack = 60, tLabelBack = 25, labelStagger = true, labelGapPct = 5, labelStep = 18;
const calcStrikes = chainStrikes;

// ============================================================================
// COLORS  (CascadeQuant dashboard palette)
// ============================================================================
const C_HDR  = '#8490C0';   // periwinkle — headers
const C_BULL = '#4FB6A8';   // muted teal — positive
const C_BEAR = '#DB8395';   // dusty rose — negative
const C_GOLD = '#D6AE62';   // soft amber — King, highlights
const C_FLIP = '#8490C0';   // periwinkle — Gamma Flip
const C_MP   = '#9577BA';   // lilac — Max Pain
const C_WARN = '#C8705A';   // terracotta — warnings
const C_DIM  = '#6E7681', C_TXT = '#C9D1D9', C_SUB = '#8B949E', C_HEAT_TXT = '#E6EDF3';
const C_POS = C_BULL, C_NEG = C_BEAR, C_LIL = C_MP;
const C_TRADE = '#E6EDF3', C_TRADE_BE = '#B8C4D6';   // trade lines: light neutrals, distinct from gamma colours
const C_BORDER = '#30363D';
const BG = '#0D1117', BG_HDR = '#161B22', BG_A = '#0D1117', BG_B = '#11161D', BG_MHDR = '#161B22';
const BG_KING = 'rgba(214,174,98,0.14)', BG_QUEEN = 'rgba(219,131,149,0.14)', BG_FLIP = 'rgba(132,144,192,0.16)';
const FS = panelFont - 9;
// v3.9 presentation helpers (no calculation changes). tinycolor2 via the platform loader, plain fallback.
var tiny = null;
try { tiny = library('tinycolor2'); } catch (e) { tiny = null; }
var hx8 = function (hex, al) { var s8 = Math.round(Math.max(0, Math.min(1, al)) * 255).toString(16); return hex.slice(0, 7) + (s8.length < 2 ? '0' + s8 : s8); };
var lite = function (hex, amt) { if (tiny) { try { return tiny(hex).lighten(amt).toHexString(); } catch (e) {} } return hex; };
var gHead = function (col) { return 'linear-gradient(90deg,' + hx8(col, 0.26) + ' 0%,' + hx8(col, 0.06) + ' 60%,' + hx8(BG_HDR, 1) + ' 100%)'; };
var gSoft = function (col, a0) { return 'linear-gradient(90deg,' + hx8(col, a0 || 0.14) + ' 0%,' + hx8(col, 0.02) + ' 100%)'; };
var gChip = function (col) { return 'linear-gradient(90deg,' + hx8(col, 0.04) + ' 0%,' + hx8(col, 0.24) + ' 50%,' + hx8(col, 0.04) + ' 100%)'; };
var gFill = function (frac, col) { var p = (100 * Math.max(0, Math.min(1, frac))).toFixed(1); return 'linear-gradient(90deg,' + hx8(col, 0.10) + ' 0%,' + hx8(col, 0.42) + ' ' + p + '%,' + hx8(col, 0.04) + ' ' + p + '%,' + hx8(col, 0.04) + ' 100%)'; };
var gCentre = function (frac, col) { var f = Math.max(0, Math.min(1, frac)), lo = (100 * Math.min(0.5, f)).toFixed(1), hi = (100 * Math.max(0.5, f)).toFixed(1), a0 = hx8(C_DIM, 0.05), c0 = hx8(col, 0.45);
  return 'linear-gradient(90deg,' + a0 + ' 0%,' + a0 + ' ' + lo + '%,' + c0 + ' ' + lo + '%,' + c0 + ' ' + hi + '%,' + a0 + ' ' + hi + '%,' + a0 + ' 100%)'; };
var glow = function (col, al) { return '0 0 8px ' + hx8(col, al || 0.7); };
var BG_SEC = hx8(C_HDR, 0.14);   // flat tint for column-header rows

// Stable paint reference so appearance settings persist across tickers
paint(close.map(function () { return NaN; }),
  { name: 'CQOS ref', style: 'line', color: 'orange', hideInLegend: true, ignoreWhenScaling: true });

// ============================================================================
// LABEL ALLOCATOR  (shared by gamma and trade lines)
// ============================================================================
// Each label asks for a column `back` bars left of the latest bar. Two labels clash when
// they are within 'Label Gap' (a share of the recent price range, i.e. roughly a label's
// height on screen) in price AND within 'Label Stagger Bars' of each other in time. A
// clashing label moves 'Label Stagger Bars' further left until it sits clear.
// Range reference = high/low of the last 150 bars (≈ a normal chart view).
var labelSlots = [];
var labelRange = (function () {
  var hi = -Infinity, lo = Infinity, n0 = close.length;
  for (var k = Math.max(0, n0 - 150); k < n0; k++) {
    if (high[k] > hi) hi = high[k];
    if (low[k] > 0 && low[k] < lo) lo = low[k];
  }
  return hi > lo ? hi - lo : Math.abs(close[n0 - 1] || 1) * 0.1;
})();
function labelIndex(value, back) {
  var idx = Math.max(0, close.length - 1 - back);
  if (labelStagger) {
    var gap = labelRange * labelGapPct / 100;
    for (var tries = 0; tries < 12; tries++) {
      var clash = labelSlots.some(function (p) { return Math.abs(p.v - value) < gap && Math.abs(p.i - idx) < labelStep; });
      if (!clash) break;
      idx = Math.max(0, idx - labelStep);
    }
  }
  labelSlots.push({ v: value, i: idx });
  return idx;
}

// Error exit: every overlay, line and signal is still emitted under its fixed name (empty rows,
// null series, false signals) so TrendSpider sees the same outputs on every run
// (docs: Common mistakes, Problem 1 — conditional painting).
function renderError(msg) {
  paint_overlay('CQO-S Panel', { position: 'top_right', order: 'above_all', offset_x: 0, offset_y: 0 }, {
    background: BG, border: '1px solid ' + C_BORDER, borderRadius: 4,
    rows: [{ cells: [{ text: 'CQO-S — ' + current.ticker + 'n' + msg, color: C_BEAR,
      fontSize: 10 + FS, fontWeight: 'bold', textAlign: 'center', padding: '6px 10px', background: BG }] }]
  });
  paint_overlay('CQO-S Calc Panel', { position: 'top_left', order: 'above_all', offset_x: 0, offset_y: 0 }, { rows: [] });
  [['CQOL King', C_GOLD, 2, 'line'], ['CQOL Queen', C_BEAR, 2, 'line'], ['CQOL Flip', C_FLIP, 1, 'line'],
   ['CQOL CallWall', C_BULL, 1, 'line'], ['CQOL PutWall', C_BEAR, 1, 'line'], ['CQOL MaxPain', C_MP, 1, 'dotted'],
   ['CQOL King Glow', '#3c311b', 7, 'line'], ['CQOL Queen Glow', '#3d252a', 7, 'line'],
   ['CQOL EM Upper', 'rgba(201,209,217,0.45)', 1, 'dotted'], ['CQOL EM Lower', 'rgba(201,209,217,0.45)', 1, 'dotted'],
   ['CQOC Strike A', C_TRADE, 1, 'line'], ['CQOC Strike B', C_TRADE, 1, 'line'], ['CQOC Strike C', C_TRADE, 1, 'line'],
   ['CQOC Strike D', C_TRADE, 1, 'line'], ['CQOC Breakeven Low', C_TRADE_BE, 1, 'dotted'], ['CQOC Breakeven High', C_TRADE_BE, 1, 'dotted']]
    .forEach(function (p) { paint(series_of(null), { name: p[0], color: p[1], thickness: p[2], style: p[3] }); });
  color_cloud(series_of(null), series_of(null), '#151b26', '#151b26');
  ['CQO-S: Long Gamma', 'CQO-S: Short Gamma', 'CQO-S: Above Gamma Flip', 'CQO-S: Below Gamma Flip', 'CQO-S: Backwardation',
   'CQO-S: Market POP above 50', 'CQO-S: Model view beats market', 'CQO-S: Short strikes beyond walls',
   'CQO-S: Trade idea available', 'CQO-S: Top pick is a credit trade', 'CQO-S: Top pick is a debit trade',
   'CQO-S: Earnings inside engine window', 'CQO-S: IV rich vs forecast', 'CQO-S: IV cheap vs forecast', 'CQO-S: Market regime Grind', 'CQO-S: Market fresh shock']
    .forEach(function (nm) { register_signal(series_of(false), nm); });
}

// ============================================================================
// FETCH  (one schedule, one chain, one daily history)
// ============================================================================
var schedule = await request.options_schedule(current.ticker);
if (schedule.error || !Array.isArray(schedule) || schedule.length === 0) {
  renderError('No options data'); throw 'CQO-S: no schedule';
}
schedule.sort(function (a, b) { return a.expiration.dte - b.expiration.dte; });

// The request fails outright when it asks for too many strikes on a big chain, so step down
// (requested → 25 → 15 strikes/side) until it returns.
var chain = null, strikesFetched = 0;
var tryN = [Math.max(strikesPerSide, calcStrikes), 25, 15].filter(function (x, i, a) { return a.indexOf(x) === i && x <= Math.max(strikesPerSide, calcStrikes); });
for (var ti = 0; ti < tryN.length && !chain; ti++) {
  try {
    var cr = await request.options_data_for_all_expirations(current.ticker, tryN[ti], ['b', 'a', 'iv', 'oi', 'gg', 'gd'], 'all');
    if (cr && !cr.error && cr.underlyingSymbolQuote && cr.resultByExpiration) { chain = cr; strikesFetched = tryN[ti]; }
  } catch (e) { chain = null; }
}
if (!chain) { renderError('Chain fetch failed'); throw 'CQO-S: chain'; }
var spot = chain.underlyingSymbolQuote.lastPrice;
if (!(spot > 0)) { renderError('Invalid spot'); throw 'CQO-S: spot'; }

// realised vol + technical state from daily bars
//   rvol = 0.4·HV20 + 0.3·HV60 + 0.3·HV252 (blended). Tested on 50 liquid names, 1995–2026, 21-day
//   horizon: ~10% lower forecast error than HV20 in both halves (log RMSE 0.330 vs 0.364 / 0.373 vs 0.420),
//   and nearly unbiased across regimes (next-month RV ÷ forecast 0.91–1.02) where HV20 runs 0.72×
//   after vol spikes and 1.28–1.37× in quiet regimes. HV20 alone is kept for display only.
var hv20 = null, hv60 = null, hv252 = null, rvol = null; var adv20Usd = null;
var techRSI14 = null, techDown = false, techBreakdown = false, techSMA50 = null, techSMA200 = null, techVolRank = null, techRcx = '';
var techStop = '', techSwLo = false, techSwHi = false, techStopMult = 1;
{
  var dcl = null, dlo = null, dhi = null, dop = null, dvo = null;
  if (current.resolution === 'D') { dcl = Array.from(close); dlo = Array.from(low); dhi = Array.from(high); dop = Array.from(open); dvo = typeof volume !== 'undefined' && volume ? Array.from(volume) : null; }
  else { try { var dh = await request.history(current.ticker, 'D'); if (dh && !dh.error && dh.close) { dcl = Array.from(dh.close); dlo = dh.low ? Array.from(dh.low) : null; dhi = dh.high ? Array.from(dh.high) : null; dop = dh.open ? Array.from(dh.open) : null; dvo = dh.volume ? Array.from(dh.volume) : null; } } catch (e) { dcl = null; } }
  var rmsVol = function (len) {
    if (!dcl || dcl.length < len + 2) return null;
    var s2 = 0, k = 0;
    for (var ri = dcl.length - len; ri < dcl.length; ri++) if (dcl[ri] > 0 && dcl[ri - 1] > 0) { var lr = Math.log(dcl[ri] / dcl[ri - 1]); s2 += lr * lr; k++; }
    return k >= len * 0.75 ? Math.sqrt(s2 / k * 252) : null;
  };
  hv20 = rmsVol(20); hv60 = rmsVol(60); hv252 = rmsVol(252);
  // v3.7 20-day average daily dollar volume, prior 20 completed daily bars (today's partial bar excluded)
  if (dcl && dvo && dvo.length === dcl.length && dcl.length > 22) {
    var advS = 0, advN = 0;
    for (var qv = dcl.length - 21; qv < dcl.length - 1; qv++) if (dvo[qv] > 0 && dcl[qv] > 0) { advS += dvo[qv] * dcl[qv]; advN++; }
    if (advN >= 15) adv20Usd = advS / advN;
  }
  rvol = hv20 && hv60 && hv252 ? 0.4 * hv20 + 0.3 * hv60 + 0.3 * hv252 : hv20 && hv60 ? 0.5 * hv20 + 0.5 * hv60 : hv20;
  if (dcl && dcl.length > 210) {
    var nD = dcl.length, avgN = function (p) { var s3 = 0; for (var q = nD - p; q < nD; q++) s3 += dcl[q]; return s3 / p; };
    techSMA50 = avgN(50); techSMA200 = avgN(200);
    var lastC = dcl[nD - 1];
    techDown = lastC < techSMA50 && techSMA50 < techSMA200;
    var ag = 0, al = 0;
    for (var qi = 1; qi < nD; qi++) { var ch = dcl[qi] - dcl[qi - 1], gg = Math.max(ch, 0), ll = Math.max(-ch, 0);
      if (qi <= 14) { ag += gg / 14; al += ll / 14; } else { ag = (ag * 13 + gg) / 14; al = (al * 13 + ll) / 14; } }
    techRSI14 = 100 - 100 / (1 + ag / Math.max(al, 1e-12));
    if (dlo && dlo.length === nD) { var mnL = Infinity; for (var qj = nD - 21; qj < nD - 1; qj++) mnL = Math.min(mnL, dlo[qj]); techBreakdown = lastC < mnL; }
    // TVSE volatility rank: percent of the prior 120 daily ATR14% values ≤ today's. Tested (50 names, both
    // halves): at rank ≥ 80 next-month realised vol ran 5–7% above the blended forecast → scale rvol × 1.05.
    if (dlo && dhi && dhi.length === nD) {
      var atrD = [], aw = null, trS = 0;
      for (var qa = 1; qa < nD; qa++) { var trD = Math.max(dhi[qa] - dlo[qa], Math.abs(dhi[qa] - dcl[qa - 1]), Math.abs(dlo[qa] - dcl[qa - 1]));
        if (qa <= 14) { trS += trD; if (qa === 14) aw = trS / 14; } else aw = (aw * 13 + trD) / 14; atrD[qa] = aw != null ? aw / dcl[qa] : null; }
      var curA = atrD[nD - 1];
      if (curA != null && nD > 140) { var le = 0; for (var qb = nD - 121; qb < nD - 1; qb++) if (atrD[qb] != null && atrD[qb] <= curA) le++; techVolRank = 100 * le / 120; }
      if (techVolRank != null && techVolRank >= 80 && rvol) rvol *= 1.05;
    }
    // CQ RCX 2.0 compression logic (verbatim detector below, daily bars, default settings). Tested (50 names,
    // both halves, placebo): after 'C ↑' next-month realised vol ran 0.90–0.93× the blended forecast (placebo 1.01),
    // while a compression candidate was active 0.93–0.95×; no directional follow-through (C ↑ is a vol signal).
    // The calmer vol alone reproduces the observed +3–4 pp condor / short-call gain, so only the vol is scaled.
    if (dop && dhi && dlo && dop.length === nD) {
      var rcxOut = runRCXDaily(dop, dhi, dlo, dcl, nD, { body: .35, wick: .25, decline: 1.5, contraction: .75, width: 2, buffer: .1, wait: 10 });
      var upRecent = false; for (var qc = Math.max(0, nD - 3); qc < nD; qc++) if (rcxOut.compUp[qc]) upRecent = true;
      if (upRecent) { techRcx = 'C↑ recent'; if (rvol) rvol *= 0.92; }
      else if (rcxOut.compActive[nD - 1]) { techRcx = 'compression active'; if (rvol) rvol *= 0.95; }
    }
    // Trend state (v2.3.1): Supertrend-style stop + swing breaks → realised-vol scaling (see header)
    if (dhi && dlo && dhi.length === nD && dlo.length === nD) {
      var tsd = runTrendStateDaily(dhi, dlo, dcl, nD);
      techStop = tsd.dir === 1 ? 'up' : tsd.dir === -1 ? 'down' : '';
      techSwLo = tsd.swingLoBreak5; techSwHi = tsd.swingHiBreak5;
      if (rvol && tsd.dir !== 0) {
        var stM = 1;
        if (tsd.dir === -1) { stM *= 1.015; if (techDown) stM *= 1.03; }
        else if (techVolRank != null && techVolRank >= 80) stM *= 0.955;
        if (tsd.swingLoBreak5) stM *= 1.04;
        if (tsd.swingHiBreak5) stM *= 0.96;
        rvol *= stM; techStopMult = stM;
      }
    }
  }
}

// Empirical Realised distribution (v2.1): the stock's own past h-day log returns, each standardised by the
// blended vol at its start and demeaned, then re-scaled by today's blended vol with a risk-free drift.
// Out-of-sample calibration (50 names, expanding window, both halves, 21 and 42 days): predicted vs observed
// win rates within ~1.5 pp for short puts at −1σ, short calls at +1σ and ±1σ condors, where the log-normal
// view missed by up to 4.4 pp (it has too little mass near the centre and in the tails).
var empCache = {};
var empZ = function (h) {
  if (empCache[h]) return empCache[h];
  var zsOut = [];
  if (dcl && dcl.length > 260 + h) {
    var nE = dcl.length, sq = [0];
    for (var qe = 1; qe < nE; qe++) { var lre = dcl[qe] > 0 && dcl[qe - 1] > 0 ? Math.log(dcl[qe] / dcl[qe - 1]) : 0; sq.push(sq[qe - 1] + lre * lre); }
    var volAt = function (j, len) { return Math.sqrt((sq[j] - sq[j - len]) / len * 252); };
    var TT = h / 252;
    for (var je = 253; je + h < nE; je++) {
      var sj = 0.4 * volAt(je, 20) + 0.3 * volAt(je, 60) + 0.3 * volAt(je, 252);
      if (!(sj > 0) || !(dcl[je] > 0)) continue;
      var sdj = sj * Math.sqrt(TT);
      zsOut.push((Math.log(dcl[je + h] / dcl[je]) - (riskFreeRate - sj * sj / 2) * TT) / sdj);
    }
    var mz = zsOut.reduce(function (a2, v2) { return a2 + v2; }, 0) / Math.max(1, zsOut.length);
    for (var ke = 0; ke < zsOut.length; ke++) zsOut[ke] -= mz;          // demean: drift comes only from the risk-free rate
  }
  empCache[h] = zsOut;
  return zsOut;
};

// ============================================================================
// ============================  GAMMA MODULE (CQO-L)  =========================
// ============================================================================
// Near set = first N valid expiries; candidate pool = every expiry ≤ Max DTE
var nearCodes = {}, nearList = [], pool = [];
for (var si = 0; si < schedule.length; si++) {
  var sx = schedule[si], sd0 = sx.expiration.dte;
  if (sd0 < 0) continue;
  if (nearList.length < nearExpiries) { nearList.push(sx); nearCodes[sx.expiration.code] = true; }
  if (sd0 <= maxDTE || nearCodes[sx.expiration.code]) pool.push(sx);
}
if (!nearList.length) { renderError('No valid expiries'); throw 'CQO-S: no expiries'; }
var rangeMult = strikeRangePct / 100;

// ---- CQ RCX 2.0 detector (verbatim from 'CQ Reversal & Compression v2.0'; only 'box' renamed to 'boxR')
function runRCXDaily(o,h,l,c,count,p) {
 const n=c.length, keys=['revNew','revActive','revUp','revFail','revExpire','compNew','compActive','compUp','compDown','compExpire','dataReset'];
 const out={}; keys.forEach(k=>out[k]=Array(n).fill(false));
 ['revHigh','revLow','compHigh','compLow'].forEach(k=>out[k]=Array(n).fill(null));
 const tr=Array(n).fill(null), a=Array(n).fill(null), valid=Array(n).fill(false);
 let seed=0, streak=0, rv=null, cp=null, revEnd=-1, compEnd=-1;
 const finite=x=>typeof x==='number' && Number.isFinite(x);
 const boxR=(start,end)=>{
  let hi=-Infinity,lo=Infinity,total=0;
  for(let j=start;j<=end;j++) { if(j<0||!valid[j]||!finite(tr[j]))return null; hi=Math.max(hi,h[j]);lo=Math.min(lo,l[j]);total+=tr[j]; }
  return {hi,lo,mean:total/(end-start+1)};
 };
 function resolve(s,i,reversal) {
  if(i-s.born>p.wait)return 'Expire';
  if(reversal && l[i]<s.lo-p.buffer*s.atr)return 'Fail';
  if(c[i]>s.hi+p.buffer*s.atr)return 'Up';
  if(!reversal && c[i]<s.lo-p.buffer*s.atr)return 'Down';
  return null;
 }
 for(let i=0;i<count;i++) {
  valid[i]=[o[i],h[i],l[i],c[i]].every(finite)&&h[i]>=Math.max(o[i],c[i])&&l[i]<=Math.min(o[i],c[i])&&h[i]>=l[i];
  if(!valid[i]) { if(rv||cp)out.dataReset[i]=true; rv=cp=null;revEnd=compEnd=i;seed=streak=0;continue; }
  tr[i]=i>0&&valid[i-1]?Math.max(h[i]-l[i],Math.abs(h[i]-c[i-1]),Math.abs(l[i]-c[i-1])):h[i]-l[i];
  streak++;seed+=tr[i];
  if(streak===14)a[i]=seed/14;
  else if(streak>14)a[i]=(a[i-1]*13+tr[i])/14;
  if(rv) {const x=resolve(rv,i,true);if(x){out['rev'+x][i]=true;rv=null;revEnd=i;}}
  if(cp) {const x=resolve(cp,i,false);if(x){out['comp'+x][i]=true;cp=null;compEnd=i;}}
  const rs=i-2, ra=a[rs-1];
  if(!rv && rs>revEnd && rs>=11 && finite(ra)&&ra>0) {
   const b=boxR(rs,i), before=boxR(rs-11,rs-1);
   let rejects=0;
   for(let j=rs;j<=i;j++) {const r=h[j]-l[j];if(valid[j]&&r>=.2*ra&&r>0&&Math.abs(c[j]-o[j])/r<=p.body&&(Math.min(o[j],c[j])-l[j])/r>=p.wick)rejects++;}
   if(b&&before&&rejects>=2&&b.hi-b.lo<=2.5*ra&&c[rs-11]-c[rs-1]>=p.decline*ra&&c[i]>=b.lo+.5*(b.hi-b.lo)) {
    rv={...b,born:i,atr:ra};out.revNew[i]=true;
   }
  }
  const cs=i-5, ca=a[cs-1];
  if(!cp && cs>compEnd && cs>=6 && finite(ca)&&ca>0) {
   const b=boxR(cs,i), old=boxR(cs-6,cs-1);
   if(b&&old&&old.mean>0&&b.hi>b.lo&&b.mean<=p.contraction*old.mean&&b.hi-b.lo<=p.width*ca&&b.hi-b.lo<=.85*(old.hi-old.lo)) {
    cp={...b,born:i,atr:ca};out.compNew[i]=true;
   }
  }
  if(rv){out.revActive[i]=true;out.revHigh[i]=rv.hi;out.revLow[i]=rv.lo;}
  if(cp){out.compActive[i]=true;out.compHigh[i]=cp.hi;out.compLow[i]=cp.lo;}
 }
 return out;
}


// ============================================================================
// TREND STATE (v2.3.1) — generic primitives: Supertrend-style stop (Wilder ATR(20) × 3 around (H+L)/2 with
// the standard band ratchet) and swing-point breaks (5-bar pivots, known 5 bars after the pivot).
// ============================================================================
function runTrendStateDaily(h, l, c, n) {
  var LEN = 20, MUL = 3, PV = 5, atr = null, fu = null, fl = null, dr = 1, dirNow = 0;
  for (var i = 1; i < n; i++) {
    var tr = Math.max(h[i] - l[i], Math.abs(h[i] - c[i - 1]), Math.abs(l[i] - c[i - 1]));
    atr = i <= LEN ? (atr == null ? tr : atr + (tr - atr) / i) : (atr * (LEN - 1) + tr) / LEN;
    if (i < LEN) continue;
    var md = (h[i] + l[i]) / 2, ub = md + MUL * atr, lb = md - MUL * atr, pu = fu, pl = fl;
    fu = (pu == null || ub < pu || c[i - 1] > pu) ? ub : pu;
    fl = (pl == null || lb > pl || c[i - 1] < pl) ? lb : pl;
    if (dr === -1 && pu != null && c[i] > pu) dr = 1; else if (dr === 1 && pl != null && c[i] < pl) dr = -1;
    dirNow = dr;
  }
  var lastLo = null, lastHi = null, loBrk = -99, hiBrk = -99;
  for (var j = 2 * PV; j < n; j++) {
    var p = j - PV, isL = true, isH = true;
    for (var k = p - PV; k <= p + PV; k++) { if (k === p) continue; if (l[k] <= l[p]) isL = false; if (h[k] >= h[p]) isH = false; }
    if (isL) lastLo = l[p];
    if (isH) lastHi = h[p];
    if (lastLo != null && c[j] < lastLo) { loBrk = j; lastLo = null; }
    if (lastHi != null && c[j] > lastHi) { hiBrk = j; lastHi = null; }
  }
  return { dir: dirNow, swingLoBreak5: n - 1 - loBrk < 5, swingHiBreak5: n - 1 - hiBrk < 5 };
}
// ============================================================================
// PER-EXPIRY PASS  (each expiry in the pool is priced once into its own bucket)
// ============================================================================
// ============================================================================
// v3.10 MARKET REGIME (same definition and defaults as CQ Regime v1.1 — keep the two in step)
// ============================================================================
// SPY close vs 200-day MA (±1% band) × VIX ÷ VXV (stress enters ≥ 1.00, exits < 0.95), plus the PULLBACK tier inside
// CALM BULL (close < 20-day MA and ≥ 1.5% below the 20-day high; ends on a close back above the 20-day MA).
// Lab (SPY 2008–2026, VIX-priced 21-day trades): GRIND (below trend, no stress) was the one robust bad state for short
// puts (bull put −3.4% of risk, 25Δ put hit 29.6%); a FRESH SHOCK (first 5 days) had 10Δ put hits of 10.5% vs ~4%.
// On single names the market state barely moved breach rates (−2 pts, n.s.) → CQO-S only WARNS (or, with
// Regime Filter = Filter, drops short-put ideas in GRIND). No score term uses the regime.
// Request budget (found live 2026-10-04): a script may add only TWO daily histories on top of its own chart data, so
// off a SPY chart the stress input uses a VIX-only proxy: VIX ÷ its 63-day mean (enter ≥ 1.30, exit < 1.10). Lab
// check vs VIX/VXV (SPY 2008–2026): same regime state 90% of days, GRIND recall 93% / precision 71%. Row says 'proxy'.
var MKT = { state: 0, name: 'n/a', runDays: 0, trendPct: null, ratio: null, note: '', proxy: false };
{
  const MK_NAME = { 0: 'n/a', 1: 'CALM BULL', 2: 'SHOCK', 3: 'GRIND', 4: 'CAPITULATION', 5: 'BULL PULLBACK' };
  const dayK = (ts) => Math.floor((ts > 1e11 ? ts / 1000 : ts) / 86400);
  const getD = async (sym) => { try { const r = await request.history(sym, 'D'); if (r && !r.error && r.close && r.time && r.close.length) return r; } catch (e) {} return null; };
  if (regimeMode !== 'Off') {
    // budget: two extra daily histories. Intraday charts already spend one on the chart ticker's daily bars (dh above).
    const isSPY = current.ticker === 'SPY', daily = current.resolution === 'D';
    const ix = isSPY && !daily ? (typeof dh !== 'undefined' && dh && dh.close ? dh : null) : await getD('SPY');
    const vx = isSPY || daily ? await getD('$VIX') : null, vv = isSPY && daily ? await getD('$VXV') : null;
    if (!isSPY && !daily) MKT.note = 'intraday: trend only (request cap) · see Daily';
    if (!ix) MKT.note = 'SPY history unavailable';
    else {
      const m = ix.close.length, cl = Array.from(ix.close), hiA = ix.high ? Array.from(ix.high) : cl, tk = Array.from(ix.time);
      const asOf = (h) => { const o = Array.from({ length: m }, () => NaN); if (!h) return o; const mp = {}; for (let i = 0; i < h.close.length; i++) if (h.close[i] > 0) mp[dayK(h.time[i])] = h.close[i]; let lv = NaN; for (let i = 0; i < m; i++) { const v = mp[dayK(tk[i])]; if (v > 0) lv = v; o[i] = lv; } return o; };
      const vixA = asOf(vx), vxvA = asOf(vv);
      MKT.proxy = !!vx && !vv;
      if (!vx && !MKT.note) MKT.note = 'VIX unavailable → trend only';
      const vix63 = vixA.map((_, i) => { if (i < 62) return NaN; let a = 0; for (let j = i - 62; j <= i; j++) a += vixA[j]; return a / 63; });
      const SE = MKT.proxy ? 1.30 : 1.00, SX = MKT.proxy ? 1.10 : 0.95;
      const smaAt = (len, i) => { if (i < len - 1) return NaN; let s = 0; for (let j = i - len + 1; j <= i; j++) s += cl[j]; return s / len; };
      let up = null, on = false, pb = false; const st = [];
      for (let i = 0; i < m; i++) {
        const ma = smaAt(200, i); if (!(ma > 0)) { st.push(0); continue; }
        const p = 100 * (cl[i] / ma - 1); if (up === null) up = p > 0; else if (up && p < -1) up = false; else if (!up && p > 1) up = true;
        const r = MKT.proxy ? vixA[i] / vix63[i] : vixA[i] / vxvA[i]; let s0;
        if (!isFinite(r)) s0 = up ? 1 : 3;
        else { if (!on && r >= SE) on = true; else if (on && r < SX) on = false; s0 = up ? (on ? 2 : 1) : (on ? 4 : 3); }
        if (s0 === 1) {
          const ma20 = smaAt(20, i); let h20 = -Infinity; for (let j = Math.max(0, i - 19); j <= i; j++) h20 = Math.max(h20, hiA[j] > 0 ? hiA[j] : cl[j]);
          const depth = h20 > 0 ? 100 * (1 - cl[i] / h20) : 0;
          if (!pb && cl[i] < ma20 && depth >= 1.5) pb = true; else if (pb && cl[i] >= ma20) pb = false;
          st.push(pb ? 5 : 1);
        } else { pb = false; st.push(s0); }
        if (i === m - 1) { MKT.trendPct = p; MKT.ratio = isFinite(r) ? r : null; }
      }
      const L = m - 1; MKT.state = st[L]; MKT.name = MK_NAME[MKT.state];
      let rd = 0; for (let i = L; i >= 0 && st[i] === st[L]; i--) rd++; MKT.runDays = rd;
    }
  }
}
const MKT_COL = { 0: '#8a8a9a', 1: '#089981', 2: '#FFB300', 3: '#F23645', 4: '#9C27B0', 5: '#64B5F6' };
const mktFreshShock = MKT.state === 2 && MKT.runDays <= 5;
const mktApprox = MKT.proxy || MKT.ratio == null;          // v3.11: proxy or trend-only stress
const mktGrindOn = MKT.state === 3 && (!mktApprox || MKT.runDays >= 21);
const mktEarlyGrind = MKT.state === 3 && !mktGrindOn;
const mktWarnFor = (legs, st) => {          // warning text for one candidate, or ''
  if (regimeMode === 'Off') return '';
  const shortPut = legs.some((g) => g.qty < 0 && g.type === 'P');
  if (mktGrindOn && shortPut) return 'market GRIND' + (mktApprox ? ' ' + MKT.runDays + 'd' : '') + ': short puts historically worst (25Δ hit ~30%)';
  if (mktFreshShock && CREDIT[st]) return 'FRESH market shock (' + MKT.runDays + 'd): tail risk high';
  return '';
};

function processExpiry(ex) {
  var code = ex.expiration.code, dte = ex.expiration.dte;
  var expLabel = (ex.expiration.month || '').slice(0, 3) + ' ' + (ex.expiration.day || '');
  var dteW = dte >= 1 ? 1 / Math.sqrt(dte) : 1;
  var bk = {
    ex: ex, code: code, dte: dte,
    rec: { label: expLabel, code: code, dte: dte, atmIV: null, atmStrike: null, skew25: null, top: false, share: null },
    gexByK: {}, cOIByK: {}, pOIByK: {}, callOI: 0, putOI: 0, grossW: 0, grossRaw: 0, zg: []
  };
  var ec = chain.resultByExpiration[code];
  if (!ec) return bk;
  var rec = bk.rec;

  // trim to the GEX Strikes/Side nearest strikes each side of spot (chain is fetched wider for the calculator)
  var allKs = Object.keys(ec).map(Number).sort(function (a, b) { return a - b; });
  var upIx = allKs.findIndex(function (k) { return k >= spot; }); if (upIx < 0) upIx = allKs.length;
  var strikes = allKs.slice(Math.max(0, upIx - strikesPerSide), upIx + strikesPerSide);
  var atmDist = Infinity, iv25C = null, b25C = Infinity, iv25P = null, b25P = Infinity;

  for (var ki = 0; ki < strikes.length; ki++) {
    var K = strikes[ki], sd = ec[K];
    if (!sd) continue;
    var Cc = sd.C || {}, Pp = sd.P || {};
    var cOI = Cc.oi || 0, pOI = Pp.oi || 0;
    bk.cOIByK[K] = (bk.cOIByK[K] || 0) + cOI;
    bk.pOIByK[K] = (bk.pOIByK[K] || 0) + pOI;
    bk.callOI += cOI; bk.putOI += pOI;

    var distPct = Math.abs(K - spot) / spot;
    if (distPct > rangeMult) continue;

    // IV / delta / gamma: mid-market greeks preferred, native fallback, native gamma wins
    var isCall = K >= spot;
    var side = isCall ? Cc : Pp, sideType = isCall ? 'call' : 'put';
    if (side.b == null && side.a == null && side.iv == null) { side = isCall ? Pp : Cc; sideType = isCall ? 'put' : 'call'; }
    var mid = ((side.b || 0) + (side.a || 0)) / 2;
    var iv = null, delta = null, gamma = null;
    if (mid > 0) {
      try {
        var g = options.compute_greeks(mid, spot, K, dte, sideType, riskFreeRate);
        if (g && g.iv > 0 && g.iv <= 400) { iv = g.iv; delta = g.delta != null ? g.delta / 100 : null; gamma = g.gamma != null ? g.gamma / 100 : null; }
      } catch (e) {}
    }
    if (iv == null && side.iv > 0 && side.iv <= 400) {
      iv = side.iv;
      try {
        var sp = options.compute_option_price(spot, K, dte, side.iv, sideType, riskFreeRate);
        var sg = sp > 0 ? options.compute_greeks(sp, spot, K, dte, sideType, riskFreeRate) : null;
        if (sg) { if (sg.delta != null) delta = sg.delta / 100; if (sg.gamma != null) gamma = sg.gamma / 100; }
      } catch (e) {}
    }
    if (Cc.gg > 0) gamma = Cc.gg; else if (Pp.gg > 0) gamma = Pp.gg;

    if (iv != null) {
      var dd = Math.abs(K - spot);
      if (dd < atmDist) { atmDist = dd; rec.atmIV = iv; rec.atmStrike = K; }
      if (delta != null) {
        var d25 = Math.abs(Math.abs(delta) - 0.25);
        if (isCall && d25 < b25C) { b25C = d25; iv25C = iv; }
        if (!isCall && d25 < b25P) { b25P = d25; iv25P = iv; }
      }
    }

    if ((cOI >= minOI || pOI >= minOI) && gamma > 0) {
      var rawGEX = (cOI - pOI) * gamma * spot * spot * 0.01 * 100;
      var sGEX = rawGEX * dteW;
      bk.gexByK[K] = (bk.gexByK[K] || 0) + sGEX;
      bk.grossW += Math.abs(sGEX);
      bk.grossRaw += Math.abs(rawGEX);
      if (iv != null) bk.zg.push([K, cOI - pOI, iv / 100, Math.max(dte, 0.5) / 365, dteW]);   // v3.7 ZG-SPOT inputs
    }
  }
  // v3.7 ZG / hedge inputs beyond the 15 GEX strikes: every other fetched strike within the GEX range
  // (Chain Strikes/Side). On SPY / QQQ the 15 nearest strikes cover only ±2–3% and about half the gamma;
  // tested on CascadeQuant chains, 30/side cut the ZG gap vs the full ±15% chain from 1.9% to 0.7% of spot (SPY).
  var coreLo = Math.max(0, upIx - strikesPerSide), coreHi = upIx + strikesPerSide;
  for (var kx = 0; kx < allKs.length; kx++) {
    if (kx >= coreLo && kx < coreHi) continue;
    var Kz = allKs[kx], sz = ec[Kz];
    if (!sz || Math.abs(Kz - spot) / spot > rangeMult) continue;
    var Cz = sz.C || {}, Pz = sz.P || {}, cOz = Cz.oi || 0, pOz = Pz.oi || 0;
    if (!(cOz >= minOI || pOz >= minOI)) continue;
    var callZ = Kz >= spot, sdz = callZ ? Cz : Pz, tz = callZ ? 'call' : 'put';
    if (sdz.b == null && sdz.a == null && sdz.iv == null) { sdz = callZ ? Pz : Cz; tz = callZ ? 'put' : 'call'; }
    var midz = ((sdz.b || 0) + (sdz.a || 0)) / 2, ivz = null;
    if (midz > 0) { try { var gz = options.compute_greeks(midz, spot, Kz, dte, tz, riskFreeRate); if (gz && gz.iv > 0 && gz.iv <= 400) ivz = gz.iv; } catch (e) {} }
    if (ivz == null && sdz.iv > 0 && sdz.iv <= 400) ivz = sdz.iv;
    if (ivz != null) bk.zg.push([Kz, cOz - pOz, ivz / 100, Math.max(dte, 0.5) / 365, dteW]);
  }
  if (b25C > 0.30) iv25C = null;
  if (b25P > 0.30) iv25P = null;
  if (iv25C != null && iv25P != null) rec.skew25 = iv25P - iv25C;   // put minus call
  return bk;
}

var buckets = pool.map(processExpiry);

// ============================================================================
// EXPIRY SELECTION  — near set + top-M gross-GEX expiries ≤ Max DTE
// ============================================================================
var rankKey = rankMode === 'Weighted GEX' ? 'grossW' : 'grossRaw';
var poolGross = 0;
buckets.forEach(function (bk) { if (bk.dte <= maxDTE) poolGross += bk[rankKey]; });
buckets.forEach(function (bk) { bk.rec.share = poolGross > 0 && bk.dte <= maxDTE ? bk[rankKey] / poolGross : null; });

var topPicks = buckets
  .filter(function (bk) { return !nearCodes[bk.code] && bk.dte <= maxDTE && bk[rankKey] > 0; })
  .sort(function (x, y) { return y[rankKey] - x[rankKey]; })
  .slice(0, topExpiries);
topPicks.forEach(function (bk) { bk.rec.top = true; });

var selected = buckets
  .filter(function (bk) { return nearCodes[bk.code] || bk.rec.top; })
  .sort(function (x, y) { return x.dte - y.dte; });

// ============================================================================
// AGGREGATE SELECTED EXPIRIES
// ============================================================================
var gex = {};            // K → { net, byExp }
var callOIByK = {}, putOIByK = {};
var totalGEX = 0, totalCallOI = 0, totalPutOI = 0;
var expData = [];        // { label, code, dte, atmIV, atmStrike, skew25, top, share }

selected.forEach(function (bk) {
  expData.push(bk.rec);
  totalCallOI += bk.callOI; totalPutOI += bk.putOI;
  Object.keys(bk.cOIByK).forEach(function (k) { callOIByK[k] = (callOIByK[k] || 0) + bk.cOIByK[k]; });
  Object.keys(bk.pOIByK).forEach(function (k) { putOIByK[k]  = (putOIByK[k]  || 0) + bk.pOIByK[k]; });
  Object.keys(bk.gexByK).forEach(function (k) {
    var K = Number(k), v = bk.gexByK[k];
    if (!gex[K]) gex[K] = { net: 0, byExp: {} };
    gex[K].net += v;
    gex[K].byExp[bk.code] = (gex[K].byExp[bk.code] || 0) + v;
    totalGEX += v;
  });
});
if (!expData.length) { renderError('No valid expiries'); throw 'CQO-S: no expiries'; }

// ============================================================================
// KEY LEVELS
// ============================================================================
var strikeList = Object.keys(gex).map(Number).sort(function (a, b) { return a - b; });
var king = null, kingG = -Infinity, queen = null, queenG = Infinity;
var callWall = null, cwG = -Infinity, putWall = null, pwG = -Infinity;
for (var i = 0; i < strikeList.length; i++) {
  var Kx = strikeList[i], gx = gex[Kx].net;
  if (gx > kingG)  { kingG = gx;  king = Kx; }
  if (gx < queenG) { queenG = gx; queen = Kx; }
  if (Kx > spot && gx > cwG) { cwG = gx; callWall = Kx; }
  if (Kx < spot && gx > pwG) { pwG = gx; putWall = Kx; }
}

var strikeFlip = null, flipDist = Infinity, flipLo = null, flipHi = null;
for (var j = 1; j < strikeList.length; j++) {
  var pK = strikeList[j - 1], cK = strikeList[j], pG = gex[pK].net, cG = gex[cK].net;
  if ((pG >= 0 && cG < 0) || (pG < 0 && cG >= 0)) {
    var den = Math.abs(pG) + Math.abs(cG);
    var fK = den > 0 ? pK + (cK - pK) * Math.abs(pG) / den : (pK + cK) / 2;
    if (Math.abs(fK - spot) < flipDist) { flipDist = Math.abs(fK - spot); strikeFlip = Math.round(fK * 100) / 100; flipLo = pK; flipHi = cK; }
  }
}

// v3.7 ZERO-GAMMA SPOT (ZG-SPOT). The strike flip above is only where per-strike net GEX changes sign.
// ZG-SPOT reprices every selected strike's gamma (Black-Scholes, its own IV and expiry) at hypothetical
// spots across ±ZG_RANGE and finds where total net GEX crosses zero — the spot at which the assumed
// dealer book turns from long to short gamma. Same weighting as the regime line (1/√DTE), so
// "above / below ZG" and LONG / SHORT γ agree. Sign convention (assumption, often wrong on single
// names): dealers long calls, short puts. Grid limited to the GEX strike range (chain is truncated).
var ZG_RANGE = rangeMult, ZG_STEPS = 120;
var zgPts = [];
selected.forEach(function (bk) { bk.zg.forEach(function (p) { zgPts.push(p); }); });
var gexAtSpot = function (S, weighted, gross) {
  var tot = 0;
  for (var zi = 0; zi < zgPts.length; zi++) {
    var p = zgPts[zi], sT = p[2] * Math.sqrt(p[3]);
    if (!(sT > 0) || p[1] === 0) continue;
    var d1 = (Math.log(S / p[0]) + (riskFreeRate + p[2] * p[2] / 2) * p[3]) / sT;
    var g$ = Math.exp(-d1 * d1 / 2) / 2.5066282746310002 * S / sT;   // per contract: Γ·S²·0.01·100 = Γ·S² = φ(d1)·S/(σ√T)
    tot += (gross ? Math.abs(p[1]) : p[1]) * g$ * (weighted ? p[4] : 1);
  }
  return tot;
};
var gammaFlip = null, zgNone = null, zgSpot = null;
if (zgPts.length) {
  var zLo = spot * (1 - ZG_RANGE), zHi = spot * (1 + ZG_RANGE), zPrevS = null, zPrevG = null, zBest = Infinity;
  for (var zs = 0; zs <= ZG_STEPS; zs++) {
    var zS = zLo + (zHi - zLo) * zs / ZG_STEPS, zG = gexAtSpot(zS, true);
    if (zPrevS != null && ((zPrevG >= 0 && zG < 0) || (zPrevG < 0 && zG >= 0))) {
      var zRoot = zPrevS + (zS - zPrevS) * Math.abs(zPrevG) / (Math.abs(zPrevG) + Math.abs(zG) || 1);
      if (Math.abs(zRoot - spot) < zBest) { zBest = Math.abs(zRoot - spot); zgSpot = Math.round(zRoot * 100) / 100; }
    }
    zPrevS = zS; zPrevG = zG;
  }
  if (zgSpot == null) zgNone = gexAtSpot(spot, true) >= 0 ? 'long' : 'short';   // no crossing within ±range
}
gammaFlip = zgSpot;
if (gammaFlip != null) {            // matrix highlight: the two listed strikes bracketing ZG-SPOT
  flipLo = null; flipHi = null;
  for (var zk = 0; zk < strikeList.length; zk++) { if (strikeList[zk] <= gammaFlip) flipLo = strikeList[zk]; else if (flipHi == null) flipHi = strikeList[zk]; }
}
// Hedge flow per 1% move (raw, unweighted) and its scale vs 20-day average daily dollar volume.
var hedge1pct = zgPts.length ? gexAtSpot(spot, false) : null;
var hedgeAdvPct = (hedge1pct != null && adv20Usd > 0) ? Math.abs(hedge1pct) / adv20Usd * 100 : null;
// Gross (all open interest, sign ignored): upper bound on hedge flow. Net is a small difference of large call
// and put books, so it swings with strike coverage and the sign convention; read the two together.
var hedgeGross = zgPts.length ? gexAtSpot(spot, false, true) : null;
var grossAdvPct = (hedgeGross != null && adv20Usd > 0) ? hedgeGross / adv20Usd * 100 : null;

var painKs = Object.keys(callOIByK).map(Number).sort(function (a, b) { return a - b; });
var maxPain = null, minPain = Infinity;
for (var a = 0; a < painKs.length; a++) {
  var t = painKs[a], pv = 0;
  for (var b = 0; b < painKs.length; b++) {
    var Kp = painKs[b];
    if (Kp > t) pv += (callOIByK[Kp] || 0) * (Kp - t);
    if (Kp < t) pv += (putOIByK[Kp]  || 0) * (t - Kp);
  }
  if (pv < minPain) { minPain = pv; maxPain = t; }
}

// Expected move: front ATM straddle, IV fallback (front = nearest selected expiry)
var expMove = null;
var fc = chain.resultByExpiration[expData[0].code];
if (fc) {
  var fKs = Object.keys(fc).map(Number);
  var atmK = fKs.reduce(function (p, c) { return Math.abs(c - spot) < Math.abs(p - spot) ? c : p; });
  var f = fc[atmK] || {};
  var straddle = (((f.C && f.C.b) || 0) + ((f.C && f.C.a) || 0)) / 2 + (((f.P && f.P.b) || 0) + ((f.P && f.P.a) || 0)) / 2;
  var fIV = expData[0].atmIV, fDTE = expData[0].dte;
  expMove = straddle > 0 ? straddle : (fIV != null && fDTE >= 1 ? spot * (fIV / 100) * Math.sqrt(fDTE / 365) : null);
}

// ============================================================================
// REGIME SUMMARY
// ============================================================================
// v3.7 regime from the same widened book as ZG, so LONG / SHORT γ and above / below ZG always agree
var regimeGEX = zgPts.length ? gexAtSpot(spot, true) : totalGEX;
var isLongGamma = regimeGEX >= 0;
var frontATM = expData[0].atmIV, backATM = expData[expData.length - 1].atmIV;
var termRatio = (frontATM != null && backATM > 0) ? frontATM / backATM : null;
var termLabel = termRatio == null ? '—' : termRatio > 1.05 ? 'Backwd' : termRatio < 0.95 ? 'Contango' : 'Flat';
var termColor = termRatio != null && termRatio > 1.05 ? C_WARN : C_SUB;
var frontSkew = expData[0].skew25;
var skewColor = frontSkew == null ? C_SUB : frontSkew > 3 ? C_BEAR : frontSkew < -3 ? C_BULL : C_SUB;
var pcRatio = totalCallOI > 0 ? totalPutOI / totalCallOI : null;
var pcColor = pcRatio == null ? C_SUB : pcRatio > 1.2 ? C_BEAR : pcRatio < 0.8 ? C_BULL : C_SUB;
var spacing = strikeList.length > 1 ? Math.abs(strikeList[1] - strikeList[0]) : 1;
var kqAdjacent = king != null && queen != null && Math.abs(king - queen) <= spacing * 2;
var aboveFlip = gammaFlip != null && spot > gammaFlip;

// ============================================================================
// FORMATTERS
// ============================================================================
function fmtGEX(v) {
  if (!v) return '0';
  var m = Math.abs(v) / 1e6, s = v > 0 ? '+' : '-';
  return m >= 1000 ? s + (m / 1000).toFixed(1) + 'B' : m >= 1 ? s + m.toFixed(1) + 'M' : m >= 0.001 ? s + (m * 1000).toFixed(0) + 'K' : '0';
}
function fmtK(K) { return K % 1 === 0 ? K.toFixed(0) : K.toFixed(2); }
function pct(lvl) { if (lvl == null) return ''; var p = (lvl - spot) / spot * 100; return (p >= 0 ? '+' : '') + p.toFixed(1) + '%'; }
function heat(v, share) {
  if (!v || !share) return BG;
  return hx8(v > 0 ? C_BULL : C_BEAR, Math.min(0.08 + share * 1.5, 0.6));
}
function cell(text, o) {
  o = o || {};
  var c0 = { text: text, color: o.color || C_TXT, background: o.bg || BG, fontSize: (o.sz || 9) + FS,
    fontWeight: o.bold ? 'bold' : 'normal', textAlign: o.align || 'center', padding: o.p != null ? o.p : '1px 5px',
    colspan: o.span || 1 };
  if (o.sh) c0.textShadow = o.sh;
  if (o.ls) c0.letterSpacing = o.ls;
  return c0;
}

// Table width follows the matrix (Strike + expiries + Net); min 6 columns
var nExp = expData.length;
var COLS = showMatrix && LV >= 3 ? Math.max(6, nExp + 2) : 6;
function splitCols(parts) {           // spread COLS across `parts` cells
  var base = Math.floor(COLS / parts), extra = COLS - base * parts, out = [];
  for (var q = 0; q < parts; q++) out.push(base + (q < extra ? 1 : 0));
  return out;
}
var HALF = splitCols(2), THIRD = splitCols(3);

var rows = [];
function row1(text, color, o) { o = o || {}; o.color = color; o.span = COLS; rows.push({ cells: [cell(text, o)] }); }
// ============================================================================
// PANEL
// ============================================================================
// v3.9: title and regime chip share one row
var regimeTxt = (isLongGamma ? 'LONG γ ' : 'SHORT γ ') + fmtGEX(regimeGEX)
  + (gammaFlip != null ? (aboveFlip ? '  · above ZG' : '  · below ZG') : zgNone ? '  · no ZG ±' + Math.round(ZG_RANGE * 100) + '%' : '');
var regCol = isLongGamma ? C_BULL : C_BEAR;
rows.push({ cells: [
  cell('◆ CQO-S · ' + current.symbol + '  $' + spot.toFixed(2), { color: lite(C_HDR, 14), sz: 10, bold: true, bg: gHead(C_HDR), p: '3px 6px', align: 'left', ls: '0.04em', span: HALF[0] }),
  cell(regimeTxt, { color: lite(regCol, 8), sz: 10, bold: true, bg: gChip(regCol), p: '3px 6px', sh: glow(regCol, 0.6), ls: '0.04em', span: HALF[1] }) ] });
// v3.10 market regime row (CQ Regime v1.1 definition)
if (regimeMode !== 'Off') {
  row1('Market ' + MKT.name + (MKT.state ? ' · ' + MKT.runDays + 'd' : '') + (MKT.trendPct != null ? ' · SPY ' + (MKT.trendPct >= 0 ? '+' : '') + MKT.trendPct.toFixed(1) + '% vs 200d' : '')
    + (MKT.ratio != null ? (MKT.proxy ? ' · VIX/63d ' : ' · VIX/VXV ') + MKT.ratio.toFixed(2) + (MKT.proxy ? ' (proxy)' : '') : '') + (mktFreshShock ? ' · FRESH' : '') + (mktEarlyGrind ? ' · early grind, watch (warns at 21d)' : '') + (MKT.note ? ' · ' + MKT.note : ''),
    MKT_COL[MKT.state] || C_SUB, { sz: 8, bold: true, bg: BG_HDR, p: '1px 6px' });
}
// v3.7 scale: dealer hedge flow for a 1% move vs 20-day average daily dollar volume (v3.9: log bar, 1% ADV = mid)
if (hedge1pct != null) {
  var hRnd = hedgeAdvPct == null ? null : (hedgeAdvPct < 1 ? Math.round(hedgeAdvPct * 100) / 100 : Math.round(hedgeAdvPct * 10) / 10);   // classify on the shown value
  var hScale = hRnd == null ? '' : hRnd < 1 ? ' small' : hRnd < 5 ? ' moderate' : ' large';
  var pctTxt = function (x) { return x == null ? 'n/a' : (x < 1 ? x.toFixed(2) : x.toFixed(1)) + '%'; };
  row1('Hedge/1%: net ' + fmtGEX(hedge1pct).replace(/^[+-]/, '$') + ' = ' + pctTxt(hedgeAdvPct) + ' ADV' + hScale
    + ' · gross ' + (hedgeGross != null ? fmtGEX(hedgeGross).replace(/^[+-]/, '$') + ' = ' + pctTxt(grossAdvPct) : 'n/a'),
    hRnd != null && hRnd >= 5 ? C_GOLD : C_SUB,
    { sz: 8, bg: hedgeAdvPct > 0 ? gFill((Math.log(hedgeAdvPct) / Math.LN10 + 1) / 2, hRnd >= 5 ? C_GOLD : hRnd >= 1 ? C_HDR : C_DIM) : BG_HDR, p: '1px 6px' });
}
if (kqAdjacent) row1('⚠ King/Queen adjacent — unstable', C_WARN, { sz: 8, bg: gSoft(C_WARN, 0.18), p: '1px 6px' });

// Top-GEX expiry ranking (share of gross GEX across all expiries ≤ Max DTE)
if (topPicks.length && LV >= 2) {
  row1('● Top GEX: ' + topPicks.map(function (bk) {
    return bk.rec.label + ' ' + (bk.rec.share != null ? (bk.rec.share * 100).toFixed(0) + '%' : '—');
  }).join(' · '), C_GOLD, { sz: 8, bg: gSoft(C_GOLD, 0.10), p: '1px 6px' });
}

// Vol line: term · P/C · skew
if (LV >= 2) rows.push({ cells: [
  cell('Term ' + (termRatio != null ? termRatio.toFixed(2) + 'x ' + termLabel : '—'), { color: termColor, sz: 9, span: THIRD[0] }),
  cell('P/C ' + (pcRatio != null ? pcRatio.toFixed(2) : '—'), { color: pcColor, sz: 9, bold: true, span: THIRD[1] }),
  cell('Skw ' + (frontSkew != null ? (frontSkew >= 0 ? '+' : '') + frontSkew.toFixed(1) : '—'), { color: skewColor, sz: 9, span: THIRD[2] }),
]});
rows.push({ cells: [
  cell('ATM ' + (frontATM != null ? frontATM.toFixed(1) + '%' : '—') + ' → ' + (backATM != null ? backATM.toFixed(1) + '%' : '—'), { color: C_SUB, sz: 8, span: HALF[0] }),
  cell(expMove != null ? 'EM ±' + expMove.toFixed(2) + ' (' + (expMove / spot * 100).toFixed(1) + '%)' : 'EM —', { color: C_TXT, sz: 8, bold: true, span: HALF[1] }),
]});

// Key levels — v3.9: three per row, tinted chips (King / Queen stronger)
function lvlCell(sym, K, color, span, strong) {
  return cell(K != null ? sym + ' ' + fmtK(K) + '  ' + pct(K) : sym + ' —', { color: color, bg: gSoft(color, strong ? 0.24 : 0.12), sz: 9, bold: true, align: 'left', span: span, p: '2px 6px' });
}
var zgCell = (gammaFlip != null || !zgNone) ? lvlCell('γ ZG', gammaFlip, C_FLIP, THIRD[2])
  : cell('γ ZG none · ' + zgNone + ' γ', { color: C_FLIP, bg: gSoft(C_FLIP, 0.12), sz: 9, bold: true, align: 'left', span: THIRD[2], p: '2px 6px' });
rows.push({ cells: [lvlCell('★ King', king, C_GOLD, THIRD[0], true), lvlCell('♛ Queen', queen, C_BEAR, THIRD[1], true), zgCell] });
rows.push({ cells: [lvlCell('◤ CW', callWall, C_BULL, THIRD[0]), lvlCell('◢ PW', putWall, C_BEAR, THIRD[1]), lvlCell('◇ MP', maxPain, C_MP, THIRD[2])] });

// Strike matrix (v3.9: spot row marked ▶, Net as a diverging bar)
if (showMatrix && LV >= 3 && strikeList.length) {
  var forced = {};
  [king, queen, callWall, putWall, flipLo, flipHi, maxPain].forEach(function (K) { if (K != null && gex[K]) forced[K] = true; });
  var spotK = strikeList.reduce(function (p, c) { return Math.abs(c - spot) < Math.abs(p - spot) ? c : p; });
  forced[spotK] = true;
  var forcedArr = Object.keys(forced).map(Number);
  var rest = strikeList.filter(function (K) { return !forced[K]; })
    .sort(function (x, y) { return Math.abs(gex[y].net) - Math.abs(gex[x].net); });
  var mStrikes = forcedArr.concat(rest.slice(0, Math.max(0, matrixRows - forcedArr.length)))
    .sort(function (x, y) { return y - x; });

  var absByExp = {}, absNet = 0, maxNet = 0;
  mStrikes.forEach(function (K) {
    absNet += Math.abs(gex[K].net); maxNet = Math.max(maxNet, Math.abs(gex[K].net));
    for (var e = 0; e < nExp; e++) { var c = expData[e].code; absByExp[c] = (absByExp[c] || 0) + Math.abs(gex[K].byExp[c] || 0); }
  });

  // Pad columns so the matrix fills COLS when there are fewer than 4 expiries
  var padCols = COLS - (nExp + 2);
  var hdr = [cell('GEX · Strike', { color: lite(C_HDR, 12), bg: BG_SEC, sz: 8, bold: true, span: 1 + padCols, align: 'left', p: '2px 6px' })];
  for (var h = 0; h < nExp; h++) {
    var isTop = expData[h].top;
    hdr.push(cell((isTop ? '●' : '') + expData[h].label, { color: isTop ? C_GOLD : C_HDR, bg: BG_SEC, sz: 8, bold: true, p: '2px 4px' }));
  }
  hdr.push(cell('Net', { color: C_GOLD, bg: BG_SEC, sz: 8, bold: true, p: '2px 4px' }));
  rows.push({ cells: hdr });

  mStrikes.forEach(function (K, idx) {
    var net = gex[K].net;
    var sym = K === king ? '★' : K === queen ? '♛' : K === callWall ? '◤' : K === putWall ? '◢' : K === maxPain ? '◇' : K === spotK ? '▶' : ' ';
    var col = K === king ? C_GOLD : K === queen ? C_BEAR : K === callWall ? C_BULL : K === putWall ? C_BEAR : K === maxPain ? C_MP : K === spotK ? lite(C_HDR, 14) : C_TXT;
    var bg  = K === king ? gSoft(C_GOLD, 0.22) : K === queen ? gSoft(C_BEAR, 0.22) : K === spotK ? gSoft(C_HDR, 0.26) : (K === flipLo || K === flipHi) ? hx8(C_FLIP, 0.16) : idx % 2 ? BG_B : BG_A;
    var rc = [cell(sym + ' ' + fmtK(K), { color: col, bg: bg, sz: 8, bold: sym !== ' ', span: 1 + padCols, align: 'left', p: '1px 6px' })];
    for (var e2 = 0; e2 < nExp; e2++) {
      var c2 = expData[e2].code, v = gex[K].byExp[c2] || 0;
      rc.push(cell(fmtGEX(v), { color: C_HEAT_TXT, bg: heat(v, absByExp[c2] ? Math.abs(v) / absByExp[c2] : 0), sz: 8, p: '1px 4px' }));
    }
    rc.push(cell(fmtGEX(net), { color: C_HEAT_TXT, bg: gCentre(0.5 + 0.5 * (maxNet > 0 ? net / maxNet : 0), net >= 0 ? C_BULL : C_BEAR), sz: 8, bold: true, p: '1px 4px' }));
    rows.push({ cells: rc });
  });
}

// Gamma chart lines — every line is painted on every run under a fixed name (null series
// when absent or switched off), per TrendSpider's "Common mistakes" guidance: conditional
// paint() calls are missed when the indicator's metadata is captured at save time.
// paint() options per the API reference: thickness + style ('line' | 'dotted').
function paintLevel(value, seriesName, labelText, color, width, dotted, on) {
  var show = on && value != null;
  var s = paint(series_of(show ? value : null), { name: seriesName, color: color, thickness: width, style: dotted ? 'dotted' : 'line', ignoreWhenScaling: false });
  if (show) paint_label_at_line(s, labelIndex(value, gLabelBack), labelText + ' ' + value.toFixed(2),
    { border_width: 1, border_radius: 3, border_color: color, background_color: BG, color: color });
}
// v3.9: faint expected-move band over the last gLabelBack bars + soft glow under King / Queen. Colours are
// pre-darkened opaque hexes (line and cloud paints ignore alpha). Fixed names, painted on every run (null series when
// absent) so saved appearance settings carry over.
var emN = close.length, emUp = series_of(null), emLo = series_of(null);
if (showGLines && expMove != null) for (var ei = Math.max(0, emN - gLabelBack); ei < emN; ei++) { emUp[ei] = spot + expMove; emLo[ei] = spot - expMove; }
color_cloud(emUp, emLo, '#151b26', '#151b26');
paint(series_of(showGLines && king != null ? king : null), { name: 'CQOL King Glow', color: '#3c311b', thickness: 7, hideInLegend: true, ignoreWhenScaling: true });
paint(series_of(showGLines && queen != null ? queen : null), { name: 'CQOL Queen Glow', color: '#3d252a', thickness: 7, hideInLegend: true, ignoreWhenScaling: true });
paintLevel(king,      'CQOL King',     '★ King',  C_GOLD, 2, false, showGLines);
paintLevel(queen,     'CQOL Queen',    '♛ Queen', C_BEAR, 2, false, showGLines);
paintLevel(gammaFlip, 'CQOL Flip',     'γ ZG',  C_FLIP, 1, false, showGLines);
paintLevel(callWall,  'CQOL CallWall', '◤ CW',    C_BULL, 1, false, showGLines && callWall !== king);
paintLevel(putWall,   'CQOL PutWall',  '◢ PW',    C_BEAR, 1, false, showGLines && putWall !== queen);
paintLevel(maxPain,   'CQOL MaxPain',  '◇ MP',    C_MP,   1, true,  showGLines);
paintLevel(expMove != null ? spot + expMove : null, 'CQOL EM Upper', 'EM+', 'rgba(201,209,217,0.45)', 1, true, showGLines);
paintLevel(expMove != null ? spot - expMove : null, 'CQOL EM Lower', 'EM-', 'rgba(201,209,217,0.45)', 1, true, showGLines);

// ============================================================================
// ===========================  CALCULATOR MODULE (CQO-C)  =====================
// ============================================================================
// ---------------------------------------------------------------- pricing core (Black-Scholes, European)
const SQRT2PI = Math.sqrt(2 * Math.PI);
const nPdf = (x) => Math.exp(-0.5 * x * x) / SQRT2PI;
const nCdf = (x) => {            // Abramowitz-Stegun 7.1.26 via erf, |err| < 1.5e-7
    const sgn = x < 0 ? -1 : 1, z = Math.abs(x) / Math.SQRT2, t = 1 / (1 + 0.3275911 * z);
    const y = 1 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.exp(-z * z);
    return 0.5 * (1 + sgn * y);
};
const bs = (type, S, K, T, r, sig) => {
    if (!(T > 0) || !(sig > 0)) {
        const iv0 = type === 'C' ? Math.max(S - K, 0) : Math.max(K - S, 0);
        return { price: iv0, delta: type === 'C' ? (S > K ? 1 : 0) : (S < K ? -1 : 0), gamma: 0, theta: 0, vega: 0 };
    }
    const sT = Math.sqrt(T), d1 = (Math.log(S / K) + (r + 0.5 * sig * sig) * T) / (sig * sT), d2 = d1 - sig * sT;
    const disc = Math.exp(-r * T), pd1 = nPdf(d1);
    const bsPx = type === 'C' ? S * nCdf(d1) - K * disc * nCdf(d2) : K * disc * nCdf(-d2) - S * nCdf(-d1);
    const dlt = type === 'C' ? nCdf(d1) : nCdf(d1) - 1;
    const gmm = pd1 / (S * sig * sT);
    const vg1 = S * pd1 * sT / 100;                                   // per 1 vol point
    const thetaY = type === 'C' ? -S * pd1 * sig / (2 * sT) - r * K * disc * nCdf(d2) : -S * pd1 * sig / (2 * sT) + r * K * disc * nCdf(-d2);
    return { price: bsPx, delta: dlt, gamma: gmm, theta: thetaY / 365, vega: vg1 };          // theta per calendar day
};
// Implied vol from a price: Newton on vega (correct vega = S·√T·φ(d1)), bisection fallback.
const impliedVol = (type, px, S, K, T, r) => {
    if (!(px > 0) || !(T > 0)) return null;
    const intrinsic = type === 'C' ? Math.max(S - K * Math.exp(-r * T), 0) : Math.max(K * Math.exp(-r * T) - S, 0);
    if (px <= intrinsic + 1e-6) return null;
    let sig = 0.3;
    for (let k = 0; k < 50; k++) {
        const q = bs(type, S, K, T, r, sig), dPx = q.price - px, vg = q.vega * 100;
        if (Math.abs(dPx) < 1e-5) return sig;
        if (!(vg > 1e-8)) break;
        const nx = sig - dPx / vg;
        if (!(nx > 0.005 && nx < 5)) break;
        sig = nx;
    }
    let lo = 0.005, hi = 5;                                             // bisection fallback
    if ((bs(type, S, K, T, r, lo).price - px) * (bs(type, S, K, T, r, hi).price - px) > 0) return null;
    for (let k = 0; k < 80; k++) { const midV = 0.5 * (lo + hi); if ((bs(type, S, K, T, r, midV).price - px) > 0) hi = midV; else lo = midV; }
    return 0.5 * (lo + hi);
};

// ---------------------------------------------------------------- payoff analysis
// legs: [{type:'C'|'P', K, qty (+long/-short), px (per share, what you pay (+) or receive (-) per unit qty)}]
// net = Σ qty·px  (> 0 debit, < 0 credit), per share
const payoffAt = (legs, net, S) => {
    let v = -net;
    for (const g of legs) v += g.qty * (g.type === 'C' ? Math.max(S - g.K, 0) : Math.max(g.K - S, 0));
    return v;
};
const analyse = (legs, net) => {
    const ks = legs.map((g) => g.K).sort((a, b) => a - b);
    const topK = ks[ks.length - 1] * 3;
    const pts = [0].concat(ks).concat([topK]);
    const vals = pts.map((S) => payoffAt(legs, net, S));
    const upSlope = legs.reduce((a, g) => a + (g.type === 'C' ? g.qty : 0), 0);   // dP/dS beyond top strike
    let maxP = Math.max(...vals), maxL = Math.min(...vals);
    const unlimitedProfit = upSlope > 0, unlimitedLoss = upSlope < 0;
    if (unlimitedProfit) maxP = Infinity;
    if (unlimitedLoss) maxL = -Infinity;
    const bes = [];
    for (let k = 0; k < pts.length - 1; k++) {
        const a = pts[k], b = pts[k + 1], va = vals[k], vb = vals[k + 1];
        if (va === 0 && k > 0) { if (!bes.some((x) => Math.abs(x - a) < 1e-9)) bes.push(a); continue; }
        if ((va < 0 && vb > 0) || (va > 0 && vb < 0)) bes.push(a + (b - a) * (-va) / (vb - va));
    }
    return { maxP, maxL, bes, unlimitedProfit, unlimitedLoss };
};
// Probabilities under a log-normal terminal price: S_T = S·exp((mu − σ²/2)T + σ√T·Z)
const distStats = (legs, net, S, T, sig, mu, maxP, maxL) => {
    const sd = sig * Math.sqrt(T), m = Math.log(S) + (mu - 0.5 * sig * sig) * T;
    const N = 4000, z0 = -6, dz = 12 / N;
    let pop = 0, ev = 0, pMax = 0, pMin = 0, mass = 0;
    const tol = 1e-6 * Math.max(1, Math.abs(isFinite(maxP) ? maxP : 1));
    for (let k = 0; k <= N; k++) {
        const z = z0 + k * dz, w = nPdf(z) * dz * (k === 0 || k === N ? 0.5 : 1);
        const ST = Math.exp(m + sd * z), pl = payoffAt(legs, net, ST);
        mass += w; ev += w * pl;
        if (pl > 0) pop += w;
        if (isFinite(maxP) && pl >= maxP - tol) pMax += w;
        if (isFinite(maxL) && pl <= maxL + tol) pMin += w;
    }
    return { pop: pop / mass, ev: ev / mass, pMax: pMax / mass, pMin: pMin / mass };
};

// ---------------------------------------------------------------- skew-aware expiry distribution
// Breeden–Litzenberger: P(S_T > x) = −e^{rT}·∂C/∂K at K = x, with C(K) priced on the chosen
// expiry's own IV smile (puts below spot, calls above, lightly smoothed, flat beyond the
// listed strikes). The smile therefore sets the probability of every price at expiry,
// so downside put skew fattens the left tail exactly as the market prices it.
const buildSmile = (rowsK, S) => {
    const pts = [];
    for (const rw of rowsK) {
        const v = rw.K < S ? (rw.P.iv || rw.C.iv) : (rw.C.iv || rw.P.iv);
        if (v > 0.01 && v < 5) pts.push({ K: rw.K, v });
    }
    // 3-point smoothing so single noisy quotes cannot create negative density
    return pts.map((p, i) => {
        const a = pts[Math.max(0, i - 1)].v, c = pts[Math.min(pts.length - 1, i + 1)].v;
        return { K: p.K, v: (a + 2 * p.v + c) / 4 };
    });
};
const smileIV = (pts, K, fallback) => {
    if (!pts.length) return fallback;
    if (K <= pts[0].K) return pts[0].v;
    const last = pts[pts.length - 1];
    if (K >= last.K) return last.v;
    let lo = 0, hi = pts.length - 1;
    while (hi - lo > 1) { const md = (lo + hi) >> 1; if (pts[md].K <= K) lo = md; else hi = md; }
    const w = (K - pts[lo].K) / (pts[hi].K - pts[lo].K);
    return pts[lo].v + w * (pts[hi].v - pts[lo].v);
};
const skewStats = (legs, net, S, T, r, pts, atmV, maxP, maxL) => {
    const sdW = Math.max(atmV, 0.05) * Math.sqrt(T), NG = 1200;
    const xs = Array.from({ length: NG + 1 }, (_, i) => S * Math.exp(sdW * (-7 + 14 * i / NG)));
    const grow = Math.exp(r * T);
    const callAt = (K) => bs('C', S, K, T, r, smileIV(pts, K, atmV)).price;
    let prevF = 0;
    const cdf = xs.map((x) => {
        const h = x * 0.0025;
        let F = 1 + grow * (callAt(x + h) - callAt(x - h)) / (2 * h);
        F = Math.min(1, Math.max(prevF, F));        // clamp to [0,1] and keep it non-decreasing
        prevF = F; return F;
    });
    const tol = 1e-6 * Math.max(1, Math.abs(isFinite(maxP) ? maxP : 1));
    let pop = 0, ev = 0, pMax = 0, pMin = 0, mass = 0;
    const addCell = (w, x) => {
        if (!(w > 0)) return;
        const pl = payoffAt(legs, net, x);
        mass += w; ev += w * pl;
        if (pl > 0) pop += w;
        if (isFinite(maxP) && pl >= maxP - tol) pMax += w;
        if (isFinite(maxL) && pl <= maxL + tol) pMin += w;
    };
    addCell(cdf[0], xs[0]);                                          // left tail
    for (let i = 0; i < NG; i++) addCell(cdf[i + 1] - cdf[i], 0.5 * (xs[i] + xs[i + 1]));
    addCell(1 - cdf[NG], xs[NG]);                                    // right tail
    return { pop: pop / mass, ev: ev / mass, pMax: pMax / mass, pMin: pMin / mass };
};

// ---------------------------------------------------------------- managed trade: take profit before expiry
// tastytrade-style P50: simulate price paths (about 8 steps per day) (log-normal, path vol = the view's vol),
// re-price every leg at each step at its own entry IV (sticky strike) with the time left, and
// close when P/L reaches the target (a resting limit order fills AT the target). Optional
// time exit at 'Close at DTE'. Seeded generator + antithetic pairs so the panel is stable
// run to run (no Math.random).
const managedStats = (legs, net, S, T, r, pathVol, mu, target, closeDTE, dte, pairsN, perDay) => {
    let seedV = 20260929 >>> 0;
    const rnd = () => {                                    // mulberry32
        seedV = (seedV + 0x6D2B79F5) >>> 0;
        let t = seedV;
        t = Math.imul(t ^ (t >>> 15), t | 1);
        t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
        return (((t ^ (t >>> 14)) >>> 0) + 0.5) / 4294967296;
    };
    const PAIRS = pairsN || 1000, SPD = perDay || 8, steps = Math.max(SPD, Math.min(90 * SPD, Math.round(dte * SPD))), dt = T / steps;   // ~8 checks per day: close to a continuously resting limit order
    const drift = (mu - 0.5 * pathVol * pathVol) * dt, volStep = pathVol * Math.sqrt(dt);
    const lastStep = closeDTE > 0 && closeDTE < dte ? Math.max(1, Math.round(steps * (dte - closeDTE) / dte)) : steps;
    const markAt = (px, tLeft) => legs.reduce((a, g) => a + g.qty * (tLeft > 1e-6
        ? bs(g.type, px, g.K, tLeft, r, g.iv || pathVol).price
        : (g.type === 'C' ? Math.max(px - g.K, 0) : Math.max(g.K - px, 0))), 0);
    let hits = 0, wins = 0, plTot = 0, hitDays = 0, n = 0;
    for (let p = 0; p < PAIRS; p++) {
        const zs = Array.from({ length: lastStep }, () => Math.sqrt(-2 * Math.log(rnd())) * Math.cos(2 * Math.PI * rnd()));
        for (const sign of [1, -1]) {                         // antithetic pair
            let px = S, pl = 0, hit = false;
            for (let k = 1; k <= lastStep; k++) {
                px *= Math.exp(drift + volStep * sign * zs[k - 1]);
                pl = markAt(px, T - k * dt) - net;
                if (target > 0 && pl >= target) { pl = target; hit = true; hitDays += k * dte / steps; break; }
            }
            n++; plTot += pl;
            if (hit) hits++;
            if (pl > 0) wins++;
        }
    }
    return { p50: hits / n, pop: wins / n, ev: plTot / n, days: hits ? hitDays / hits : null };
};

// v2.3 MODEL VIEW VOL — anchored on the market's IV. Tested on CascadeQuant's own chain history
// (39 names, Jul 2024 – Sep 2026, IV recomputed from NBBO mids, 21-day horizon): next-month realised vol
// is forecast far better by 30-day ATM IV (log RMSE 0.31–0.35) than by our blended realised forecast
// (0.39–0.42), in both halves and on index ETFs; the fitted weight on our forecast given IV is ~0.1
// (not significant). Average realised ÷ IV was 1.00 (median 0.90), so no level shift is applied.
// View vol = IV^0.9 × forecast^0.1 on each expiry; the empirical return SHAPE is kept.
const VIEW_IV_W = 0.9;
const viewVol = (ivE) => (ivE > 0 && rvol > 0 ? Math.pow(ivE, VIEW_IV_W) * Math.pow(rvol, 1 - VIEW_IV_W) : (ivE > 0 ? ivE : rvol));

// Realised view on an empirical distribution: ln(S_T/S) = (mu − σ²/2)T + σ√T·z for each stored z
const empStats = (legs, net, S, T, sig, mu, zs, maxP, maxL) => {
    const sd = sig * Math.sqrt(T), m = (mu - 0.5 * sig * sig) * T;
    const tol = 1e-6 * Math.max(1, Math.abs(isFinite(maxP) ? maxP : 1));
    let pop = 0, ev = 0, pMax = 0, pMin = 0;
    for (let i = 0; i < zs.length; i++) {
        const pl = payoffAt(legs, net, S * Math.exp(m + sd * zs[i]));
        ev += pl; if (pl > 0) pop++;
        if (isFinite(maxP) && pl >= maxP - tol) pMax++;
        if (isFinite(maxL) && pl <= maxL + tol) pMin++;
    }
    const nZ = Math.max(1, zs.length);
    return { pop: pop / nZ, ev: ev / nZ, pMax: pMax / nZ, pMin: pMin / nZ, empirical: true };
};
// Realised view dispatcher: empirical when ≥ 150 samples exist for this horizon, else log-normal
const realStats = (legs, net, S, T, dteR, sig, maxP, maxL) => {
    const zs = empZ(Math.max(1, Math.round(dteR * 252 / 365)));
    return zs.length >= 150 ? empStats(legs, net, S, T, sig, riskFreeRate, zs, maxP, maxL) : distStats(legs, net, S, T, sig, riskFreeRate, maxP, maxL);
};

// ============================================================================
// ===========================  SHARED BUILD HELPERS  ==========================
// ============================================================================
// Used by both the trade idea engine and the calculator, so an idea and
// the calculator's view of it are built and priced by exactly the same rules.
// v3.6 time to expiry from the clock, not whole days: matters for 0DTE and short-dated options. Expiry = 16:00 ET
// on the expiration date (20:00 UTC in US daylight time, 21:00 UTC otherwise). Falls back to whole DTE when
// the clock or the expiration code is unavailable.
const nowSec = typeof current.now === 'number' && current.now > 0 ? (current.now > 1e11 ? current.now / 1000 : current.now) : null;
const etCloseHourUTC = (y, m, d) => {
    const dow = (yy, mm, dd) => ((Math.floor(Date.UTC(yy, mm - 1, dd) / 86400000) + 4) % 7 + 7) % 7;   // 0 = Sunday
    const nthSun = (yy, mm, k) => 1 + ((7 - dow(yy, mm, 1)) % 7) + 7 * (k - 1);
    const dstOn = (m > 3 && m < 11) || (m === 3 && d >= nthSun(y, 3, 2)) || (m === 11 && d < nthSun(y, 11, 1));
    return dstOn ? 20 : 21;
};
const expYears = (exE) => {
    const code = Number(exE.expiration.code);
    if (nowSec != null && isFinite(code) && code > 100000) {
        const y = 2000 + Math.floor(code / 10000), m = Math.floor(code / 100) % 100, d = code % 100;
        if (m >= 1 && m <= 12 && d >= 1 && d <= 31) return (Date.UTC(y, m - 1, d, etCloseHourUTC(y, m, d), 0, 0) / 1000 - nowSec) / (365 * 86400);
    }
    return Math.max(exE.expiration.dte, 0.5) / 365;
};
const makeBook = (exE) => {
    const dteE = exE.expiration.dte, TE0 = expYears(exE);
    if (!(TE0 > 0)) return null;                                   // already expired today
    const TE = Math.max(TE0, 0.25 / 24 / 365);                     // floor: 15 minutes
    const ec = chain.resultByExpiration[exE.expiration.code];
    if (!ec) return null;
    const quote = (q, type, K) => {
        q = q || {};
        const b = q.b > 0 ? q.b : null, a = q.a > 0 ? q.a : null, midQ = b && a ? (b + a) / 2 : null;
        let iv = midQ ? impliedVol(type, midQ, spot, K, TE, riskFreeRate) : null;
        if (iv == null && q.iv > 0 && q.iv < 400) iv = q.iv / 100;
        const g = iv ? bs(type, spot, K, TE, riskFreeRate, iv) : null;
        return { b, a, mid: midQ, iv, delta: g ? g.delta : null, oi: q.oi || 0 };
    };
    const bookE = Object.keys(ec).map(Number).filter((K) => K > 0).sort((x, y) => x - y)
        .map((K) => ({ K, C: quote(ec[K] && ec[K].C, 'C', K), P: quote(ec[K] && ec[K].P, 'P', K) }));
    if (bookE.length < 3) return null;
    let ai = 0; for (let i = 1; i < bookE.length; i++) if (Math.abs(bookE[i].K - spot) < Math.abs(bookE[ai].K - spot)) ai = i;
    const ar = bookE[ai], atmE = ar.C.iv && ar.P.iv ? (ar.C.iv + ar.P.iv) / 2 : (ar.C.iv || ar.P.iv);
    const lbl = (exE.expiration.month || '').slice(0, 3) + ' ' + (exE.expiration.day || '');
    return { ex: exE, book: bookE, T: TE, dte: dteE, atmIV: atmE, label: lbl, code: exE.expiration.code };
};
// prm: { DS, DL, DG (delta targets as fractions), W (width in strikes), k1, k2 (strike overrides, 0 = auto) }
const buildLegsFor = (stratName, bookE, prm) => {
    const byDeltaE = (type, target) => { let best = null, bd = Infinity; for (let i = 0; i < bookE.length; i++) { const d = bookE[i][type].delta; if (d == null) continue; const e = Math.abs(Math.abs(d) - target); if (e < bd) { bd = e; best = i; } } return best; };
    const byStrikeE = (K) => { let best = null, bd = Infinity; for (let i = 0; i < bookE.length; i++) { const e = Math.abs(bookE[i].K - K); if (e < bd) { bd = e; best = i; } } return best; };
    const pickE = (over, type, target) => (over > 0 ? byStrikeE(over) : byDeltaE(type, target));
    const out = []; let err = null;
    const L = (type, i, qty) => { if (i == null || i < 0 || i >= bookE.length) { err = 'strike out of chain range — widen Calc Strikes/Side or reduce width'; return; } out.push({ type, i, K: bookE[i].K, qty }); };
    const W = Math.round(prm.W), k1 = prm.k1 || 0, k2 = prm.k2 || 0;
    if (stratName === 'Long Call') L('C', pickE(k1, 'C', prm.DL), 1);
    else if (stratName === 'Long Put') L('P', pickE(k1, 'P', prm.DL), 1);
    else if (stratName === 'Bull Call Debit') { const i = pickE(k1, 'C', prm.DL); L('C', i, 1); L('C', k2 > 0 ? byStrikeE(k2) : i + W, -1); }
    else if (stratName === 'Bear Put Debit') { const i = pickE(k1, 'P', prm.DL); L('P', i, 1); L('P', k2 > 0 ? byStrikeE(k2) : i - W, -1); }
    else if (stratName === 'Bull Put Credit') { const i = pickE(k1, 'P', prm.DS); L('P', i, -1); L('P', k2 > 0 ? byStrikeE(k2) : i - W, 1); }
    else if (stratName === 'Bear Call Credit') { const i = pickE(k1, 'C', prm.DS); L('C', i, -1); L('C', k2 > 0 ? byStrikeE(k2) : i + W, 1); }
    else if (stratName === 'Iron Condor') {
        const ip = pickE(k1, 'P', prm.DS), ic = pickE(k2, 'C', prm.DS);
        L('P', ip - W, 1); L('P', ip, -1); L('C', ic, -1); L('C', ic + W, 1);
        if (!err && bookE[ic].K <= bookE[ip].K) err = 'short call must be above short put — lower Short Leg Delta';
    }
    else if (stratName === 'Long Straddle') { const i = k1 > 0 ? byStrikeE(k1) : byStrikeE(spot); L('C', i, 1); L('P', i, 1); }
    else if (stratName === 'Long Strangle') { L('P', pickE(k1, 'P', prm.DG), 1); L('C', pickE(k2, 'C', prm.DG), 1); }
    else if (stratName === 'Call Butterfly') { const i = k1 > 0 ? byStrikeE(k1) : byStrikeE(spot); L('C', i - W, 1); L('C', i, -2); L('C', i + W, 1); }
    if (!err && out.some((g) => out.some((h) => h !== g && h.type === g.type && h.K === g.K))) err = 'two legs landed on the same strike — increase width';
    return { legs: out, err };
};
// price each leg: Mid, or Natural (pay the ask on longs, receive the bid on shorts)
const priceLegsFor = (legsE, bookE, TE, mode) => {
    let note = '';
    for (const g of legsE) {
        const q = bookE[g.i][g.type];
        let p = mode === 'Natural' ? (g.qty > 0 ? q.a : q.b) : q.mid;
        g.modelPriced = false;
        if (p == null && q.iv) { p = bs(g.type, spot, g.K, TE, riskFreeRate, q.iv).price; note = 'some legs model-priced (no two-sided quote)'; g.modelPriced = true; }
        if (p == null) return { err: 'no price for ' + g.type + ' ' + g.K };
        g.px = p; g.iv = q.iv; g.oi = q.oi; g.spreadPct = q.b && q.a ? (q.a - q.b) / ((q.a + q.b) / 2) : null;
    }
    return { net: legsE.reduce((a, g) => a + g.qty * g.px, 0), note };
};
const legText = (legsE) => legsE.map((g) => (g.qty > 0 ? 'Buy ' : 'Sell ') + (Math.abs(g.qty) > 1 ? Math.abs(g.qty) + ' ' : '') + (g.type === 'C' ? 'Call ' : 'Put ') + (g.K % 1 === 0 ? g.K.toFixed(0) : g.K.toFixed(2))).join(' / ');

// ============================================================================
// ===========================  CONTEXT DATA (v2)  =============================
// ============================================================================
// Four extra data requests (earnings, dividends, seasonality, unusual options) — with the
// options schedule + chain that is 6 non-history requests, TrendSpider's per-script cap.
// Each is optional: a failed request only blanks its own row.
const tNow = (() => { const tv = time[time.length - 1]; return tv > 1e11 ? tv / 1000 : tv; })();
const toSec = (ts) => (ts > 1e11 ? ts / 1000 : ts);
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const monthOf = (sec) => { const cd = civilDateE(sec); return Number(cd.slice(5, 7)) - 1; };
function civilDateE(ts) {
    const s = toSec(ts); const z = Math.floor(s / 86400) + 719468; const era = Math.floor(z / 146097); const doe = z - era * 146097;
    const yoe = Math.floor((doe - Math.floor(doe / 1460) + Math.floor(doe / 36524) - Math.floor(doe / 146096)) / 365); const doy = doe - (365 * yoe + Math.floor(yoe / 4) - Math.floor(yoe / 100));
    const mp = Math.floor((5 * doy + 2) / 153); const d = doy - Math.floor((153 * mp + 2) / 5) + 1; const m = mp < 10 ? mp + 3 : mp - 9; const y = yoe + era * 400 + (m <= 2 ? 1 : 0);
    return y + '-' + (m < 10 ? '0' : '') + m + '-' + (d < 10 ? '0' : '') + d;
}
const shortDate = (sec) => { const cd = civilDateE(sec); return MONTHS[Number(cd.slice(5, 7)) - 1] + ' ' + Number(cd.slice(8)); };

let earnNext = null;             // { sec, days, when }
try {
    const er = await request.earnings(current.ticker);
    if (Array.isArray(er)) {
        const fut = er.filter((e) => e && e.timestamp && toSec(e.timestamp) > tNow - 86400).sort((a, b) => toSec(a.timestamp) - toSec(b.timestamp));
        if (fut.length) { const s0 = toSec(fut[0].timestamp); earnNext = { sec: s0, days: (s0 - tNow) / 86400, when: fut[0].when || '', estimated: false }; }
        else {
            // No announced date yet (TrendSpider often lists the next report only a few weeks ahead): estimate it
            // from the last report + the median gap between recent reports, and treat it as real for the skip.
            const past = er.filter((e) => e && e.timestamp && toSec(e.timestamp) <= tNow).map((e) => toSec(e.timestamp)).sort((a, b) => a - b);
            if (past.length >= 3) {
                const gaps = []; for (let i = Math.max(1, past.length - 6); i < past.length; i++) gaps.push(past[i] - past[i - 1]);
                gaps.sort((a, b) => a - b);
                const gapS = gaps[Math.floor(gaps.length / 2)];
                if (gapS > 60 * 86400 && gapS < 120 * 86400) { let nx = past[past.length - 1] + gapS; while (nx < tNow - 86400) nx += gapS; earnNext = { sec: nx, days: (nx - tNow) / 86400, when: '', estimated: true }; }
            }
        }
    }
} catch (e) { earnNext = null; }

let divNext = null;              // { sec, days, amount, estimated }
try {
    const dv = await request.dividends(current.ticker);
    if (Array.isArray(dv)) {
        const exd = dv.filter((d) => d && d.type === 'ex-date' && d.timestamp).map((d) => ({ s: toSec(d.timestamp), amt: d.amount })).sort((a, b) => a.s - b.s);
        const fut = exd.filter((d) => d.s > tNow);
        if (fut.length) divNext = { sec: fut[0].s, days: (fut[0].s - tNow) / 86400, amount: fut[0].amt, estimated: false };
        else if (exd.length >= 2) {
            const lastD = exd[exd.length - 1], gapS = lastD.s - exd[exd.length - 2].s;
            if (gapS > 20 * 86400 && tNow - lastD.s < 2 * gapS) { let nx = lastD.s + gapS; while (nx < tNow) nx += gapS; divNext = { sec: nx, days: (nx - tNow) / 86400, amount: lastD.amt, estimated: true }; }
        }
    }
} catch (e) { divNext = null; }

let seasRows = null;             // [{ m, up, n, avg }] for this month and next
try {
    const sz = await request.seasonality(current.ticker, 'monthly', 'change');
    if (sz && sz.dataByCategory) {
        const m0 = monthOf(tNow);
        // values may arrive as fractions (0.012) or percent (1.2): if no month ever moved more than
        // 1.5 in absolute value across all years, treat them as fractions and scale to percent
        let maxAbs = 0;
        Object.keys(sz.dataByCategory).forEach((k) => (sz.dataByCategory[k] || []).forEach((r) => { if (isFinite(r.value)) maxAbs = Math.max(maxAbs, Math.abs(r.value)); }));
        const unit = maxAbs > 0 && maxAbs < 1.5 ? 100 : 1;
        seasRows = [m0, (m0 + 1) % 12].map((m) => {
            const recs = (sz.dataByCategory[MONTHS[m]] || []).map((r) => r.value * unit).filter((v) => isFinite(v));
            const up = recs.filter((v) => v > 0).length, avgC = recs.length ? recs.reduce((a, v) => a + v, 0) / recs.length : null;
            return { m, up, n: recs.length, avg: avgC };
        });
    }
} catch (e) { seasRows = null; }

let flow = null;                 // unusual options, last 10 days: bullish / bearish premium and counts
try {
    const uo = await request.unusual_options(current.ticker);
    if (Array.isArray(uo)) {
        const cut = tNow - 10 * 86400;
        const acc = { bullN: 0, bearN: 0, bullUsd: 0, bearUsd: 0, n: 0 };
        uo.forEach((u) => {
            if (!u || !u.timestamp || toSec(u.timestamp) < cut) return;
            const tags = Array.isArray(u.tags) ? u.tags : [];
            acc.n++;
            if (tags.indexOf('bullish') >= 0) { acc.bullN++; acc.bullUsd += u.costBasis || 0; }
            else if (tags.indexOf('bearish') >= 0) { acc.bearN++; acc.bearUsd += u.costBasis || 0; }
        });
        flow = acc;
    }
} catch (e) { flow = null; }

// ============================================================================
// ===========================  TRADE IDEA ENGINE (v2)  ========================
// ============================================================================
// 1 stance per expiry from IV/HV20 · 2 candidate structures from stance × Bias ·
// 3 grid over deltas and widths · 4 hard filters · 5 fast score (Realised-view EV at
// Natural fills ÷ max loss), then the path simulation on the finalists (managed EV with
// the calculator's Take Profit % / Close at DTE) · 6 top three, forced to differ.
const REC_MAP = {
    Sell:    { Bullish: ['Bull Put Credit'], Neutral: ['Iron Condor'], Bearish: ['Bear Call Credit'] },
    Spreads: { Bullish: ['Bull Call Debit', 'Bull Put Credit'], Neutral: ['Call Butterfly', 'Iron Condor'], Bearish: ['Bear Put Debit', 'Bear Call Credit'] },
    Buy:     { Bullish: ['Long Call', 'Bull Call Debit'], Neutral: ['Long Straddle', 'Long Strangle'], Bearish: ['Long Put', 'Bear Put Debit'] },
};
const wingWidth = (legsW) => {   // widest same-type strike span (vertical width; condor = wider side)
    const span = (tp) => { const ks = legsW.filter((g) => g.type === tp).map((g) => g.K); return ks.length > 1 ? Math.max(...ks) - Math.min(...ks) : 0; };
    return Math.max(span('P'), span('C'));
};
const RR_EXTRA = { Bullish: 'Bull Call Debit', Neutral: 'Call Butterfly', Bearish: 'Bear Put Debit' };
const SHORT_VOL = { 'Bull Put Credit': 1, 'Bear Call Credit': 1, 'Iron Condor': 1, 'Call Butterfly': 1 };
const LONG_VOL = { 'Long Call': 1, 'Long Put': 1, 'Long Straddle': 1, 'Long Strangle': 1 };
const CREDIT = { 'Bull Put Credit': 1, 'Bear Call Credit': 1, 'Iron Condor': 1 };
const gridFor = (st) => {
    const out = [], Ws = [1, 2, 3, 4, 5];
    if (CREDIT[st]) [0.15, 0.20, 0.25, 0.30, 0.35].forEach((d) => Ws.forEach((w) => out.push({ DS: d, W: w })));
    else if (st === 'Bull Call Debit' || st === 'Bear Put Debit') [0.40, 0.50, 0.60, 0.70].forEach((d) => Ws.forEach((w) => out.push({ DL: d, W: w })));
    else if (st === 'Long Call' || st === 'Long Put') [0.40, 0.50, 0.60, 0.70].forEach((d) => out.push({ DL: d, W: 1 }));
    else if (st === 'Long Strangle') [0.15, 0.20, 0.25, 0.30].forEach((d) => out.push({ DG: d, W: 1 }));
    else if (st === 'Long Straddle') out.push({ W: 1 });
    else if (st === 'Call Butterfly') Ws.forEach((w) => out.push({ W: w }));
    return out;
};
// Tested technical adjustments (event study, 50 names 1995–2026, 21-day horizon, excess over each
// name's own baseline, placebo = events shifted +42 bars, halves split at 2011):
//   RSI14 < 30 or a close below the prior 20-day low → a +1σ short call wins 4–7 pp less often and a
//   ±1σ condor 4–7 pp less often, in both halves (placebo +1 to +2 pp) → short-call structures −5 pp.
//   Close < SMA50 < SMA200 → a −1σ short put wins 1.5–4 pp less often (placebo +1 pp) → short puts −2.5 pp.
//   Uptrend, breakouts, pullbacks, RSI > 70, low ADX and vol compression showed no edge beyond placebo,
//   and monthly seasonality had none out of sample, so none of those move the score.
// A win-rate shift dp changes EV per $ risk by about dp × (max profit + |max loss|) ÷ |max loss|.
const techAdj = (st, A) => {
    const shortCall = st === 'Bear Call Credit' || st === 'Iron Condor';
    const shortPut = st === 'Bull Put Credit' || st === 'Iron Condor';
    let dp = 0; const notes = [];
    if (shortCall && ((techRSI14 != null && techRSI14 < 30) || techBreakdown)) { dp -= 0.05; notes.push((techBreakdown ? '20-day breakdown' : 'RSI ' + techRSI14.toFixed(0)) + ': short calls −5 pp (tested)'); }
    if (shortPut && techDown) { dp -= 0.025; notes.push('downtrend: short puts −2.5 pp (tested)'); }
    const adj = isFinite(A.maxP) && A.maxL < 0 ? dp * (A.maxP - A.maxL) / -A.maxL : 0;
    return { dp, adj, notes };
};
function runEngine() {
    const res = { recs: [], searched: 0, passed: 0, why: {}, expiries: [], note: '' };
    if (!engineOn) return res;
    const riskBudget = acctSize * riskPctIn / 100;
    const whyAdd = (k) => { res.why[k] = (res.why[k] || 0) + 1; };
    const inWin = schedule.filter((s) => s && s.expiration && s.expiration.dte >= engDteMin && s.expiration.dte <= engDteMax);
    // up to 6 expiries, spread across the window (short windows on index ETFs can list 15+ dailies)
    const exps = inWin.length <= 6 ? inWin : [0, 1, 2, 3, 4, 5].map((k) => inWin[Math.round(k * (inWin.length - 1) / 5)]);
    if (!exps.length) { res.note = engDteMax === 0 ? 'no 0DTE expiry today' : 'no expiry between ' + engDteMin + ' and ' + engDteMax + ' DTE'; return res; }
    const cands = [];
    exps.forEach((exE) => {
        const bk = makeBook(exE);
        if (!bk) return;
        const ratio = rvol && bk.atmIV ? bk.atmIV / rvol : null;
        const stance = ratio == null ? 'Spreads' : ratio > 1.2 ? 'Sell' : ratio < 0.85 ? 'Buy' : 'Spreads';
        const spansEarn = earnNext != null && earnNext.days >= 0 && earnNext.days <= bk.dte;
        res.expiries.push({ label: bk.label, dte: bk.dte, stance, ratio, spansEarn });
        if (spansEarn && avoidEarn) { whyAdd('earnings'); return; }
        const seen = {};
        const biasList = biasIn === 'Auto' ? ['Bullish', 'Neutral', 'Bearish'] : [biasIn];
        const stList = biasList.reduce((acc2, bz) => acc2.concat(REC_MAP[stance][bz].filter((x) => acc2.indexOf(x) < 0)), []);
        // v3.2: with a reward/risk floor ≥ 1 the stance's credit structures rarely qualify, so the
        // defined-risk debit structure for each bias is always searched too (it still has to pass every filter)
        if (engMinRR >= 1) biasList.forEach((bz) => { const dx = RR_EXTRA[bz]; if (stList.indexOf(dx) < 0) stList.push(dx); });
        stList.forEach((st) => gridFor(st).forEach((prm) => {
            res.searched++;
            const b = buildLegsFor(st, bk.book, prm);
            if (b.err) { whyAdd('build'); return; }
            const sig = st + '|' + b.legs.map((g) => g.type + g.K + ':' + g.qty).join(',');
            if (seen[sig]) return; seen[sig] = 1;
            const pz = priceLegsFor(b.legs, bk.book, bk.T, 'Natural');
            if (pz.err || b.legs.some((g) => g.modelPriced)) { whyAdd('no quote'); return; }
            if (b.legs.some((g) => g.oi < engMinOI)) { whyAdd('open interest'); return; }
            const midNet = b.legs.reduce((a3, g) => { const q = bk.book[g.i][g.type]; return a3 + g.qty * (q.mid != null ? q.mid : g.px); }, 0);
            const slip = Math.abs(midNet) > 1e-9 ? Math.abs(pz.net - midNet) / Math.abs(midNet) : 1;
            if (slip > engMaxBA / 100) { whyAdd('slippage'); return; }
            const A = analyse(b.legs, pz.net);
            if (!isFinite(A.maxL) || !(A.maxL < 0) || (CREDIT[st] && pz.net >= 0) || (!CREDIT[st] && pz.net <= 0)) { whyAdd('payoff'); return; }
            // v3.3 payoff floors (preferences, not tested edges): debit / long structures need max profit ÷ max loss
            // ≥ Min Debit Reward/Risk (open-ended upside passes); credit structures need credit ≥ Min Credit % of Width
            // of the widest wing — selling premium has low reward/risk by design, so it gets its own floor
            if (CREDIT[st]) {
                const wd = wingWidth(b.legs), crPct = wd > 0 ? -pz.net / wd * 100 : 0;
                if (engMinCrW > 0 && crPct < engMinCrW - 1e-9) { whyAdd('credit/width'); return; }
            } else {
                const rrC = isFinite(A.maxP) ? A.maxP / -A.maxL : Infinity;
                if (engMinRR > 0 && rrC < engMinRR - 1e-9) { whyAdd('reward/risk'); return; }
            }
            const regWarn = mktWarnFor(b.legs, st);
            if (regimeMode === 'Filter' && mktGrindOn && b.legs.some((g) => g.qty < 0 && g.type === 'P')) { whyAdd('regime: grind'); return; }
            const perCon = -A.maxL * 100, nCon = Math.min(engMaxCon, Math.floor(riskBudget / perCon));
            if (nCon < 1) { whyAdd('risk budget'); return; }
            let wallOk = null;
            if (CREDIT[st]) {
                const okP = b.legs.filter((g) => g.qty < 0 && g.type === 'P').every((g) => putWall == null || g.K <= putWall);
                const okC = b.legs.filter((g) => g.qty < 0 && g.type === 'C').every((g) => callWall == null || g.K >= callWall);
                wallOk = okP && okC;
                if (engWalls && !wallOk) { whyAdd('walls'); return; }
            }
            const vv = viewVol(bk.atmIV), rl = vv ? realStats(b.legs, pz.net, spot, bk.T, bk.dte, vv, A.maxP, A.maxL) : null;
            const tech = techAdj(st, A);
            const fast = (rl ? rl.ev / -A.maxL : 0) + tech.adj;
            res.passed++;
            cands.push({ st, prm, bk, legs: b.legs, net: pz.net, A, nCon, wallOk, rl, fast, stance, ratio, spansEarn, tech, regWarn });
        }));
    });
    if (!cands.length) return res;
    // finalists: best fast scores, at most 3 per structure so the shortlist stays varied
    cands.sort((x, y) => y.fast - x.fast);
    const per = {}, fin = [];
    for (const c of cands) { if (fin.length >= 8) break; const k = c.st; if ((per[k] || 0) >= 3) continue; per[k] = (per[k] || 0) + 1; fin.push(c); }
    fin.forEach((c) => {
        const tgt = tpPct > 0 ? (isFinite(c.A.maxP) ? tpPct / 100 * c.A.maxP : tpPct / 100 * Math.abs(c.net)) : 0;
        const managedOn = tgt > 0 || (closeDTE > 0 && closeDTE < c.bk.dte);
        c.mg = managedOn && viewVol(c.bk.atmIV) ? managedStats(c.legs, c.net, spot, c.bk.T, riskFreeRate, viewVol(c.bk.atmIV), riskFreeRate, tgt, closeDTE, c.bk.dte, 300, 4) : null;
        c.mkt = c.bk.atmIV ? skewStats(c.legs, c.net, spot, c.bk.T, riskFreeRate, buildSmile(c.bk.book, spot), c.bk.atmIV, c.A.maxP, c.A.maxL) : null;
        const evUse = c.mg ? c.mg.ev : (c.rl ? c.rl.ev : 0);
        c.evRisk = evUse / -c.A.maxL + c.tech.adj;
        // regime fit, wall support, ZG between spot and a breakeven (wall support scored; the others are context)
        const narrowCondor = c.st === 'Iron Condor' && c.prm.W <= 2;
        c.rf = SHORT_VOL[c.st] ? (isLongGamma ? 1 : (narrowCondor ? -1 : -0.5)) : LONG_VOL[c.st] ? (isLongGamma ? -0.5 : 1) : (isLongGamma ? 0 : 0.5);
        const tgtPx = c.st === 'Bull Call Debit' || c.st === 'Bear Put Debit' ? c.legs[1].K : (c.st === 'Long Call' || c.st === 'Long Put') ? c.A.bes[0] : null;
        const pathClear = tgtPx == null ? null : (c.st === 'Bull Call Debit' || c.st === 'Long Call') ? !(callWall != null && callWall > spot && callWall < tgtPx) : !(putWall != null && putWall < spot && putWall > tgtPx);
        c.ws = CREDIT[c.st] ? (c.wallOk ? 1 : 0) : (pathClear ? 1 : 0);
        c.fb = gammaFlip != null && c.A.bes.some((b) => (b - gammaFlip) * (spot - gammaFlip) < 0) ? 1 : 0;
        // v3.7: regime fit and ZG-between-breakeven are context only. Tested on CascadeQuant replays (39 names,
        // Jul 2024 – Sep 2026, ~30 DTE, paired by entry date): short premium did NOT do better in long γ
        // (straddle −6.7 pts, t −1.2) and trades with ZG between spot and a breakeven did not do worse
        // (straddle +11 pts, t 2.5, both halves). Wall support kept (+1–4 pts in 11 of 12 cells, t < 1.5).
        c.score = c.evRisk + 0.01 * c.ws;
        c.pathClear = pathClear;
    });
    fin.sort((x, y) => (y.score - x.score) || ((y.mkt ? y.mkt.pop : 0) - (x.mkt ? x.mkt.pop : 0)));
    for (const c of fin) {
        if (res.recs.length >= 3) break;
        if (res.recs.some((r) => r.st === c.st && r.bk.code === c.bk.code)) continue;   // differ in structure or expiry
        res.recs.push(c);
    }
    return res;
}
const ENG = runEngine();
// v3.10 move check: what the trade needs (debit) or how much room it has (credit), vs the expiry's implied and forecast move
const moveCheck = (c) => {
    const T = c.bk.T, sdI = c.bk.atmIV ? c.bk.atmIV * Math.sqrt(T) * 100 : null, sdF = rvol ? rvol * Math.sqrt(T) * 100 : null;
    const bes = (c.A.bes || []).slice().sort((x, y) => x - y);
    const lo = bes.filter((x) => x < spot).pop(), hi = bes.find((x) => x > spot);
    const pct = (K) => (K / spot - 1) * 100;
    const sig = (x) => (sdI ? (Math.abs(x) / sdI).toFixed(1) + 'σ IV' : '') + (sdF ? ' · ' + (Math.abs(x) / sdF).toFixed(1) + 'σ fcst' : '');
    const f = (x) => (x >= 0 ? '+' : '−') + Math.abs(x).toFixed(1) + '%';
    let txt = '', key = null;
    if (c.st === 'Bull Put Credit' && lo != null) { key = pct(lo); txt = 'room to BE ' + f(key) + ' (' + sig(key) + ')'; }
    else if (c.st === 'Bear Call Credit' && hi != null) { key = pct(hi); txt = 'room to BE ' + f(key) + ' (' + sig(key) + ')'; }
    else if (c.st === 'Iron Condor' && lo != null && hi != null) { const a = pct(lo), b = pct(hi); key = Math.min(Math.abs(a), Math.abs(b)); txt = 'stays inside ' + f(a) + ' / ' + f(b) + ' (nearest ' + sig(key) + ')'; }
    else if (c.st === 'Call Butterfly' && lo != null && hi != null) { key = (pct(hi) - pct(lo)) / 2; txt = 'finish within ' + f(pct(lo)) + ' / ' + f(pct(hi)) + ' (half-width ' + sig(key) + ')'; }
    else if ((c.st === 'Long Straddle' || c.st === 'Long Strangle') && lo != null && hi != null) { key = Math.min(-pct(lo), pct(hi)); txt = 'needs a move > ±' + key.toFixed(1) + '% (' + sig(key) + ')'; }
    else if ((c.st === 'Bull Call Debit' || c.st === 'Long Call') && hi != null) { key = pct(hi); txt = 'needs ' + f(key) + ' (' + sig(key) + ')'; }
    else if ((c.st === 'Bear Put Debit' || c.st === 'Long Put') && lo != null) { key = pct(lo); txt = 'needs ' + f(key) + ' (' + sig(key) + ')'; }
    else if (bes.length) { key = pct(bes[0]); txt = 'breakeven ' + f(key); }
    return { txt, key, sdI, sdF, sigI: key != null && sdI ? Math.abs(key) / sdI : null };
};
ENG.recs.forEach((c) => { c.mv = moveCheck(c); });
// IV ÷ our forecast on the first engine expiry: a cross-sectional RANKING signal (tested: short straddles on the
// richest third of names beat the cheapest third by +15 pts of premium, t 2.8, same sign both halves; no bucket
// was profitable on its own after Natural fills). Shown on the panel and exported as two scan signals.
var ivRank = null;
{ const e0 = ENG.expiries.find((e) => e.ratio != null); if (e0) ivRank = e0.ratio;
  if (ivRank == null && rvol && frontATM) ivRank = frontATM / 100 / rvol; }
// one CSV line per idea for cross-ticker review (console only, no outputs)
ENG.recs.forEach((c, ix) => console.log(['CQOS_REC', current.ticker, ix + 1, c.st, c.bk.label, c.bk.dte, legText(c.legs), c.nCon, (c.net * 100).toFixed(0), (c.A.maxL * 100).toFixed(0),
    (100 * c.evRisk).toFixed(1), c.mkt ? (100 * c.mkt.pop).toFixed(0) : '', c.mg ? (100 * c.mg.p50).toFixed(0) : '', c.ratio ? c.ratio.toFixed(2) : '', c.stance, isLongGamma ? 'longG' : 'shortG', c.tech.notes.join(';'), 'mkt ' + MKT.name, c.mv && c.mv.sigI != null ? 'mv ' + c.mv.sigI.toFixed(2) : '', c.regWarn ? 'regwarn' : ''].join(',')));
console.log(['CQOS_CTX', current.ticker, 'spot ' + spot.toFixed(2), 'rvol ' + (rvol ? (100 * rvol).toFixed(1) : ''), 'hv20 ' + (hv20 ? (100 * hv20).toFixed(1) : ''), 'rsi ' + (techRSI14 != null ? techRSI14.toFixed(0) : ''), techDown ? 'downtrend' : '', techBreakdown ? 'breakdown' : '', techStop ? 'stop ' + techStop + ' x' + techStopMult.toFixed(3) : '',
    'ivfc ' + (ivRank != null ? ivRank.toFixed(3) : ''), 'zg ' + (gammaFlip != null ? gammaFlip : (zgNone || '')), 'sflip ' + (strikeFlip != null ? strikeFlip : ''), 'hedgeadv ' + (hedgeAdvPct != null ? hedgeAdvPct.toFixed(2) : ''), 'grossadv ' + (grossAdvPct != null ? grossAdvPct.toFixed(2) : ''), 'strikes ' + strikesFetched, 'skew25 ' + (frontSkew != null ? frontSkew.toFixed(2) : ''), 'term ' + (termRatio != null ? termRatio.toFixed(3) : ''), 'atm ' + (frontATM != null ? frontATM.toFixed(1) : ''), 'earn ' + (earnNext ? (earnNext.estimated ? '~' : '') + Math.round(earnNext.days) + 'd' : 'none'), 'searched ' + ENG.searched, 'passed ' + ENG.passed, 'why ' + JSON.stringify(ENG.why).replace(/[",{}]/g, ' ')].join(','));

// Pack short phrases into lines of at most maxLen characters (keeps the panel narrow: long
// single-line cells, not the tables, are what set its width).
function packParts(parts, maxLen) {
    var lines = [], cur = '';
    parts.filter(function (x) { return x; }).forEach(function (p) {
        if (cur && (cur + ' · ' + p).length > maxLen) { lines.push(cur); cur = p; }
        else cur = cur ? cur + ' · ' + p : p;
    });
    if (cur) lines.push(cur);
    return lines;
}
function runCalc() {
const riskFree = riskFreeRate;
// ---------------------------------------------------------------- panel helpers
const cell = (txt, o) => {
    o = o || {};
    const c0 = { text: txt, color: o.color || C_TXT, background: o.bg || BG, fontSize: (o.sz || 9) + FS,
        fontWeight: o.bold ? 'bold' : 'normal', textAlign: o.align || 'left', padding: o.p || '1px 5px', colspan: o.span || 1 };
    if (o.sh) c0.textShadow = o.sh;
    if (o.ls) c0.letterSpacing = o.ls;
    return c0;
};
const COLS = 5;
const rows = [];
const full = (txt, col, o) => { o = o || {}; o.color = col; o.span = COLS; rows.push({ cells: [cell(txt, o)] }); };
const kv = (k, vtxt, vcol) => rows.push({ cells: [cell(k, { color: C_SUB, span: 2 }), cell(vtxt, { color: vcol || C_TXT, span: 3 })] });
const section = (t) => full(t, lite(C_HDR, 12), { bg: gHead(C_HDR), bold: true, sz: 8, p: '2px 6px', ls: '0.06em' });
const fail = (msg) => {
    rows.length = 0;
    rows.push({ cells: [cell('CQO-S — ' + current.ticker + ' · ' + msg, { color: C_NEG, bold: true, sz: 10, p: '6px 10px', span: COLS })] });
};
const FULL = LV >= 3;
const money = (x) => (!isFinite(x) ? (x > 0 ? 'unlimited' : '−unlimited') : (x <= -0.5 ? '−$' : '$') + Math.abs(x).toFixed(0));
const px2 = (x) => (x == null || !isFinite(x) ? '—' : x.toFixed(2));
const pctS = (x) => (x == null || !isFinite(x) ? '—' : (100 * x).toFixed(0) + '%');
const sgn2 = (x) => (x == null || !isFinite(x) ? '—' : (x >= 0 ? '+' : '−') + Math.abs(x).toFixed(2));

let ok = true;
// ---------------------------------------------------------------- what to show: an idea, or the manual settings
const recIx = calcSource === 'Idea 1' ? 0 : calcSource === 'Idea 2' ? 1 : calcSource === 'Idea 3' ? 2 : -1;
const recUsed = recIx >= 0 && ENG.recs[recIx] ? ENG.recs[recIx] : null;
let bk = null, stratUsed = strat, nCon = contracts, prmUsed = null;
if (recIx >= 0 && !recUsed) {
    // v3.2: no silent fallback to the manual settings when the chosen idea doesn't exist
    rows.push({ cells: [cell('▸ ' + calcSource + ': none · set Calculator Uses = Manual to price your own trade', { color: C_DIM, sz: 9, span: COLS })] });
    return { ok: false, rows };
}
if (recUsed) { bk = recUsed.bk; stratUsed = recUsed.st; nCon = recUsed.nCon; prmUsed = recUsed.prm; }
else {
    const cands = schedule.filter((s) => s && s.expiration && s.expiration.dte >= (targetDTE < 1 ? 0 : 1));
    if (!cands.length) { fail('no valid expiries'); ok = false; }
    else {
        const exM = cands.reduce((b, s) => (Math.abs(s.expiration.dte - targetDTE) < Math.abs(b.expiration.dte - targetDTE) ? s : b));
        bk = makeBook(exM);
        if (!bk) { fail('expiry missing from chain or too few strikes'); ok = false; }
        prmUsed = { DS: shortDelta / 100, DL: longDelta / 100, DG: strgDelta / 100, W: wingW, k1: k1Over, k2: k2Over };
    }
}
const book = bk ? bk.book : [], T = bk ? bk.T : null, dte = bk ? bk.dte : null;
const expLabel = bk ? bk.label + ' · ' + dte + ' DTE' : '';
let legs = [], net = null, priceNote = '';
if (ok) {
    const bl = buildLegsFor(stratUsed, book, prmUsed);
    if (bl.err) { fail(bl.err); ok = false; } else legs = bl.legs;
}
if (ok) {
    const pz = priceLegsFor(legs, book, T, fillMode);
    if (pz.err) { fail(pz.err); ok = false; } else { net = pz.net; priceNote = pz.note; }
}
if (!ok) return { ok: false, rows };
{
    const posMult = 100 * nCon;
    const A = analyse(legs, net);
    const atmIV = bk.atmIV;
    const smilePts = buildSmile(book, spot);
    const mkt = atmIV ? skewStats(legs, net, spot, T, riskFree, smilePts, atmIV, A.maxP, A.maxL) : null;   // skew-aware (IV smile)
    const vView = viewVol(atmIV);
    const rlz = vView ? realStats(legs, net, spot, T, dte, vView, A.maxP, A.maxL) : null;
    // managed trade (take profit / time exit) — target = TP% of max profit, or of the debit when upside is open-ended
    const tpTarget = tpPct > 0 ? (isFinite(A.maxP) ? tpPct / 100 * A.maxP : tpPct / 100 * Math.abs(net)) : 0;
    const MANAGED = tpTarget > 0 || (closeDTE > 0 && closeDTE < dte);
    const mktM = MANAGED && atmIV ? managedStats(legs, net, spot, T, riskFree, atmIV, riskFree, tpTarget, closeDTE, dte) : null;
    const rlzM = MANAGED && vView ? managedStats(legs, net, spot, T, riskFree, vView, riskFree, tpTarget, closeDTE, dte) : null;
    const mTag = (tpTarget > 0 ? 'TP ' + tpPct + '%' : '') + (closeDTE > 0 && closeDTE < dte ? (tpTarget > 0 ? ', ' : '') + 'close ' + closeDTE + ' DTE' : '');
    // position greeks (per share → × multiplier)
    const G = { delta: 0, gamma: 0, theta: 0, vega: 0 };
    for (const g of legs) { const q = bs(g.type, spot, g.K, T, riskFree, g.iv || atmIV || 0.3); G.delta += g.qty * q.delta; G.gamma += g.qty * q.gamma; G.theta += g.qty * q.theta; G.vega += g.qty * q.vega; }
    // P/L at half-time: reprice legs at T/2 with their own IVs
    const plMid = (S) => legs.reduce((a, g) => a + g.qty * bs(g.type, S, g.K, T / 2, riskFree, g.iv || atmIV || 0.3).price, 0) - net;
    const em = atmIV ? spot * atmIV * Math.sqrt(T) : null;
    const rr = isFinite(A.maxP) && isFinite(A.maxL) && A.maxL < 0 ? A.maxP / -A.maxL : null;

    // ---------------- panel (v3.9: section titles folded into the column-header rows, payoff two-up, Greeks one row)
    if (!SINGLE) full('◆ CQO-S Calc · ' + current.symbol + '  $' + spot.toFixed(2), lite(C_HDR, 14), { bg: gHead(C_HDR), bold: true, sz: 10, p: '3px 6px', ls: '0.04em' });
    full((SINGLE ? '▸ ' : '') + (recUsed ? calcSource + ': ' : '') + stratUsed + ' · ' + expLabel + ' · ' + nCon + 'x · ' + fillMode, lite(C_GOLD, 6), { bg: gHead(C_GOLD), bold: true, sz: SINGLE ? 10 : 9, p: '3px 6px' });
    const mLegs = rows.length;
    rows.push({ cells: ['LEGS', 'Strike', 'Δ', 'IV', 'Price'].map((t, k) => cell(t, { color: k ? C_DIM : lite(C_HDR, 12), bold: !k, sz: 8, bg: BG_SEC, p: '2px 5px', ls: k ? undefined : '0.06em' })) });
    legs.forEach((g, ix) => {
        const q = book[g.i][g.type];
        const lab = (g.qty > 0 ? 'Buy ' : 'Sell ') + (Math.abs(g.qty) > 1 ? Math.abs(g.qty) + ' ' : '') + (g.type === 'C' ? 'Call' : 'Put');
        const zb = ix % 2 ? BG_B : BG;
        rows.push({ cells: [cell(lab, { color: g.qty > 0 ? C_POS : C_NEG, bold: true, bg: zb }), cell(g.K % 1 === 0 ? g.K.toFixed(0) : g.K.toFixed(2), { bg: zb }),
            cell(q.delta != null ? q.delta.toFixed(2) : '—', { color: C_SUB, bg: zb }), cell(g.iv ? (100 * g.iv).toFixed(1) + '%' : '—', { color: C_SUB, bg: zb }),
            cell(g.px.toFixed(2) + (g.spreadPct != null && g.spreadPct > 0.15 ? ' ⚠' : ''), { color: g.spreadPct != null && g.spreadPct > 0.15 ? C_WARN : C_TXT, bg: zb })] });
    });
    if (LV < 2) { rows.length = mLegs; if (!(recUsed && recIx === 0)) full(legText(legs), C_TXT, { sz: 9 }); }
    if (LV >= 2) section('PAYOFF AT EXPIRY');
    const kv2 = (k1, v1, c1, k2, v2, c2) => rows.push({ cells: [cell(k1, { color: C_SUB }), cell(v1, { color: c1 || C_TXT, bold: true }),
        cell(k2 || '', { color: C_SUB }), cell(v2 || '', { color: c2 || C_TXT, bold: true, span: 2 })] });
    const isCr = !!CREDIT[stratUsed], crW = isCr ? wingWidth(legs) : 0, crPctC = isCr && crW > 0 ? -net / crW * 100 : null;
    const floorMiss = isCr ? (engMinCrW > 0 && crPctC != null && crPctC < engMinCrW) : (rr != null && engMinRR > 0 && rr < engMinRR);
    kv2(net >= 0 ? 'Net debit' : 'Net credit', '$' + Math.abs(net * posMult).toFixed(0) + ' (' + Math.abs(net).toFixed(2) + '/sh)', net >= 0 ? C_NEG : C_POS,
        'Max profit', money(A.maxP * posMult), C_POS);
    kv2('Max loss', money(A.maxL * posMult), C_NEG,
        LV >= 2 ? 'Reward/risk' : '', LV >= 2 ? (rr != null ? rr.toFixed(2) + ':1' : (A.unlimitedProfit ? 'open-ended' : '—')) + (crPctC != null ? ' · ' + crPctC.toFixed(0) + '% of wd' : '') : '', floorMiss ? C_WARN : C_TXT);
    kv('Breakeven' + (A.bes.length > 1 ? 's' : ''), A.bes.length ? A.bes.map((b) => b.toFixed(2) + ' (' + sgn2((b - spot) / spot * 100) + '%)').join('  ·  ') : 'none', C_GOLD);
    if (floorMiss) full(isCr ? '⚠ credit < ' + engMinCrW + '% of width' : '⚠ RR < ' + engMinRR + ':1', C_WARN, { sz: 9, bold: true, bg: gSoft(C_WARN, 0.16) });
    const mProb = rows.length;
    rows.push({ cells: [cell('PROBABILITIES', { color: lite(C_HDR, 12), bold: true, sz: 8, span: 2, bg: BG_SEC, p: '2px 5px', ls: '0.06em' }),
        cell('Market ' + (atmIV ? (100 * atmIV).toFixed(1) + '%' : '—'), { color: C_DIM, sz: 8, bg: BG_SEC, p: '2px 5px' }),
        cell('Model ' + (vView ? (100 * vView).toFixed(1) + '%' : '—'), { color: C_DIM, sz: 8, span: 2, bg: BG_SEC, p: '2px 5px' })] });
    const fmtV = (x, kind) => (x == null || !isFinite(x) ? '—' : kind === 'money' ? money(x * posMult) : kind === 'days' ? x.toFixed(0) + ' d' : pctS(x));
    const colV = (x, kind) => (kind === 'money' ? (x == null ? C_TXT : x > 0 ? C_POS : C_NEG) : C_TXT);
    const pBar = (x, kind) => (kind === 'pct' && x != null && isFinite(x) ? gFill(x, C_HDR) : BG);
    const pr = (k, a, b, kind) => rows.push({ cells: [cell(k, { color: C_SUB, span: 2 }),
        cell(fmtV(a, kind), { color: colV(a, kind), bg: pBar(a, kind) }), cell(fmtV(b, kind), { color: colV(b, kind), span: 2, bg: pBar(b, kind) })] });
    const g2 = (o, f) => (o ? f(o) : null);
    pr('Prob. of profit, at expiry', g2(mkt, (d) => d.pop), g2(rlz, (d) => d.pop), 'pct');
    if (FULL && isFinite(A.maxP)) pr('Prob. max profit, expiry', g2(mkt, (d) => d.pMax), g2(rlz, (d) => d.pMax), 'pct');
    if (FULL && isFinite(A.maxL)) pr('Prob. max loss, expiry', g2(mkt, (d) => d.pMin), g2(rlz, (d) => d.pMin), 'pct');
    pr('EV, hold to expiry', g2(mkt, (d) => d.ev), g2(rlz, (d) => d.ev), 'money');
    if (MANAGED) {
        if (tpTarget > 0) pr('Prob. of ' + tpPct + '% profit (P' + tpPct + ')', g2(mktM, (d) => d.p50), g2(rlzM, (d) => d.p50), 'pct');
        pr('Win rate, managed (' + mTag + ')', g2(mktM, (d) => d.pop), g2(rlzM, (d) => d.pop), 'pct');
        pr('EV, managed', g2(mktM, (d) => d.ev), g2(rlzM, (d) => d.ev), 'money');
        if (FULL && tpTarget > 0) pr('Avg days to target', g2(mktM, (d) => d.days), g2(rlzM, (d) => d.days), 'days');
    }
    if (LV < 2) {
        rows.length = mProb;
        kv('POP market / model', (mkt ? pctS(mkt.pop) : '—') + ' / ' + (rlz ? pctS(rlz.pop) : '—'));
        kv('EV to expiry, model', rlz ? money(rlz.ev * posMult) : '—', rlz ? (rlz.ev > 0 ? C_POS : C_NEG) : C_TXT);
        if (MANAGED && rlzM) kv('EV managed, model', money(rlzM.ev * posMult), rlzM.ev > 0 ? C_POS : C_NEG);
    }
    if (LV >= 2 && rvol && atmIV) kv('IV ÷ forecast (rank)', (atmIV / rvol).toFixed(2) + 'x · ' + (atmIV / rvol > 1.2 ? 'rich' : atmIV / rvol < 0.85 ? 'cheap' : 'fair'), atmIV / rvol > 1.2 ? C_GOLD : atmIV / rvol < 0.85 ? C_LIL : C_SUB);
    const mGreeks = rows.length;
    rows.push({ cells: [cell('GREEKS ' + nCon + 'x', { color: lite(C_HDR, 12), bold: true, sz: 8, bg: BG_SEC, p: '2px 5px', ls: '0.06em' }),
        cell('Δ ' + (G.delta * posMult).toFixed(0)), cell('Γ ' + (G.gamma * posMult).toFixed(2)),
        cell('Θ/day ' + money(G.theta * posMult), { color: G.theta >= 0 ? C_POS : C_NEG }), cell('Vega/pt ' + money(G.vega * posMult), { color: C_SUB })] });
    if (LV < 2) rows.length = mGreeks;
    if (FULL) section('P/L LADDER · expiry · halfway (' + Math.round(dte / 2) + ' DTE left)');
    const sd = (atmIV || rvol || 0.3) * Math.sqrt(T);
    const steps = [2, 1.5, 1, 0.5, 0, -0.5, -1, -1.5, -2];
    const ladder = steps.map((k) => { const S = spot * Math.exp(k * sd); return { k, S, e: payoffAt(legs, net, S) * posMult, h: plMid(S) * posMult }; });
    const ladScale = Math.max(1, ...ladder.map((r) => Math.abs(r.e)));
    if (FULL) ladder.forEach((r) => {
        const zb = r.k === 0 ? hx8(C_GOLD, 0.08) : BG;
        rows.push({ cells: [cell((r.k > 0 ? '+' : r.k < 0 ? '−' : ' ') + Math.abs(r.k) + 'σ', { color: C_DIM, sz: 8, bg: zb }), cell(r.S.toFixed(2), { color: r.k === 0 ? C_GOLD : C_TXT, bg: zb }),
            cell(money(r.e), { color: r.e >= 0 ? C_POS : C_NEG, bold: true, bg: zb }), cell(money(r.h), { color: r.h >= 0 ? C_POS : C_NEG, bg: zb }),
            cell(r.e !== 0 ? ' ' : '', { sz: 8, bg: gCentre(0.5 + 0.5 * r.e / ladScale, r.e >= 0 ? C_POS : C_NEG) })] });
    });
    const warn = [];
    if (legs.some((g) => g.spreadPct != null && g.spreadPct > 0.15)) warn.push('⚠ wide bid/ask on a leg: expect Natural fills');
    if (priceNote) warn.push(priceNote);
    if (legs.some((g) => g.oi < 100)) warn.push('low open interest on a leg (under 100)');
    warn.forEach((w) => full(w, C_WARN, { sz: 8 }));
    // ---------------- VS GAMMA: where the structure sits against dealer positioning
    const mGamma = rows.length;
    section('VS GAMMA LEVELS');
    if (!SINGLE) full(isLongGamma ? 'Long γ · dealer hedging dampens moves' : 'Short γ · dealer hedging extends moves',
        isLongGamma ? C_POS : C_NEG, { sz: 9 });
    const fk = (K) => (K % 1 === 0 ? K.toFixed(0) : K.toFixed(2));
    const shorts = legs.filter((g) => g.qty < 0 && legs.every((h) => !(h.qty > 0 && h.type === g.type && h.K === g.K)));
    let wallChecks = 0, wallPass = 0;
    shorts.forEach((g) => {
        if (!(stratUsed === 'Bull Put Credit' || stratUsed === 'Bear Call Credit' || stratUsed === 'Iron Condor')) return;   // debit/fly shorts cap profit, they are not risk strikes
        if (g.type === 'P') {
            if (putWall == null) { full('Sell Put ' + fk(g.K) + ' · no Put Wall found', C_SUB, { sz: 8 }); return; }
            wallChecks++; const okW = g.K <= putWall; if (okW) wallPass++;
            full('Sell Put ' + fk(g.K) + (okW ? ' ≤ Put Wall ' + fk(putWall) + ' ✓' : ' above Put Wall ' + fk(putWall) + ' · no wall in front'), okW ? C_POS : C_GOLD, { sz: 8 });
        } else {
            if (callWall == null) { full('Sell Call ' + fk(g.K) + ' · no Call Wall found', C_SUB, { sz: 8 }); return; }
            wallChecks++; const okW = g.K >= callWall; if (okW) wallPass++;
            full('Sell Call ' + fk(g.K) + (okW ? ' ≥ Call Wall ' + fk(callWall) + ' ✓' : ' below Call Wall ' + fk(callWall) + ' · no wall in front'), okW ? C_POS : C_GOLD, { sz: 8 });
        }
    });
    // directional longs: is a wall in the way of the target?
    const upT = stratUsed === 'Long Call' ? (A.bes[0] != null ? A.bes[0] : null) : stratUsed === 'Bull Call Debit' ? legs[1].K : null;   // long call: breakeven · debit spread: short strike (max profit)
    const dnT = stratUsed === 'Long Put' ? (A.bes[0] != null ? A.bes[0] : null) : stratUsed === 'Bear Put Debit' ? legs[1].K : null;
    const pathRow = (wall, nm, tgt, up) => {
        if (tgt == null || wall == null) return;
        const near = Math.abs(wall - tgt) / tgt < 0.005;
        const inPath = up ? (wall > spot && wall < tgt) : (wall < spot && wall > tgt);
        if (near) full(nm + ' ' + fk(wall) + ' at target ' + fk(tgt) + ' · expect friction', C_GOLD, { sz: 8 });
        else if (inPath) full('⚠ ' + nm + ' ' + fk(wall) + ' between spot and target ' + fk(tgt), C_GOLD, { sz: 8 });
        else full(nm + ' ' + fk(wall) + ' not in the path to ' + fk(tgt) + ' ✓', C_POS, { sz: 8 });
    };
    pathRow(callWall, 'Call Wall', upT, true);
    pathRow(putWall, 'Put Wall', dnT, false);
    if (stratUsed === 'Call Butterfly' && maxPain != null) { const body = legs[1].K; full('Body ' + fk(body) + ' vs Max Pain ' + fk(maxPain) + ' (' + sgn2((maxPain - body) / body * 100) + '%)', Math.abs(maxPain - body) / body < 0.01 ? C_POS : C_SUB, { sz: 8 }); }
    if (gammaFlip != null && A.bes.some((b) => (b - gammaFlip) * (spot - gammaFlip) < 0)) full('γ ZG ' + fk(gammaFlip) + ' between spot and a breakeven', C_SUB, { sz: 8 });

    if (LV < 3) rows.length = mGamma;   // walls check is still computed for the signal
    else full('Model view · European BS · no early exercise / dividends', C_DIM, { sz: 8 });
    return { ok: true, rows, legs, A, em, mkt, rlz, mktM, rlzM, wallsOk: wallChecks > 0 && wallPass === wallChecks };
}
}

// ============================================================================
// TRADE IDEAS PANEL BLOCK (v2) — 5-column rows, re-spanned like the calculator rows
// ============================================================================
function engineRows() {
    // v3.9 layout: same content as v3.7, fewer rows — technical + IV lines packed wider, idea cards 3 rows
    // (title + EV bar · legs + POP bar · max loss + reasons), warnings on their own tinted row.
    const out = [];
    const ec = (txt, o) => { o = o || {}; const c0 = { text: txt, color: o.color || C_TXT, background: o.bg || BG, fontSize: (o.sz || 9) + FS,
        fontWeight: o.bold ? 'bold' : 'normal', textAlign: o.align || 'left', padding: o.p || '1px 5px', colspan: o.span || 1 };
        if (o.sh) c0.textShadow = o.sh;
        if (o.ls) c0.letterSpacing = o.ls;
        return c0; };
    const addLine = (txt, col, o) => { o = o || {}; o.color = col; o.span = 5; out.push({ cells: [ec(txt, o)] }); };
    const addLines = (parts, col, o, maxLen) => packParts(parts, maxLen || 64).forEach((ln) => addLine(ln, col, Object.assign({}, o)));
    const usd = (x) => (x <= -0.5 ? '−$' : '$') + Math.abs(x).toFixed(0);
    const kUsd = (x) => (x >= 1e6 ? '$' + (x / 1e6).toFixed(1) + 'M' : x >= 1e3 ? '$' + (x / 1e3).toFixed(0) + 'K' : '$' + x.toFixed(0));
    if (!engineOn) return out;
    addLine('▸ TRADE IDEAS · ' + (dteWindow === 'Custom' ? engDteMin + '-' + engDteMax + ' DTE' : dteWindow) + ' · ' + ideaMode + ' · Bias ' + biasIn + ' · risk ' + usd(acctSize * riskPctIn / 100) + '/trade',
        lite(C_GOLD, 6), { bg: gHead(C_GOLD), bold: true, sz: 10, p: '3px 6px', ls: '0.03em' });
    if (SHORT_DTE) addLines(['⚠ short-dated window: the model was tested at 21–60 DTE', 'technical penalties use a 21-day horizon'], C_WARN, { sz: 8, bg: gSoft(C_WARN, 0.14) }, 78);
    if (LV >= 2) addLines([techRSI14 != null ? 'RSI ' + techRSI14.toFixed(0) : '', techDown ? 'downtrend' : '', techBreakdown ? '20-d breakdown' : '',
        techVolRank != null ? 'vol rank ' + techVolRank.toFixed(0) : '', techRcx ? 'RCX ' + techRcx : '',
        techStop ? 'stop ' + techStop + (techSwLo ? ', swing low broken' : '') + (techSwHi ? ', swing high broken' : '') + (techStopMult !== 1 ? ' (RV ×' + techStopMult.toFixed(2) + ')' : '') : ''],
        C_SUB, { bg: hx8(C_HDR, 0.06), sz: 8 }, 78);
    if (LV >= 2 && ivRank != null) {
        const ivCol = ivRank > 1.2 ? C_GOLD : ivRank < 0.85 ? C_LIL : C_SUB;
        addLines(['IV ÷ forecast ' + ivRank.toFixed(2) + ' ' + (ivRank > 1.2 ? 'rich' : ivRank < 0.85 ? 'cheap' : 'fair') + ' (cross-name rank)',
            frontSkew != null ? 'skew ' + (frontSkew >= 0 ? '+' : '') + frontSkew.toFixed(1) : '', termRatio != null ? 'term ' + termRatio.toFixed(2) : ''],
            ivCol, { bg: ivRank > 1.2 || ivRank < 0.85 ? gSoft(ivCol, 0.14) : hx8(C_HDR, 0.06), sz: 8 }, 78);
    }
    // context
    const ctx = [];
    if (earnNext) ctx.push('Earnings ' + (earnNext.estimated ? '~' : '') + shortDate(earnNext.sec) + ' (' + Math.max(0, Math.round(earnNext.days)) + ' d' + (earnNext.estimated ? ', estimated' : '') + ')');
    else ctx.push('Earnings: none scheduled');
    if (divNext) ctx.push('Ex-div ' + (divNext.estimated ? '~' : '') + shortDate(divNext.sec) + (divNext.amount ? ' $' + Number(divNext.amount).toFixed(2) : ''));
    const earnHot = earnNext && earnNext.days <= engDteMax;
    if (LV >= 2 || earnHot) addLines(ctx, earnHot ? C_WARN : C_SUB, { sz: 8, bg: earnHot ? gSoft(C_WARN, 0.14) : BG }, 78);
    if (LV >= 3 && seasRows) addLine('Seasonality ' + seasRows.map((s) => MONTHS[s.m] + ' ' + s.up + '/' + s.n + ' up, ' + (s.avg == null ? '—' : (s.avg >= 0 ? '+' : '−') + Math.abs(s.avg).toFixed(1) + '%')).join(' · '),
        seasRows[0].n && seasRows[0].up / seasRows[0].n >= 0.6 ? C_POS : seasRows[0].n && seasRows[0].up / seasRows[0].n <= 0.4 ? C_NEG : C_SUB, { sz: 8 });
    if (LV >= 3 && flow) {
        const tilt = flow.bullUsd + flow.bearUsd > 0 ? flow.bullUsd / (flow.bullUsd + flow.bearUsd) : null;
        addLine('Flow 10 d: ' + flow.bullN + ' bull ' + kUsd(flow.bullUsd) + ' · ' + flow.bearN + ' bear ' + kUsd(flow.bearUsd) + (tilt == null ? '' : ' · ' + (tilt >= 0.65 ? 'bullish tilt' : tilt <= 0.35 ? 'bearish tilt' : 'mixed')),
            tilt == null ? C_SUB : tilt >= 0.65 ? C_POS : tilt <= 0.35 ? C_NEG : C_SUB, { sz: 8, bg: tilt == null ? BG : gFill(tilt, C_POS) });
    }
    if (!ENG.recs.length) {
        // v3.4: prominent banner — the first thing read when the engine has nothing to offer
        const allEarn = ENG.expiries.length && ENG.expiries.every((e) => e.spansEarn) && avoidEarn;
        const floorsOnly = (ENG.why['reward/risk'] || ENG.why['credit/width']) && ENG.passed === 0;
        const head = ENG.note ? 'no expiry in range' : allEarn && earnNext ? 'earnings inside every expiry' : floorsOnly ? 'payoff floors not met' : 'no candidate passed the filters';
        addLine('⛔ NO TRADE IDEA · ' + head, lite(C_WARN, 8), { bg: gChip(C_WARN), bold: true, sz: 11, p: '5px 6px', align: 'center', sh: glow(C_WARN, 0.55) });
        const reasons = ENG.note ? [ENG.note]
            : allEarn && earnNext ? ['Every ' + engDteMin + '–' + engDteMax + ' DTE expiry spans earnings ' + (earnNext.estimated ? '~' : '') + shortDate(earnNext.sec), 'wait for the report', 'or turn off Skip Earnings Expiries']
            : floorsOnly ? [].concat(ENG.why['reward/risk'] ? ['debit RR < ' + engMinRR + ':1 (' + ENG.why['reward/risk'] + ')'] : [],
                ENG.why['credit/width'] ? ['credit < ' + engMinCrW + '% of width (' + ENG.why['credit/width'] + ')'] : [], ['lower the floors or change Bias'])
            : [ENG.searched + ' tried'].concat(Object.keys(ENG.why).map((k) => k + ' ' + ENG.why[k]));
        addLines(reasons, C_WARN, { bg: hx8(C_WARN, 0.08), sz: 9, p: '2px 6px' }, 72);
        return out;
    }
    if (!(ENG.recs[0].evRisk > 0)) addLines(LV >= 2 ? ['⚠ No positive edge on the IV-anchored view at Natural fills', 'least negative shown', 'waiting is valid'] : ['⚠ No positive edge at Natural fills'], C_WARN, { sz: 8, bold: true, bg: gSoft(C_WARN, 0.14) }, 78);
    ENG.recs.forEach((c, ix) => {
        if (LV < 2 && ix > 0) return;   // Compact: top pick only
        const sel = ix === (calcSource === 'Idea 1' ? 0 : calcSource === 'Idea 2' ? 1 : calcSource === 'Idea 3' ? 2 : -1);
        const bgR = sel ? gSoft(C_GOLD, 0.16) : (ix % 2 ? BG_B : BG);
        const per = 100 * c.nCon;
        const evCol = c.evRisk >= 0 ? C_POS : C_NEG;
        // row 1: rank · structure · size / premium · EV bar (centred on zero, ±20% of risk fills a half)
        out.push({ cells: [
            ec('#' + (ix + 1) + (sel ? ' ◂' : ''), { color: C_GOLD, bold: true, bg: bgR, p: '2px 5px' }),
            ec(c.st + ' · ' + c.bk.label + ' · ' + c.bk.dte + ' DTE', { bold: true, span: 2, bg: bgR, p: '2px 5px' }),
            ec(c.nCon + 'x · ' + (c.net < 0 ? 'credit ' : 'debit ') + usd(Math.abs(c.net) * per), { color: c.net < 0 ? C_POS : C_NEG, bg: bgR, p: '2px 5px' }),
            ec('EV ' + (c.evRisk >= 0 ? '+' : '−') + Math.abs(100 * c.evRisk).toFixed(1) + '%', { color: evCol, bold: true, p: '2px 5px',
                bg: gCentre(0.5 + Math.max(-0.5, Math.min(0.5, c.evRisk / 0.4)), evCol) }),
        ] });
        // row 2: legs · market POP as a fill bar
        out.push({ cells: [ec('', { bg: bgR }), ec(legText(c.legs), { span: 3, bg: bgR }),
            ec('POP ' + (c.mkt ? (100 * c.mkt.pop).toFixed(0) + '%' : '—') + (c.mg && tpPct > 0 ? ' · P' + tpPct + ' ' + (100 * c.mg.p50).toFixed(0) + '%' : ''),
                { color: C_SUB, bg: c.mkt ? gFill(c.mkt.pop, C_HDR) : bgR })] });
        // row 3: max loss + reasons; warnings on their own tinted row
        const why = [];
        why.push('IV÷fcst ' + (c.ratio != null ? c.ratio.toFixed(2) + ' ' + (c.stance === 'Sell' ? 'rich' : c.stance === 'Buy' ? 'cheap' : 'fair') : 'n/a'));
        why.push(isLongGamma ? 'long γ' : 'short γ');
        if (CREDIT[c.st]) why.push(c.wallOk ? 'shorts beyond walls ✓' : 'shorts inside walls');
        else if (c.pathClear != null) why.push(c.pathClear ? 'no wall before target ✓' : '⚠ wall before target');
        if (c.fb) why.push('γ ZG before breakeven');
        if (c.mv && c.mv.txt) why.splice(1, 0, 'move ' + c.mv.txt);
        if (c.spansEarn) why.push('⚠ earnings before expiry');
        if (c.regWarn) why.push('⚠ ' + c.regWarn);
        c.tech.notes.forEach((t) => why.push('⚠ ' + t));
        if (divNext && divNext.days <= c.bk.dte && c.legs.some((g) => g.qty < 0 && g.type === 'C' && g.K <= spot * 1.03)) why.push('⚠ ex-div before expiry: early assignment risk');
        const whyShow = LV >= 2 ? why : [why[0]].concat(why.filter((t) => t.indexOf('⚠') === 0));
        const calm = whyShow.filter((t) => t.indexOf('⚠') !== 0), warns = whyShow.filter((t) => t.indexOf('⚠') === 0);
        packParts(['max loss ' + usd(c.A.maxL * per)].concat(calm), 66).forEach((ln, k) =>
            out.push({ cells: [ec('', { bg: bgR }), ec(ln, { color: k ? C_DIM : C_SUB, sz: 8, span: 4, bg: bgR })] }));
        if (warns.length) packParts(warns, 66).forEach((ln) =>
            out.push({ cells: [ec('', { bg: bgR }), ec(ln, { color: C_WARN, sz: 8, span: 4, bg: gSoft(C_WARN, 0.12) })] }));
    });
    if (LV < 3) return out;
    const skippedE = ENG.expiries.filter((e) => e.spansEarn && avoidEarn).length;
    addLines(['Searched ' + ENG.searched + ', passed ' + ENG.passed,
        strikesFetched && strikesFetched < Math.max(strikesPerSide, calcStrikes) ? 'chain cut to ' + strikesFetched + ' strikes/side' : '',
        skippedE ? skippedE + ' expir' + (skippedE > 1 ? 'ies' : 'y') + ' skipped (earnings)' : '',
        'ranked by ' + (tpPct > 0 ? 'EV at TP ' + tpPct + '%' : 'EV to expiry') + ' ÷ max loss', 'view 90% IV / 10% forecast, Natural fills'], C_DIM, { sz: 8 }, 78);
    return out;
}

var calcRes = runCalc();

// ============================================================================
// PANELS
// ============================================================================
// Re-span a calculator row (5 columns) onto the gamma table width (COLS ≥ 6):
// cumulative boundaries are scaled and rounded, so every cell keeps ≥ 1 column.
function respan(row, from, to) {
  var cum = 0, prevB = 0;
  return { cells: row.cells.map(function (c) {
    cum += c.colspan || 1;
    var bnd = Math.round(cum * to / from), out = Object.assign({}, c, { colspan: Math.max(1, bnd - prevB) });
    prevB = bnd; return out;
  }) };
}
var PANEL_STYLE = { background: 'rgba(13,17,23,0.95)', border: '1px solid ' + hx8(C_HDR, 0.38), borderRadius: 8 };
// Long text cells wrap at 'Panel Max Width' instead of stretching the panel (0 = off).
if (panelMaxW > 0) PANEL_STYLE.maxWidth = panelMaxW + 'px';
function wrapRows(rs) {
  if (!(panelMaxW > 0)) return rs;
  return rs.map(function (r) { return { cells: r.cells.map(function (c) {
    return c && typeof c.text === 'string' && c.text.length > 30 ? Object.assign({}, c, { whiteSpace: 'normal', wordBreak: 'break-word', maxWidth: panelMaxW + 'px' }) : c;
  }) }; });
}
// Overlay params are LITERAL so TrendSpider offers its native panel editor (Position /
// Order / Offset X / Offset Y) under the indicator settings, as on TVSE Plan v2.
var engRows = engineRows();
var calcAll = engRows.concat(calcRes.rows || []);
var mainRows = SINGLE ? rows.concat(calcAll.map(function (r) { return respan(r, 5, COLS); })) : rows;
// Both overlays are painted on every run under fixed names; a hidden panel gets empty rows
// (docs: Common mistakes, Problem 1 — conditional painting).
var showMainPnl = showPanel, showCalcPnl2 = !SINGLE && showCalcPnl && calcAll.length > 0;
paint_overlay('CQO-S Panel', { position: 'top_right', order: 'above_all', offset_x: -56, offset_y: 0 },
  showMainPnl ? Object.assign({}, PANEL_STYLE, { rows: wrapRows(mainRows) }) : { rows: [] });
paint_overlay('CQO-S Calc Panel', { position: 'top_left', order: 'above_all', offset_x: 0, offset_y: 0 },
  showCalcPnl2 ? Object.assign({}, PANEL_STYLE, { rows: wrapRows(calcAll) }) : { rows: [] });

// Trade chart lines (fixed names; null when absent so appearance settings persist)
function tradeLevel(value, seriesName, labelText, color, style, on) {
  var show = on && value != null;
  var s = paint(series_of(show ? value : null), { name: seriesName, color: color, thickness: 1, style: style === 'dotted' ? 'dotted' : 'line', ignoreWhenScaling: true });
  if (show) paint_label_at_line(s, labelIndex(value, tLabelBack), labelText + ' ' + value.toFixed(2),
    { border_width: 1, border_radius: 3, border_color: color, background_color: BG, color: color });
}
var tStrikes = [], tBes = [], tEm = null;
if (calcRes.ok) {
  // one line per distinct strike, labelled with the legs at that strike (e.g. "Sell Put", "Buy Call + Buy Put")
  var byK = {};
  calcRes.legs.forEach(function (g) {
    var tag = (g.qty > 0 ? 'Buy ' : 'Sell ') + (Math.abs(g.qty) > 1 ? Math.abs(g.qty) + ' ' : '') + (g.type === 'C' ? 'Call' : 'Put');
    byK[g.K] = byK[g.K] ? byK[g.K] + ' + ' + tag : tag;
  });
  tStrikes = Object.keys(byK).map(Number).sort(function (x, y) { return x - y; }).map(function (K) { return { K: K, tag: byK[K] }; });
  tBes = calcRes.A.bes;
  tEm = calcRes.em;
}
['A', 'B', 'C', 'D'].forEach(function (nm, ix) {
  var sk = tStrikes[ix];
  tradeLevel(sk ? sk.K : null, 'CQOC Strike ' + nm, sk ? sk.tag : '', C_TRADE, null, showTLines);
});
tradeLevel(tBes[0] != null ? tBes[0] : null, 'CQOC Breakeven Low',  'BE', C_TRADE_BE, 'dotted', showTLines);
tradeLevel(tBes[1] != null ? tBes[1] : null, 'CQOC Breakeven High', 'BE', C_TRADE_BE, 'dotted', showTLines);

// ============================================================================
// SIGNALS
// ============================================================================
register_signal(series_of(isLongGamma),  'CQO-S: Long Gamma');
register_signal(series_of(!isLongGamma), 'CQO-S: Short Gamma');
register_signal(series_of(gammaFlip != null && aboveFlip),  'CQO-S: Above Gamma Flip');
register_signal(series_of(gammaFlip != null && !aboveFlip), 'CQO-S: Below Gamma Flip');
register_signal(series_of(termRatio != null && termRatio > 1.05), 'CQO-S: Backwardation');
register_signal(series_of(!!(calcRes.ok && calcRes.mkt) && calcRes.mkt.pop > 0.5), 'CQO-S: Market POP above 50');
register_signal(series_of(!!(calcRes.ok && calcRes.mkt && calcRes.rlz) && calcRes.rlz.ev > calcRes.mkt.ev), 'CQO-S: Model view beats market');
register_signal(series_of(!!(calcRes.ok && calcRes.wallsOk)), 'CQO-S: Short strikes beyond walls');
// v2 engine signals (states of the latest chain — use "Signal active" in scans)
var topRec = ENG.recs.length ? ENG.recs[0] : null;
register_signal(series_of(!!topRec && topRec.evRisk > 0), 'CQO-S: Trade idea available');
register_signal(series_of(!!topRec && topRec.evRisk > 0 && !!CREDIT[topRec.st]), 'CQO-S: Top pick is a credit trade');
register_signal(series_of(!!topRec && topRec.evRisk > 0 && !CREDIT[topRec.st]), 'CQO-S: Top pick is a debit trade');
register_signal(series_of(earnNext != null && earnNext.days >= 0 && earnNext.days <= engDteMax), 'CQO-S: Earnings inside engine window');
// v2.3 ranking signals — use in a market scan to shortlist names, not as a stand-alone edge
register_signal(series_of(ivRank != null && ivRank > 1.2), 'CQO-S: IV rich vs forecast');
register_signal(series_of(ivRank != null && ivRank < 0.85), 'CQO-S: IV cheap vs forecast');
register_signal(series_of(MKT.state === 3), 'CQO-S: Market regime Grind');
register_signal(series_of(mktFreshShock), 'CQO-S: Market fresh shock');