// @ts-nocheck
/**
 * Sweep-detection liquidity moat validation.
 *
 * Instead of a global or round-conditional moat, only apply the behind-zone
 * moat ($25) when market microstructure suggests stop-hunting is active.
 *
 * Detection methods tested:
 *   1. adverse-wick ratio over recent bars (5/10/15 bar lookback)
 *   2. price clustering near the round level (hovering without breaking)
 *   3. ATR spike with flat net movement (absorption/accumulation)
 *   4. combined wick + ATR signal
 *
 * Current production baseline mirrored:
 *   L16/S8 cap420 wait30 minSoft25 roundMoat=$25 adaptive-imm clamp(4,10,0.75*ATR60)
 *   realistic maker-SL model.
 */
import fs from 'node:fs'
import readline from 'node:readline'

type Bar={ts:number;o:number;h:number;l:number;c:number;v?:number}
type Side='long'|'short'
type Trade={gross:number;net:number;month:string;side:Side;bar:number;sizingBps:number;softSlBps:number;isSl:boolean;isHardSl:boolean;reason:string;hold:number;moatUsd:number;sweepFlag:boolean}
type Cfg={label:string;mode:'none'|'global'|'round'|'wick'|'cluster'|'absorp'|'wickAtr';wickBars?:number;wickThresh?:number;clusterBars?:number;clusterDist?:number;absorpAtr?:number;absorpNet?:number}

async function loadBars(files:string[]):Promise<Bar[]>{const seen=new Set<number>(),all:Bar[]=[];for(const f of files){if(!fs.existsSync(f))continue;const rl=readline.createInterface({input:fs.createReadStream(f),crlfDelay:Infinity});for await(const line of rl){if(!line.trim())continue;const b=JSON.parse(line);if(!seen.has(b.ts)){seen.add(b.ts);all.push(b)}}}all.sort((a,b)=>a.ts-b.ts);return all}
function lbRet(bars:Bar[],i:number,n:number){return i>=n&&bars[i-n].c>0?(bars[i].c-bars[i-n].c)/bars[i-n].c*10000:0}
function closePos(b:Bar){return b.h>b.l?(b.c-b.l)/(b.h-b.l):0.5}
function avgCP(bars:Bar[],i:number,n:number){let s=0;for(let j=Math.max(0,i-n+1);j<=i;j++)s+=closePos(bars[j]);return s/Math.min(n,i+1)}
function rsi(bars:Bar[],i:number,n:number){if(i<n)return 50;let u=0,d=0;for(let j=i-n+1;j<=i;j++){const x=bars[j].c-bars[j-1].c;if(x>0)u+=x;else d-=x}return d===0?100:100-100/(1+u/d)}
function atrBps(bars:Bar[],i:number,n:number){let s=0,c=0;for(let j=Math.max(1,i-n+1);j<=i;j++){const prev=bars[j-1].c;const tr=Math.max(bars[j].h-bars[j].l,Math.abs(bars[j].h-prev),Math.abs(bars[j].l-prev));s+=tr/bars[j].c*10000;c++}return c?s/c:0}
function computeScore(bars:Bar[],i:number){const d=new Date(bars[i].ts),h=d.getUTCHours(),m=d.getUTCMinutes(),dow=d.getUTCDay();let w=0,hW=0,tW=0;const add=(dir:number,wt:number,hold:number)=>{w+=dir*wt;hW+=hold*wt;tW+=wt};if(dow===4)add(-1,20,240);if(dow===3)add(1,19.05,240);if(dow===0)add(1,15.05,240);if(dow===1)add(1,10.61,240);if(dow===5)add(-1,6,240);if(h===22)add(1,2.86,120);if(h===21)add(1,17.9,120);if(h===20)add(1,9.49,60);if(h===23)add(-1,8.94,30);if(i>=60){const bp=avgCP(bars,i,60);if(bp>0.58)add(1,15.49,240);if(bp<0.42)add(-1,8,240)}if(i>=60&&(h===13||(h===14&&m<=15))&&m<=15){const gap=lbRet(bars,i,60);if(Math.abs(gap)>5)add(gap>0?-1:1,15,120)}if(i>=60){const rv=rsi(bars,i,60);if(rv<30)add(1,4.63,120)}if(i>=1440){const dy=lbRet(bars,i,1440);if(Math.abs(dy)>30)add(dy>0?-1:1,5,240)}return{score:w,hold:tW>0?Math.max(60,Math.min(240,Math.round(hW/tW))):120}}

const SWING_N=5,SWING_LOOKBACK=480
function compositeLiquidityLevel(bars:Bar[],i:number,dir:1|-1,refPrice?:number){const N=i+1,price=refPrice??bars[i].c,candidates:number[]=[];candidates.push(dir>0?Math.floor(price/1000)*1000:Math.ceil(price/1000)*1000,dir>0?Math.floor(price/500)*500:Math.ceil(price/500)*500,dir>0?Math.floor(price/250)*250:Math.ceil(price/250)*250);let bestSwing:number|null=null,bestDist=Infinity;const scanEnd=N-SWING_N-1,scanStart=Math.max(SWING_N,N-SWING_LOOKBACK-SWING_N);for(let j=scanEnd;j>=scanStart;j--){let ok=true;for(let k=1;k<=SWING_N&&ok;k++){if(dir>0){if((bars[j-k]?.l??Infinity)<=bars[j].l)ok=false;if((bars[j+k]?.l??Infinity)<=bars[j].l)ok=false}else{if((bars[j-k]?.h??0)>=bars[j].h)ok=false;if((bars[j+k]?.h??0)>=bars[j].h)ok=false}}if(!ok)continue;const lvl=dir>0?bars[j].l:bars[j].h;if(dir>0?lvl>=price:lvl<=price)continue;const dist=Math.abs(price-lvl);if(dist<bestDist){bestDist=dist;bestSwing=lvl}}if(bestSwing!==null)candidates.push(bestSwing);const d=new Date(bars[N-1].ts);d.setUTCHours(0,0,0,0);const today=d.getTime();let pdH=0,pdL=Infinity;for(let j=N-1;j>=0;j--){if(bars[j].ts>=today)continue;if(bars[j].ts<today-86400000)break;if(bars[j].h>pdH)pdH=bars[j].h;if(bars[j].l<pdL)pdL=bars[j].l}if(dir>0&&pdL<price&&pdL>0)candidates.push(pdL);if(dir<0&&pdH>price&&pdH>0)candidates.push(pdH);const volMap=new Map<number,number>();for(let j=N-1;j>=0&&bars[j].ts>=today;j--){const bucket=Math.round(bars[j].c/50)*50;volMap.set(bucket,(volMap.get(bucket)??0)+(bars[j].v??0))}let maxV=0,poc=0;volMap.forEach((v,p)=>{if(v>maxV){maxV=v;poc=p}});if(poc>0&&(dir>0?poc<price:poc>price))candidates.push(poc);const valid=candidates.filter(l=>dir>0?l<=price:l>=price);if(!valid.length)return dir>0?Math.floor(price/250)*250:Math.ceil(price/250)*250;return valid.reduce((b,c)=>Math.abs(price-c)<Math.abs(price-b)?c:b)}
function stopCandidates(bars:Bar[],i:number,dir:1|-1,refPrice:number){const N=i+1,price=refPrice;const c=[dir>0?Math.floor(price/1000)*1000:Math.ceil(price/1000)*1000,dir>0?Math.floor(price/500)*500:Math.ceil(price/500)*500,dir>0?Math.floor(price/250)*250:Math.ceil(price/250)*250];const d=new Date(bars[N-1].ts);d.setUTCHours(0,0,0,0);const today=d.getTime();let pdH=0,pdL=Infinity;for(let j=N-1;j>=0;j--){if(bars[j].ts>=today)continue;if(bars[j].ts<today-86400000)break;if(bars[j].h>pdH)pdH=bars[j].h;if(bars[j].l<pdL)pdL=bars[j].l}if(dir>0&&pdL<price&&pdL>0)c.push(pdL);if(dir<0&&pdH>price&&pdH>0)c.push(pdH);return[...new Set(c)].filter(l=>dir>0?l<=price:l>=price).sort((a,b)=>Math.abs(price-a)-Math.abs(price-b))}
function stopLiquidityLevel(bars:Bar[],i:number,dir:1|-1,refPrice:number,minBps=0){const c=stopCandidates(bars,i,dir,refPrice),price=refPrice;const v=minBps>0?c.filter(l=>Math.abs(price-l)/price*10000>=minBps):c;if(v.length)return v[0];if(c.length)return c.at(-1)!;return dir>0?Math.floor(price/250)*250:Math.ceil(price/250)*250}
function isRoundStopZone(level:number){return Number.isFinite(level)&&Math.abs(level/250-Math.round(level/250))*250<=0.05}
function moveAwayUsd(px:number,dir:1|-1,usd:number){return dir>0?px-usd:px+usd}
function moveAway(px:number,dir:1|-1,bps:number){return dir>0?px*(1-bps/10000):px*(1+bps/10000)}
function distBps(a:number,b:number){return Math.abs(a-b)/a*10000}
function crossedClose(b:Bar,dir:1|-1,px:number){return dir>0?b.c<=px:b.c>=px}
function touchedHard(b:Bar,dir:1|-1,px:number){return dir>0?b.l<=px:b.h>=px}
function reclaimedFill(b:Bar,dir:1|-1,px:number){return dir>0?b.h>=px:b.l<=px}
function pnlBps(dir:1|-1,entry:number,exit:number){return dir*(exit-entry)/entry*10000}
function clamp(n:number,lo:number,hi:number){return Math.max(lo,Math.min(hi,n))}

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

/** Price clustering: average distance from recent bars' closes to a round level. */
function clusterDist(bars:Bar[],i:number,n:number,level:number):number{
  let s=0;for(let j=Math.max(0,i-n+1);j<=i;j++)s+=Math.abs(bars[j].c-level)
  return s/Math.min(n,i+1)
}

/** Absorption: high ATR but flat net movement over n bars, bps-form. */
function absorptionScore(bars:Bar[],i:number,n:number):{atr:number;netAbs:number;score:number}{
  const atr=atrBps(bars,i,n)
  const netAbs=Math.abs(lbRet(bars,i,n))
  return{atr,netAbs,score:atr>0&&netAbs>0?atr/Math.max(0.01,netAbs):0}
}

function isSweepZone(bars:Bar[],i:number,dir:1|-1,l2Level:number,cfg:Cfg):boolean{
  if(cfg.mode==='none'||cfg.mode==='global')return cfg.mode==='global'
  if(cfg.mode==='round')return isRoundStopZone(l2Level)
  if(cfg.mode==='wick'){
    const w=adverseWickRatio(bars,i,cfg.wickBars??10,dir)
    return w>=(cfg.wickThresh??0.6)
  }
  if(cfg.mode==='cluster'){
    const d=clusterDist(bars,i,cfg.clusterBars??20,l2Level)
    const distUsd=d
    return distUsd<=(cfg.clusterDist??30)
  }
  if(cfg.mode==='absorp'){
    const a=absorptionScore(bars,i,12)
    return a.atr>=(cfg.absorpAtr??6)&&a.netAbs<=(cfg.absorpNet??3)
  }
  if(cfg.mode==='wickAtr'){
    const w=adverseWickRatio(bars,i,cfg.wickBars??10,dir)
    const atr=atrBps(bars,i,12)
    return w>=(cfg.wickThresh??0.55)&&atr>=(cfg.absorpAtr??6)
  }
  return false
}

function buildStops(bars:Bar[],i:number,dir:1|-1,target:number,cfg:Cfg){
  const slRef=dir>0?target-0.01:target+0.01
  const baseL2=stopLiquidityLevel(bars,i,dir,slRef,25)
  const baseBps=distBps(target,baseL2)
  const isStruct=baseBps>=15&&baseBps<=200
  const currentSoft=isStruct?baseL2:(dir>0?target*0.99:target*1.01)
  const sweep=isStruct&&isSweepZone(bars,i,dir,baseL2,cfg)
  const moat=sweep?25:0
  const soft=moat>0?moveAwayUsd(currentSoft,dir,moat):currentSoft
  const l3ref=dir>0?soft-0.01:soft+0.01
  const l3=stopLiquidityLevel(bars,i,dir,l3ref,0)
  const gap=Math.max(10,Math.min(100,distBps(soft,l3)))
  const hard=isStruct?moveAway(soft,dir,gap):(dir>0?target*(1-1.25/100):target*(1+1.25/100))
  return{softPx:soft,hardPx:hard,softBps:distBps(target,soft),hardBps:distBps(target,hard)+8,sizingBps:isStruct?distBps(target,hard):125,moatUsd:moat,sweepFlag:sweep}
}

function entryImmBps(bars:Bar[]):number{
  const atr60=atrBps(bars,bars.length-1,60)
  return clamp(atr60*0.75,4,10)
}

function simulate(bars:Bar[],cfg:Cfg,startIdx=20160,endIdx=bars.length):Trade[]{const trades:Trade[]=[],stopCounts=new Map<string,number>();let cooldown=Math.max(startIdx,20160);const EXT=8,MIN_H=60,MAX_H=240,CAP=420,CONFIRM=4,SL_CD=30,MAKER_WAIT=30,N=bars.length;const first=Math.max(startIdx,20160),last=Math.min(endIdx,N-60);for(let i=first;i<last;i++){if(i<cooldown)continue;const {score,hold}=computeScore(bars,i);if(score===0)continue;const dir:1|-1=score>0?1:-1,side:Side=dir>0?'long':'short',thresh=dir>0?16:8;if(Math.abs(score)<thresh)continue;let confirmed=true;for(let k=1;k<CONFIRM;k++){const sk=computeScore(bars,i-k).score;if(sk*dir<thresh){confirmed=false;break}}if(!confirmed)continue;if(dir*lbRet(bars,i,4320)<-600)continue;if(dir*lbRet(bars,i,10080)<-700)continue;if(dir*lbRet(bars,i,20160)<-800)continue;const day=new Date(bars[i].ts).toISOString().slice(0,10),ck=`${day}_${side}`;if((stopCounts.get(ck)??0)>=2)continue;const target=compositeLiquidityLevel(bars,i,dir);const immBps=entryImmBps(bars);const d0=distBps(bars[i].c,target);let entryPx=bars[i].c,entryBar=i,filled=d0<=immBps;if(!filled){for(let j=i+1;j<=Math.min(N-1,i+30);j++){const hit=dir>0?bars[j].l<=target:bars[j].h>=target;if(hit){filled=true;entryBar=j;entryPx=target;break}}}if(!filled)continue;const st=buildStops(bars,i,dir,target,cfg);if(st.softBps<5||st.sizingBps<15||st.sizingBps>350)continue;const hardCap=i+CAP;let deadline=i+Math.max(MIN_H,Math.min(MAX_H,hold));let exitBar=-1,isSl=false,isHardSl=false,gross=0,reason='time';for(let j=entryBar+1;j<Math.min(N,hardCap+1);j++){if(touchedHard(bars[j],dir,st.hardPx)){exitBar=j;isSl=true;isHardSl=true;reason='hard_sl';gross=-st.hardBps;break}if(crossedClose(bars[j],dir,st.softPx)){isSl=true;let done=false;for(let k=j+1;k<=Math.min(N-1,j+MAKER_WAIT,hardCap);k++){if(touchedHard(bars[k],dir,st.hardPx)){exitBar=k;isHardSl=true;reason='hard_sl_after_soft';gross=-st.hardBps;done=true;break}if(reclaimedFill(bars[k],dir,st.softPx)){exitBar=k;reason='soft_maker_fill';gross=pnlBps(dir,entryPx,st.softPx);done=true;break}}if(!done){exitBar=Math.min(N-1,j+MAKER_WAIT,hardCap);reason='soft_timeout';gross=pnlBps(dir,entryPx,bars[exitBar].c)}break}if(j>=i+MIN_H){const es=computeScore(bars,j);if(es.score*dir>=EXT){const proposed=Math.min(j+Math.max(MIN_H,Math.min(MAX_H,es.hold)),hardCap);if(proposed>deadline)deadline=proposed}}if(j>=deadline){exitBar=j;reason='time';gross=pnlBps(dir,entryPx,bars[j].c);break}}if(exitBar<0){exitBar=Math.min(N-2,hardCap);reason='cap';gross=pnlBps(dir,entryPx,bars[exitBar].c)}const dt=new Date(bars[i].ts);trades.push({gross,net:gross,reason,isSl,isHardSl,month:dt.toISOString().slice(0,7),side,bar:i,softSlBps:st.softBps,sizingBps:st.sizingBps,hold:exitBar-entryBar,moatUsd:st.moatUsd,sweepFlag:st.sweepFlag});if(isSl)stopCounts.set(ck,(stopCounts.get(ck)??0)+1);cooldown=exitBar+(isSl?SL_CD:5)}return trades}

function stats(v:number[]){const n=v.length;if(n<3)return{n,mean:0,sd:0,t:0,wr:0};const mean=v.reduce((a,b)=>a+b,0)/n;const sd=Math.sqrt(v.reduce((s,x)=>s+(x-mean)**2,0)/(n-1));return{n,mean,sd,t:sd>0?mean/(sd/Math.sqrt(n)):0,wr:v.filter(x=>x>0).length/n}}
function sig(t:number){const a=Math.abs(t);return a>3.89?'****':a>3.29?'***':a>2.58?'**':a>1.96?'*':''}
function monthly(tr:Trade[],fixed=1500){const m:Record<string,number>={};for(const t of tr)m[t.month]=(m[t.month]??0)+fixed*t.net/10000;return m}
function actualMonthly(tr:Trade[],acct=10000,risk=0.03){const m:Record<string,number>={};for(const t of tr){const notional=acct*risk/(t.sizingBps/10000);m[t.month]=(m[t.month]??0)+notional*t.net/10000}return m}
function ir(vals:number[]){if(vals.length<3)return 0;const s=stats(vals);return s.sd>0?s.mean/s.sd:0}
function moIR(tr:Trade[]){return ir(Object.values(monthly(tr)))}
function actualIR(tr:Trade[]){return ir(Object.values(actualMonthly(tr)))}
function maxDD(tr:Trade[],risk=0.03){let eq=500,pk=500,dd=0;for(const t of tr){eq+=eq*risk/(t.sizingBps/10000)*t.net/10000;if(eq>pk)pk=eq;dd=Math.max(dd,(pk-eq)/pk)}return dd}
function totalActual(tr:Trade[]){return Object.values(actualMonthly(tr)).reduce((a,b)=>a+b,0)}
function pairedMaps(a:Record<string,number>,b:Record<string,number>){const months=[...new Set([...Object.keys(a),...Object.keys(b)])].sort();const diffs=months.map(m=>(b[m]??0)-(a[m]??0));const s=stats(diffs);return{n:diffs.length,imp:diffs.filter(x=>x>0).length,mean:s.mean,t:s.t,total:diffs.reduce((x,y)=>x+y,0)}}
function metric(label:string,tr:Trade[]){const s=stats(tr.map(t=>t.net)),mp=Object.values(monthly(tr));const sl=tr.filter(t=>t.isSl),hard=tr.filter(t=>t.isHardSl),moated=tr.filter(t=>t.moatUsd>0),swept=tr.filter(t=>t.sweepFlag);return{label,n:s.n,mean:s.mean,t:s.t,wr:s.wr,moIR:moIR(tr),actIR:actualIR(tr),maxDD:maxDD(tr),mp:mp.filter(x=>x>0).length,mt:mp.length,slRate:tr.length?sl.length/tr.length:0,hardRate:sl.length?hard.length/sl.length:0,moatRate:tr.length?moated.length/tr.length:0,sweepRate:tr.length?swept.length/tr.length:0}}
function withFee(tr:Trade[],fee:number){return tr.map(t=>({...t,net:t.gross-fee}))}
function fmt(m:ReturnType<typeof metric>,ref?:Trade[],tr?:Trade[]){let p='';if(ref&&tr){const pa=pairedMaps(actualMonthly(ref),actualMonthly(tr));p=` actualΔ=$${pa.mean.toFixed(0)}(${pa.imp}/${pa.n},t=${pa.t.toFixed(2)}) total=$${totalActual(tr).toFixed(0)}`}return`${m.label.padEnd(14)} n=${String(m.n).padStart(4)} mean=${m.mean.toFixed(2).padStart(6)} t=${m.t.toFixed(2)}${sig(m.t).padEnd(4)} WR=${(m.wr*100).toFixed(1)}% moIR=${m.moIR.toFixed(2)} actIR=${m.actIR.toFixed(2)} DD=${(m.maxDD*100).toFixed(1)}% m+=${m.mp}/${m.mt} SL=${(m.slRate*100).toFixed(1)}% hard=${(m.hardRate*100).toFixed(1)}% moat=${(m.moatRate*100).toFixed(1)}% sweep=${(m.sweepRate*100).toFixed(1)}%${p}`}
function tradeYear(t:Trade,bars:Bar[]){return new Date(bars[t.bar].ts).getUTCFullYear()}
function byYear(tr:Trade[],bars:Bar[],year:number){return tr.filter(t=>tradeYear(t,bars)===year)}
function monthList(sims:{cfg:Cfg;trades:Trade[]}[]){return [...new Set(sims.flatMap(s=>s.trades.map(t=>t.month)))].sort()}
function rollingDiff(ref:Trade[],tr:Trade[],months:string[],win:number){const a=actualMonthly(ref),b=actualMonthly(tr),diffs:number[]=[];for(let i=0;i+win<=months.length;i++){let d=0;for(let j=i;j<i+win;j++){const m=months[j];d+=(b[m]??0)-(a[m]??0)}diffs.push(d)}const s=stats(diffs);return{n:diffs.length,win:diffs.filter(x=>x>0).length,mean:s.mean,t:s.t,total:diffs.reduce((x,y)=>x+y,0)}}

;(async()=>{console.log('Loading bars...');const bars=await loadBars(['data/klines/BTCUSDT-1m-2022-2025.jsonl','data/klines/BTCUSDT-1m-2022-2025b.jsonl','data/klines/BTCUSDT-1m.jsonl']);console.log(`${bars.length.toLocaleString()} bars`);const cfgs:Cfg[]=[
  {label:'none',mode:'none'},
  {label:'global',mode:'global'},
  {label:'round',mode:'round'},
  {label:'wick10x0.60',mode:'wick',wickBars:10,wickThresh:0.60},
  {label:'wick10x0.55',mode:'wick',wickBars:10,wickThresh:0.55},
  {label:'wick15x0.55',mode:'wick',wickBars:15,wickThresh:0.55},
  {label:'wick5x0.65',mode:'wick',wickBars:5,wickThresh:0.65},
  {label:'cluster30',mode:'cluster',clusterBars:20,clusterDist:30},
  {label:'cluster50',mode:'cluster',clusterBars:20,clusterDist:50},
  {label:'absorp6_3',mode:'absorp',absorpAtr:6,absorpNet:3},
  {label:'absorp8_3',mode:'absorp',absorpAtr:8,absorpNet:3},
  {label:'wick10_Atr6',mode:'wickAtr',wickBars:10,wickThresh:0.55,absorpAtr:6},
  {label:'wick15_Atr6',mode:'wickAtr',wickBars:15,wickThresh:0.55,absorpAtr:6},
  {label:'wick5_Atr8',mode:'wickAtr',wickBars:5,wickThresh:0.60,absorpAtr:8},
];
const sims=cfgs.map(cfg=>({cfg,trades:simulate(bars,cfg)}));
for(const fee of [0,4]){
  console.log(`\n================ SWEEP-MOAT full feeRT=${fee} ================`);
  const ref=withFee(sims[0].trades,fee);
  for(const sim of sims){
    const tr=withFee(sim.trades,fee);
    console.log(fmt(metric(sim.cfg.label,tr),sim.cfg.label==='none'?undefined:ref,sim.cfg.label==='none'?undefined:tr));
  }
}
for(const fee of [0,4]){
  console.log(`\n================ YEAR SLICES feeRT=${fee} ================`);
  for(const y of [2022,2023,2024,2025,2026]){
    console.log(`\n-- ${y} --`);
    const ref=withFee(byYear(sims[0].trades,bars,y),fee);
    const best=['none','global','round','wick10x0.60','wick10x0.55','wick15x0.55','cluster30','absorp6_3','wick10_Atr6'];
    for(const sim of sims.filter(s=>best.includes(s.cfg.label))){
      const tr=withFee(byYear(sim.trades,bars,y),fee);
      const m=metric(sim.cfg.label,tr);
      console.log(`${sim.cfg.label.padEnd(14)} n=${String(m.n).padStart(4)} mean=${m.mean.toFixed(2).padStart(6)} moIR=${m.moIR.toFixed(2)} actIR=${m.actIR.toFixed(2)} DD=${(m.maxDD*100).toFixed(1).padStart(5)}% SL=${(m.slRate*100).toFixed(1).padStart(4)}% moat=${(m.moatRate*100).toFixed(1).padStart(5)}% act=$${totalActual(tr).toFixed(0)}${sim.cfg.label==='none'?'':` Δ=$${(totalActual(tr)-totalActual(ref)).toFixed(0)}`}`);
    }
  }
}
for(const fee of [0,4]){
  console.log(`\n================ ROLLING WINDOW ACTUAL Δ vs none feeRT=${fee} ================`);
  const months=monthList(sims);
  const ref=withFee(sims[0].trades,fee);
  for(const win of [3,6,12]){
    console.log(`\n-- ${win}m windows --`);
    for(const sim of sims.filter(s=>s.cfg.label!=='none')){
      const tr=withFee(sim.trades,fee);
      const r=rollingDiff(ref,tr,months,win);
      console.log(`${sim.cfg.label.padEnd(14)} win=${r.win}/${r.n} meanΔ=$${r.mean.toFixed(0)} t=${r.t.toFixed(2)} totalΔ=$${r.total.toFixed(0)}`);
    }
  }
}
})().catch(e=>{console.error(e);process.exit(1)})
