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

Winvest Charm Close Magnet — Delta Decay Pull Into The Close

TSBuild26

Where dealers are forced to push price into the close, and when.

Gamma tells you where dealers hedge. Charm tells you when they're forced to.

Charm is the second-order greek that measures how fast an option's delta decays as time runs out. On expiration day it's the reason price drifts toward heavy strikes into the close, even with no news. It isn't in the options chain, so this computes it with Black-Scholes across every strike in the front expiries, weighted by open interest.

On the chart

🟣 Pull bands — the five heaviest charm strikes above price and five below. Brighter means stronger pull, and they intensify as the session burns down.

🎯 The magnet — the weighted centre of that pull, coloured by state: green LONG, red SHORT, amber WAIT, grey NO TRADE. The panel always shows where it is and how far away, at any hour.

⏱️ The window — a countdown to the final 90 minutes, when charm builds, so you know whether to act now or come back later.

📍 Stop and entry — placed off the recent swing with an ATR buffer, with the reward-to-risk shown before you take it. A triangle marks the trigger bar. Levels extend past the last candle, with their tags in the empty space so they never cover price.

🔔 Alerts — LONG and SHORT are registered signals, so you can attach an alert and stop watching the clock.

It says no more often than it says yes

Charm drift is a 0–1 DTE effect. If the nearest expiry is days away, there's no delta decaying by this bell, however clean the level looks, so it stands aside and says why. It also stands aside when the front expiry holds too little of the total pull, when the magnet is too far to fade, when reward-to-risk is thin, and early in the session before charm has built.

How to use it

Final hour, 5 or 15 minute chart, on a liquid index or ETF such as SPY or QQQ. Wait for the magnet to sit within about 1% of price, then take the reversal bar in the fade direction. The trigger needs a real body, not a doji.

The thesis ends at the bell. This is a close trade, not a swing.

Intraday only; on a daily chart it draws nothing and tells you why. The panel adapts to light and dark themes.


Works well with

Winvest Gamma Suite — the gamma walls, flip and pin-or-breakout regime around the magnet, so you know whether dealers will hold price there or let it run.

GEX Wall + 0DTE Expected Move — the day's priced range and the gamma wall the magnet has to sit inside.

📈 Watch me trade — my live 30-minute setups go up on StockTwits, taken with the Multi-Target Options Trade Plan Pro: stocktwits.com/winvestinsights

📬 Free weekly newsletter — market structure, options flow, and the longer-term positions I'm building: winvest-insights.beehiiv.com

For educational purposes only. Not financial advice.

describe_indicator('Winvest Charm Close Magnet', 'price', { shortName: 'CHARM' });

const rangePct = input.number('Strike Range %', 10);
const maxDTE = input.number('Max DTE', 7);
const riskFree = input.number('Risk Free %', 4.5);
const maxPull = input.number('Max Magnet %', 1);
const minStretch = input.number('Min Stretch %', 0.15);
const stopLook = input.number('Stop Lookback', 5);
const stopAtr = input.number('Stop Buffer ATR', 0.5);
const minRR = input.number('Min R:R', 1.5);
const startAfter = input.number('Start After %', 30);
const bandAtr = input.number('Band Width ATR', 0.18);
const pullDecay = input.number('Pull Decay %', 1.5);
const minBody = input.number('Min Body %', 40);
const maxFrontDTE = input.number('Max Front DTE', 1);
const minFrontPull = input.number('Min Front Pull %', 50);

const n = close.length;
const spot = close[n - 1];
const lastBar = n - 1;
const rr = riskFree / 100;

const GREEN = '#00c853';
const RED = '#e53935';
const AMBER = '#ffa000';
const GREY = '#9e9e9e';
const PURPLE = '#7b1fa2';

const atrArr = atr(14);
const atrNow = atrArr[n - 1] !== null ? atrArr[n - 1] : 0;

function toMs(ts) {
    let t = ts;
    if (t < 1e12) t = t * 1000;
    return t;
}
let minStep = 1e15;
let stepFrom = n - 300;
if (stepFrom < 1) stepFrom = 1;
for (let i = stepFrom; i < n; i++) {
    const dt = toMs(time[i]) - toMs(time[i - 1]);
    if (dt > 0 && dt < minStep) minStep = dt;
}
const isIntraday = minStep >= 60000 && minStep <= 3600000;

const dayOf = Array(n).fill(0);
for (let i = 0; i < n; i++) {
    dayOf[i] = Math.floor(toMs(time[i]) / 86400000);
}

const dayCounts = {};
for (let i = 0; i < n; i++) {
    const d = dayOf[i];
    if (!dayCounts[d]) dayCounts[d] = 0;
    dayCounts[d] = dayCounts[d] + 1;
}
const today = dayOf[n - 1];
const barsToday = dayCounts[today];

let sessionBars = 0;
const dayKeys = Object.keys(dayCounts);
const completed = [];
for (let i = 0; i < dayKeys.length; i++) {
    const d = parseInt(dayKeys[i], 10);
    if (d !== today) completed.push(dayCounts[d]);
}
if (completed.length > 0) {
    completed.sort((a, b) => a - b);
    sessionBars = completed[Math.floor(completed.length / 2)];
}
if (sessionBars < 2) sessionBars = barsToday > 1 ? barsToday : 2;

let sessFrac = barsToday / sessionBars;
if (sessFrac > 1) sessFrac = 1;
if (sessFrac < 0) sessFrac = 0;
const sess = sessFrac * 100;
const dayLeft = 1 - sessFrac;

function daysFromCivil(y, m, d) {
    let yy = y;
    if (m <= 2) yy = yy - 1;
    const era = Math.floor((yy >= 0 ? yy : yy - 399) / 400);
    const yoe = yy - era * 400;
    const doy = Math.floor((153 * (m > 2 ? m - 3 : m + 9) + 2) / 5) + (d - 1);
    const doe = yoe * 365 + Math.floor(yoe / 4) - Math.floor(yoe / 100) + doy;
    return era * 146097 + doe - 719468;
}
const nowStamp = (typeof current !== 'undefined' && current !== null && typeof current.now === 'number' && current.now > 0)
    ? current.now
    : time[n - 1];
const nowDays = Math.floor(toMs(nowStamp) / 86400000);

function keyToDTE(key) {
    const y = parseInt(key.slice(0, 2), 10) + 2000;
    const m = parseInt(key.slice(2, 4), 10);
    const d = parseInt(key.slice(4, 6), 10);
    return daysFromCivil(y, m, d) - nowDays;
}

function pdf(x) {
    return Math.exp(-x * x / 2) / Math.sqrt(2 * Math.PI);
}
function charmOf(S, K, T, sig) {
    if (T <= 0) return 0;
    if (sig <= 0) return 0;
    const sq = sig * Math.sqrt(T);
    const d1 = (Math.log(S / K) + (rr + sig * sig / 2) * T) / sq;
    const d2 = d1 - sq;
    return -pdf(d1) * (2 * rr * T - d2 * sq) / (2 * T * sq);
}

let callsData = { resultByExpiration: {} };
let putsData = { resultByExpiration: {} };
if (isIntraday) {
    callsData = await request.options_data_for_all_expirations(current.ticker, 30, ['iv', 'oi'], 'C');
    assert(!callsData.error, 'Calls error: ' + callsData.error);
    putsData = await request.options_data_for_all_expirations(current.ticker, 30, ['iv', 'oi'], 'P');
    assert(!putsData.error, 'Puts error: ' + putsData.error);
}

function inRange(s) {
    return Math.abs(s - spot) / spot * 100 <= rangePct;
}

const pullAt = {};
const netAt = {};

const pullByExp = {};
let pullAllExp = 0;
let nearDTE = 999;
let sawChain = false;

function accumulate(data, isCall) {
    for (const exp in data.resultByExpiration) {
        const dte = keyToDTE(exp);
        if (dte < 0) continue;
        if (dte > maxDTE) continue;
        let T = dte / 365;
        if (T <= 0) T = 0.5 / 365;
        const expData = data.resultByExpiration[exp];
        for (const sStr in expData) {
            const s = parseFloat(sStr);
            if (!inRange(s)) continue;
            const leg = isCall ? expData[sStr].C : expData[sStr].P;
            if (!leg) continue;
            if (leg.iv === undefined) continue;
            if (leg.oi === undefined) continue;
            const sig = leg.iv / 100;
            if (sig <= 0) continue;
            const ch = charmOf(spot, s, T, sig);
            sawChain = true;
            const mag = Math.abs(ch * leg.oi * 100);
            if (!pullAt[s]) pullAt[s] = 0;
            pullAt[s] = pullAt[s] + mag;

            const dPct = Math.abs(s - spot) / spot * 100;
            const zz = dPct / pullDecay;
            const wgt = mag * Math.exp(-zz * zz);
            if (!pullByExp[dte]) pullByExp[dte] = 0;
            pullByExp[dte] = pullByExp[dte] + wgt;
            pullAllExp = pullAllExp + wgt;

            if (dte < nearDTE) nearDTE = dte;
            const signed = ch * leg.oi * 100 * (isCall ? 1 : -1);
            if (!netAt[s]) netAt[s] = 0;
            netAt[s] = netAt[s] + signed;
        }
    }
}
accumulate(callsData, true);
accumulate(putsData, false);

const allStrikes = Object.keys(pullAt).map(s => parseFloat(s));

const aboveList = [];
const belowList = [];
for (let i = 0; i < allStrikes.length; i++) {
    const s = allStrikes[i];
    const w = pullAt[s];
    const net = netAt[s] === undefined ? 0 : netAt[s];
    if (s > spot) aboveList.push({ k: s, w: w, net: net });
    if (s < spot) belowList.push({ k: s, w: w, net: net });
}
aboveList.sort((a, b) => b.w - a.w);
belowList.sort((a, b) => b.w - a.w);

const bands = [];
for (let i = 0; i < aboveList.length && i < 5; i++) bands.push(aboveList[i]);
for (let i = 0; i < belowList.length && i < 5; i++) bands.push(belowList[i]);

for (let i = 0; i < bands.length; i++) {
    const distPct = Math.abs(bands[i].k - spot) / spot * 100;
    const z = distPct / pullDecay;
    const prox = Math.exp(-z * z);
    bands[i].eff = bands[i].w * prox;
}

const frontPull = pullByExp[nearDTE] === undefined ? 0 : pullByExp[nearDTE];
const frontShare = pullAllExp > 0 ? (frontPull / pullAllExp) * 100 : 0;

let maxW = 0;
for (let i = 0; i < bands.length; i++) {
    if (bands[i].eff > maxW) maxW = bands[i].eff;
}

let mSum = 0;
let mTot = 0;
for (let i = 0; i < bands.length; i++) {
    mSum = mSum + bands[i].k * bands[i].eff;
    mTot = mTot + bands[i].eff;
}
const magnet = mTot > 0 ? mSum / mTot : spot;
const hasCharm = mTot > 0;

let wAbove = 0;
let wBelow = 0;
for (let i = 0; i < bands.length; i++) {
    if (bands[i].k > spot) wAbove = wAbove + bands[i].eff;
    else wBelow = wBelow + bands[i].eff;
}
let pullWord = 'contested';
if (wAbove + wBelow > 0) {
    const share = wAbove / (wAbove + wBelow);
    if (share > 0.65) pullWord = 'one sided up';
    else if (share < 0.35) pullWord = 'one sided down';
}

let netTotal = 0;
for (let i = 0; i < bands.length; i++) netTotal = netTotal + bands[i].net;

let remainDays = nearDTE;
if (remainDays <= 0) remainDays = dayLeft > 0.05 ? dayLeft : 0.05;
const flowShares = Math.abs(netTotal) * (remainDays / 365);

function shortNum(v) {
    const a = Math.abs(v);
    if (a >= 1000000) return (v / 1000000).toFixed(1) + 'M';
    if (a >= 1000) return (v / 1000).toFixed(0) + 'K';
    return v.toFixed(0);
}

const stretch = ((spot - magnet) / spot) * 100;
const absStretch = Math.abs(stretch);
const goShort = spot > magnet;

let ext = goShort ? -1e12 : 1e12;
let sFrom = n - stopLook;
if (sFrom < 0) sFrom = 0;
for (let i = sFrom; i < n; i++) {
    if (goShort && high[i] > ext) ext = high[i];
    if (!goShort && low[i] < ext) ext = low[i];
}
const stopPx = goShort ? ext + stopAtr * atrNow : ext - stopAtr * atrNow;

const riskPts = Math.abs(stopPx - spot);
let rrRatio = 0;
if (riskPts > 0) rrRatio = Math.abs(spot - magnet) / riskPts;

let turned = false;
if (n >= 3) {
    const body = Math.abs(close[n - 1] - open[n - 1]);
    const rng = high[n - 1] - low[n - 1];
    const bodyPct = rng > 0 ? (body / rng) * 100 : 0;
    const solid = bodyPct >= minBody;
    if (goShort && close[n - 1] < close[n - 2] && close[n - 1] < open[n - 1] && solid) turned = true;
    if (!goShort && close[n - 1] > close[n - 2] && close[n - 1] > open[n - 1] && solid) turned = true;
}

let state;
let stateColor;
let headline;

if (!isIntraday) {
    state = 'NO TRADE'; stateColor = GREY; headline = 'INTRADAY ONLY - use 5 to 15 min';
} else if (!hasCharm) {
    state = 'NO TRADE'; stateColor = GREY; headline = 'NO TRADE - no options data';
} else if (nearDTE > maxDTE) {
    state = 'NO TRADE'; stateColor = GREY; headline = 'NO TRADE - no near expiry';
} else if (nearDTE > maxFrontDTE) {

    state = 'NO TRADE'; stateColor = GREY; headline = 'NO TRADE - front expiry ' + nearDTE + 'DTE, no close pull';
} else if (frontShare < minFrontPull) {

    state = 'NO TRADE'; stateColor = GREY; headline = 'NO TRADE - front holds only ' + frontShare.toFixed(0) + '% of pull';
} else if (absStretch > maxPull) {
    state = 'NO TRADE'; stateColor = GREY; headline = 'NO TRADE - magnet ' + absStretch.toFixed(1) + '% away';
} else if (sess < startAfter) {
    state = 'NO TRADE'; stateColor = GREY; headline = 'TOO EARLY - charm builds later';
} else if (absStretch < minStretch) {
    state = 'NO TRADE'; stateColor = GREY; headline = 'AT MAGNET - nothing to fade';
} else if (rrRatio < minRR) {
    state = 'NO TRADE'; stateColor = GREY; headline = 'NO TRADE - R:R only ' + rrRatio.toFixed(1);
} else if (!turned) {
    state = 'WAIT'; stateColor = AMBER;
    headline = 'WAIT for ' + (goShort ? 'a red bar to SHORT' : 'a green bar to LONG');
} else {
    state = goShort ? 'SHORT' : 'LONG';
    stateColor = goShort ? RED : GREEN;
    headline = state + ' NOW -> ' + magnet.toFixed(2);
}

const live = state === 'LONG' || state === 'SHORT';
const planned = live || state === 'WAIT';

const halfBand = atrNow * bandAtr;

for (let b = 0; b < 10; b++) {
    const hiArr = Array(n).fill(null);
    const loArr = Array(n).fill(null);
    let op = 0;
    if (b < bands.length && isIntraday) {
        const k = bands[b].k;
        let intensity = maxW > 0 ? bands[b].eff / maxW : 0;
        intensity = intensity * intensity;
        const timeBoost = 0.7 + 0.3 * (sess / 100);
        op = 0.02 + 0.55 * intensity * timeBoost;
        for (let i = 0; i < n; i++) {
            hiArr[i] = k + halfBand;
            loArr[i] = k - halfBand;
        }
    }
    const hId = paint(hiArr, { name: 'Pull' + (b + 1) + 'H', color: PURPLE, style: 'dotted', thickness: 1, hidden: true });
    const lId = paint(loArr, { name: 'Pull' + (b + 1) + 'L', color: PURPLE, style: 'dotted', thickness: 1, hidden: true });
    fill(hId, lId, PURPLE, op);
}

let topIdx = -1;
let topEff = 0;
for (let b = 0; b < bands.length; b++) {
    if (bands[b].eff > topEff) { topEff = bands[b].eff; topIdx = b; }
}
const tHi = Array(n).fill(null);
if (topIdx >= 0 && isIntraday) {
    const tk = bands[topIdx].k;
    for (let i = 0; i < n; i++) tHi[i] = tk + halfBand;
}
const tId = paint(tHi, { name: 'Strongest Pull', color: PURPLE, style: 'dotted', thickness: 1, hidden: true });

const magId = paint(series_of(hasCharm && isIntraday ? magnet : null), { name: 'Charm Magnet', color: stateColor, style: 'line', thickness: 3 });
const stopId = paint(series_of(planned ? stopPx : null), { name: 'Stop Loss', color: RED, style: 'dotted', thickness: 2 });
const entId = paint(series_of(live ? spot : null), { name: 'Entry', color: stateColor, style: 'dotted', thickness: 1 });

const projBars = 30;
function projectionOf(value) {
    const arr = [];
    for (let i = 0; i < projBars; i++) arr.push(value);
    return arr;
}
const magP = paint_projection(projectionOf(hasCharm && isIntraday ? magnet : null), {
    name: 'Magnet Ahead', color: stateColor, style: 'line', thickness: 3, ignoreWhenScaling: true, hideInLegend: true
});
const stopP = paint_projection(projectionOf(planned ? stopPx : null), {
    name: 'Stop Ahead', color: RED, style: 'dotted', thickness: 2, ignoreWhenScaling: true, hideInLegend: true
});
const entP = paint_projection(projectionOf(live ? spot : null), {
    name: 'Entry Ahead', color: stateColor, style: 'dotted', thickness: 1, ignoreWhenScaling: true, hideInLegend: true
});

let tgtBg = 'rgba(158,158,158,0.8)';
if (stateColor === GREEN) tgtBg = 'rgba(0,200,83,0.85)';
if (stateColor === RED) tgtBg = 'rgba(229,57,53,0.85)';
if (stateColor === AMBER) tgtBg = 'rgba(255,160,0,0.85)';

const strP = paint_projection(projectionOf(topIdx >= 0 && isIntraday ? bands[topIdx].k : null), {
    name: 'Strongest Pull Ahead', color: PURPLE, style: 'dotted', thickness: 1, ignoreWhenScaling: true, hideInLegend: true
});

let visHi = -1e12;
let visLo = 1e12;
let visFrom = n - 250;
if (visFrom < 0) visFrom = 0;
for (let i = visFrom; i < n; i++) {
    if (high[i] > visHi) visHi = high[i];
    if (low[i] < visLo) visLo = low[i];
}
const tagSep = visHi > visLo ? (visHi - visLo) * 0.045 : spot * 0.002;
const tagItems = [];
if (hasCharm && isIntraday) tagItems.push({ px: magnet, ref: magP, text: 'MAGNET ' + magnet.toFixed(2), bg: tgtBg });
if (planned) tagItems.push({ px: stopPx, ref: stopP, text: 'Stop ' + stopPx.toFixed(2), bg: 'rgba(229,57,53,0.6)' });
if (live) tagItems.push({ px: spot, ref: entP, text: (goShort ? 'Short Entry ' : 'Long Entry ') + spot.toFixed(2), bg: 'rgba(66,66,66,0.6)' });
if (topIdx >= 0 && isIntraday) tagItems.push({ px: bands[topIdx].k, ref: strP, text: 'strongest drag ' + bands[topIdx].k.toFixed(2), bg: 'rgba(123,31,162,0.8)' });
tagItems.sort((x, y) => y.px - x.px);
const tagGroups = [];
for (let i = 0; i < tagItems.length; i++) {
    const it = tagItems[i];
    const last = tagGroups.length > 0 ? tagGroups[tagGroups.length - 1] : null;
    if (last !== null && Math.abs(last.anchorPx - it.px) < tagSep) last.text = last.text + '  u00b7  ' + it.text;
    else tagGroups.push({ anchorPx: it.px, ref: it.ref, text: it.text, bg: it.bg });
}
for (let g = 0; g < tagGroups.length; g++) {
    paint_label_at_line(tagGroups[g].ref, 10, tagGroups[g].text, {
        color: '#ffffff', background_color: tagGroups[g].bg, border_radius: 4, border_width: 0
    });
}

const trigUp = Array(n).fill(null);
const trigDn = Array(n).fill(null);
if (live) {
    const pad = atrNow > 0 ? atrNow * 0.45 : spot * 0.001;
    const mk = {
        enabled: true,
        radius: 9,
        symbol: goShort ? 'triangle-down' : 'triangle',
        fillColor: stateColor,
        lineColor: '#ffffff',
        lineWidth: 2
    };
    if (goShort) trigDn[lastBar] = { y: high[lastBar] + pad, marker: mk };
    else trigUp[lastBar] = { y: low[lastBar] - pad, marker: mk };
}
paint(trigUp, { name: 'Long Trigger', color: 'transparent' });
paint(trigDn, { name: 'Short Trigger', color: 'transparent' });

const longSignal = series_of(false);
const shortSignal = series_of(false);
longSignal[lastBar] = live && !goShort;
shortSignal[lastBar] = live && goShort;
register_signal(longSignal, 'Charm Magnet Long');
register_signal(shortSignal, 'Charm Magnet Short');

let sessTxt = 'OPEN';
if (sess > 85) sessTxt = 'FINAL HOUR';
else if (sess > 60) sessTxt = 'AFTERNOON';
else if (sess > 35) sessTxt = 'MIDDAY';
else if (sess > 10) sessTxt = 'MORNING';

function headerCell(text, bg) {
    return {
        text: text, colspan: 2, color: '#ffffff', background: bg,
        padding: '6px 8px', fontSize: '11px', fontWeight: 'bold',
        textAlign: 'center', whiteSpace: 'nowrap'
    };
}
function keyCell(text) {
    return {
        text: text, color: '#8b97a4', background: 'rgba(128,128,128,0.10)',
        border: 'solid var(--border-color) 1px', padding: '4px 8px', fontSize: '10px',
        fontWeight: 'bold', textAlign: 'left', whiteSpace: 'nowrap'
    };
}
function valCell(text, color) {
    return {
        text: text, color: color || 'var(--text-color)', background: 'var(--background-color)',
        border: 'solid var(--border-color) 1px', padding: '4px 8px', fontSize: '10px',
        fontWeight: 'bold', textAlign: 'right', whiteSpace: 'nowrap'
    };
}

let reason = headline;
if (reason.indexOf(state + ' - ') === 0) reason = reason.slice(state.length + 3);
else if (reason.indexOf(state + ' ') === 0) reason = reason.slice(state.length + 1);

const rows = [];
rows.push({ cells: [headerCell('WINVEST CHARM CLOSE MAGNET', 'linear-gradient(90deg,#37474f,#546e7a)')] });
rows.push({ cells: [headerCell(state, stateColor === GREY ? '#78909c' : stateColor)] });
rows.push({ cells: [{
    text: reason, colspan: 2, color: stateColor === GREY ? '#8b97a4' : stateColor,
    background: 'var(--background-color)', border: 'solid var(--border-color) 1px',
    padding: '4px 8px', fontSize: '10px', fontWeight: 'bold',
    textAlign: 'center', whiteSpace: 'nowrap'
}] });

if (!planned && hasCharm && isIntraday) {
    rows.push({ cells: [keyCell('MAGNET'), valCell(magnet.toFixed(2) + '  u00b7  ' + absStretch.toFixed(2) + '% away', PURPLE)] });
}
if (planned) {
    rows.push({ cells: [keyCell('MAGNET'), valCell(magnet.toFixed(2), stateColor)] });
    rows.push({ cells: [keyCell('STOP'), valCell(stopPx.toFixed(2), RED)] });
    rows.push({ cells: [keyCell('R:R'), valCell(rrRatio.toFixed(1) + ' to 1', rrRatio >= 2 ? GREEN : 'var(--text-color)')] });
}

if (isIntraday) {
    rows.push({ cells: [headerCell('OPTIONS BOOK', 'linear-gradient(90deg,#4a148c,#7b1fa2)')] });
    if (hasCharm) {

        let shareTxt = frontShare.toFixed(0) + '% of pull';
        if (frontShare > 0 && frontShare < 1) shareTxt = 'under 1% of pull';
        rows.push({ cells: [keyCell('FRONT EXPIRY'), valCell(nearDTE + 'DTE  u00b7  ' + shareTxt, frontShare >= minFrontPull ? GREEN : AMBER)] });
        rows.push({ cells: [keyCell('DECAY'), valCell(shortNum(flowShares) + ' sh to expiry', PURPLE)] });
        rows.push({ cells: [keyCell('BOOK'), valCell(pullWord, PURPLE)] });
    } else {
        rows.push({ cells: [keyCell('CHAIN'), valCell('no options data', GREY)] });
    }
    rows.push({ cells: [keyCell('SESSION'), valCell(sessTxt, sess > 85 ? GREEN : 'var(--text-color)')] });
    const barMin = minStep / 60000;
    const sessMin = sessionBars * barMin;
    if (sessMin >= 120) {
        const toFinal = Math.round(sessMin - 90 - barsToday * barMin);
        let winTxt = 'final 90 min - charm peaks';
        let winColor = GREEN;
        if (toFinal > 0) {
            const hh = Math.floor(toFinal / 60);
            const mm = toFinal % 60;
            winTxt = 'charm builds in ' + (hh > 0 ? hh + 'h ' : '') + mm + 'm';
            winColor = AMBER;
        }
        rows.push({ cells: [keyCell('WINDOW'), valCell(winTxt, winColor)] });
    }
}

paint_overlay('Charm', {
    position: 'top_right',
    parent: 'chart',
    order: 'above_all',
    offset_x: -60,
    offset_y: 30
}, {
    width: '250px',
    borderCollapse: 'collapse',
    background: 'var(--background-color)',
    border: 'solid var(--border-color) 1px',
    borderRadius: '8px',
    boxShadow: '0 3px 12px rgba(0,0,0,0.18)',
    overflow: 'hidden',
    rows: rows
});