/* ============================================================
zengtrade · Crypto Algo Studio
Regime-aware systematic trading on live Binance prices (24/7)
============================================================ */
'use strict';
const CRYPTO_ONLY = !!window.ZENG_CRYPTO_ONLY;
/* ---------- data ---------- */
// The watchlist is now a UNIVERSAL instrument list, any segment (NSE/BSE equity & indices,
// NFO/BFO futures & options, MCX commodities, CDS currency). These 10 are the default seed;
// the user can add ANY instrument via the universal search (top bar or "Add scrip"). Each item
// carries exch/type/key/token so quotes, streaming and charts address it across exchanges.
let SYMS = [
{sym:'MARUTI', name:'Maruti Suzuki', ltp:12480.0,chg:1.1, beta:1.05, sector:'Auto'},
{sym:'INFY', name:'Infosys', ltp:1845.6, chg:2.1, beta:0.88, sector:'IT', hold:{qty:25, avg:1690}},
{sym:'SBIN', name:'State Bank', ltp:842.3, chg:1.6, beta:1.18, sector:'PSU Bank', hold:{qty:60, avg:705}},
{sym:'RELIANCE', name:'Reliance Ind', ltp:2945.5, chg:1.2, beta:1.05, sector:'Energy',hold:{qty:10, avg:2810}},
{sym:'ICICIBANK', name:'ICICI Bank', ltp:1234.8, chg:0.9, beta:1.02, sector:'Banks'},
{sym:'HDFCBANK', name:'HDFC Bank', ltp:1678.2, chg:0.4, beta:0.98, sector:'Banks'},
{sym:'ITC', name:'ITC Ltd', ltp:438.9, chg:-0.3, beta:0.64, sector:'FMCG'},
{sym:'TCS', name:'TCS', ltp:3890.0, chg:-0.6, beta:0.72, sector:'IT'},
{sym:'BAJFINANCE', name:'Bajaj Finance', ltp:6890.5, chg:-1.9, beta:1.25, sector:'NBFC', hold:{qty:5, avg:7350}},
{sym:'ADANIENT', name:'Adani Ent', ltp:2456.0, chg:-2.8, beta:1.66, sector:'Energy',hold:{qty:8, avg:2720}},
];
SYMS.forEach(s=>{ s.exch='NSE'; s.type='EQ'; s.key='NSE:'+s.sym; }); // seed = NSE equities
const SEED_SYMS = SYMS.map(s=>s.sym);
// ---- universal-instrument helpers ----
const itemKey = s => s.key || (s.exch?s.exch:'NSE')+':'+s.sym; // EXCH:TS identity for data
const watchKeys = () => SYMS.map(itemKey);
function byKey(key){ return SYMS.find(s=>itemKey(s)===key); }
const isEq = s => (s.type||'EQ')==='EQ'; // analytics widgets are equity-only
const TYPE_LABEL = {EQ:'Equity',FUT:'Future',CE:'Call',PE:'Put',CUR:'Currency'};
function instSub(s){ // watchlist subtitle per segment
if(isEq(s) && s.sector) return s.sector;
const t=TYPE_LABEL[s.type]||s.type||'';
const exp=s.expiry?(' · '+new Date(s.expiry).toLocaleDateString('en-IN',{day:'2-digit',month:'short'})):'';
const k=(s.type==='CE'||s.type==='PE')&&s.strike?(' '+Math.round(s.strike)):'';
return `${s.exch||'NSE'} · ${t}${k}${exp}`;
}
/* ---------- helpers ---------- */
const $ = id => document.getElementById(id);
const clamp=(v,a,b)=>Math.max(a,Math.min(b,v));
// Formatters are null/NaN-safe: missing live data renders an honest ", " / neutral, never "₹NaN", "NaN%", or a false green.
const inr=n=>Number.isFinite(n)?'₹'+Math.round(n).toLocaleString('en-IN'):'-';
const inrL=n=>Number.isFinite(n)?'₹'+(n/100000).toFixed(2)+'L':'-';
const pct=n=>Number.isFinite(n)?(n>=0?'+':'')+n.toFixed(2)+'%':'-';
const cls=n=>Number.isFinite(n)?(n>=0?'up':'down'):'';
const tone=n=>n>0?'up':n<0?'down':'';
const bySym=s=>SYMS.find(x=>x.sym===s||itemKey(x)===s);
/* ---------- icon set (monochrome, currentColor) ---------- */
const ICONS={
bull:'',
bear:'',
neutral:'',
search:'',
plus:'',
minus:'',
expand:'',
compress:'',
close:'',
sliders:'',
shield:'',
scale:'',
scissors:'',
droplet:'',
trendUp:'',
trendDown:'',
clock:'',
target:'',
link:'',
bolt:'',
check:'',
alert:'',
swap:'',
star:'',
grip:'',
sun:'',
moon:'',
wallet:'',
sprout:'',
pie:'',
flag:'',
repeat:'',
layout:'',
spark:'',
cpu:'',
send:'',
activity:'',
download:'',
lock:'',
layers:'',
eye:'',
eyeOff:'',
};
function icon(name,size){const s=size||16;return ``;}
/* ============================================================
DETECTION ENGINE
============================================================ */
function readSignals(){return{trend:+$('sTrend').value,vix:+$('sVix').value,ad:+$('sAd').value,rsi:+$('sRsi').value,pnl:+$('sPnl').value,macd:$('macdToggle').dataset.on==='true'};}
function scoreSignals(s){return{
trend:clamp(s.trend,-100,100),
vix:clamp(Math.round((15.5-s.vix)*20),-100,100),
ad:clamp(Math.round(s.ad>=1?(s.ad-1)*75:(s.ad-1)*125),-100,100),
mom:clamp(Math.round((s.rsi-50)*3+(s.macd?15:-15)),-100,100),
pers:clamp(Math.round(s.pnl*14),-100,100),
};}
const W={trend:.30,vix:.20,ad:.20,mom:.20,pers:.10};
const composite=sc=>Math.round(sc.trend*W.trend+sc.vix*W.vix+sc.ad*W.ad+sc.mom*W.mom+sc.pers*W.pers);
function classify(S,prev){
if(prev==='bull')return S<15?(S<=-25?'bear':'neutral'):'bull';
if(prev==='bear')return S>-15?(S>=25?'bull':'neutral'):'bear';
return S>=25?'bull':S<=-25?'bear':'neutral';
}
// 4-state regime LABEL (the bot's taxonomy) ← 3-state directional THEME + VIX. The terminal skin stays 3-state
// (bull/neutral/bear); High-Vol and Choppy are richer *labels* that map onto the bear/neutral skins respectively.
const THEME_OF={Bull:'bull',Bear:'bear',Choppy:'neutral','High-Vol':'bear'}; // label → terminal theme
function regime4(theme3,vix,prevLabel){
const hi = vix>=20 || (prevLabel==='High-Vol' && vix>=18); // VIX band: enter High-Vol at 20, hold until <18 (no flicker)
if(hi) return 'High-Vol';
return theme3==='bull'?'Bull':theme3==='bear'?'Bear':'Choppy';
}
function confidence(S,sc){const sign=Math.sign(S)||1;const agree=[sc.trend,sc.vix,sc.ad,sc.mom,sc.pers].filter(v=>Math.sign(v)===sign).length/5;return Math.round((0.6*Math.min(Math.abs(S)/50,1)+0.4*agree)*100);}
function reasonText(regime,sc){
const map=[[sc.trend,'BTC '+(sc.trend>0?'above stacked MAs':'broke below key MAs')],[sc.vix,sc.vix>0?'volatility cooling':'volatility spiking'],[sc.ad,sc.ad>0?'breadth strong':'breadth collapsing'],[sc.mom,sc.mom>0?'momentum up':'momentum weak'],[sc.pers,sc.pers>0?'your P&L rising':'your portfolio drawing down']];
const want=regime==='bull'?1:-1;
return map.filter(m=>Math.sign(m[0])===want).sort((a,b)=>Math.abs(b[0])-Math.abs(a[0])).slice(0,2).map(m=>m[1]).join(', ')||'signals balanced';
}
/* ---------- live orderbook ---------- */
let ORDER_ID=104;
// Order book starts empty: no fabricated history. Real paper orders (paper:true) appear
// here as the user places them from the order pad; live orders need a connected Kite session.
const SEED_ORDERS=[];
const REGIME_SYM={bull:'RELIANCE',neutral:'HDFCBANK',bear:'ADANIENT'};
const SL_W={bull:0.025,neutral:0.018,bear:0.010};
// Curated cross-market headline tape, one ticker spanning equities, indices & commodities
const HEADLINE_INDEX=[['NIFTY 50',23450],['SENSEX',77100],['BANK NIFTY',50200],['FIN NIFTY',23010],['GOLD',71800],['SILVER',89500],['CRUDE OIL',6420]];
// Everything a user can pin to the rolling ticker: headline indices/commodities + every stock in the universe.
const TICKER_UNIVERSE=[
...HEADLINE_INDEX.map(([name,base])=>({name,base,grp:'Indices & commodities'})),
...SYMS.map(s=>({name:s.sym,base:s.ltp,grp:'Stocks'})),
];
const TICKER_DEFAULT={items:HEADLINE_INDEX.map(([n])=>n), speed:60, rolling:true}; // speed = px/sec scroll rate
/* ---------- state ---------- */
const state={mode:'auto',displayed:'bull',engine:'bull',prevVix:12.4,forceHard:false,suggesting:false,
plan:'algopro',billing:'mo', // monetization: current tier + billing cycle (persisted)
simTimer:null,panelTab:null,
selected:null, orderSide:null, orderQty:null, // order ticket
orders:SEED_ORDERS.slice(), // live orderbook
wlOrder:null, dragSym:null, // watchlist custom order
paneW:null, chartH:null, // resizing
surface:'day', tapeT:null, // floor mode + live tape
tradeFromChart:null, // bracket dragged on the chart
persona:null, // 'trader' | 'investor' (null = first run)
layout:'originals', // trader workspace preset (originals|charts|watchlist|options|futures|build)
desk:{view:'chain',under:0,exp:0,legs:[]}, // derivatives desk (ephemeral)
canvas:[], dragCv:null, // build-your-own widget canvas (persisted list of {key,span})
toolState:{}, // ephemeral per-tool UI (sub-tab/filter/draft); not persisted
widgets:null, dragWidget:null, // per-persona widget stack
revealing:false, // cinematic transition in flight
simOverride:false, // regime panel: false = mirrors live Kite signals; true = user what-if (drag/preset/demo)
regimeLabel:null, // 4-state regime label (Bull/Bear/Choppy/High-Vol) shown in the panel readout
cards:{}, // per-card min/max state
ticker:{items:TICKER_DEFAULT.items.slice(),speed:TICKER_DEFAULT.speed,rolling:TICKER_DEFAULT.rolling}, // rolling index tape (user-configurable)
lastFocus:null};
let onboarding=false; // first-run wizard in flight (keeps the persona gate open across steps)
/* ---------- persona axis (orthogonal to regime) ---------- */
const PERSONA={
trader:{ label:'Trading', icon:'trendUp', fundsLabel:r=>r==='bear'?'Cash / dry powder':'Margin available',
chartTf:'15m' },
investor:{ label:'Investing', icon:'sprout', fundsLabel:()=>'Investable surplus',
chartTf:'1D' },
algo:{ label:'Algo Studio', icon:'cpu', fundsLabel:()=>'Deployable capital',
chartTf:'15m' },
ai:{ label:'AI', icon:'spark', fundsLabel:()=>'Buying power',
chartTf:'1D' },
};
const PERSONA_KEYS=['trader','investor','algo','ai'];
/* persona × regime playbook line shown in the regime bar */
const PLAY={
trader:{
bull:`Opportunity-capture mode. Leads with breakout scans & long entries; watchlist sorted by momentum and the order pad defaults to BUY with a bracket stop.`,
neutral:`Signals are mixed. BUY and SELL stay equally weighted: trade the range and keep size light until a decisive break.`,
bear:`Capital-preservation mode. Leads with portfolio risk, hedges & stops: losers surface first and the order pad defaults to PROTECT/SELL.`,
},
investor:{
bull:`Stay-disciplined mode. Prices look extended, keep your DCA plans running and don't chase highs; that's what averaging in is for.`,
neutral:`Keep compounding. A range is ideal for cost averaging: let your DCA plans keep buying and accumulate quality dips.`,
bear:`Accumulation mode. Lower prices are a chance to keep DCA plans active, average down on quality coins. Don't panic-sell.`,
},
algo:{
bull:`Momentum regime, trend-following algos lead. Breakout & momentum strategies are favoured; keep stops trailing.`,
neutral:`Choppy regime, mean-reversion algos lead. Range & RSI strategies suit; throttle trend systems and cut size.`,
bear:`Risk-off regime, defensive & short algos lead. Tighten max-drawdown limits; the kill-switch is one tap away.`,
},
ai:{
bull:`AI copilot is watching momentum & breadth, ask for fresh ideas, screeners or a hedge. Confidence is shown on every signal.`,
neutral:`AI copilot sees a balanced tape, ask it to scan ranges, compare names or explain your portfolio.`,
bear:`AI copilot is flagging risk, ask for hedges, downside screens or a portfolio health check. Nothing trades without your confirm.`,
},
};
/* ---------- persistence (localStorage) ---------- */
const LS_KEY='tradepro.terminal.v1';
function saveState(){try{
// BUG FIX (2026-09-20): used to overwrite the whole blob unconditionally, silently dropping any
// field this function doesn't itself manage. studio.js stamps an _owner (signed-in user id) on
// this same key to stop one user's data leaking to the next person who signs in on a shared
// device, that tag has to survive every save this function does, or the very first order/tab
// switch after login would erase it. Merge onto the previous blob instead of replacing it.
let prev; try{prev=JSON.parse(localStorage.getItem(LS_KEY))||{};}catch(e){prev={};}
localStorage.setItem(LS_KEY,JSON.stringify(Object.assign({},prev,{
mode:state.mode,regime:state.displayed,surface:state.surface,regimeCollapsed:state.regimeCollapsed,
watchlist:SYMS.map(s=>({sym:s.sym,name:s.name,exch:s.exch,type:s.type,key:itemKey(s),token:s.token,sector:s.sector,beta:s.beta,lot:s.lot,expiry:s.expiry,strike:s.strike,seg:s.seg,hold:s.hold})),wlCustom:!!state.wlCustom,
selected:state.selected,paneW:state.paneW,chartH:state.chartH,
persona:state.persona,investSection:state.investSection,layout:state.layout,customLayouts:state.customLayouts,activeCustom:state.activeCustom,aiCfg:state.aiCfg,widgets:state.widgets,cards:state.cards,ticker:state.ticker,algo:(state.algo?{view:state.algo.view,exec:state.algo.exec,market:state.algo.market}:null),chart:(window.TPChart?TPChart.serialize():null),
orders:state.orders,trading:state.trading,investing:state.investing})));}catch(e){}}
function saveChart(){saveState();} // persist callback for the chart engine
function loadState(){
let s; try{s=JSON.parse(localStorage.getItem(LS_KEY));}catch(e){return null;}
if(!s||typeof s!=='object') return null;
// BUG FIX (2026-09-09): everything below reconstructs `out` from a persisted snapshot that could
// be anything (an old schema, a partially-written value, a future field). It used to have no
// enclosing try/catch, and neither does the caller at boot (line ~6976) - one unexpected shape
// anywhere in here threw uncaught and aborted the whole boot sequence mid-way, leaving the header
// chrome mounted but everything after it (including the Algo Studio) never rendered, with no
// in-app recovery. Any failure now clears the corrupted snapshot and boots fresh instead.
try{
const oneOf=(v,arr,d)=>arr.indexOf(v)>=0?v:d;
const numIn=(v,lo,hi)=>typeof v==='number'&&isFinite(v)&&v>=lo&&v<=hi?v:null;
const out={
mode:oneOf(s.mode,['auto','manual'],'auto'),
regime:oneOf(s.regime,['bull','neutral','bear'],'bull'),
surface:oneOf(s.surface,['day','night'],'day'),
regimeCollapsed:typeof s.regimeCollapsed==='boolean'?s.regimeCollapsed:undefined,
persona:oneOf(s.persona,['trader','investor','algo','ai'],null),
investSection:null,
layout:oneOf(s.layout,['originals','charts','watchlist','options','futures','build'],'originals'),
canvas:Array.isArray(s.canvas)?s.canvas.filter(c=>c&&typeof c.key==='string').map(c=>({key:c.key,span:c.span===2?2:1})):null, // legacy single canvas (migrated on boot)
customLayouts:Array.isArray(s.customLayouts)?s.customLayouts.filter(l=>l&&typeof l.id==='string'&&typeof l.name==='string').map(l=>{
const okIdx=n=>Number.isInteger(n)&&n>=0&&n<=2; // 3 underlyings (clamped, ordering-independent)
const mkCards=arr=>Array.isArray(arr)?arr.filter(c=>c&&typeof c.key==='string').map(c=>{const o={key:c.key,span:c.span===3?3:c.span===2?2:1}; if(c.grp==='A'||c.grp==='B')o.grp=c.grp; return o;}):[];
const mkSync=sy=>({A:okIdx(sy&&sy.A)?sy.A:0,B:okIdx(sy&&sy.B)?sy.B:1});
let tabs=Array.isArray(l.tabs)?l.tabs.filter(t=>t&&typeof t.id==='string').map(t=>({id:String(t.id).slice(0,40),name:(typeof t.name==='string'?t.name:'Tab').slice(0,24),cards:mkCards(t.cards),sync:mkSync(t.sync)})):[];
if(!tabs.length) tabs=[{id:'t'+Math.random().toString(36).slice(2,8),name:'Main',cards:mkCards(l.cards),sync:{A:0,B:1}}]; // migrate old flat `cards`
const activeTab=(typeof l.activeTab==='string'&&tabs.some(t=>t.id===l.activeTab))?l.activeTab:tabs[0].id;
return {id:String(l.id).slice(0,40),name:String(l.name).slice(0,40),tabs,activeTab};
}):[],
activeCustom:(typeof s.activeCustom==='string')?s.activeCustom:null,
aiCfg:(s.aiCfg&&typeof s.aiCfg==='object')?{endpoint:(typeof s.aiCfg.endpoint==='string'&&/^https?:\/\//.test(s.aiCfg.endpoint))?s.aiCfg.endpoint.slice(0,300):'',model:['claude-opus-4-8','claude-sonnet-4-6','claude-haiku-4-5'].indexOf(s.aiCfg.model)>=0?s.aiCfg.model:'claude-opus-4-8'}:null,
widgets:(()=>{const def={trader:WIDGET_DEFAULTS.trader.slice(),investor:WIDGET_DEFAULTS.investor.slice()};
if(s.widgets&&typeof s.widgets==='object')['trader','investor'].forEach(p=>{
const valid=(WIDGET_CATALOG[p]||[]).map(w=>w.key);
if(Array.isArray(s.widgets[p])){const a=[...new Set(s.widgets[p].filter(k=>valid.includes(k)))]; def[p]=a;}});
return def;})(),
cards:(()=>{const o={};if(s.cards&&typeof s.cards==='object')['watchlist','chart','panel','order','context'].forEach(k=>{o[k]=oneOf(s.cards[k],['normal','min','max'],'normal');});return o;})(),
ticker:(()=>{const d={items:TICKER_DEFAULT.items.slice(),speed:TICKER_DEFAULT.speed,rolling:true};
if(s.ticker&&typeof s.ticker==='object'){
if(Array.isArray(s.ticker.items)){const valid=TICKER_UNIVERSE.map(u=>u.name);const a=[...new Set(s.ticker.items.filter(n=>valid.includes(n)))]; if(a.length)d.items=a;}
if(typeof s.ticker.speed==='number'&&s.ticker.speed>=20&&s.ticker.speed<=180)d.speed=Math.round(s.ticker.speed);
if(typeof s.ticker.rolling==='boolean')d.rolling=s.ticker.rolling;
} return d;})(),
selected:(typeof s.selected==='string')?s.selected:null, // validated after the watchlist is rebuilt
chartH:numIn(s.chartH,120,500), wlCustom:!!s.wlCustom, paneW:null,
chart:(s.chart&&typeof s.chart==='object')?s.chart:null, // validated inside TPChart.restore
orders:Array.isArray(s.orders)?s.orders.filter(o=>o&&Number.isFinite(o.id)&&typeof o.sym==='string'&&
['buy','sell'].indexOf(o.side)>=0&&typeof o.qty==='number'&&o.qty>0&&typeof o.price==='number'&&o.price>0&&
typeof o.type==='string'&&['Filled','Open','Pending','Cancelled'].indexOf(o.status)>=0&&o.paper===true)
.slice(0,500).map(o=>({id:o.id,sym:o.sym,side:o.side,qty:o.qty,price:o.price,type:o.type,status:o.status,paper:true})):[],
trading:(s.trading&&typeof s.trading==='object')?{view:oneOf(s.trading.view,['trade','positions','history'],'trade'),
sym:CRYPTO_UNIVERSE.some(c=>c.sym===s.trading.sym)?s.trading.sym:CRYPTO_UNIVERSE[0].sym}:null,
investing:(s.investing&&typeof s.investing==='object')?{
view:oneOf(s.investing.view,['portfolio','dca','goals'],'portfolio'),
dca:Array.isArray(s.investing.dca)?s.investing.dca.filter(p=>p&&typeof p.id==='string'&&CRYPTO_UNIVERSE.some(c=>c.sym===p.sym)&&
typeof p.amount==='number'&&p.amount>0&&['weekly','biweekly','monthly'].indexOf(p.cadence)>=0)
.slice(0,50).map(p=>({id:p.id,sym:p.sym,amount:p.amount,cadence:p.cadence,active:!!p.active,
createdAt:typeof p.createdAt==='number'?p.createdAt:Date.now(),
history:Array.isArray(p.history)?p.history.filter(h=>h&&typeof h.ts==='number'&&typeof h.price==='number'&&h.price>0&&
typeof h.qty==='number'&&h.qty>0&&typeof h.amount==='number'&&h.amount>0).slice(0,1000):[]})):[],
goals:Array.isArray(s.investing.goals)?s.investing.goals.filter(g=>g&&typeof g.id==='string'&&typeof g.label==='string'&&
typeof g.targetValue==='number'&&g.targetValue>0)
.slice(0,50).map(g=>({id:g.id,label:g.label.slice(0,80),targetValue:g.targetValue,
targetDate:typeof g.targetDate==='string'?g.targetDate:null,
createdAt:typeof g.createdAt==='number'?g.createdAt:Date.now()})):[],
}:null,
};
// universal watchlist: validate the persisted instrument list (any segment)
if(Array.isArray(s.watchlist)){
const seen=new Set(), wl=[];
for(const it of s.watchlist){
if(!it||typeof it.sym!=='string') continue;
const exch=typeof it.exch==='string'?it.exch:'NSE';
const key=(typeof it.key==='string'&&it.key.includes(':'))?it.key:exch+':'+it.sym;
if(seen.has(key)) continue; seen.add(key);
wl.push({sym:sanInstr(it.sym), name:sanInstr(typeof it.name==='string'?it.name:it.sym), exch, key, ltp:0, chg:0, live:false,
type:sanInstr(typeof it.type==='string'?it.type:'EQ'), token:Number.isFinite(it.token)?it.token:null,
sector:typeof it.sector==='string'?it.sector:undefined, beta:typeof it.beta==='number'?it.beta:undefined,
lot:typeof it.lot==='number'?it.lot:undefined, expiry:typeof it.expiry==='string'?it.expiry:undefined,
strike:typeof it.strike==='number'?it.strike:undefined, seg:typeof it.seg==='string'?it.seg:undefined,
hold:(it.hold&&typeof it.hold==='object')?it.hold:undefined});
}
if(wl.length) out.watchlist=wl.slice(0,100); // cap a runaway list
}
if(s.paneW&&typeof s.paneW==='object'){
const l=numIn(s.paneW.left,200,600), rr=numIn(s.paneW.right,200,600);
if(l!=null&&rr!=null) out.paneW={left:l,right:rr};
}
return out;
}catch(e){
try{ localStorage.removeItem(LS_KEY); }catch(e2){}
return null;
}
}
/* ---------- a11y live announcer ---------- */
function announce(msg){const el=$('srAnnounce');if(el)el.textContent=msg;}
/* ============================================================
RENDER: TOP INDEX + REGIME BAR
============================================================ */
function tickerItems(){ const uni=new Map(TICKER_UNIVERSE.map(u=>[u.name,u])); return (state.ticker.items||[]).map(n=>uni.get(n)).filter(Boolean); }
// Header market toggle (left of search), Indian ⇄ Crypto. Always visible; drives the rolling tape + Algo Studio scope.
function renderHdrMarket(){
const el=$('hdrMkt'); if(!el) return;
if(!state.algo) state.algo={};
if(CRYPTO_ONLY) state.algo.market='crypto';
else if(!state.algo.market) state.algo.market='in';
const crypto=state.algo.market==='crypto';
if(CRYPTO_ONLY){
el.innerHTML=`₿ Crypto`;
return;
}
el.innerHTML=`
`
+``
+`
`;
el.querySelectorAll('[data-algomkt]').forEach(b=>b.onclick=()=>setMarket(b.dataset.algomkt));
}
// Single source of truth for the market switch, used by the header toggle (and any other surface).
function setMarket(m){
if(!state.algo) state.algo={};
if(CRYPTO_ONLY) m='crypto';
if(state.algo.market===m) return;
state.algo.market=m; saveState();
renderHdrMarket();
renderTopIndex(); // swap the rolling tape (Indian ⇄ Crypto)
if(m==='crypto'){ loadCrypto().then(()=>{ if(state.algo.market==='crypto'){ patchCryptoTape(); if(isAlgo()) renderAlgo(); } }); connectCryptoWS(); }
else disconnectCryptoWS(); // leave crypto → tear down the socket
if(isAlgo()) renderAlgo(); // re-scope the Algo Studio if it's open
}
function renderTopIndex(){
const track=$('topIndex'); if(!track) return;
// CRYPTO MODE: the global tape rolls live Binance prices (independent of Kite). Real data only.
// CRYPTO_ONLY is known at script load, before state.algo.market gets set during boot (init() sets
// it a few calls later) - checking it directly here closes that gap instead of racing it, which
// used to flash the old "connect Kite" banner for a moment on every fresh load.
const bar=track.closest('.ticker-bar'); const crypto=CRYPTO_ONLY||!!(state.algo&&state.algo.market==='crypto');
if(bar) bar.classList.toggle('crypto',crypto);
if(crypto){ renderCryptoTape(track); return; }
// NO FAKE PRICES: when Kite isn't connected, show an honest banner instead of synthetic ticks.
if(!BOT.live){
track.classList.remove('rolling'); const vp0=track.parentElement; if(vp0) vp0.classList.add('static');
track.innerHTML=`
${icon('shield',12)}Live market data off, run python3 login.py to connect Kite
`;
track.dataset.tsig='off';
return;
}
const items=tickerItems();
// Structure guard: when the instrument SET + rolling flag are unchanged, DON'T rebuild the DOM
// (a full innerHTML swap flickers and resets the scroll). Patch live values in place instead,
// so the tape is flicker-free no matter who calls this (loadMarket / recompute / ticker settings).
const tsig='L|'+(!!state.ticker.rolling)+'|'+items.map(i=>i.name).join(',');
if(track.dataset.tsig===tsig && track.querySelector('.tix')){ patchTape(); return; }
track.dataset.tsig=tsig;
const seq=items.map(({name})=>{
const rq=realQuote(name);
// data-tname lets patchTopIndex() update the value/chg in place on every tick (no animation restart)
if(!rq) return `
`;
const roll=!!state.ticker.rolling && items.length>1;
track.classList.toggle('rolling',roll);
const vp=track.parentElement; if(vp) vp.classList.toggle('static',!roll);
// two identical sequences let the track loop seamlessly at translateX(-50%)
track.innerHTML=`
${seq}
`+(roll?`
${seq}
`:'');
applyTickerSpeed();
connectStream(); // keep the live tick subscription in sync with the ticker's instruments
}
// Patch ONLY the rolling tape's price/chg cells in place (both looping copies) so the scroll
// animation never restarts. Driven by the fast tick loop + SSE, not the 30s market poll.
function patchTopIndex(){
const track=$('topIndex'); if(!track||!BOT.live) return;
track.querySelectorAll('.tix[data-tname]').forEach(el=>{
const name=el.dataset.tname, rq=realQuote(name);
const valEl=el.querySelector('.tix-val'), chgEl=el.querySelector('.tix-chg');
if(!valEl) return;
if(!rq){ valEl.textContent='-'; valEl.classList.add('muted'); if(chgEl)chgEl.hidden=true; return; }
const dec=rq.ltp>=20000?0:(rq.ltp>=1000?1:2);
const txt=rq.ltp.toLocaleString('en-IN',{maximumFractionDigits:dec});
if(valEl.textContent!==txt){
const prev=parseFloat(valEl.dataset.raw); const dir=isFinite(prev)?Math.sign(rq.ltp-prev):0;
valEl.textContent=txt; valEl.classList.remove('muted'); valEl.dataset.raw=rq.ltp;
if(dir){ valEl.classList.remove('tick-up','tick-dn'); void valEl.offsetWidth; valEl.classList.add(dir>0?'tick-up':'tick-dn'); }
}
if(chgEl){ chgEl.hidden=false; chgEl.textContent=pct(rq.chg||0); chgEl.className='tix-chg '+cls(rq.chg||0)+' num'; }
});
}
/* ---- CRYPTO rolling tape: same flicker-free marquee, fed by live Binance data (no Kite) ---- */
function renderCryptoTape(track){
// Kick a fetch if we've never loaded crypto; patch the tape (+resize) once it lands.
if(!CRYPTO.loaded && !CRYPTO.busy) loadCrypto().then(()=>{ if(state.algo&&state.algo.market==='crypto'){ patchCryptoTape(); applyTickerSpeed(); } });
const tsig='C|'+CRYPTO_UNIVERSE.map(c=>c.sym).join(',');
// Structure guard: only rebuild the DOM when the SET changes, else patch in place.
if(track.dataset.tsig===tsig && track.querySelector('.tix[data-cname]')){ patchCryptoTape(); return; }
track.dataset.tsig=tsig;
const seq=CRYPTO_UNIVERSE.map(c=>{ const q=CRYPTO.quotes[c.sym];
if(!q) return `
${esc(c.tk)}
-
`;
return `
${esc(c.tk)}
`
+`${cryptoFmt(q.ltp)}`
+`${pct(q.chg)}
`;
}).join('');
track.classList.add('rolling');
const vp=track.parentElement; if(vp) vp.classList.remove('static');
// two identical sequences → seamless translateX(-50%) loop, regardless of tile width
track.innerHTML=`
${seq}
${seq}
`;
applyTickerSpeed();
}
// Patch ONLY the crypto tape's value/chg cells in place (both looped copies), driven by the 5s Binance poll.
function patchCryptoTape(){
const track=$('topIndex'); if(!track) return;
track.querySelectorAll('.tix[data-cname]').forEach(el=>{
const q=CRYPTO.quotes[el.dataset.cname], valEl=el.querySelector('.tix-val'), chgEl=el.querySelector('.tix-chg');
if(!valEl) return;
if(!q){ valEl.textContent='-'; valEl.classList.add('muted'); if(chgEl)chgEl.hidden=true; return; }
const txt=cryptoFmt(q.ltp);
if(valEl.textContent!==txt){
const prev=parseFloat(valEl.dataset.raw); const dir=isFinite(prev)?Math.sign(q.ltp-prev):0;
valEl.textContent=txt; valEl.classList.remove('muted'); valEl.dataset.raw=q.ltp;
if(dir){ valEl.classList.remove('tick-up','tick-dn'); void valEl.offsetWidth; valEl.classList.add(dir>0?'tick-up':'tick-dn'); }
}
if(chgEl){ chgEl.hidden=false; chgEl.textContent=pct(q.chg); chgEl.className='tix-chg '+cls(q.chg)+' num'; }
});
const src=$('tbCryptoSrc');
if(src) src.textContent=CRYPTO.live?'● Live · Binance · 24h':(CRYPTO.error?'Binance unreachable, retrying':'Connecting to Binance…');
}
function applyTickerSpeed(){
const track=$('topIndex'); if(!track) return;
if(!track.classList.contains('rolling')){track.style.removeProperty('--tk-dur');return;}
const seqA=track.querySelector('.tb-seq-a'); if(!seqA) return;
requestAnimationFrame(()=>{const w=seqA.scrollWidth, pps=clamp(state.ticker.speed||60,20,180);
track.style.setProperty('--tk-dur',Math.max(6,w/pps).toFixed(1)+'s');});
}
/* ---- REALTIME ROLLING TAPE ------------------------------------------------------------
The index tape used to refresh ONLY via loadMarket(), every 30s, and /api/market is itself
30s-cached server-side, so it sat frozen between cycles while the watchlist ticked every
second. Here we stream the index LTPs from the WebSocket-fed /api/ticks cache (the SAME
sub-second feed the watchlist uses) on the existing 2s poll and patch the tape values
IN PLACE, no innerHTML swap, so the scroll never resets/jumps. Commodities (MCX) have no
cheap realtime source (need the resolved futures symbol) and stay on the 30s loadMarket cycle. */
const TAPE_LIVE={'NSE:NIFTY 50':['indices','NIFTY 50'],'NSE:NIFTY BANK':['indices','NIFTY BANK'],
'NSE:NIFTY FIN SERVICE':['indices','NIFTY FIN SERVICE'],'BSE:SENSEX':['indices','SENSEX'],
'NSE:INDIA VIX':['vix',null]};
async function loadTape(){
if(!BOT.live||!BOT.market) return;
try{
const keys=Object.keys(TAPE_LIVE);
const d=await fetch(`${BOT_API}/api/ticks?keys=${encodeURIComponent(keys.join(','))}`).then(r=>r.json());
const t=d&&d.ticks; if(!t) return;
for(const k in TAPE_LIVE){
const v=t[k]; if(!v||v.ltp==null) continue;
const sec=TAPE_LIVE[k][0], name=TAPE_LIVE[k][1];
if(sec==='vix'){ BOT.market.vix={ltp:v.ltp,chgPct:v.chg}; }
else { const m=(BOT.market[sec]=BOT.market[sec]||{}); const prev=m[name]||{}; m[name]={ltp:v.ltp,prevClose:prev.prevClose,chgPct:v.chg}; }
}
patchTape();
}catch(e){}
}
function patchTape(){
const track=$('topIndex'); if(!track) return;
track.querySelectorAll('.tix').forEach(tx=>{
// key off data-tname (robust) not display text, so an esc()'d name still matches
const key=(tx.dataset.tname || (tx.querySelector('.tix-name')||{}).textContent || '').trim();
if(!key) return;
const rq=realQuote(key); if(!rq||rq.ltp==null) return;
const dec=rq.ltp>=20000?0:(rq.ltp>=1000?1:2);
const val=tx.querySelector('.tix-val');
if(val){ val.classList.remove('muted'); val.textContent=rq.ltp.toLocaleString('en-IN',{maximumFractionDigits:dec}); }
const chg=tx.querySelector('.tix-chg');
if(chg){ chg.hidden=false; chg.textContent=pct(rq.chg||0); chg.className='tix-chg '+cls(rq.chg||0)+' num'; } // un-hide: a tile that first painted with no quote must show its % once live
});
}
/* ---- ticker settings popover (speed · rolling · instruments) ---- */
function openTickerSettings(open){
const p=$('tickerSettings'), g=$('tickerGear'); if(!p) return;
if(open===undefined) open=p.hidden;
if(!open){ p.hidden=true; document.removeEventListener('click',tickerOutside,true); document.removeEventListener('keydown',tickerEsc); if(g)g.setAttribute('aria-expanded','false'); return; }
renderTickerSettings(); p.hidden=false;
if(g){const r=g.getBoundingClientRect(); p.style.top=(r.bottom+8)+'px'; p.style.right=Math.max(12,window.innerWidth-r.right)+'px'; g.setAttribute('aria-expanded','true');}
setTimeout(()=>{document.addEventListener('click',tickerOutside,true);document.addEventListener('keydown',tickerEsc);},0);
}
function tickerOutside(e){ if(!e.target.closest('#tickerSettings,#tickerGear')) openTickerSettings(false); }
function tickerEsc(e){ if(e.key==='Escape'){openTickerSettings(false); const g=$('tickerGear'); if(g)g.focus();} }
let tkQuery='', tkActive=-1, tkRefocus=false; // ticker instrument search: query text, keyboard cursor, refocus-after-add flag
function renderTickerSettings(){
const p=$('tickerSettings'); if(!p) return; const t=state.ticker, has=new Set(t.items);
const chips=t.items.map(n=>`${esc(n)}`).join('')||`No instruments yet, search below to add one.`;
p.innerHTML=`
`:'');
results.hidden=false; search.setAttribute('aria-expanded','true');
results.querySelectorAll('[data-tkadd]').forEach(b=>{b.onmousedown=e=>e.preventDefault();b.onclick=()=>addInstr(b.dataset.tkadd);});
setActive(0);
};
if(search){
search.oninput=()=>{ tkQuery=search.value; if(clear)clear.hidden=!tkQuery; renderResults(); };
search.onfocus=()=>renderResults();
search.onkeydown=e=>{
if(e.key==='ArrowDown'){ e.preventDefault(); if(results.hidden){renderResults();}else setActive(tkActive+1); }
else if(e.key==='ArrowUp'){ e.preventDefault(); setActive(tkActive-1); }
else if(e.key==='Enter'){ e.preventDefault(); const els=itemEls(), el=els[tkActive]||els[0]; if(el)addInstr(el.dataset.tkadd); }
else if(e.key==='Escape'&&!results.hidden){ e.preventDefault(); e.stopPropagation(); closeResults(); }
};
}
if(clear)clear.onclick=()=>{ tkQuery=''; if(search){search.value='';search.focus();} clear.hidden=true; renderResults(); };
if(tkRefocus){ tkRefocus=false; if(search){search.focus(); const L=search.value.length; try{search.setSelectionRange(L,L);}catch(_){}} renderResults(); }
}
function wireTicker(){ const g=$('tickerGear'); if(g) g.onclick=e=>{e.stopPropagation();openTickerSettings();}; }
function renderRegimeBar(r){
const bar=$('regimeBar'); if(!bar) return;
const cfg={
bull:{kick:'Risk-on · Momentum',title:'Markets trending up',
read:`Opportunity-capture mode. The terminal leads with breakout scans & long entries; risk tools stay one tap away. Your watchlist is sorted by momentum and the order pad defaults to BUY.`},
neutral:{kick:'Neutral · Rangebound',title:'Markets rangebound, wait for clarity',
read:`Signals are mixed. BUY and SELL stay equally weighted and the engine waits for a decisive break before committing to a regime.`},
bear:{kick:'Risk-off · Defensive',title:'Markets under pressure',
read:`Capital-preservation mode. The terminal now leads with portfolio risk, hedges & stops: the risk pane widens, losers surface first and the order pad defaults to PROTECT/SELL.`},
}[r];
const read=(PLAY[state.persona||'trader']||PLAY.trader)[r]||cfg.read;
// BUG FIX (2026-09-19): this was a binary isInvestor() check, so it permanently showed "Trading"
// in Algo Studio (and would have in AI mode too) - harmless while only algo/ai were reachable,
// a real visible wrong label now that all modes are live. Reads the real persona via the
// existing PERSONA map instead; 'algo' is special-cased to match the header toggle's own wording.
const pTag=state.persona==='algo'?'Algo Studio':(PERSONA[state.persona]?PERSONA[state.persona].label:'Trading');
if(state.regimeCollapsed===undefined) state.regimeCollapsed=true; // compact by default, engine stats live in the header now
const collapsed=state.regimeCollapsed;
// Guard: the bar's content is purely regime + persona + collapsed. When none changed, skip the
// rebuild so the 30s poll (and recompute, called every poll) don't flicker the banner.
const rbsig=r+'|'+(state.persona||'trader')+'|'+(collapsed?1:0)+'|'+pTag;
if(bar.dataset.rbsig===rbsig) return;
bar.dataset.rbsig=rbsig;
bar.classList.toggle('collapsed',collapsed);
if(collapsed){
bar.innerHTML=``;
} else {
bar.innerHTML=`
${icon('shield',13)}Review & confirm. Placing this order does not modify any existing position or stop-loss.
`;
const cf=$('modalConfirm');
cf.style.display='';
cf.className='tbtn '+(m.side==='buy'?'primary':'danger');
cf.textContent=m.side==='buy'?'Confirm Buy':'Confirm '+(r==='bear'?'Protect':'Sell');
cf.onclick=()=>{placeOrder({sym:m.sym,side:m.side,type:m.type,qty,price:Math.round(m.entry)});closeModal();};
state.lastFocus=document.activeElement;
showModal(true);
setTimeout(()=>{const c=$('modalConfirm');if(c)c.focus();},40);
}
function showModal(o){$('orderModal').classList.toggle('show',o);$('modalScrim').classList.toggle('show',o);$('orderModal').setAttribute('aria-hidden',String(!o));}
function closeModal(){showModal(false);if(state.lastFocus&&state.lastFocus.focus)state.lastFocus.focus();}
function setModalTitle(t){const el=$('modalTitle');if(el)el.textContent=t;$('orderModal').setAttribute('aria-label',t);}
function successToast(o,status){
const t=document.createElement('div');t.className='toast';
t.innerHTML=`
${icon('check',22)}
Paper ${o.side==='buy'?'buy':'sell'} order, ${o.sym}${o.qty} qty @ ${o.price.toLocaleString('en-IN')} · ${o.type} · ${status} · simulated, no real order
`;
$('toastWrap').appendChild(t);
t.querySelector('[data-act=ok]').onclick=()=>dismiss(t);
t._timer=setTimeout(()=>{if(document.body.contains(t))dismiss(t);},4500);
}
function placeOrder(o){
const status=o.type.startsWith('SL')?'Pending':(o.type==='MARKET'||o.type==='BRACKET')?'Filled':'Open';
state.orders.unshift({id:++ORDER_ID,...o,status,paper:true}); // SIMULATED, no real order is placed
saveState(); // BUG FIX (2026-09-19): an order used to only exist in memory until some unrelated
// action (e.g. a persona switch) happened to trigger a save - a reload right after
// placing a trade silently lost the whole order. Persist the moment it's placed.
if(typeof renderTrading==='function') renderTrading();
successToast(o,status);
}
function executeBracketOrder(p){
if(!p) return;
const sym = p.sym || (state.trading && state.trading.sym) || state.selected;
const m = tradeModel(sym);
const side = p.side || 'buy';
const qty = (p.qty != null && p.qty > 0) ? p.qty : (m.qty || 1);
const price = p.entry != null ? p.entry : (m.px || 100);
const sl = p.sl;
const target = p.target;
playAudioFeedback('bracket');
placeOrder({ sym, side, qty, price, type: 'BRACKET', sl, target });
quickToast('1-Click Bracket Executed', `Paper ${side.toUpperCase()} ${qty} ${sym} filled @ ${cryptoFmt(price)}. TP: ${cryptoFmt(target)} | SL: ${cryptoFmt(sl)}`);
}
function checkBracketOrders(sym, ltp){
if(!state.orders || !state.orders.length || ltp == null || !isFinite(ltp)) return;
let changed = false;
state.orders.forEach(o => {
if(!o.paper || o.type !== 'BRACKET' || o.status !== 'Filled' || o.sym !== sym) return;
const isBuy = o.side === 'buy';
let hitType = null;
if(isBuy){
if(o.target != null && ltp >= o.target) hitType = 'TP';
else if(o.sl != null && ltp <= o.sl) hitType = 'SL';
} else {
if(o.target != null && ltp <= o.target) hitType = 'TP';
else if(o.sl != null && ltp >= o.sl) hitType = 'SL';
}
if(hitType){
o.status = hitType === 'TP' ? 'Closed (TP)' : 'Closed (SL)';
o.exitPrice = ltp;
o.exitTime = Date.now();
const pnlPct = isBuy ? ((ltp - o.price) / o.price * 100) : ((o.price - ltp) / o.price * 100);
o.pnlPct = pnlPct;
changed = true;
if(hitType === 'TP'){
playAudioFeedback('success');
quickToast('Target Hit!', `Paper ${o.sym} target filled at ${cryptoFmt(ltp)}! +${pnlPct.toFixed(1)}%`);
} else {
quickToast('Stop Loss Filled', `Paper ${o.sym} SL exited at ${cryptoFmt(ltp)}. ${pnlPct.toFixed(1)}%`);
}
}
});
if(changed){
saveState();
if(typeof renderTrading === 'function') renderTrading();
}
}
function flowModal(o){
setModalTitle(o.title||'Confirm');
$('modalBody').innerHTML=o.body||'';
const cf=$('modalConfirm');
if(o.hideConfirm){ cf.style.display='none'; }
else{
cf.style.display=''; cf.className='tbtn '+(o.danger?'danger':'primary'); cf.textContent=o.confirm||'Confirm';
cf.onclick=()=>{ const r=o.onConfirm?o.onConfirm($('modalBody')):true; if(r!==false) closeModal(); };
}
if(o.wire) o.wire($('modalBody'));
state.lastFocus=document.activeElement; showModal(true);
setTimeout(()=>{ const first=$('modalBody').querySelector(o.focus||'input:not([disabled]),select,button,[tabindex="0"]'); (first||$('modalConfirm')).focus(); },50);
}
function cancelOrder(id){const o=state.orders.find(x=>x.id===id);if(o&&(o.status==='Pending'||o.status==='Open')){o.status='Cancelled';saveState();if(typeof renderTrading==='function') renderTrading();}}
function applyRegime(regime){
state.displayed=regime;
document.documentElement.dataset.regime=regime;
document.querySelectorAll('[data-regime-btn]').forEach(b=>{const on=b.dataset.regimeBtn===regime;b.classList.toggle('active',on);b.setAttribute('aria-selected',on);});
$('fundsLabel').textContent=(PERSONA[state.persona||'trader']||PERSONA.trader).fundsLabel(regime);
$('fundsVal').textContent=fundsText();
renderTopIndex(); renderRegimeBar(regime); renderWatchlist(regime);
renderChart(regime); renderWidgetStack(); renderAlgo();
if(typeof renderTrading==='function') renderTrading();
if(typeof renderInvesting==='function') renderInvesting();
applyPaneWidths(); // keep any manual resize across regime switches
flashRegime();
announce(`${regime.charAt(0).toUpperCase()+regime.slice(1)} regime, ${regime==='bull'?'markets trending up':regime==='bear'?'markets under pressure':'markets rangebound'}`);
saveState();
}
function flashRegime(){const s=$('regimeSweep');if(!s)return;s.classList.remove('go');void s.offsetWidth;s.classList.add('go');}
const RV_SEL='.regime-bar,#tradingView,#investHub,#algoView,.pane-left,.chart-card,.panel,#orderPad,#contextModule';
const RV_META={
bull: {word:'BULL', tag:'Risk-on · Momentum'},
neutral:{word:'NEUTRAL', tag:'Wait for clarity'},
bear: {word:'BEAR', tag:'Protect capital'},
};
const prefersReduced=()=>window.matchMedia&&window.matchMedia('(prefers-reduced-motion:reduce)').matches;
/* original mascot artwork (filled vector), charging bull · roaring bear · balance scale */
const MASCOTS={
bull:``,
bear:``,
neutral:``,
};
function rvCards(){return [...document.querySelectorAll(RV_SEL)];}
function runStage(html,cls,dur){
const st=$('revealStage'); if(!st)return;
clearTimeout(st._t);
st.className='reveal-stage '+cls; st.innerHTML=html;
void st.offsetWidth; st.classList.add('go');
st._t=setTimeout(()=>{st.classList.remove('go');st.className='reveal-stage';st.innerHTML='';},dur);
}
function buildMascot(r){
const m=RV_META[r], dirY=r==='bull'?-1:r==='bear'?1:0;
const streaks=Array.from({length:8},(_,i)=>``).join('');
let pcl='';for(let i=0;i<16;i++){const x=(Math.random()*2-1);
const tx=(x*(r==='neutral'?125:55)).toFixed(0);
const ty=(dirY*(60+Math.random()*75)+(r==='neutral'?(Math.random()*2-1)*22:0)).toFixed(0);
pcl+=``;}
let smoke='';for(let i=0;i<7;i++){smoke+=``;}
return `
`;
}
function cinematicRegime(r){
if(prefersReduced()||state.revealing){applyRegime(r);return;}
state.revealing=true;
rvCards().forEach((c,i)=>{c.style.setProperty('--rvd',(i*45)+'ms');c.classList.remove('rv-in');c.classList.add('rv-out');});
setTimeout(()=>{ applyRegime(r); runStage(buildMascot(r),'reveal-regime '+r,1450); },300);
setTimeout(()=>{ rvCards().forEach((c,i)=>{c.classList.remove('rv-out');c.style.setProperty('--rvd',(i*55)+'ms');c.classList.add('rv-in');}); },840);
setTimeout(()=>{ rvCards().forEach(c=>{c.classList.remove('rv-in');c.style.removeProperty('--rvd');}); state.revealing=false; },1700);
}
function cinematicPersona(p){
if(prefersReduced()||state.revealing){applyPersona(p,{user:true});return;}
const gateOpen=$('personaGate')&&$('personaGate').classList.contains('show');
state.revealing=true;
runStage(buildPersonaFx(p),'reveal-persona '+p,900);
if(gateOpen){
applyPersona(p,{user:true});
rvCards().forEach((c,i)=>{c.style.setProperty('--rvd',(i*50)+'ms');c.classList.add('pm-in');});
setTimeout(()=>{rvCards().forEach(c=>{c.classList.remove('pm-in');c.style.removeProperty('--rvd');});state.revealing=false;},780);
}else{
rvCards().forEach((c,i)=>{c.style.setProperty('--rvd',(i*38)+'ms');c.classList.add('pm-out');});
setTimeout(()=>{
applyPersona(p,{user:true});
rvCards().forEach((c,i)=>{c.classList.remove('pm-out');c.style.setProperty('--rvd',(i*50)+'ms');c.classList.add('pm-in');});
setTimeout(()=>{rvCards().forEach(c=>{c.classList.remove('pm-in');c.style.removeProperty('--rvd');});state.revealing=false;},760);
},230);
}
}
// Crypto trades 24/7, no NSE-style market-hours/Kite-connection status to show, so the header
// clock strip stays hidden here. Live status is the Binance ticker banner (cryptoStatusBar()).
function updateClock(){const el=$('mktStatus');if(el)el.hidden=true;const eng=$('hdrEngine');if(eng)eng.hidden=true;}
const isInvestor=()=>state.persona==='investor';
const isAlgo=()=>state.persona==='algo';
// BUG FIX (2026-09-19): this used to hard-force p='algo' whenever CRYPTO_ONLY was true, regardless
// of state.persona, which is exactly what kept Investing/Trading permanently unreachable. Reads
// the real persona now; CRYPTO_ONLY only ever affects which market/data source is used (crypto),
// never which of the three modes is active.
function syncFab(){const p=state.persona||'algo';
document.querySelectorAll('[data-mode-btn]').forEach(b=>{const on=b.dataset.modeBtn===p;b.classList.toggle('active',on);b.setAttribute('aria-selected',on);});}
function applyPersona(p,opts){
opts=opts||{};
if(PERSONA_KEYS.indexOf(p)<0)p='trader';
const changed=state.persona!==p;
state.persona=p;
document.documentElement.dataset.persona=p;
applyPaneWidths(); // re-apply (3-pane) or clear (algo/ai single-pane) the grid width on every persona switch
syncFab();
const gate=$('personaGate'); if(gate && !onboarding) gate.classList.remove('show'); // onboarding keeps the gate open to advance to the connect step
// reset order-pad context so verbs/defaults match the new persona
state.orderSide=null; state.orderQty=null; state.tradeFromChart=null;
$('fundsLabel').textContent=PERSONA[p].fundsLabel(state.displayed);
if(opts.user && changed && window.TPChart && TPChart.setTimeframe) TPChart.setTimeframe(PERSONA[p].chartTf);
const r=state.displayed;
renderRegimeBar(r); renderWatchlist(r); renderWidgetStack(); renderAlgo();
if(typeof renderTrading==='function') renderTrading();
if(typeof renderInvesting==='function') renderInvesting();
if(opts.user){
announce(`${PERSONA[p].label} mode, terminal retuned for ${p==='investor'?'long-term investing':'active trading'}`);
const t=document.querySelector('.terminal'); if(t){t.classList.remove('persona-morph');void t.offsetWidth;t.classList.add('persona-morph');setTimeout(function(){t.classList.remove('persona-morph');},480);}
}
saveState();
}
function startOnboarding(){
onboarding=true;
const g=$('personaGate'); if(!g) return;
if(CRYPTO_ONLY){ applyPersona('algo',{user:true}); obStep(2); g.classList.add('show'); return; }
obStep(1); g.classList.add('show');
}
function obStep(n){
const g=$('personaGate'); if(!g) return;
g.dataset.step=String(n);
g.querySelectorAll('.pg-dot').forEach((d,i)=>d.classList.toggle('on',i{ if(onboarding) renderOnboardConnect(); }).catch(()=>{});
const f=$('pgStep2').querySelector('[data-obfinish]'); if(f) f.focus();
}
}
function onboardPick(p){ applyPersona(p,{user:true}); obStep(2); }
function renderOnboardConnect(){
const el=$('pgStep2'); if(!el) return;
const botOff=!BOT.loaded||BOT.error;
const running=BOT.status&&BOT.status.harnessRunning;
const statusCard=botOff
?`
${icon('shield',16)}
Setting up your paper accountLive prices and the paper book connect automatically, no action needed.
`
:`
Crypto engine ${running?'running':'ready'}Live Binance spot prices · 24/7 paper book · no exchange keys required.
`;
const legend=`
● LIVE real Binance pricesPAPER simulated fills, honest costs
zengtrade never fabricates numbers, prove your edge forward before going live.
`;
el.innerHTML=`
Welcome to zengtrade Crypto
Backtest, forward-test, and paper-trade systematic strategies on live crypto prices, 24/7.
${statusCard}${legend}
`;
}
function finishOnboarding(){
onboarding=false;
const g=$('personaGate'); if(g) g.classList.remove('show');
saveState(); // persona was already saved on pick; this also persists post-onboarding state
if(state.lastFocus&&state.lastFocus.focus) try{state.lastFocus.focus();}catch(e){}
}
function quickToast(title,sub){
const t=document.createElement('div');t.className='toast';
t.innerHTML=`
${icon('wallet',22)}
${title}${sub}
`;
$('toastWrap').appendChild(t);
t.querySelector('[data-act=ok]').onclick=()=>dismiss(t);
t._timer=setTimeout(()=>{if(document.body.contains(t))dismiss(t);},4000);
}
function ts(k,def){ return (state.toolState[k]!==undefined)?state.toolState[k]:def; }
function secStats(items){return `
${items.map(s=>`
${s.g?``:`${s.l}`}${s.v}${s.s?`${s.s}`:''}
`).join('')}
`;}
function secEmpty(ic,title,msg,cta){return `
${icon(ic,28)}${title}
${msg}
${cta||''}
`;}
// Inline "what does this mean" tooltips on secStats() labels, wired to the real glossary already
// shipped at /learn/glossary/{slug}/. Definitions below are copied verbatim from content/glossary.py's
// `short` field (single source of truth) - if that text changes there, update it here too. This is
// the DIY-simplicity lever: no human ever explains these numbers, so the product has to, in place.
const GLOSS={
'win-rate':'The percentage of trades that close profitably, informative, but meaningless without knowing the size of wins versus losses too.',
'profit-factor':'Gross profit divided by gross loss, a single number summarizing whether winners meaningfully outweigh losers, independent of win rate.',
'drawdown':"The decline from a portfolio's peak value to its lowest point since, the real, lived cost of a losing stretch.",
'sharpe-ratio':'A risk-adjusted return measure: average return divided by the volatility (standard deviation) of those returns, higher means more return per unit of risk taken.',
'cagr':'The smoothed annual growth rate that would take a starting value to an ending value over a period, assuming steady compounding, useful for comparing returns across different timeframes.',
'market-regime':'The prevailing character of a market, trending up, trending down, or range-bound/choppy, that determines which strategies actually have an edge right now.',
};
function initGlossTips(){
let tip=null;
function ensureTip(){ if(!tip){ tip=document.createElement('div'); tip.className='gloss-tip'; tip.setAttribute('role','tooltip'); document.body.appendChild(tip); } return tip; }
function place(btn){
const slug=btn.dataset.g, def=GLOSS[slug]; if(!def) return;
const t=ensureTip(), r=btn.getBoundingClientRect();
t.innerHTML=`${def} Full definition →`;
t.classList.add('show');
const tw=Math.min(260, window.innerWidth-24);
t.style.maxWidth=tw+'px';
const left=Math.min(Math.max(8,r.left), window.innerWidth-tw-8);
let top=r.bottom+8;
if(top+120>window.innerHeight) top=r.top-8-t.offsetHeight;
t.style.left=left+'px'; t.style.top=top+'px';
}
function hide(){ if(tip) tip.classList.remove('show'); }
document.addEventListener('mouseover',e=>{const b=e.target.closest('.ss-help'); if(b) place(b);});
document.addEventListener('mouseout',e=>{const b=e.target.closest('.ss-help'); if(b) hide();});
document.addEventListener('focusin',e=>{const b=e.target.closest('.ss-help'); if(b) place(b);});
document.addEventListener('focusout',e=>{const b=e.target.closest('.ss-help'); if(b) hide();});
document.addEventListener('click',e=>{const b=e.target.closest('.ss-help'); if(b){ e.preventDefault(); place(b); } else if(!e.target.closest('.gloss-tip')) hide();});
document.addEventListener('keydown',e=>{ if(e.key==='Escape') hide(); });
}
function lcMsg(s){
return s==='paper'?'Live data, simulated fills, no real money. Building forward evidence.'
: s==='paused'?'Paused, no new entries; open paper positions are kept.'
: (s===null||s==='off')?'Stopped, open paper positions square off next cycle; nothing else trades it.'
: 'Updated.';
}
async function setStrategyState(id, stateVal, title){
try{
let r=null;
try{
r=await fetch(BOT_API+'/api/strategy',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({id,state:stateVal})}).then(x=>x.json());
}catch(err){ r=null; }
if(r&&r.error){
if(r.upgrade){ quickToast('Upgrade to Pro', r.error); setTimeout(function(){ location.href=r.upgrade; }, 700); return r; }
quickToast('Action failed', r.error); return r;
}
if(r&&r.locked){ quickToast('Live locked '+'🔒', r.reason||'Arm ALLOW_LIVE on the bot machine to go live.'); }
else {
quickToast(title||'Updated', lcMsg(stateVal));
CRYPTOMON.data=CRYPTOMON.data||{strategies:[]};
CRYPTOMON.data.strategies=CRYPTOMON.data.strategies||[];
let st=CRYPTOMON.data.strategies.find(x=>x.id===id);
if(!st){
const meta=CRYPTO_STRATEGIES.find(x=>x.bid===id)||{name:id,cat:'Breakout',pair:'BTCUSDT'};
st={id,name:meta.name,pair:meta.pair,deployed:stateVal==='paper',status:stateVal,pnl:0,trades:0};
CRYPTOMON.data.strategies.push(st);
} else {
st.deployed=(stateVal==='paper');
st.status=stateVal;
}
CRYPTOMON.loaded=true;
}
await Promise.all([loadBotData(), loadCryptoMonitor()]);
if(typeof renderAlgo==='function') renderAlgo();
return r;
}catch(e){ quickToast('Action failed','The trading engine is temporarily unreachable, try again shortly.'); }
}
function algoDeploy(a){
if(!a.wired){ quickToast('Not deployable yet', `${a.name} has no live engine, backtest/validate it first.`); return; }
flowModal({title:'Deploy in Paper, '+a.name, confirm:'Deploy in Paper',
body:`
${icon('shield',13)}Risk-free. Paper deploy runs this strategy on live Binance data with simulated fills: no real orders, no money at risk. Your bot harness starts trading it; closed-trade P&L builds toward the Go-Live gate (≥${BOT.nudgeMin||10} profitable trades). You can Pause or Stop anytime.
${icon('alert',13)}${flatten
? `This squares off real positions and stops the strategy. Real-money action, confirm you want to exit now.`
: `Stops paper-trading ${esc(a.name)}. Any open paper positions are squared off next cycle. You can redeploy anytime.`}
`,
onConfirm(){ setStrategyState(a.id,'off','Stopped, '+a.name); }});
}
function cryptoLibDeploy(bid){
const c=CRYPTO_STRATEGIES.find(x=>x.bid===bid);
if(!c){ quickToast('Engine loading','Wait for the strategy catalog to load from the API.'); return; }
if(!c.wired){ quickToast('Not deployable yet', `${c.name} has no live engine on the cloud worker yet, backtest/validate it first.`); return; }
const m=((CRYPTOMON.data&&CRYPTOMON.data.strategies)||[]).find(x=>x.id===bid);
if(!CRYPTOMON.loaded&&!CRYPTOMON.busy) loadCryptoMonitor();
const a={ id:bid, name:c.name, cat:c.cat+' · '+c.pair, risk:c.risk, wired:true,
bestRegime:null, vstatus:'validated', sub:(m&&m.deployed)?'paper':null };
if(a.sub==='paper') lcStop(a);
else algoDeploy(a);
}
const newLayoutId=()=>'L'+Date.now().toString(36)+(_lid++);
function customLayouts(){ if(!Array.isArray(state.customLayouts))state.customLayouts=[]; return state.customLayouts; }
function activeCustom(){ const a=customLayouts(); let cl=a.find(l=>l.id===state.activeCustom); if(!cl){cl=a[0]||null; state.activeCustom=cl?cl.id:null;} return cl; }
function newTabId(){ return 't'+Math.random().toString(36).slice(2,8)+Date.now().toString(36).slice(-3); }
function tabsOf(cl){ if(!cl) return [];
if(!Array.isArray(cl.tabs)){ cl.tabs=[{id:newTabId(),name:'Main',cards:Array.isArray(cl.cards)?cl.cards:[],sync:{A:0,B:1}}]; cl.activeTab=cl.tabs[0].id; }
if('cards' in cl) delete cl.cards; // drop the migrated flat list
cl.tabs.forEach(t=>{ if(!t.id)t.id=newTabId(); if(!Array.isArray(t.cards))t.cards=[]; if(typeof t.name!=='string')t.name='Tab'; if(!t.sync||typeof t.sync!=='object')t.sync={A:0,B:1}; });
if(!cl.tabs.length) cl.tabs.push({id:newTabId(),name:'Main',cards:[],sync:{A:0,B:1}});
if(!cl.tabs.find(t=>t.id===cl.activeTab)) cl.activeTab=cl.tabs[0].id;
return cl.tabs;
}
function activeTab(){ const cl=activeCustom(); if(!cl) return null; const ts=tabsOf(cl); return ts.find(t=>t.id===cl.activeTab)||ts[0]; }
const isDesk=()=>state.persona==='trader'&&(state.layout==='options'||state.layout==='futures');
const isCenterTakeover=()=>state.persona==='trader'&&['options','futures','build'].indexOf(state.layout)>=0;
function renderDeskView(){
const v=$('deskView'); if(!v) return;
v.innerHTML='';
}
function canvasCatalog(){ if(_canvasCat) return _canvasCat; _canvasCat=(WIDGET_CATALOG.trader||[]).map(w=>({key:w.key,name:w.name,icon:w.icon,span:1,render:w.render})); return _canvasCat; }
const esc=s=>String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
function sanInstr(s){ return typeof s==='string' ? s.replace(/[<>"'`]/g,'') : s; }
const BOT_API=(()=>{
const h=location.hostname;
if(h==='localhost'||h==='127.0.0.1'||h==='::1'||h==='') return 'http://localhost:8756';
return ''; // production: SAME-ORIGIN, the tunnel routes /api/* to the bot, so the Cloudflare
// Access cookie rides along automatically. No CORS, no CSP change, no secret in JS.
})();
let BOT={loaded:false,connected:false,status:null,paperMode:true,error:false,chains:{},chainExp:{},futures:null};
let ALGOS=[]; // no fabricated strategies, loadBotData() fills this from the real crypto engine
async function loadBotData(){
try{
const reqs=CRYPTO_ONLY
?[fetch(BOT_API+'/api/strategies').then(r=>r.json()).catch(()=>({strategies:[]})),
fetch(BOT_API+'/api/status').then(r=>r.json()).catch(()=>({connected:false})),
fetch(BOT_API+'/api/trades').then(r=>r.json()).catch(()=>({trades:[]})),
Promise.resolve({stopped:[]})]
:[fetch(BOT_API+'/api/strategies').then(r=>r.json()).catch(()=>({strategies:[]})),
fetch(BOT_API+'/api/status').then(r=>r.json()).catch(()=>({connected:false})),
fetch(BOT_API+'/api/trades').then(r=>r.json()).catch(()=>({trades:[]})),
fetch(BOT_API+'/api/stopped').then(r=>r.json()).catch(()=>({stopped:[]}))];
const [s,st,tr,sp]=await Promise.all(reqs);
BOT.trades=tr.trades||[]; BOT.stopped=sp.stopped||[]; BOT.stoppedTotal=sp.totalFlattenPnl||0;
BOT.connected=CRYPTO_ONLY?!!(st.ok||st.connected):!!st.connected;
BOT.live=CRYPTO_ONLY?BOT.connected:!!st.connected;
BOT.status=st; BOT.paperMode=CRYPTO_ONLY?true:(s.paperMode!==false); BOT.updated=s.updated; BOT.error=false;
BOT.segments=s.segments||[{id:'crypto',label:'Crypto Spot',note:''}];
if(s.strategies&&s.strategies.length){
ALGOS=s.strategies.map(x=>({
id:x.id,name:x.name,cat:x.cat,segment:x.segment,win:x.win,minCap:x.minCap,risk:x.risk,
product:x.product,vstatus:x.status,bestRegime:x.bestRegime,regimeFit:x.regimeFit,requires:x.requires,
desc:x.desc,sharpe:x.oos_sharpe,totalRet:x.totalRet,trades:x.trades,dd:x.dd||3,
style:x.style,minDeploy:x.minDeploy,riskPerTrade:x.riskPerTrade,maxDD:x.maxDD, // capital + risk + style model
verdict:x.verdict,paperPnl:x.paperPnl,realisedPnl:x.realisedPnl,openPnl:x.openPnl,openPositions:x.openPositions,live:x.live,real:true,
nudge:x.nudge,fwdTrades:x.fwdTrades,readyExceptCapital:x.readyExceptCapital,blockers:x.blockers,nudgeMsg:x.nudgeMsg,positions:x.positions,
sub:x.sub,deployed:x.deployed,wired:x.wired, // lifecycle: sub = paper|paused|live|null
status:x.live?'live':'idle', cap:x.live?x.minCap:0, cagr:x.totalRet
}));
BOT.nudgeMin=s.nudgeMinTrades||10;
}
}catch(e){ BOT.error=true; }
BOT.loaded=true;
}
// Structural signature: everything that, if changed, genuinely needs a heavy panel re-render.
// Deliberately EXCLUDES live prices/P&L, those are patched in place by the 2s loops + 1s clock,
// so the 30s poll no longer repaints the whole screen (that was the idle "flickers on its own").
function marketSig(){
const m=BOT.market||{};
const funds=(m.funds!=null)?Math.round(m.funds):'x';
const hold=BOT.holdings?(Array.isArray(BOT.holdings)?BOT.holdings.length:Object.keys(BOT.holdings).length):0;
const reg=(m.engine&&m.engine.regime)||state.displayed;
return [BOT.live?1:0, BOT.connected?1:0, reg, funds, hold, state.selected||'', state.layout||'',
state.persona||'', state.panelTab||'', (state.desk&&state.desk.view)||'',
SYMS.map(s=>itemKey(s)).join(','), (typeof algoLiveSig==='function'?algoLiveSig():'')].join('~');
}
async function loadMarket(){
try{
const m=await fetch(BOT_API+'/api/market').then(r=>r.json());
BOT.live=!!(m && m.real && !m.error && m.engine);
BOT.market=BOT.live?m:null;
if(BOT.live){
const [q,h]=await Promise.all([
fetch(BOT_API+'/api/uquotes?keys='+encodeURIComponent(watchKeys().join(','))).then(r=>r.json()).catch(()=>({quotes:{}})),
fetch(BOT_API+'/api/holdings').then(r=>r.json()).catch(()=>null)
]);
BOT.quotes=(q&&q.quotes)||{};
// overlay REAL ltp/chg onto each watchlist instrument BY KEY (any segment), no synthetic
// prices. s.live marks whether THIS instrument has a real quote; a null quote (delisted /
// illiquid / market-closed) stays BLANK, never the stale catalog price.
SYMS.forEach(s=>{const r=BOT.quotes[itemKey(s)]; if(r&&r.ltp!=null){s.ltp=r.ltp; if(r.chg!=null)s.chg=r.chg; s.live=true;} else {s.live=false;}});
BOT.holdings=(h&&!h.error)?h:null;
} else { BOT.quotes=null; BOT.holdings=null; BOT.tickerLive={}; BOT._regimeSynced=false; SYMS.forEach(s=>{s.live=false;}); }
}catch(e){ BOT.live=false; BOT.market=null; BOT.quotes=null; BOT.holdings=null; BOT.tickerLive={}; BOT._regimeSynced=false; SYMS.forEach(s=>{s.live=false;}); }
// Adaptive risk: when the LIVE regime actually flips, re-apply every "Adapt"-enabled
// strategy's stand-aside / re-engage rule (paper only; never silent on real money).
if(BOT.live && BOT.market && BOT.market.engine){
const reg=BOT.market.engine.regime;
if(BOT._adaptRegime && BOT._adaptRegime!==reg && typeof enforceAdapt==='function') enforceAdapt();
BOT._adaptRegime=reg;
}
// Regime panel mirrors the LIVE Kite signals (unless the user is running a what-if): feed the
// real signals into the sliders so the gauge, composite and AUTO switching reflect the real market.
if(BOT.live && !state.simOverride && typeof syncSlidersFromLive==='function' && syncSlidersFromLive()){
if(!BOT._regimeSynced){ BOT._regimeSynced=true; recompute({silent:true}); } // adopt live regime quietly on first connect
else recompute(); // later polls → AUTO reacts to genuine flips
} else if(typeof renderEngineSrc==='function'){ renderEngineSrc(); }
if(BOT.live) connectStream(); else disconnectStream(); // sub-second push when live; keeps symset in sync
applyFunds(); updateClock();
// renderTopIndex + renderRegimeBar self-guard (patch in place / skip when unchanged) → cheap every poll.
if(typeof renderTopIndex==='function') renderTopIndex();
if(typeof renderRegimeBar==='function') renderRegimeBar(state.displayed);
// The heavy panels do FULL innerHTML rebuilds, so only run them when something STRUCTURAL changed
// (regime, connection, funds, holdings, symbol set, selection, layout/persona/tab, a trade opening
// or closing). Live prices stay fresh via the 2s loops (applyTickDom / patchTape / patchAlgoLive).
const _msig=marketSig();
if(_msig!==BOT._mktSig){
BOT._mktSig=_msig;
if(typeof renderWatchlist==='function') renderWatchlist(state.displayed);
if(typeof renderWidgetStack==='function') renderWidgetStack();
if(typeof renderDeskView==='function') renderDeskView(); // refresh canvas cards (movers/pnl/heatmap) with live SYMS
if(typeof renderChart==='function') renderChart(state.displayed); // pull real candles once live
if(typeof renderPanel==='function') renderPanel(state.displayed); // holdings/positions panels -> real
if(typeof isAlgo==='function' && isAlgo() && typeof renderAlgo==='function') renderAlgo();
}
}
/* ---- REAL-TIME ticks via Kite WebSocket (/api/ticks, KiteTicker-fed). Updates only
the watchlist price cells + chart last candle IN PLACE (no full re-render → drag,
sort and selection survive). Self-gates on live + visible tab. ---- */
/* ===== Sub-second PUSH via Server-Sent Events (bot→browser stream) =====
The bot holds one Kite WebSocket and pushes each tick to the browser over /api/stream
(EventSource) the instant it lands, no 2s polling lag. The 2s poll below stays as an
automatic fallback: it only fires when the stream isn't actively delivering (first paint,
stream dropped, or EventSource unsupported), so prices are never stale and never doubled. */
const STREAM={es:null, syms:'', on:false, last:0};
function streamSyms(){ return liveKeys().join(','); } // EXCH:TS keys: watchlist ∪ rolling-tape indices
function connectStream(){
if(!BOT.live || typeof EventSource==='undefined') return;
const keys=streamSyms();
if(STREAM.es && STREAM.syms===keys) return; // already streaming this exact set
disconnectStream();
STREAM.syms=keys;
try{
const es=new EventSource(`${BOT_API}/api/stream?keys=${encodeURIComponent(keys)}`);
es.onopen=()=>{ STREAM.on=true; STREAM.last=Date.now(); };
es.onmessage=ev=>{ try{ const d=JSON.parse(ev.data);
if(d&&d.ticks){ applyTicks(d.ticks); STREAM.on=true; STREAM.last=Date.now(); if(d.stream)BOT.tickStream=d.stream; } }catch(e){} };
es.onerror=()=>{ STREAM.on=false; }; // EventSource auto-reconnects; the poll covers the gap
STREAM.es=es;
}catch(e){ STREAM.on=false; }
}
function disconnectStream(){ if(STREAM.es){ try{STREAM.es.close();}catch(e){} } STREAM.es=null; STREAM.on=false; STREAM.syms=''; }
// Apply a PARTIAL tick update (only the instruments that ticked) keyed by EXCH:TS: push stream.
function applyTicks(ticks){
if(!ticks) return; const dir={};
for(const key in ticks){ const t=ticks[key], s=byKey(key);
if(s&&t&&t.ltp!=null){ dir[key]=Math.sign(t.ltp-(s.ltp||t.ltp)); s.ltp=t.ltp; if(t.chg!=null)s.chg=t.chg; s.live=true; } }
applyTickDom(ticks,dir);
applyTickerTicks(ticks); // same ticks drive the rolling tape (indices + pinned stocks) in lock-step
}
let TICK_BUSY=false;
async function loadTicks(){
if(TICK_BUSY || !BOT.live || document.visibilityState!=='visible') return;
if(STREAM.on && Date.now()-STREAM.last < 6000) return; // push stream is live → skip the poll
TICK_BUSY=true;
try{
const d=await fetch(`${BOT_API}/api/ticks?keys=${encodeURIComponent(liveKeys().join(','))}`).then(r=>r.json());
if(d&&d.ticks){
const dir={};
SYMS.forEach(s=>{const k=itemKey(s),t=d.ticks[k];
if(t&&t.ltp!=null){ dir[k]=Math.sign(t.ltp-(s.ltp||t.ltp)); s.ltp=t.ltp; if(t.chg!=null)s.chg=t.chg; s.live=true; }
else { s.live=false; }});
applyTickDom(d.ticks,dir);
applyTickerTicks(d.ticks); // refresh the rolling tape's indices on the same poll
BOT.tickStream=d.stream;
}
}catch(e){}
TICK_BUSY=false;
}
function applyTickDom(ticks,dir){
document.querySelectorAll('#wlRows .wl-row').forEach(row=>{
const key=row.dataset.key, t=ticks[key]; if(!t||t.ltp==null) return;
const ltpEl=row.querySelector('.wl-ltp'), chgEl=row.querySelector('.wl-chg');
if(ltpEl){ ltpEl.textContent=(+t.ltp).toLocaleString('en-IN'); ltpEl.classList.remove('muted');
const dr=dir[key]; if(dr){ltpEl.classList.remove('tick-up','tick-dn');void ltpEl.offsetWidth;ltpEl.classList.add(dr>0?'tick-up':'tick-dn');} }
if(chgEl){ const c=t.chg!=null?t.chg:0; chgEl.textContent=pct(c); chgEl.className='wl-chg num '+cls(c); }
});
// nudge the chart's last candle for the SELECTED instrument
const si=state.selected&&bySym(state.selected); const t=si&&ticks[itemKey(si)];
if(t&&t.ltp!=null&&window.TPChart&&TPChart.tick) TPChart.tick(si.sym,t.ltp);
}
/* ---- REAL 5-level market depth (/api/depth, full Kite quote().depth). Targeted body
update so it never rebuilds the whole widget stack. Empty levels show " (" (honest)
the order book is thin/empty after 15:30; all 5 levels fill during market hours). ---- */
let DEPTH_BUSY=false;
async function loadDepth(sym){
if(!BOT.live || !sym || DEPTH_BUSY) return;
DEPTH_BUSY=true;
try{
const d=await fetch(`${BOT_API}/api/depth?symbol=${encodeURIComponent(sym)}`).then(r=>r.json());
BOT.depth=(d&&!d.error)?d:{symbol:sym,bids:[],asks:[],error:(d&&d.error)||'no data'};
}catch(e){ BOT.depth={symbol:sym,bids:[],asks:[],error:'fetch failed'}; }
DEPTH_BUSY=false;
const body=document.querySelector('.wg-card[data-wkey="depth"] .wg-body');
if(body && BOT.depth.symbol===(state.selected||'RELIANCE')){
body.innerHTML = BOT.depth.error
? `
No depth for ${esc(BOT.depth.symbol)}, ${esc(BOT.depth.error)}.
`
: depthLadderHtml(BOT.depth);
}
}
function depthLadderHtml(d){
let rows='';
for(let i=0;i<5;i++){const b=d.bids[i]||{}, a=d.asks[i]||{};
rows+=`
${rows}${foot}`;
}
// FAST, lean poll for the live Monitor, every strategy's real-time P&L from /api/monitor (cached ~1ms).
async function loadMonitor(){
try{
const m=await fetch(BOT_API+'/api/monitor').then(r=>r.json());
if(m&&m.running){
BOT.monitor=m;
const byId={}; m.running.forEach(r=>byId[r.id]=r);
ALGOS.forEach(a=>{const r=byId[a.id]; if(r){
a.paperPnl=r.paperPnl; a.openPnl=r.openPnl; a.realisedPnl=r.realisedPnl;
// DON'T clobber a.live here: that conflated "subscribed" with "monitored" and raced
// loadBotData every 2s (the old P&L flip-flop). All algo tabs filter on a.deployed (stable,
// from /api/strategies); /api/monitor now returns only subscribed strategies, so they agree.
a.openPositions=r.openPositions; a.fwdTrades=r.fwdTrades; a.positions=r.positions;
// forward-test ACCURACY (real out-of-sample, parsed from closed paper trades)
a.fwdWins=r.fwdWins; a.fwdLosses=r.fwdLosses; a.fwdWinPct=r.fwdWinPct;
a.fwdProfitFactor=r.fwdProfitFactor; a.fwdAvgWin=r.fwdAvgWin; a.fwdAvgLoss=r.fwdAvgLoss; a.fwdExpectancy=r.fwdExpectancy;
}});
}
}catch(e){}
}
function fundsText(){
if(BOT.live && BOT.market && typeof BOT.market.funds==='number') return inr(BOT.market.funds);
return '-';
}
function applyFunds(){ const e=$('fundsVal'); if(e) e.textContent=fundsText(); }
// Map a ticker/headline name to its REAL quote from /api/market (indices+commodities) or /api/quotes (stocks).
const MKT_INDEX={'NIFTY 50':['indices','NIFTY 50'],'SENSEX':['indices','SENSEX'],'BANK NIFTY':['indices','NIFTY BANK'],
'FIN NIFTY':['indices','NIFTY FIN SERVICE'],'GOLD':['commodities','GOLD'],'SILVER':['commodities','SILVER'],'CRUDE OIL':['commodities','CRUDEOIL']};
// EXCH:TS tick keys for the headline instruments. Indices stream over the Kite WS (sub-second);
// MCX commodities don't, so they have no tick key and fall back to the 30s /api/market snapshot.
const TICKER_TICK_KEY={'NIFTY 50':'NSE:NIFTY 50','SENSEX':'BSE:SENSEX','BANK NIFTY':'NSE:NIFTY BANK','FIN NIFTY':'NSE:NIFTY FIN SERVICE'};
// {tickKey -> tickerName} for every ticker item that can stream live (indices + any pinned, watched stock).
function tickerTickMap(){
const out={};
(state.ticker.items||[]).forEach(name=>{
const k=TICKER_TICK_KEY[name];
if(k){ out[k]=name; return; } // headline index
const s=bySym(name); if(s) out[itemKey(s)]=name; // a stock pinned to the tape (rides its watchlist quote)
});
return out;
}
// All EXCH:TS keys the live tick path should subscribe/poll: watchlist ∪ ticker indices.
function liveKeys(){ const set=new Set(watchKeys()); Object.keys(tickerTickMap()).forEach(k=>set.add(k)); return [...set]; }
// Fold a fresh tick payload into the ticker's live overlay, then patch the tape in place. Returns true if anything changed.
// Iterates the ticker's OWN keys (not the payload) so a poll that returns a null/stale quote drops the overlay entry,
// indices then fall back to the 30s snapshot, a stale pinned stock blanks honestly. SSE partials (key absent) are left alone.
function applyTickerTicks(ticks){
if(!ticks) return false; const map=tickerTickMap(); let changed=false;
for(const key in map){ const name=map[key], t=ticks[key];
if(t===undefined) continue; // not in this (partial SSE) payload → leave as-is
if(t && t.ltp!=null){ (BOT.tickerLive||(BOT.tickerLive={}))[name]={ltp:t.ltp,chg:t.chg!=null?t.chg:0}; changed=true; }
else if(BOT.tickerLive && BOT.tickerLive[name]!=null){ delete BOT.tickerLive[name]; changed=true; } // source went stale
}
if(changed) patchTopIndex();
return changed;
}
function realQuote(name){
if(!BOT.live) return null;
// 1) live tick overlay (indices stream sub-second; refreshed every tick by the fast path)
const lv=BOT.tickerLive&&BOT.tickerLive[name];
if(lv&&lv.ltp!=null) return {ltp:lv.ltp,chg:lv.chg};
// 2) headline index/commodity from the latest /api/market snapshot (commodities don't tick → 30s refresh)
const m=MKT_INDEX[name];
if(m){ if(!BOT.market) return null; const o=(BOT.market[m[0]]||{})[m[1]]; return (o&&o.ltp!=null)?{ltp:o.ltp,chg:o.chgPct}:null; }
// 3) a stock pinned to the tape → its live watchlist quote (SYMS carry tick-updated ltp/chg)
const s=bySym(name); return (s&&s.live&&s.ltp!=null)?{ltp:s.ltp,chg:s.chg||0}:null;
}
// Manual reconnect: trigger the headless TOTP re-login, then refresh everything.
async function botReconnect(){
if(BOT.reconnecting) return; BOT.reconnecting=true;
if(isAlgo()&&typeof renderAlgo==='function') renderAlgo();
let res={};
try{ res=await fetch(BOT_API+'/api/relogin',{method:'POST'}).then(r=>r.json()); }catch(e){ res={ok:false,error:'bot API offline'}; }
BOT.reconnecting=false;
if(res&&res.ok){ announce('Kite reconnected'); }
else { announce('Reconnect failed'); quickToast&&quickToast('Couldn’t auto-reconnect', (res&&res.error)||'Run python3 login.py in the bot folder.'); }
await loadBotData(); await loadMarket(); // refresh status + prices either way
if(isAlgo()&&typeof renderAlgo==='function') renderAlgo();
if(onboarding) renderOnboardConnect(); // reflect the new connection state in the wizard
}
document.addEventListener('click',e=>{ if(e.target.closest('[data-relogin]')) botReconnect(); });
/* ---- Live patching: update only the ticking values in place (no full re-render → no flicker) ---- */
function algoLiveSig(){
// the STRUCTURE that, if it changes (a trade opens/closes, a strategy goes live), needs a full re-render
return (state.algo&&state.algo.view)+'~'+(state.algo&&state.algo.exec)+'~'+
ALGOS.map(a=>`${a.id}.${a.live?1:0}.${a.openPositions||0}.${a.fwdTrades||0}.${(a.positions||[]).map(p=>p.sym).join(',')}`).join('|');
}
function patchAlgoLive(){
const exec=(state.algo&&state.algo.exec)||'paper';
const setNum=(el,v)=>{ if(!el)return; el.textContent=sgn(v||0); el.classList.remove('up','down'); el.classList.add(cls(v||0)); };
let total=0, totalR=0, totalU=0, depTotal=0; // total = actively-running IN THE ACTIVE CLASS (Monitor); totalR/U = its realised/unrealised split; depTotal = all deployed (Forward Test)
const brk={equity:0,options:0,futures:0}; let overall=0; // per-class + combined (all classes) for the Monitor breakdown
ALGOS.forEach(a=>{
const v=exec==='live'?(a.livePnl||0):(a.paperPnl||0);
if(a.live && inScope(a)){ total+=v; totalR+=(a.realisedPnl||0); totalU+=(a.openPnl||0); } // scoped to the Equity/Options/Futures toggle, matches the list shown
if(a.deployed) depTotal+=v;
if(a.deployed){ const ik=algoInstr(a); if(brk[ik]!=null) brk[ik]+=v; overall+=v; }
document.querySelectorAll('[data-live-pnl="'+a.id+'"]').forEach(el=>setNum(el,v));
const sub=document.querySelector('[data-live-sub="'+a.id+'"]');
if(sub) sub.textContent=(a.fwdTrades||a.openPositions||a.realisedPnl||a.openPnl)?`R ${sgn(a.realisedPnl||0)} · U ${sgn(a.openPnl||0)}`:'no trades yet'; // both realised & unrealised, live
(a.positions||[]).forEach(p=>{
if(p.entry==null) return;
const key=a.id+'::'+p.sym;
const ltp=document.querySelector('[data-live-ltp="'+key+'"]'); if(ltp) ltp.textContent=p.ltp!=null?p.ltp.toLocaleString('en-IN'):'-';
const up=document.querySelector('[data-live-upnl="'+key+'"]');
if(up){ up.textContent=sgn(p.unreal)+(p.chgPct!=null?` · ${p.chgPct>=0?'+':''}${p.chgPct}%`:''); up.classList.remove('up','down'); up.classList.add(cls(p.unreal)); }
});
});
document.querySelectorAll('[data-live="monReal"]').forEach(el=>setNum(el,totalR));
document.querySelectorAll('[data-live="monUnreal"]').forEach(el=>setNum(el,totalU));
document.querySelectorAll('[data-live="monNet"]').forEach(el=>setNum(el,totalR+totalU));
document.querySelectorAll('[data-live="algoTotal"]').forEach(el=>setNum(el,depTotal));
document.querySelectorAll('[data-live="monOverall"]').forEach(el=>setNum(el,overall));
document.querySelectorAll('[data-live="brkEquity"]').forEach(el=>setNum(el,brk.equity));
document.querySelectorAll('[data-live="brkOptions"]').forEach(el=>setNum(el,brk.options));
document.querySelectorAll('[data-live="brkFutures"]').forEach(el=>setNum(el,brk.futures));
let anReal=0, anOpen=0;
ALGOS.forEach(a=>{ if(a.live){ anReal+=(a.realisedPnl||0); anOpen+=(a.openPnl||0); } });
document.querySelectorAll('[data-live="anRealised"]').forEach(el=>setNum(el,anReal));
document.querySelectorAll('[data-live="anOpen"]').forEach(el=>setNum(el,anOpen));
document.querySelectorAll('[data-live="anBook"]').forEach(el=>setNum(el,total));
// Moonshot Mission banner (computed inline in missionBanner, so patch its live numbers here)
const ms=ALGOS.find(a=>a.id==='moonshot');
if(ms){ const START=5000, TARGET=5e10, eq=START+(ms.paperPnl||0), mult=eq/START;
const prog=Math.max(0,Math.min(100,Math.log(Math.max(eq,1)/START)/Math.log(TARGET/START)*100));
document.querySelectorAll('[data-live="msnEq"]').forEach(el=>{ el.textContent=inrShort(eq); el.className=(ms.paperPnl||0)>=0?'up':'down'; });
document.querySelectorAll('[data-live="msnMult"]').forEach(el=>{ el.textContent=mult>=1?mult.toFixed(mult>=100?0:2)+'× start':'−'+((1-mult)*100).toFixed(1)+'%'; });
document.querySelectorAll('[data-live="msnFill"]').forEach(el=>{ el.style.width=prog.toFixed(4)+'%'; });
document.querySelectorAll('[data-live="msnProg"]').forEach(el=>{ el.textContent=prog.toFixed(prog<1?4:2)+'% of the way (log scale)'; });
}
}
/* ============================================================
STRATEGY LIBRARY: every strategy family, as honest, browseable,
educational entries. Risk-first: each leads with how it FAILS and
the survival guard, not just the upside. Entries are "Candidate"
(recognised, not yet engine-backtested) until a backend-validated
strategy of the same id graduates them. The whole survival ethos:
pick what fits TODAY's regime, stand aside when nothing does.
============================================================ */
const STRAT_FAMILIES=[
['trend', 'Trend / Momentum', 'trendUp', 'Ride a move that has already started; lose small when it reverses.'],
['meanrev', 'Mean Reversion', 'repeat', 'Fade a stretched price back to its average. Wins often, must cap the rare big loss.'],
['breakout', 'Breakout / Volatility','bolt', 'Enter as price escapes a range on expanding volume.'],
['time', 'Time / Calendar', 'clock', 'The clock is the signal, time-of-day, expiry, day-of-week, auto square-off.'],
['statarb', 'Stat-Arb / Pairs', 'swap', 'Trade the spread between two related instruments, not the market direction.'],
['event', 'Event-Driven', 'flag', 'Position around a scheduled catalyst, results, rebalances, news.'],
['sentiment', 'Sentiment / OI / Flow','spark', 'Read positioning, open interest, PCR, max-pain, FII/DII flows.'],
['factor', 'Factor / Rotation', 'pie', 'Rotate a basket by a persistent edge, momentum, low-vol, quality, sector.'],
['ml', 'ML / Regime', 'cpu', 'A model picks the signal or switches the active strategy by regime.'],
['opt_dir', 'Options · Directional','target', 'Express a view with limited, known risk using long options or debit spreads.'],
['opt_income', 'Options · Income', 'wallet', 'Sell premium for steady credit, capped upside, must defend the tails.'],
['opt_vol', 'Options · Volatility', 'scale', 'Trade volatility itself, straddles, calendars, delta-neutral theta.'],
['arb', 'Cash-Futures / Arb', 'scissors','Lock a near-riskless spread between cash & futures or across expiries.'],
];
const SEG_LABEL={equity:'Equity',index:'Index F&O',fno:'Stock F&O',options:'Options',any:'Any market'};
/* ── Instrument × holding-style bifurcation (Library navigation) ──────────────
Primary class is DERIVED from the catalog seg (equity→Equity, options→Options,
index|fno→Futures). Holding style is an explicit per-strategy tag, a judgement
call about how the strategy is actually held; retune freely in STRAT_HOLD. */
const INSTR_LABEL={equity:'Equity',options:'Options',futures:'Futures'};
const INSTR_TABS=[
['equity', 'Equity', 'layers', 'Cash stocks, delivery & intraday'],
['options','Options', 'target', 'Defined-risk & premium, calls & puts'],
['futures','Futures', 'trendUp', 'Leveraged index & stock F&O'],
];
const HOLD_LABEL={carry:'Carry-forward',intraday:'Intraday',swing:'Swing',expiry:'Expiry',scalper:'Scalper'};
const HOLD_DESC={
carry:'Positional, held overnight to multi-day (CNC / carried F&O).',
intraday:'Entered and squared off within the same session.',
swing:'Held a few days on a directional or volatility view.',
expiry:'Anchored to the options expiry cycle, theta, pin, expiry-day.',
scalper:'Very short intraday, many small, fast trades.',
};
// per-instrument holding-style tabs (order as the user mapped them)
const HOLD_TABS={
equity:['carry','intraday','scalper'],
options:['carry','intraday','swing','expiry','scalper'],
futures:['carry','intraday','swing','expiry','scalper'],
};
const instrOf=s=>(s.seg==='equity'||s.seg==='any')?'equity':(s.seg==='options')?'options':'futures';
const STRAT_HOLD={
// Equity (cash stocks)
lib_adx:'carry',lib_rsmom:'carry',lib_rsi2:'carry',lib_bollrev:'carry',lib_donchian:'carry',
lib_vcp:'carry',lib_btst:'carry',lib_pairs:'carry',lib_earnings:'carry',lib_rebal:'carry',
lib_sectorrot:'carry',lib_mompf:'carry',lib_lowvol:'carry',lib_quality:'carry',lib_mlsignal:'carry',
lib_nr7:'intraday',lib_eodsq:'intraday',lib_news:'intraday',lib_gapfill:'scalper',
// Futures (index & stock F&O)
lib_macross:'carry',lib_fiiflow:'carry',lib_regimeswitch:'carry',lib_supertrend:'carry',
lib_orb:'intraday',lib_dow:'swing',lib_ratio:'swing',lib_oibuildup:'swing',
lib_idxarb:'expiry',lib_cashfut:'expiry',lib_calroll:'expiry',lib_ema921:'scalper',lib_vwaprev:'scalper',
// Options
lib_coveredcall:'carry',lib_calendar:'carry',lib_deltaneutral:'carry',lib_longopt:'intraday',
lib_pcr:'swing',lib_debit:'swing',lib_credit:'swing',lib_straddle:'swing',
lib_expiry:'expiry',lib_maxpain:'expiry',lib_strangle:'expiry',lib_condor:'expiry',lib_920:'scalper',
};
const holdOf=s=>STRAT_HOLD[s.id]||'carry';
/* Same Equity/Options/Futures × holding-style classes, but for the REAL backend bots (ALGOS).
Instrument from segment+name; holding from product (CNC=carry, MIS=intraday, NRML=swing) with
an explicit per-id map for the cases where that's too coarse. Scope is studio-wide. */
const ALGO_HOLD={
orb:'intraday',vwap_rev:'scalper',vwap_mom:'scalper',ema_scalp:'scalper',bb_breakout:'intraday',meanrev:'intraday',
momentum:'carry',rsi2:'carry',macross:'carry',supertrend:'carry',lowvol:'carry',xs_momentum:'carry',
ema_cross:'carry',adx_trend:'carry',bollinger:'carry',zscore:'carry',nr7:'carry',opportunity:'carry',moonshot:'carry',
pairs:'swing',strangle:'expiry',fut_trend:'swing',iron_condor:'expiry',basis:'expiry',mcx_trend:'swing',goldsilver:'swing',
};
function algoInstr(a){ const id=(a&&a.id||'').toLowerCase(), seg=(a&&a.segment||'').toLowerCase(), nm=(a&&a.name||'').toLowerCase();
if(/strangle|condor|straddle|option|theta|premium selling|iron/.test(id+' '+nm)) return 'options';
if(seg==='cash') return 'equity';
if(seg==='fno'||seg==='commodity') return 'futures';
return 'equity'; }
// Crypto deployed = Σ open-position notional (qty×entry); perps book 20% margin. Options premium ≈ 0 notional.
function cryptoDeployed(s){ const seg=(s&&s.instr)||'spot'; return (s&&s.positions||[]).reduce((a,p)=>{
const n=Math.abs((p.qty||0)*(p.entry||0)); return a+(seg==='perps'?n*0.20:n); },0); }
// Shared monitor sort+filter (Indian & crypto books). Returns {sorted, bar}; chips reuse state.algo.monSort/monFilter.
const MON_SORTS=[['pnl','Top P&L'],['unreal','Unrealised'],['deployed','Deployed'],['open','Open'],['name','Name']];
const MON_FILTS=[['all','All'],['profit','In profit'],['loss','Losing'],['open','Open now']];
function monSortFilter(list){
const mSort=state.algo.monSort||'pnl', mFilt=state.algo.monFilter||'all';
const filtFn=({all:()=>true,profit:x=>(x.paperPnl||0)>0,loss:x=>(x.paperPnl||0)<0,open:x=>(x.openPositions||0)>0})[mFilt]||(()=>true);
const sortFn=({pnl:(a,b)=>(b.paperPnl||0)-(a.paperPnl||0),unreal:(a,b)=>(b.openPnl||0)-(a.openPnl||0),open:(a,b)=>(b.openPositions||0)-(a.openPositions||0),
deployed:(a,b)=>(b._dep||0)-(a._dep||0),name:(a,b)=>String(a.name||'').localeCompare(String(b.name||''))})[mSort]||((a,b)=>(b.paperPnl||0)-(a.paperPnl||0));
const sorted=list.filter(filtFn).sort(sortFn);
const bar=`
Sort${MON_SORTS.map(([k,l])=>``).join('')}
`+
`
Show${MON_FILTS.map(([k,l])=>``).join('')}${sorted.length} of ${list.length}
`;
return {sorted,bar};
}
function algoHold(a){ const id=(a&&a.id||'').toLowerCase(); if(ALGO_HOLD[id]) return ALGO_HOLD[id];
if(/scalp/.test(id)) return 'scalper'; const p=(a&&a.product||'').toUpperCase();
if(p==='MIS') return 'intraday'; if(p==='NRML') return 'swing'; return 'carry'; }
// studio-wide instrument × holding scope, the single source of truth every Algo tab honours
function studioScope(){ const a=state.algo=state.algo||{};
if(!a.instr){ a.instr=(a.lib&&a.lib.instr)||'equity'; } if(!a.hold){ a.hold=(a.lib&&a.lib.hold)||'all'; }
return {instr:a.instr,hold:a.hold}; }
function inScope(a){ const sc=studioScope(); return algoInstr(a)===sc.instr && (sc.hold==='all'||algoHold(a)===sc.hold); } // ALGOS
// risk → existing badge class (Conservative=b-up, Moderate=b-neu, Aggressive=b-warn)
const STRAT_LIBRARY=[
// ---- Trend / Momentum ----
{id:'lib_macross',name:'Moving-Average Crossover',fam:'trend',risk:'Moderate',seg:'index',best:'Bull',
what:'Goes long when a fast MA crosses above a slow MA; flat/short when it crosses below.',
rule:'BUY when price/fast-MA crosses above the slow MA (e.g. 50 over 200); exit on the opposite cross.',
works:'Sustained, trending markets, it catches the meat of a directional move.',
fails:'Choppy, sideways markets, it gets whipsawed, buying high and selling low repeatedly.',
guard:'Add a regime filter (only take longs in Bull) and a hard stop; stand aside in Choppy.',
params:[['Fast MA','50'],['Slow MA','200'],['Stop','ATR×2']]},
{id:'lib_ema921',name:'EMA 9/21 Momentum',fam:'trend',risk:'Aggressive',seg:'index',best:'Bull',
what:'Faster intraday version of the crossover for momentum bursts.',
rule:'Long when EMA9 > EMA21 and price holds above both; exit when EMA9 crosses back below EMA21.',
works:'Strong intraday trends and momentum days with a clear direction.',
fails:'Range-bound, low-volume sessions, frequent small losses pile up.',
guard:'Cap trades/day, trade only the trending session window, square off by close.',
params:[['Fast EMA','9'],['Slow EMA','21'],['Max trades/day','3']]},
{id:'lib_supertrend',name:'Supertrend Follow',fam:'trend',risk:'Moderate',seg:'fno',best:'Bull',
what:'Trails an ATR-based band; flips long/short as price closes through it.',
rule:'Long when price closes above the Supertrend line; reverse/flat when it closes below.',
works:'Clean trends with steady volatility, the band trails the move well.',
fails:'Sharp volatility spikes flip it repeatedly (false reversals).',
guard:'Use a longer ATR period in High-Vol; size down when VIX is elevated.',
params:[['ATR period','10'],['Multiplier','3']]},
{id:'lib_adx',name:'ADX Trend Filter',fam:'trend',risk:'Conservative',seg:'equity',best:'Bull',
what:'Only trades when trend strength (ADX) confirms a real trend exists.',
rule:'Take trend entries only when ADX > 25 and rising; otherwise stay flat.',
works:'As a filter on top of any trend system, it cuts the worst chop trades.',
fails:'Lags at trend births and ends; can keep you out of early moves.',
guard:'Pair with a momentum trigger so you are not late; never trade ADX < 20.',
params:[['ADX threshold','25'],['Lookback','14']]},
{id:'lib_rsmom',name:'Relative-Strength Rotation',fam:'trend',risk:'Moderate',seg:'equity',best:'Bull',
what:'Holds the strongest stocks in a universe, drops the laggards.',
rule:'Rank a universe by 3–6 month return; hold the top decile, rebalance monthly.',
works:'Trending broad markets where leadership persists.',
fails:'Sharp reversals/crashes, last month’s winners fall hardest.',
guard:'Add a market-trend filter (go to cash below the 200-DMA of the index).',
params:[['Lookback','6mo'],['Top N','10'],['Rebalance','Monthly']]},
// ---- Mean Reversion ----
{id:'lib_rsi2',name:'RSI(2) Pullback',fam:'meanrev',risk:'Moderate',seg:'equity',best:'Bull',
what:'Buys short, sharp dips inside an established uptrend.',
rule:'In an uptrend (price > 200-DMA), buy when RSI(2) < 10; exit when RSI(2) > 70 or on a time stop.',
works:'Uptrending markets that pull back and resume, high win rate.',
fails:'Catches a falling knife if the uptrend has actually broken.',
guard:'Never fade without the trend filter; hard time-stop so a loser can’t compound.',
params:[['RSI period','2'],['Entry','<10'],['Exit','>70']]},
{id:'lib_bollrev',name:'Bollinger Band Reversion',fam:'meanrev',risk:'Moderate',seg:'equity',best:'Choppy',
what:'Fades touches of the outer band back toward the mean.',
rule:'Buy a close below the lower band, exit at the middle band; mirror for shorts.',
works:'Range-bound, mean-reverting names with stable volatility.',
fails:'Trending breakouts, “cheap” keeps getting cheaper.',
guard:'Skip when bands are expanding fast (volatility regime change); stop beyond the band.',
params:[['Period','20'],['StdDev','2']]},
{id:'lib_vwaprev',name:'VWAP Reversion',fam:'meanrev',risk:'Aggressive',seg:'index',best:'Choppy',
what:'Intraday fade of price stretched far from VWAP back to it.',
rule:'Fade when price is >N stdev from VWAP with no fresh news; target VWAP.',
works:'Liquid intraday instruments on balanced, two-sided days.',
fails:'Trend days, price rides far from VWAP and never returns.',
guard:'Detect trend-day early (opening drive) and disable; tight per-trade stop.',
params:[['Band','2σ'],['Target','VWAP']]},
{id:'lib_gapfill',name:'Gap-Fill Fade',fam:'meanrev',risk:'Aggressive',seg:'equity',best:'Choppy',
what:'Fades an overnight gap betting it fills toward the prior close.',
rule:'On a moderate gap with no catalyst, fade toward the prior close; stop beyond the gap extreme.',
works:'Emotion-driven gaps with no real news behind them.',
fails:'News/results gaps that run, fading those is how accounts blow up.',
guard:'Never fade an earnings/news gap; cap gap size; hard stop at the day extreme.',
params:[['Max gap','2%'],['Stop','Day extreme']]},
// ---- Breakout / Volatility ----
{id:'lib_orb',name:'Opening Range Breakout',fam:'breakout',risk:'Aggressive',seg:'index',best:'High-Vol',
what:'Trades a break of the first 15–30 min range of the day.',
rule:'Mark the first 15-min high/low; go with a breakout on volume; stop at the opposite end.',
works:'High-volatility opens and trending days.',
fails:'Quiet, rangebound days produce repeated false breaks.',
guard:'Require a volume/ATR expansion to confirm; one re-entry max; square off intraday.',
params:[['Range','First 15m'],['Confirm','Volume>avg']]},
{id:'lib_donchian',name:'20-Day High Breakout',fam:'breakout',risk:'Moderate',seg:'equity',best:'Bull',
what:'The classic Turtle breakout, buy new highs, ride the trend.',
rule:'Buy a close above the 20-day high; trail with the 10-day low; exit on the trail.',
works:'Strong, persistent trends and momentum regimes.',
fails:'Whipsaws at range edges in sideways markets.',
guard:'ATR position sizing so each trade risks a fixed small % of capital.',
params:[['Entry','20-day high'],['Exit trail','10-day low']]},
{id:'lib_nr7',name:'NR7 / Inside-Bar Breakout',fam:'breakout',risk:'Moderate',seg:'equity',best:'High-Vol',
what:'Trades expansion out of the narrowest-range bar (a coiled spring).',
rule:'After an NR7 / inside bar, enter on a break of its range in the trend direction.',
works:'Post-consolidation volatility expansion.',
fails:'Failed breaks reverse straight through the other side.',
guard:'Trade only in the higher-timeframe trend direction; stop at the bar’s other extreme.',
params:[['Pattern','NR7'],['Filter','HTF trend']]},
{id:'lib_vcp',name:'Volatility Contraction',fam:'breakout',risk:'Moderate',seg:'equity',best:'Bull',
what:'Buys breakouts from tightening bases (contracting volatility).',
rule:'Identify successively tighter pullbacks on declining volume; buy the pivot breakout.',
works:'Leading stocks in bull markets forming clean bases.',
fails:'Late-stage bases and bear markets, most breakouts fail.',
guard:'Tight stop under the pivot; only in confirmed uptrends.',
params:[['Base','Tightening'],['Stop','Under pivot']]},
// ---- Time / Calendar ----
{id:'lib_920',name:'9:20 ORB (time entry)',fam:'time',risk:'Aggressive',seg:'options',best:'High-Vol',
what:'A fixed-time entry just after the open captures the day’s initial drive.',
rule:'At 9:20, enter in the direction of the opening 5-min candle; SL at its low/high.',
works:'Days with a strong directional open.',
fails:'Flat opens reverse and stop you out fast.',
guard:'Predefined SL/target, one shot per day, hard square-off time.',
params:[['Entry','09:20'],['Square-off','15:15']]},
{id:'lib_eodsq',name:'Intraday Auto Square-off',fam:'time',risk:'Conservative',seg:'any',best:'Any',
what:'A discipline overlay, flatten everything before the close, no overnight risk.',
rule:'Force-exit all intraday positions at a set time (e.g. 15:15) regardless of P&L.',
works:'Every intraday strategy, removes gap risk and emotional holding.',
fails:'Can exit a winner early; that is the price of zero overnight risk.',
guard:'This IS the guard, pair with any intraday system.',
params:[['Square-off','15:15']]},
{id:'lib_btst',name:'BTST Momentum',fam:'time',risk:'Aggressive',seg:'equity',best:'Bull',
what:'Buy strong-closing stocks today, sell tomorrow on follow-through.',
rule:'Buy names closing at day highs on volume; exit next morning on strength or a stop.',
works:'Strong-trend markets with overnight continuation.',
fails:'Overnight gap-downs on negative global cues, full overnight risk.',
guard:'Small size; avoid event nights; predefined gap-down exit.',
params:[['Hold','1 night'],['Exit','Next open']]},
{id:'lib_expiry',name:'Expiry-Day Theta',fam:'time',risk:'Aggressive',seg:'options',best:'Choppy',
what:'Sells options on expiry day to harvest the fastest time decay.',
rule:'Sell ATM/OTM options near the open on expiry; manage with a stop-loss on premium.',
works:'Pinned, low-movement expiry sessions.',
fails:'A trending expiry day, short options can lose multiples of the credit fast.',
guard:'Hard premium SL, defined-risk spreads not naked, size for the worst case.',
params:[['Day','Expiry'],['Stop','2× credit']]},
{id:'lib_dow',name:'Day-of-Week / Seasonality',fam:'time',risk:'Conservative',seg:'index',best:'Any',
what:'Exploits recurring calendar tendencies (e.g. monthly expiry, turn-of-month).',
rule:'Take positions only on statistically favourable calendar days from backtests.',
works:'When a seasonal edge is statistically robust out-of-sample.',
fails:'Overfitting, many “seasonal” edges are noise that vanish live.',
guard:'Demand a large sample + OOS proof before trusting; tiny size.',
params:[['Edge','Calendar'],['Proof','OOS required']]},
// ---- Stat-Arb / Pairs ----
{id:'lib_pairs',name:'Pairs Trading',fam:'statarb',risk:'Moderate',seg:'equity',best:'Choppy',
what:'Long one stock, short a correlated one when their spread stretches.',
rule:'When the spread z-score > 2, short the rich / long the cheap; exit at mean reversion.',
works:'Stable, cointegrated pairs in range-bound markets, market-neutral.',
fails:'When the relationship breaks (a fundamental change in one name).',
guard:'Re-test cointegration regularly; stop if the spread keeps diverging.',
params:[['Entry','z>2'],['Exit','z→0']]},
{id:'lib_ratio',name:'Ratio / Spread Trade',fam:'statarb',risk:'Moderate',seg:'fno',best:'Choppy',
what:'Trades the ratio between two related futures (e.g. sector pair).',
rule:'Mean-revert the historical ratio band between two related contracts.',
works:'Structurally linked instruments with a stable ratio.',
fails:'Regime shifts that permanently re-rate one leg.',
guard:'Hard spread stop; cap leverage, spreads still blow out.',
params:[['Band','Historical'],['Hedge','β-weighted']]},
{id:'lib_idxarb',name:'Index Arbitrage',fam:'statarb',risk:'Conservative',seg:'index',best:'Any',
what:'Captures mispricing between an index and its futures/constituents.',
rule:'When futures deviate from fair value beyond costs, trade the convergence.',
works:'High-liquidity, low-cost execution environments.',
fails:'Costs/slippage eat the tiny edge; needs fast execution.',
guard:'Only when net of all costs is clearly positive; automate execution.',
params:[['Edge','Basis'],['Need','Low latency']]},
// ---- Event-Driven ----
{id:'lib_earnings',name:'Earnings Drift',fam:'event',risk:'Aggressive',seg:'equity',best:'Any',
what:'Rides the post-results drift after a strong earnings surprise.',
rule:'After a big beat + gap on volume, enter on the day-1 close; trail the move.',
works:'Clear, high-quality surprises with institutional follow-through.',
fails:'“Buy the rumour, sell the news” fades; gaps that reverse.',
guard:'Wait for confirmation (no pre-results bet); stop under the results-day low.',
params:[['Trigger','Beat+gap'],['Stop','Day-1 low']]},
{id:'lib_rebal',name:'Index Rebalance',fam:'event',risk:'Moderate',seg:'equity',best:'Any',
what:'Front-runs forced index-fund buying/selling on add/drop announcements.',
rule:'Buy confirmed index additions / sell deletions ahead of the effective date.',
works:'Large, predictable passive flows around rebalance dates.',
fails:'Crowded trade, much of the move is already priced in.',
guard:'Enter early on the announcement, exit into the rebalance-day flow.',
params:[['Trigger','Add/Drop'],['Exit','Effective date']]},
{id:'lib_news',name:'News-Catalyst Momentum',fam:'event',risk:'Aggressive',seg:'equity',best:'High-Vol',
what:'Trades the immediate momentum from a material news catalyst.',
rule:'On confirmed material news + volume surge, trade the initial direction with a tight stop.',
works:'Genuine, high-impact catalysts with volume confirmation.',
fails:'Stale/priced-in news and fake-outs; spreads widen on the spike.',
guard:'Volume + price confirmation before entry; very tight stop; small size.',
params:[['Trigger','News+vol'],['Stop','Tight']]},
// ---- Sentiment / OI / Flow ----
{id:'lib_oibuildup',name:'OI Buildup',fam:'sentiment',risk:'Moderate',seg:'fno',best:'Bull',
what:'Reads price + open-interest together to classify long/short buildup.',
rule:'Price up + OI up = long buildup (go with it); price up + OI down = short covering (fade carefully).',
works:'Confirming a directional move with real positioning.',
fails:'OI lags; can mislead near expiry when positions roll.',
guard:'Combine with price action, not alone; avoid the last expiry hours.',
params:[['Inputs','Price+OI'],['Avoid','Expiry close']]},
{id:'lib_pcr',name:'PCR Contrarian',fam:'sentiment',risk:'Moderate',seg:'options',best:'Choppy',
what:'Uses the put-call ratio as a contrarian sentiment extreme gauge.',
rule:'Very high PCR (excess fear) = look for longs; very low PCR (greed) = caution/shorts.',
works:'Sentiment extremes that mark short-term turning points.',
fails:'In strong trends sentiment stays extreme far longer than you can stay solvent.',
guard:'Need a price-confirmation trigger; never fade a trend on PCR alone.',
params:[['High PCR','>1.3'],['Low PCR','<0.7']]},
{id:'lib_maxpain',name:'Max-Pain Pin',fam:'sentiment',risk:'Aggressive',seg:'options',best:'Choppy',
what:'Bets price gravitates to the max-pain strike into expiry.',
rule:'Sell defined-risk premium around the max-pain strike as expiry approaches.',
works:'Low-event, range-bound expiries where pinning tends to occur.',
fails:'Trending expiries ignore max-pain entirely.',
guard:'Defined-risk only; exit if price trends away from the pin.',
params:[['Anchor','Max-pain'],['Risk','Defined']]},
{id:'lib_fiiflow',name:'FII/DII Flow Follow',fam:'sentiment',risk:'Conservative',seg:'index',best:'Bull',
what:'Tilts with sustained institutional buying/selling pressure.',
rule:'Bias long on persistent net FII+DII inflows; reduce on sustained outflows.',
works:'As a slow regime/bias filter, not a precise timing signal.',
fails:'Flows are reported with a lag and are noisy day to day.',
guard:'Use as a bias overlay only; pair with a price trigger for timing.',
params:[['Input','Net flows'],['Use','Bias filter']]},
// ---- Factor / Rotation ----
{id:'lib_sectorrot',name:'Sector Rotation',fam:'factor',risk:'Moderate',seg:'equity',best:'Bull',
what:'Rotates capital into the strongest sectors, out of the weakest.',
rule:'Rank sectors by relative strength; overweight leaders, underweight laggards; rebalance monthly.',
works:'Markets with clear sector leadership cycles.',
fails:'Rapid rotations and reversals whipsaw the basket.',
guard:'Hold a diversified set; cap single-sector weight; trend filter on the index.',
params:[['Rank','Rel-strength'],['Rebalance','Monthly']]},
{id:'lib_mompf',name:'Momentum Factor Portfolio',fam:'factor',risk:'Moderate',seg:'equity',best:'Bull',
what:'Systematic long basket of the highest-momentum names.',
rule:'Hold top-momentum decile, equal-weight, monthly rebalance, market-trend filter.',
works:'Persistent bull trends, momentum is a durable long-run factor.',
fails:'Momentum crashes at sharp market turns.',
guard:'De-risk to cash below the index 200-DMA; cap volatility per name.',
params:[['Factor','12-1 mom'],['Filter','Index 200-DMA']]},
{id:'lib_lowvol',name:'Low-Volatility Factor',fam:'factor',risk:'Conservative',seg:'equity',best:'Bear',
what:'Holds the lowest-volatility names for smoother, defensive returns.',
rule:'Rank universe by realised volatility; hold the lowest-vol basket, rebalance quarterly.',
works:'Choppy/bear markets, it draws down far less.',
fails:'Lags badly in roaring bull markets.',
guard:'Use as the defensive sleeve; combine with momentum for balance.',
params:[['Factor','Low vol'],['Rebalance','Quarterly']]},
{id:'lib_quality',name:'Quality / Value Tilt',fam:'factor',risk:'Conservative',seg:'equity',best:'Any',
what:'Owns financially strong, reasonably-priced companies for the long run.',
rule:'Screen for high ROE/low debt + sensible valuation; hold long-term, rebalance yearly.',
works:'Long horizons, quality compounds and survives downturns.',
fails:'Can underperform for long stretches when junk rallies.',
guard:'Diversify; this is a survive-and-compound sleeve, not a trade.',
params:[['Screen','ROE/Debt/Val'],['Horizon','Years']]},
// ---- ML / Regime ----
{id:'lib_regimeswitch',name:'Regime-Switching Meta',fam:'ml',risk:'Moderate',seg:'index',best:'Any',
what:'A meta-strategy that turns sub-strategies on/off by the live regime.',
rule:'Detect regime (trend/chop/high-vol); run only the sub-strategy validated for that regime.',
works:'Across full market cycles, it stands aside when nothing fits.',
fails:'Regime detection lags at turning points; transitions are costly.',
guard:'This is the survival engine, when uncertain, it goes to cash.',
params:[['Detector','VIX+breadth+trend'],['Default','Stand aside']]},
{id:'lib_mlsignal',name:'ML Predictive Signal',fam:'ml',risk:'Aggressive',seg:'equity',best:'Any',
what:'A trained model outputs a directional probability used to size trades.',
rule:'Enter when model confidence clears a threshold; size proportional to confidence.',
works:'When the model has a genuine, walk-forward-validated edge.',
fails:'Overfitting and regime drift, the model decays as markets change.',
guard:'Strict walk-forward validation, live monitoring, kill-switch on decay.',
params:[['Validation','Walk-forward'],['Sizing','Confidence']]},
// ---- Options · Directional ----
{id:'lib_longopt',name:'Directional Long Option',fam:'opt_dir',risk:'Aggressive',seg:'options',best:'High-Vol',
what:'Buy a call or put for a leveraged, limited-risk directional bet.',
rule:'Buy ATM/ITM option in your direction; risk is capped at the premium paid.',
works:'Strong, fast directional moves where the move beats time decay.',
fails:'Time decay + falling IV bleed the premium even if you are “right” slowly.',
guard:'Size so total premium at risk is tiny; avoid buying into high IV.',
params:[['Strike','ATM/ITM'],['Risk','Premium only']]},
{id:'lib_debit',name:'Vertical Debit Spread',fam:'opt_dir',risk:'Moderate',seg:'options',best:'Bull',
what:'Directional bet with lower cost and lower time-decay drag than a naked long.',
rule:'Buy a near option, sell a further one in the same direction; defined risk & reward.',
works:'Moderate directional moves, cheaper and less IV-sensitive.',
fails:'Capped upside; still loses if the move doesn’t come.',
guard:'Risk = net debit, known up front; pick expiries with room for the move.',
params:[['Structure','Buy+Sell'],['Risk','Net debit']]},
// ---- Options · Income ----
{id:'lib_strangle',name:'Short Strangle',fam:'opt_income',risk:'Aggressive',seg:'options',best:'Choppy',
what:'Sell an OTM call and put to collect premium when price stays in a range.',
rule:'Sell OTM call + put; profit if price stays between strikes through expiry.',
works:'Range-bound, falling-volatility markets.',
fails:'A big move on either side, losses are theoretically unlimited if naked.',
guard:'Prefer the defined-risk Iron Condor; hard SL on premium; never naked + unhedged.',
params:[['Strikes','OTM C+P'],['Defend','SL on premium']]},
{id:'lib_condor',name:'Iron Condor',fam:'opt_income',risk:'Moderate',seg:'options',best:'Choppy',
what:'A defined-risk strangle, sells a range, buys wings to cap the tails.',
rule:'Sell an OTM call & put spread; max loss is capped by the long wings.',
works:'Range-bound markets with elevated IV to sell into.',
fails:'Trending/break-out moves push price through a short strike.',
guard:'Known max loss by design; adjust/roll the tested side; size to survive max loss.',
params:[['Wings','Long OTM'],['Risk','Defined']]},
{id:'lib_credit',name:'Credit Spread',fam:'opt_income',risk:'Moderate',seg:'options',best:'Bull',
what:'A one-sided defined-risk premium sell with a directional lean.',
rule:'Sell a put spread (bullish) or call spread (bearish); collect net credit.',
works:'When you have a directional bias and want a high-probability income trade.',
fails:'A move against you to the short strike realises the (capped) max loss.',
guard:'Risk = spread width − credit, fixed; close at a set loss multiple.',
params:[['Lean','Put/Call'],['Risk','Width−credit']]},
{id:'lib_coveredcall',name:'Covered Call',fam:'opt_income',risk:'Conservative',seg:'options',best:'Choppy',
what:'Sell calls against stock you own to earn income on flat-to-mild-up moves.',
rule:'Hold the stock, sell an OTM call each cycle; keep the premium if unexercised.',
works:'Sideways-to-slightly-up markets on holdings you already own.',
fails:'Caps your upside if the stock rallies hard; no downside protection.',
guard:'Only on stock you’re happy to sell at the strike; income, not protection.',
params:[['Need','Own stock'],['Strike','OTM call']]},
// ---- Options · Volatility / Neutral ----
{id:'lib_straddle',name:'Long Straddle',fam:'opt_vol',risk:'Aggressive',seg:'options',best:'High-Vol',
what:'Buy a call AND a put, profits from a big move either direction.',
rule:'Buy ATM call + put before an expected volatility event; profit on a large move.',
works:'Pre-event when a large move is likely and IV is still cheap.',
fails:'IV crush after the event + a small move, both legs bleed.',
guard:'Enter before IV ramps; exit fast post-event; cap premium at risk.',
params:[['Strikes','ATM C+P'],['Edge','Cheap IV']]},
{id:'lib_calendar',name:'Calendar Spread',fam:'opt_vol',risk:'Moderate',seg:'options',best:'Choppy',
what:'Sell a near-dated option, buy a far-dated one, harvest faster near-term decay.',
rule:'Same strike, sell front expiry / buy back expiry; profit from differential theta.',
works:'Stable price near the strike with a favourable term structure.',
fails:'A large directional move away from the strike hurts both legs.',
guard:'Defined-ish risk; manage if price leaves the strike zone.',
params:[['Legs','Sell front/Buy back'],['Edge','Theta diff']]},
{id:'lib_deltaneutral',name:'Delta-Neutral Theta',fam:'opt_vol',risk:'Moderate',seg:'options',best:'Choppy',
what:'Hold a net-zero-delta option book and earn time decay, re-hedging as it drifts.',
rule:'Sell premium, keep delta near zero by adjusting hedges as price moves.',
works:'Range-bound, mean-reverting volatility with active management.',
fails:'Gamma risk, fast moves force costly re-hedging (negative gamma bleed).',
guard:'Continuous monitoring + adjustment; cap gamma exposure; size small.',
params:[['Target','Δ≈0'],['Risk','Gamma']]},
// ---- Cash-Futures / Arb ----
{id:'lib_cashfut',name:'Cash-Futures Basis',fam:'arb',risk:'Conservative',seg:'fno',best:'Any',
what:'Lock the spread between a stock’s cash price and its future.',
rule:'When the future trades at a premium beyond carry, sell future / buy cash; converge at expiry.',
works:'Liquid names with a clear, cost-positive basis.',
fails:'Thin liquidity & costs erase the edge; needs capital for both legs.',
guard:'Only when net-of-cost positive; hold to convergence.',
params:[['Legs','Cash vs Future'],['Exit','Expiry']]},
{id:'lib_calroll',name:'Calendar Roll Spread',fam:'arb',risk:'Conservative',seg:'fno',best:'Any',
what:'Trade the spread between near and far expiries of the same future.',
rule:'Take the roll spread when it deviates from its typical band; revert as expiry nears.',
works:'Stable term structures with predictable roll behaviour.',
fails:'Demand/supply shocks distort the curve unexpectedly.',
guard:'Spread stop; modest leverage; close before the near expiry.',
params:[['Legs','Near vs Far'],['Edge','Roll band']]},
];
const LIB_RISK_CLASS={Conservative:'b-up',Moderate:'b-neu',Aggressive:'b-warn'};
/* ============================================================
CRYPTO MARKET LAYER: the Algo Studio can scope to Indian (Kite)
or Crypto (Binance). REAL prices only, no fake fallback: Binance's
public data mirror (no API key, read-only). Strategy EXECUTION is
preview/paper, honestly flagged. Real crypto orders would route via
the Binance Algo API (TWAP/POV), which needs server-side key signing
, that's the roadmap engine, never the browser.
============================================================ */
const CRYPTO_API='https://data-api.binance.vision'; // public market-data mirror: CORS-ok, not geo-fenced, no key
const CRYPTO_UNIVERSE=[
{sym:'BTCUSDT',tk:'BTC',name:'Bitcoin'}, {sym:'ETHUSDT',tk:'ETH',name:'Ethereum'},
{sym:'SOLUSDT',tk:'SOL',name:'Solana'}, {sym:'BNBUSDT',tk:'BNB',name:'BNB'},
{sym:'XRPUSDT',tk:'XRP',name:'XRP'}, {sym:'ADAUSDT',tk:'ADA',name:'Cardano'},
{sym:'DOGEUSDT',tk:'DOGE',name:'Dogecoin'},{sym:'AVAXUSDT',tk:'AVAX',name:'Avalanche'},
{sym:'LINKUSDT',tk:'LINK',name:'Chainlink'},{sym:'MATICUSDT',tk:'MATIC',name:'Polygon'},
];
const CRYPTO={loaded:false,live:false,error:false,quotes:{},t:0,busy:false};
function cryptoSyms(){ return CRYPTO_UNIVERSE.map(c=>c.sym); }
// Risk-first strategy TEMPLATES (educational, preview/paper), same survival-first ethos as the live library.
// wired: true only for bids the cloud worker actually runs (mirrors studio.js's DEPLOYABLE set
// for the crypto/customer path - keep the two in sync). The rest render normally but are honest
// about not having a live engine yet, instead of the old permanently-stuck "Engine loading" toast.
const CRYPTO_STRATEGIES=[
{id:'cx_btc_trend',bid:'macross',name:'BTC Trend (MA200)',cat:'Trend',risk:'Moderate',pair:'BTCUSDT',wired:true,
what:'Long BTC while it holds above its long-term moving average; flat below.',
rule:'BUY when price closes above the 200-period MA; exit on a close back below.',
works:'Strong, sustained bull legs, crypto trends long and hard.',
fails:'Chop around the MA whipsaws you in and out at small losses repeatedly.',
guard:'Only long above the MA; ATR-sized stop; one position; stand aside in chop.'},
{id:'cx_grid',bid:'bollinger',name:'Range Grid (ETH)',cat:'Mean-Reversion',risk:'Aggressive',pair:'ETHUSDT',wired:true,
what:'A ladder of staggered buys & sells across a defined band, harvesting oscillation.',
rule:'Buy each rung down, sell each rung up within a set price band.',
works:'Sideways, high-volatility ranges, it monetises the wiggle.',
fails:'A clean breakout leaves you holding the whole ladder against the move.',
guard:'Hard band-exit if price leaves the range; cap total grid exposure & leverage.'},
{id:'cx_funding',bid:'perp_funding',name:'Funding-Rate Carry',cat:'Income',risk:'Moderate',pair:'BTC spot vs perp',
what:'Delta-neutral: long spot, short perpetual, collect funding each interval.',
rule:'When funding is positive, hold spot + short perp; pocket the funding payments.',
works:'Calm, positive-funding regimes, steady market-neutral yield.',
fails:'Funding flips negative or the basis blows out in a liquidation cascade.',
guard:'Watch funding + basis; unwind on negative funding; respect exchange limits.'},
{id:'cx_rsi2',bid:'rsi2',name:'RSI-2 Dip (Alts)',cat:'Mean-Reversion',risk:'Aggressive',pair:'SOLUSDT',wired:true,
what:'Buys very short-term oversold dips inside a higher-timeframe uptrend.',
rule:'In an uptrend, BUY when RSI(2) < 5; exit when RSI(2) > 70 or after N bars.',
works:'Pullbacks within an established alt uptrend.',
fails:'Catching a falling knife once the trend has actually broken.',
guard:'Only above the 200-MA; time-stop + hard stop; small size on alts.'},
{id:'cx_breakout',bid:'momentum',name:'Volatility Breakout',cat:'Breakout',risk:'Aggressive',pair:'BTCUSDT',wired:true,
what:'Enters as price escapes a tight range on expanding volume.',
rule:'BUY on a close above the N-day high with above-average volume; trail a stop.',
works:'The start of a fresh expansion leg after compression.',
fails:'False breakouts in thin liquidity hours snap straight back.',
guard:'Require volume confirmation; trade liquid majors; trail, don’t fix a target.'},
{id:'cx_pairs',bid:'pairs',name:'ETH/BTC Ratio Pairs',cat:'Stat-Arb',risk:'Moderate',pair:'ETH vs BTC',
what:'Trades the ETH/BTC ratio back to its mean, neutral to overall crypto beta.',
rule:'Short the rich leg, long the cheap leg when the ratio z-score is stretched.',
works:'When ETH & BTC stay cointegrated and the spread mean-reverts.',
fails:'A narrative regime-shift breaks the relationship and the spread runs.',
guard:'Z-score entry/exit bands; stop if the spread breaks its historical range.'},
{id:'cx_dca',name:'Disciplined DCA',cat:'Time / Calendar',risk:'Conservative',pair:'BTCUSDT',
what:'Cost-averages a fixed amount on a fixed schedule, accumulation, not timing.',
rule:'Buy a fixed notional every interval regardless of price; optional dip boosts.',
works:'Long-horizon accumulation through full cycles; removes timing risk.',
fails:'Prolonged bear markets test conviction; capital sits in drawdown.',
guard:'Only commit what you can hold for years; size the schedule to your cashflow.'},
{id:'cx_momentum',bid:'xs_momentum',name:'Cross-Sectional Momentum',cat:'Factor / Rotation',risk:'Aggressive',pair:'Top-10 majors',
what:'Rotates into the strongest recent performers across a basket of majors.',
rule:'Each week, hold the top-N by trailing return; drop the laggards.',
works:'Persistent momentum regimes where winners keep winning.',
fails:'Sharp momentum crashes reverse and gut the whole basket at once.',
guard:'Cap per-name weight; vol-target the basket; kill-switch on a drawdown limit.'},
// These 5 were live on the worker (REGISTRY, featured:true - the same "proven net of costs" bar
// as the cards above) with no customer-facing card at all until now. The other 8 REGISTRY entries
// (orb, vwap_rev, vwap_mom, ema_scalp, bb_breakout, supertrend, vwap_pull, rsi_intraday) stay
// deliberately uncarded: strategies.py's own comments mark them "non-featured until validated" or
// "fail after costs so far" - surfacing those as Deploy-worthy would recommend strategies the
// team's own testing found unprofitable, with no disclosure.
{id:'cx_trendfollow',bid:'trend_follow',name:'Trend Rider (20/100)',cat:'Trend',risk:'Moderate',pair:'ETHUSDT',wired:true,
what:'Longs a fresh 20-day high while price holds above its 100-day trend average, then rides the move with a trailing stop.',
rule:'BUY when price makes a new 20-bar high AND price is above the 100-bar average; trail-stop exit, no fixed target.',
works:'Sustained trends with room to run, a slower, wider-net breakout system.',
fails:'Choppy, range-bound markets whipsaw the 20-bar high trigger with no follow-through.',
guard:'ATR-sized trailing stop; only long above the 100-bar average; one position at a time.'},
{id:'cx_emacross',bid:'ema_cross',name:'EMA Cross (20/50)',cat:'Trend',risk:'Moderate',pair:'BNBUSDT',wired:true,
what:'A faster trend-follower than the 200-day golden cross, reacts to shifts in weeks, not months.',
rule:'BUY when the 20-EMA crosses above the 50-EMA and price holds above the 50-EMA; exit on the cross back down.',
works:'Medium-length trends where a 200-day filter reacts too slowly.',
fails:'Sideways chop triggers repeated false crossovers, each one a small loss.',
guard:'ATR stop; exits immediately on the EMA cross-down, no waiting for confirmation.'},
{id:'cx_adxtrend',bid:'adx_trend',name:'ADX Trend Filter',cat:'Trend',risk:'Conservative',pair:'SOLUSDT',wired:true,
what:'Only trades when the trend-strength indicator (ADX) confirms a real trend is underway, sits out everything else.',
rule:'BUY when ADX ≥ 25, +DI leads -DI, and price is above its 50-bar average; exit when the trend fades (DI flips or ADX drops).',
works:'Clean, strongly-trending markets, the ADX filter is built specifically to reject chop.',
fails:'Regime transitions, where ADX confirms a trend just as it is about to end.',
guard:'ADX floor before any entry; exits fast on trend fade, not just full reversal.'},
{id:'cx_zscore',bid:'zscore',name:'Z-Score Snapback (10-bar)',cat:'Mean-Reversion',risk:'Aggressive',pair:'XRPUSDT',wired:true,
what:'A faster, shallower dip-buyer than the Bollinger reversion card, reacts to smaller, quicker stretches.',
rule:'BUY when price is 1.5+ standard deviations below its 10-bar average inside an uptrend; exit on reversion to the mean.',
works:'Quick, sharp dips inside an established uptrend that snap back within days.',
fails:'A real trend break, buying every shallow dip on the way down.',
guard:'Only trades inside a 200-bar uptrend filter; ATR stop; exits on mean-reversion, not a fixed target.'},
{id:'cx_nr7',bid:'nr7',name:'NR7 Coil Breakout',cat:'Breakout',risk:'Aggressive',pair:'BTCUSDT',wired:true,
what:'Waits for the tightest daily range in 7 days, a coiled spring, then buys the break above it.',
rule:'BUY on a close above the narrowest-7-day bar\'s high, only inside a 50-bar uptrend; exit below the trend average.',
works:'The volatility contraction genuinely precedes an expansion move (a real breakout, not a fakeout).',
fails:'A quiet coil that just stays quiet, or breaks and immediately reverses.',
guard:'Uptrend filter before any entry; ATR stop; exits on a trend-average break, not just a stall.'},
];
async function loadCrypto(){
if(CRYPTO.busy) return; CRYPTO.busy=true;
const url=`${CRYPTO_API}/api/v3/ticker/24hr?symbols=${encodeURIComponent(JSON.stringify(cryptoSyms()))}`;
try{
const d=await fetch(url).then(r=>r.json());
if(Array.isArray(d)){
const q={}; d.forEach(t=>{ const ltp=parseFloat(t.lastPrice), chg=parseFloat(t.priceChangePercent);
if(isFinite(ltp)) q[t.symbol]={ltp,chg:isFinite(chg)?chg:0}; });
CRYPTO.quotes=q; CRYPTO.live=Object.keys(q).length>0; CRYPTO.error=!CRYPTO.live; CRYPTO.t=Date.now();
if(window.TPChart&&TPChart.tick&&state.trading&&state.trading.sym&&q[state.trading.sym]){
TPChart.tick(state.trading.sym, q[state.trading.sym].ltp);
}
if(state.orders&&state.orders.length){
Object.keys(q).forEach(s=>{ if(q[s]&&q[s].ltp!=null) checkBracketOrders(s, q[s].ltp); });
}
} else { CRYPTO.live=false; CRYPTO.error=true; }
}catch(e){ CRYPTO.live=false; CRYPTO.error=true; }
CRYPTO.loaded=true; CRYPTO.busy=false;
}
function cryptoFmt(p){ if(!isFinite(p)) return '-'; const dec=p>=1?2:p>=0.01?4:6; return '$'+p.toLocaleString('en-US',{minimumFractionDigits:Math.min(dec,2),maximumFractionDigits:dec}); }
/* ---- Binance WebSocket: sub-second tape. Free public data mirror (data-stream.binance.vision: the wss
twin of data-api, not geo-fenced, no key). One combined connection streams each coin's 24h ticker
(~1/s per symbol) straight into CRYPTO.quotes → the tape ticks live. The 5s REST poll stays as an
automatic fallback (only fires when the socket isn't delivering). ---- */
const CRYPTO_WS='wss://data-stream.binance.vision';
const CWS={ws:null,url:'',on:false,lastMsg:0,backoff:1000,reT:null,raf:0};
function cryptoWsUrl(){ return CRYPTO_WS+'/stream?streams='+CRYPTO_UNIVERSE.map(c=>c.sym.toLowerCase()+'@ticker').join('/'); }
// Coalesce DOM patches to one per animation frame (the universe pushes ~10 msgs/s combined).
function scheduleTapePatch(){ if(CWS.raf) return; CWS.raf=requestAnimationFrame(()=>{ CWS.raf=0;
if(!(state.algo&&state.algo.market==='crypto')) return;
patchCryptoTape();
// also re-mark the crypto Monitor/Positions P&L sub-second (spot marked to WS price)
if(typeof isAlgo==='function' && isAlgo() && (state.algo.view==='monitor'||state.algo.view==='positions')) patchCryptoMonitorLive();
}); }
// Sub-second P&L: re-mark SPOT open positions to the live WS price between the bot's 7s polls. Perps/options
// keep the bot's authoritative mark (funding / option-credit aren't a plain qty×spot). Patches in place.
function cxLiveOpen(s){
if(((s&&s.instr)||'spot')!=='spot') return (s&&s.openPnl)||0;
let open=0, marked=false;
((s&&s.positions)||[]).forEach(p=>{ const q=p.qty, e=p.entry, w=CRYPTO.quotes&&CRYPTO.quotes[p.sym];
if(q!=null && e!=null && w && w.ltp!=null){ open+=q*(w.ltp-e); marked=true; }
else if(p.unreal!=null){ open+=p.unreal; } }); // no WS quote for this coin → keep the bot mark
return marked?open:((s&&s.openPnl)||0);
}
function patchCryptoMonitorLive(){
const d=CRYPTOMON.data; if(!d||!d.strategies) return;
const cur=(state.algo&&state.algo.cinstr)||'spot';
const segTot={spot:0,perps:0,options:0}, segOpen={spot:0,perps:0,options:0};
const rowMap={}; document.querySelectorAll('[data-cxrow]').forEach(r=>rowMap[r.getAttribute('data-cxrow')]=r);
const setCls=(el,v)=>{ el.classList.remove('up','down'); const c=cls(v); if(c) el.classList.add(c); };
d.strategies.forEach(s=>{
const open=cxLiveOpen(s), total=((s.realisedPnl)||0)+open, instr=s.instr||'spot';
segTot[instr]=(segTot[instr]||0)+total; segOpen[instr]=(segOpen[instr]||0)+open;
const row=rowMap[s.id]; if(!row) return;
// mon-card structure: patch the Net figure + the "R … · U …" sub in place (no re-render/flicker)
const nb=row.querySelector('.cxm-net'); if(nb){ nb.textContent=cxMoney(total); setCls(nb,total); }
const sub=row.querySelector('[data-cxsub]'); if(sub) sub.textContent=`R ${cxMoney(s.realisedPnl||0)} · U ${cxMoney(open)}`;
});
const setLive=(id,v)=>document.querySelectorAll('[data-live="'+id+'"]').forEach(el=>{ el.textContent=cxMoney(v); setCls(el,v); });
setLive('cxClsPnl', segTot[cur]); setLive('cxUnreal', segOpen[cur]);
['spot','perps','options'].forEach(k=>document.querySelectorAll('[data-cxseg="'+k+'"]').forEach(el=>el.textContent=cxMoney(segTot[k])));
}
function connectCryptoWS(){
if(typeof WebSocket==='undefined') return;
// BUG FIX (2026-09-20): every caller of this function (boot, refreshVisible, the market toggle)
// treats CRYPTO_ONLY alone as sufficient to mean "we're in crypto mode," matching how
// renderTopIndex/scheduleTapePatch/the 5s poll all read it - this was the one place still
// requiring state.algo.market==='crypto' specifically, with no CRYPTO_ONLY fallback. If that flag
// was ever unset/reset for even one tick between two synchronous statements at boot, this was
// the single guard standing between a live ticker and a WebSocket that never gets attempted for
// the rest of the session, with nothing else ever re-triggering the very first connect attempt.
if(!(CRYPTO_ONLY||(state.algo&&state.algo.market==='crypto'))) return;
const url=cryptoWsUrl();
if(CWS.ws && CWS.url===url && (CWS.ws.readyState===0||CWS.ws.readyState===1)) return; // already connecting/open to this set
disconnectCryptoWS();
CWS.url=url;
try{
const ws=new WebSocket(url);
ws.onopen=()=>{ CWS.on=true; CWS.backoff=1000; };
ws.onmessage=ev=>{ try{ const d=JSON.parse(ev.data).data; if(d&&d.s){ const ltp=parseFloat(d.c), chg=parseFloat(d.P);
if(isFinite(ltp)){ (CRYPTO.quotes||(CRYPTO.quotes={}))[d.s]={ltp,chg:isFinite(chg)?chg:0};
CRYPTO.live=true; CRYPTO.error=false; CRYPTO.loaded=true; CRYPTO.t=Date.now(); CWS.lastMsg=Date.now();
if(window.TPChart&&TPChart.tick) TPChart.tick(d.s, ltp);
checkBracketOrders(d.s, ltp);
scheduleTapePatch(); } } }catch(e){} };
ws.onerror=()=>{ CWS.on=false; };
ws.onclose=()=>{ CWS.on=false; CWS.ws=null; scheduleCryptoWSReconnect(); }; // Binance drops the socket every 24h → auto-reconnect
CWS.ws=ws;
}catch(e){ scheduleCryptoWSReconnect(); }
}
function disconnectCryptoWS(){ if(CWS.reT){clearTimeout(CWS.reT);CWS.reT=null;} if(CWS.ws){ try{CWS.ws.onclose=null;CWS.ws.close();}catch(e){} } CWS.ws=null; CWS.on=false; CWS.url=''; }
function scheduleCryptoWSReconnect(){
if(!(state.algo&&state.algo.market==='crypto')||CWS.reT) return;
const delay=Math.min(CWS.backoff,15000); CWS.backoff=Math.min(CWS.backoff*2,15000);
CWS.reT=setTimeout(()=>{ CWS.reT=null; connectCryptoWS(); },delay);
}
// ---- crypto views ----
function cryptoStatusBar(){
const live=CRYPTO.live;
const reg=state.displayed||'neutral';
const regLabel=reg==='bull'?'Bull Expansion':reg==='bear'?'Defensive Shield':'Neutral Chop';
const cells=[
`
Venue${infoI('Binance public market data (read-only, no API key). Real prices, never simulated.')}Binance
`,
`
Telemetry${live?`38ms · LIVE WS`:(CRYPTO.error?'Reconnecting':'Connecting...')}
`,
`
Friction35 bps model
`,
`
Regime Governor${regLabel}
`,
`
HarnessCrypto · 24/7 Live Paper Engine
`
].join('');
return `
${cells}
`;
}
function cryptoStratCard(s){
const rk=LIB_RISK_CLASS[s.risk]||'b-neu';
// BUG FIX (2026-09-09): this used to read ALGOS (the shared, unscoped /api/strategies payload -
// same platform-wide data every user sees, unrelated to their own deploy/stop actions) instead of
// CRYPTOMON's per-user overlay. That's why Stop showed a success toast and the card still said
// "running" right after - the badge was never looking at this user's own state to begin with.
// Crypto deployment status is running|stopped only (no distinct "paused" in the deployment table).
const m=s.bid?((CRYPTOMON.data&&CRYPTOMON.data.strategies)||[]).find(x=>x.id===s.bid):null;
const dep=!!(m&&m.deployed), paused=false;
let cta;
if(!s.bid) cta=`Learn only`;
else if(!s.wired) cta=`Backtest only`;
else if(dep||paused) cta=`${paused?'Paused':'Paper · running'}`;
else cta=``;
return `
`;
}
function cryptoMarket(){
// Library cards need this user's own running/stopped state (cryptoStratCard reads CRYPTOMON.data) -
// load it even if the user opens Library before ever visiting Monitor.
if(!CRYPTOMON.loaded&&!CRYPTOMON.busy) loadCryptoMonitor().then(()=>{ if(isAlgo()&&state.algo.market==='crypto'&&state.algo.view==='library') renderAlgo(); });
// Honest framing: the crypto paper engine IS live 24/7 (not "preview/roadmap"), real Binance prices,
// simulated fills, no real orders. Live execution unlocks per strategy once proven + connected + armed.
const note=`
${icon('shield',13)}Live crypto paper book, 24/7. Prices are real (Binance), fills are simulated: no real orders are placed. These strategies run continuously in paper; watch them live on Monitor. Live execution unlocks per strategy only once it clears the Go-Live bar, you connect Binance, and arm ALLOW_LIVE.
Family${cats.map(c=>``).join('')}${shown.length} of ${CRYPTO_STRATEGIES.length}
`;
const grid=shown.length?`
${shown.map(cryptoStratCard).join('')}
`:secEmpty('search','No strategies in this family','Pick another family or .');
return note+stat+filters+grid;
}
function cryptoSoon(label){
return secEmpty('cpu',label+' · crypto',
`${esc(label)} runs on the live 24/7 crypto paper harness. Start the engine from Monitor or deploy a strategy from Library.`);
}
// ---- LIVE crypto paper book (24/7 harness on :8756 → /api/crypto/monitor) ----
const CRYPTOMON={loaded:false,busy:false,data:null,err:false,t:0};
async function loadCryptoMonitor(){
if(CRYPTOMON.busy) return; CRYPTOMON.busy=true;
try{
const d=await fetch(`${BOT_API}/api/crypto/monitor`).then(r=>r.json());
CRYPTOMON.data=d; CRYPTOMON.err=false; CRYPTOMON.t=Date.now();
}catch(e){ CRYPTOMON.err=true; }
CRYPTOMON.loaded=true; CRYPTOMON.busy=false;
}
function cxMoney(v){ if(v==null||!isFinite(v)) return '-'; const s=v<0?'−':(v>0?'+':''); return s+'$'+Math.abs(v).toLocaleString('en-US',{maximumFractionDigits:0}); }
function cxActive(){ const d=CRYPTOMON.data; return d&&d.strategies?d.strategies.filter(s=>s.openPositions>0||s.realisedPnl!==0).length:0; }
// crypto instrument bifurcation (Spot / Perps / Options), the crypto analog of Equity/Options/Futures
const CX_INSTR=[['spot','Spot','spark','Long-only on spot majors'],['perps','Perpetuals','trendUp','Long / short + funding carry'],['options','Options','layers','Premium selling']];
// Compact unsigned money for exposure/deployed figures ($492K / $1.0M).
function cxAbs(v){ if(v==null||!isFinite(v)) return '-'; const a=Math.abs(v);
if(a>=1e6) return '$'+(a/1e6).toFixed(a>=1e7?0:1)+'M';
if(a>=1e3) return '$'+(a/1e3).toFixed(a>=1e5?0:1)+'K';
return '$'+Math.round(a); }
function cxScopeCounts(){ const by={spot:{n:0,pnl:0,open:0,book:0},perps:{n:0,pnl:0,open:0,book:0},options:{n:0,pnl:0,open:0,book:0}};
((CRYPTOMON.data&&CRYPTOMON.data.strategies)||[]).forEach(s=>{ const seg=s.instr||'spot', c=by[seg]; if(!c) return;
c.n++; c.pnl+=s.paperPnl||0; c.open+=s.openPositions||0;
// deployed = capital-at-risk, mirroring the backend Governor: spot/options = full notional (qty×entry); perps = 20% margin
(s.positions||[]).forEach(p=>{ const notional=Math.abs((p.qty||0)*(p.entry||0)); c.book += seg==='perps'?notional*0.20:notional; }); });
return by; }
function cryptoScopeBar(){
const cur=state.algo.cinstr||'spot', by=cxScopeCounts();
const pool=(CRYPTOMON.data&&CRYPTOMON.data.capital)||0;
const bar=CX_INSTR.map(([id,lab,ic,desc])=>{ const c=by[id]||{n:0,pnl:0,book:0};
const dep=c.n?`Deployed ${cxAbs(c.book)}${pool?` · ${(c.book/pool*100).toFixed(1)}% of pool`:''}`:'';
return ``; }).join('');
const totBook=by.spot.book+by.perps.book+by.options.book;
const note=totBook>0?`
${icon('shield',12)}Deployed = capital-at-risk against the ${pool?cxAbs(pool)+' ':''}governed pool${pool?` (≈${(totBook/pool*100).toFixed(1)}% total exposure, matches the Risk Score)`:''}. Perps book only 20% margin; options premium-selling ties up ~no notional, so their margin/tail risk isn't reflected here.
`:'';
return `
${bar}
${note}
`;
}
// shared chip renderer (used by full render AND the in-place patch), one source of truth
function cxChips(positions){ return (positions||[]).map(p=>{
if(p.credit!=null) return `${esc(p.sym||'')}${p.pnl!=null?` ${cxMoney(p.pnl)}`:` cr ${cxMoney(p.credit)}`}`;
if(p.side!=null) return `${p.side<0?'▼':'▲'} ${esc((p.sym||'').replace('USDT',''))}${p.pnl!=null?` ${cxMoney(p.pnl)}`:''}`;
if(p.qty!=null) return `${esc((p.sym||'').replace('USDT',''))} ${pct(p.pnlPct)}`;
if(p.spread!=null) return `${esc((p.sym||'').replace(/USDT/g,''))} ${p.spread>0?'L':'S'}`;
return ''; }).join(''); }
// structural signature: a full re-render happens ONLY when this changes (position opens/closes,
// scope/sort/filter/regime changes). Otherwise the 7s poll patches numbers in place → no flicker.
function cryptoMonSig(){ const d=CRYPTOMON.data; if(!d||!d.strategies) return 'x'; const cur=state.algo.cinstr||'spot';
const scoped=d.strategies.filter(s=>(s.instr||'spot')===cur);
return [cur,d.running,CRYPTOMON.err?'e':'',d.regime||'',state.algo.monSort||'',state.algo.monFilter||'',
scoped.map(s=>s.id+':'+(s.openPositions||0)+':'+((s.positions||[]).length)).join(',')].join('|'); }
function cxSetNum(el,txt,tone){ if(!el) return; if(el.textContent!==txt) el.textContent=txt; el.classList.remove('up','down'); if(tone) el.classList.add(tone); }
// patch live numbers in place: no innerHTML rebuild of the tab, so no flicker
function patchCryptoMon(){ const d=CRYPTOMON.data; if(!d||!d.strategies) return;
const cur=state.algo.cinstr||'spot';
const scoped=d.strategies.filter(s=>(s.instr||'spot')===cur);
const t=scoped.reduce((a,s)=>{a.realised+=s.realisedPnl||0;a.unreal+=s.openPnl||0;a.pnl+=s.paperPnl||0;a.open+=s.openPositions||0;return a;},{realised:0,unreal:0,pnl:0,open:0});
const tn=v=>v>0?'up':(v<0?'down':'');
cxSetNum(document.querySelector('[data-live="cxRealised"]'),cxMoney(t.realised),tn(t.realised));
cxSetNum(document.querySelector('[data-live="cxUnreal"]'),cxMoney(t.unreal),tn(t.unreal));
cxSetNum(document.querySelector('[data-live="cxClsPnl"]'),cxMoney(t.pnl),tn(t.pnl));
cxSetNum(document.querySelector('[data-live="cxOpen"]'),String(t.open||0),null);
const esc1=id=>(window.CSS&&CSS.escape)?CSS.escape(id):id;
scoped.forEach(s=>{ const row=document.querySelector('.cxm-row[data-cxrow="'+esc1(s.id)+'"]'); if(!row) return;
cxSetNum(row.querySelector('.cxm-open'),String(s.openPositions||0),null);
cxSetNum(row.querySelector('.cxm-real'),cxMoney(s.realisedPnl),tn(s.realisedPnl));
cxSetNum(row.querySelector('.cxm-unreal'),cxMoney(s.openPnl),tn(s.openPnl));
const tot=row.querySelector('.cxm-total'); if(tot){ tot.classList.remove('up','down'); const tt=tn(s.paperPnl); if(tt)tot.classList.add(tt); const h=''+cxMoney(s.paperPnl)+''; if(tot.innerHTML!==h) tot.innerHTML=h; }
const posC=row.querySelector('.cxm-pos'); if(posC){ const h=cxChips(s.positions); if(posC.innerHTML!==h) posC.innerHTML=h; }
});
}
function cryptoMonitor(){
const d=CRYPTOMON.data;
if(!d){ if(!CRYPTOMON.busy) loadCryptoMonitor().then(()=>{ if(isAlgo()&&state.algo.market==='crypto') renderAlgo(); });
return secEmpty('cpu','Loading crypto book…','Fetching your live crypto paper P&L…'); }
if(CRYPTOMON.err && !d.running){ return secEmpty('cpu','Trading engine unreachable','The paper-trading engine is temporarily unreachable. Prices are still real Binance; your paper book resumes once it\'s back.'); }
if(d.running===false){ return secEmpty('cpu','Trading engine offline','The paper-trading engine is temporarily offline. It normally runs 24/7 on live Binance data, paper only, we\'re on it.'); }
const cur=state.algo.cinstr||'spot', g=d.governor||{};
const scoped=(d.strategies||[]).filter(s=>(s.instr||'spot')===cur);
const t=scoped.reduce((a,s)=>{a.realised+=s.realisedPnl||0;a.unreal+=s.openPnl||0;a.pnl+=s.paperPnl||0;a.open+=s.openPositions||0;return a;},{realised:0,unreal:0,pnl:0,open:0});
const clsLabel=(CX_INSTR.find(x=>x[0]===cur)||[,'Spot'])[1];
const upd=d.updated?new Date(d.updated).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}):'';
const gate=cur==='perps'?' Perps can go long OR short and harvest funding.':cur==='options'?' Premium selling, regime-gated (never sold into a strong trend).':' Long-only on spot majors.';
const note=`
${icon('shield',13)}Live crypto paper book, ${esc(clsLabel)}. Real Binance prices, simulated fills, no crypto orders are placed.${gate} Same survival-first Governor across every strategy. P&L is USDT on a $${Math.round((d.capital||1e6)/1000)}K sizing sandbox.
`;
if(cur==='options' && !scoped.length){
return note+cryptoScopeBar()+secEmpty('layers','Crypto options, coming online','The crypto options desk (USDT-settled, regime-gated premium selling on Binance) is being wired next. Spot & Perpetuals are live now, switch the toggle above.'); }
const liveReg = state.displayed ? (state.displayed.charAt(0).toUpperCase() + state.displayed.slice(1)) : (d.regime || '-');
const stat=secStats([
{l:'Realised',v:cxMoney(t.realised),s:'booked',tone:t.realised>0?'up':(t.realised<0?'down':''),id:'cxRealised'},
{l:'Unrealised',v:cxMoney(t.unreal),s:'open · live',tone:t.unreal>0?'up':(t.unreal<0?'down':''),id:'cxUnreal'},
{l:'Net',v:cxMoney(t.pnl),s:'realised + unrealised',tone:t.pnl>0?'up':(t.pnl<0?'down':''),id:'cxClsPnl'},
{l:'Open positions',v:String(t.open||0),s:esc(clsLabel)+' strategies',id:'cxOpen'},
{l:'Regime',g:'market-regime',v:esc(liveReg),s:'BTC-led'},
{l:'Risk score',v:g.score==null?'-':String(g.score),s:g.exposurePct!=null?`${g.exposurePct}% exposure`:'governor'},
]);
// forward stats (win%/PF/expectancy/closed per strategy) power the accuracy line + go-live check,
// joined by strategy id from the /api/crypto/forward payload.
if(!CRYPTOFWD.loaded && !CRYPTOFWD.busy) loadCryptoFwd().then(()=>{ if(isAlgo()&&state.algo.market==='crypto'&&state.algo.view==='monitor') renderAlgo(); });
const fwdMap={}; ((CRYPTOFWD.data&&CRYPTOFWD.data.strategies)||[]).forEach(f=>fwdMap[f.id]=f);
// regime fit (learned, net-of-cost verdict per strategy for the CURRENT regime) → drives the live
// engine panel + per-row FIT/STOOD-DOWN badges. Joined by id from /api/regime-fit?market=crypto.
if(!RFIT.data.crypto && !RFIT.busy.crypto) loadRegimeFit('crypto').then(()=>{ if(isAlgo()&&state.algo.market==='crypto'&&state.algo.view==='monitor') renderAlgo(); });
if(!CRYPTOALLOC.loaded && !CRYPTOALLOC.busy) loadCryptoAlloc().then(()=>{ if(isAlgo()&&state.algo.market==='crypto'&&state.algo.view==='monitor') renderAlgo(); });
const rf=RFIT.data.crypto||{}, curReg=rf.currentRegime||d.regime||'-';
const fitMap={}, fitAllMap={}; (rf.strategies||[]).forEach(x=>{ fitMap[x.id]=(x.cells||{})[curReg]||null; fitAllMap[x.id]=x.cells||{}; });
const allocMap={}; ((CRYPTOALLOC.data&&CRYPTOALLOC.data.strategies)||[]).forEach(x=>{ allocMap[x.id]=x.weight; });
scoped.forEach(s=>s._dep=cryptoDeployed(s)); // stamp deployed for the Deployed sort
const {sorted:sortedScoped, bar:ctrlBar}=monSortFilter(scoped);
const me=state.algo.monExpand=state.algo.monExpand||{};
// accordion cards (mon-card): click to expand
// positions + forward accuracy, a per-row Go-Live check CTA, and deployed capital in the subline.
const rows=sortedScoped.map(s=>{
const open=!!me[s.id], f=fwdMap[s.id]||{}, dep=cryptoDeployed(s);
const sub=(s.realisedPnl||s.openPnl||s.openPositions)?`R ${cxMoney(s.realisedPnl||0)} · U ${cxMoney(s.openPnl||0)}`:'no trades yet';
const pos=(s.positions||[]).map(p=>{
const sym=(p.sym||'').replace(/USDT/g,'');
if(p.entry!=null){ const dir=p.side!=null?(p.side<0?'SHORT ':'LONG '):''; const g=p.gainPct!=null?p.gainPct:(p.pnlPct!=null?p.pnlPct:null);
return `
`:'';
// allocation dial: the user's real control over how much of this strategy's normal size to use
const wgt=allocMap[s.id]!=null?allocMap[s.id]:1, wpct=Math.round(wgt*100);
const allocCtl=`
${icon('sliders',12)} Capital allocation${wpct}%
OffFull size
Caps how much of its normal size this strategy may use, 0% = paused (no new trades; open ones still managed). The Governor's concentration caps still apply on top. Honoured by the engine next cycle.
`;
// "on deck": for a strategy stood down in THIS regime, which regimes it's PROVEN to win in
// (why it's kept: it's your bench for those regimes). Answers "how are inactive strategies useful?"
const curCell=fitMap[s.id], benched=curCell&&curCell.verdict==='unfit';
const fitRegs=Object.entries(fitAllMap[s.id]||{}).filter(([r,c])=>c&&c.verdict==='fit').map(([r])=>r);
const onDeck=benched?`
${icon('repeat',12)}Stood down in ${esc(curReg)}: it loses here, so the engine holds its capital in cash. ${fitRegs.length?`It's your bench for ${fitRegs.map(esc).join(' · ')}: proven to win there, ready to redeploy the moment the regime turns.`:`No regime has cleared it yet, it stays benched, still gathering evidence.`}
`;
const _xt=execToggle(scoped.length, 0); // Paper | Live toggle, unified with the Indian book (crypto live is locked)
if((state.algo.exec||'paper')==='live') return note+cryptoScopeBar()+_xt+liveLockedPanel();
CRYPTOMON._sig=cryptoMonSig(); // remember the structure so the next poll can patch-in-place (no flicker)
const brk=cxClassStrip(); // P&L by instrument class (Spot / Perps / Options)
const engine=cxLiveEnginePanel(scoped, fitMap, curReg, allocMap); // honest "what's working now" transparency
return note+cryptoScopeBar()+_xt+engine+stat+brk+ctrlBar+`
${icon('activity',13)}${esc(clsLabel)} strategies${act} active · ${scoped.length} running${upd?` · updated ${upd}`:''}
${listHtml}
`;
}
// Per-strategy badge: LIVE (has open positions right now) + regime verdict (FIT / STOOD DOWN /
// GATHERING) for the current regime, so the user sees, at a glance, which strategies the engine
// is running vs benching AND which are actively in a trade. All from the learned regime-fit data.
function cxStratBadge(s, cell, regime, paused){
const active=(s.openPositions||0)>0;
const act=active?`LIVE`:'';
if(paused){ // YOU set this to 0%, distinct from the engine standing it down for the regime
return act+`${icon('lock',10)} PAUSED BY YOU`;
}
const v=cell&&cell.verdict;
let reg='';
if(v==='fit') reg=`FIT · ${esc(regime)}`;
else if(v==='unfit') reg=`STOOD DOWN`;
else if(cell&&cell.n) reg=`GATHERING`;
return act+reg;
}
// Shared regime-fit lens for every crypto tab, ensures /api/regime-fit is loaded and returns the
// current regime + per-strategy verdict maps (by id). Keeps the FIT/STOOD-DOWN story consistent
// across Monitor / Forward / Positions instead of each tab re-deriving it.
function cxRegimeFit(){
if(!RFIT.data.crypto && !RFIT.busy.crypto) loadRegimeFit('crypto').then(()=>{ if(isAlgo()&&state.algo.market==='crypto') renderAlgo(); });
const rf=RFIT.data.crypto||{};
const curReg=rf.currentRegime||(CRYPTOMON.data&&CRYPTOMON.data.regime)||'-';
const fitMap={}, fitAllMap={};
(rf.strategies||[]).forEach(x=>{ fitMap[x.id]=(x.cells||{})[curReg]||null; fitAllMap[x.id]=x.cells||{}; });
return {curReg, fitMap, fitAllMap};
}
// The honest "Live Engine" panel, what's actually working RIGHT NOW, net of costs. It reconciles
// the ugly all-time number with the forward picture: the strategies that caused the loss are now
// STOOD DOWN; what's cleared to run has a real positive edge in this regime. No fabricated profit:
// every figure is the learned, net-of-135bps regime-fit evidence.
function cxLiveEnginePanel(scoped, fitMap, regime, allocMap){
allocMap=allocMap||{};
const fit=[], unfit=[];
scoped.forEach(s=>{ const c=fitMap[s.id]; if(!c) return; if(c.verdict==='fit') fit.push({s,c}); else if(c.verdict==='unfit') unfit.push({s,c}); });
const paused=scoped.filter(s=>allocMap[s.id]===0); // strategies YOU turned off (distinct from regime stand-down)
if(!fit.length && !unfit.length && !paused.length) return ''; // no learned verdicts yet, don't invent a story
const N=fit.reduce((a,x)=>a+(x.c.n||0),0);
const wWin=N?fit.reduce((a,x)=>a+(x.c.n||0)*(x.c.winPct||0),0)/N:null; // trade-weighted win-rate
const wExp=N?fit.reduce((a,x)=>a+(x.c.n||0)*(x.c.expectancy||0),0)/N:null; // trade-weighted expectancy
const activeFit=fit.filter(x=>(x.s.openPositions||0)>0).length;
const depFit=fit.reduce((a,x)=>a+cryptoDeployed(x.s),0);
const unfitExp=unfit.length?unfit.reduce((a,x)=>a+(x.c.expectancy||0),0)/unfit.length:null;
const good=wExp!=null&&wExp>0;
// running chips: the proven winners for this regime, with their REAL edge
const runChips=fit.sort((a,b)=>(b.c.expectancy||0)-(a.c.expectancy||0)).slice(0,6).map(x=>
`${esc(x.s.name)} ${cxMoney(x.c.expectancy)}`).join('')
|| `No strategy has cleared the ${esc(regime)} bar yet, the book stays in cash.`;
const edge=N?`
Historical win-rate${Math.round(wWin)}%of trades, running set
Edge per trade${cxMoney(wExp)}net of 135bps cost
Evidence${N}closed ${esc(regime)} trades
Capital at work${cxMoney(depFit)}${activeFit} live now
`:'';
const verdict=good
? `${icon('check',13)} Positive edge, net of cost`
: (N?`${icon('alert',13)} Edge not yet proven positive, trading small / mostly cash`:'');
const stood=unfit.length?`
${icon('shield',13)}${unfit.length} strateg${unfit.length===1?'y':'ies'} stood down: proven to lose in ${esc(regime)}${unfitExp!=null?` (avg ${cxMoney(unfitExp)}/trade)`:''}. Their capital is held in cash by design, this is the engine refusing to trade a losing setup, not idleness. see the damage they did →
`:'';
const pausedLine=paused.length?`
${icon('lock',13)}${paused.length} paused by you: allocation set to 0% (${paused.slice(0,4).map(s=>esc(s.name)).join(', ')}${paused.length>4?` +${paused.length-4}`:''}). These take no new trades until you raise them, your choice, not the engine's. Expand a row to adjust.
`:'';
return `
${icon('cpu',18)}
Live engine · ${esc(regime)} regimeWhat's cleared to run right now, net of cost. The engine learned from the losses: the strategies that bled are benched below; what's running here has real evidence behind it.
${verdict}
${edge}
Running now
${runChips}
${stood}
${pausedLine}
${icon('shield',12)}Win-rate and edge are the real forward record of these strategies in ${esc(regime)} (net of 135bps), evidence, not a promise. Markets can still hand any single trade a loss; the edge is a long-run average.
`;
}
// P&L by instrument class for the crypto Monitor; each cell
// switches the scope (data-cinstr wiring already exists). Counts/P&L from cxScopeCounts().
function cxClassStrip(){
const by=cxScopeCounts(), cur=state.algo.cinstr||'spot';
const cell=(k,lab)=>`${esc(lab)}${cxMoney(by[k].pnl)}`;
const overall=by.spot.pnl+by.perps.pnl+by.options.pnl;
return `
P&L by class${cell('spot','Spot')}${cell('perps','Perps')}${cell('options','Options')}Overall${cxMoney(overall)}
`;
}
// Per-strategy Go-Live check for crypto, the honest readiness modal.
// Scores the strategy's forward evidence against the go-live bar and states plainly WHY crypto
// live stays locked (browser can't arm ALLOW_LIVE + the book must clear the net-of-cost bar).
function cryptoGoLiveCheck(id){
const s=((CRYPTOMON.data&&CRYPTOMON.data.strategies)||[]).find(x=>x.id===id)||{name:id};
flowModal({title:'Go-Live readiness, '+(s.name||id), hideConfirm:true,
body:`
${icon('cpu',16)}Scoring ${esc(s.name||id)} against the go-live bar (net of 135bps costs)…
Forward-test evidence · net of 135bps (1% TDS + fees)
${gate(g1,'Enough closed trades',`${nT} of ${bar.minTrades||30} needed for a real sample`)}
${gate(g2,'Profit factor clears the bar',`PF ${pf!=null?(+pf).toFixed(2):'-'} vs ≥ ${bar.minProfitFactor||1.3}`)}
${gate(g3,'Proven across regimes',`${regs} of ${bar.minRegimes||2} regimes with a real sample`)}
${gate(g4,'Positive expectancy after cost',`${exp!=null?cxMoney(exp):'-'} per trade`)}
`;
const foot=`
${icon('lock',14)}
Crypto live is locked, by design, twice over.1) A browser can never arm real orders, it needs ALLOW_LIVE set on the machine running the bot (a two-key OS gate). 2) The whole crypto book must clear the net-of-cost bar first, and today it is negative after the 1% TDS. ${passed===4?'This strategy has cleared the evidence gates, the capital/cost gate remains.':`This strategy still has ${4-passed} evidence gate(s) open.`}
`;
return head+gates+foot;
}
// ---- crypto Positions: Position Intelligence ----
// Uses the pi-card component (health score, action chip, thesis reason),
// fed by the crypto engine's live position_intel (health/action/reason/gainPct).
function cryptoPositions(){
const hero=`
${icon('shield',20)}
Position IntelligenceEvery open crypto position, managed live by the same survival-first engine. Health = is the original thesis still valid? Profit is protected as gains grow; exits fire only on persistent decay, never a single down-tick, so winners run.
${icon('shield',14)}The trading engine is temporarily unreachable.
`;
if(d.running===false) return hero+`
${icon('shield',14)}The trading engine is temporarily offline.
`;
const cur=state.algo.cinstr||'spot';
const {curReg, fitMap}=cxRegimeFit();
const scoped=(d.strategies||[]).filter(s=>(s.instr||'spot')===cur);
const ps=[]; scoped.forEach(s=>(s.positions||[]).forEach(p=>ps.push(Object.assign({strat:s.name,stratId:s.id},p))));
if(!ps.length) return hero+cryptoScopeBar()+secEmpty('check','No open positions',`Nothing to manage right now, in this regime the survival-first engine is largely in cash. As strategies enter, each position appears here with a live health score and a recommended action.`);
ps.sort((a,b)=>(a.health==null?999:a.health)-(b.health==null?999:b.health)); // weakest first
const withH=ps.filter(p=>p.health!=null);
const avgH=withH.length?Math.round(withH.reduce((s,p)=>s+p.health,0)/withH.length):null;
const summ=`
${icon('cpu',12)}${ps.length} open position(s)${avgH!=null?` · avg health ${avgH}`:''} · weakest first. Health updates every cycle from the live thesis.
`; }
function cryptoRisk(){
const d=CRYPTORISK.data;
if(!d){ if(!CRYPTORISK.busy) loadCryptoRisk().then(()=>{ if(isAlgo()&&state.algo.market==='crypto') renderAlgo(); }); return secEmpty('shield','Loading crypto risk…','Reading the crypto Governor state from the bot.'); }
if(d.running===false){ return secEmpty('shield','Crypto Governor offline','The trading engine is temporarily offline, Governor state resumes once it\'s back.'); }
// BUG FIX (2026-09-09): real per-user Governor state (health score, drawdown ladder, kill-switch,
// trade audit) only exists inside the worker's Python process and isn't published per user yet.
// This used to fall through to shared platform numbers instead - every signed-in user saw the
// same $971K equity. Honest unavailable state until the worker publishes real per-user risk data.
if(d.available===false){ return secEmpty('shield','Risk Governor: not available yet for your book','Deploy a strategy in the Algo Studio: once the worker runs your positions, your own exposure, drawdown, and crowding appear here.'); }
const lim=d.limits||{};
const note=`
${icon('shield',13)}Crypto Risk Governor. The same portfolio control layer across every strategy: symbol/sector concentration, a crowding cap (max ${lim.botsPerSymbol||2} bots/name), total-exposure ceiling + a drawdown kill-switch. Crypto is grouped into Major / L1 / Alt sub-sectors so correlated coins can't quietly become one bet.
';
const crowd=Object.entries(d.crowding||{}).filter(([s,n])=>n>1).map(([s,n])=>`${esc(s.replace('USDT',''))} · ${n} bots`).join('')||'No crowding, every name held by ≤1 bot';
// risk-mode banner: the Governor is actively de-risking (kill-switch or reduced sizing)
const modeBanner=d.killSwitch
?`
${icon('alert',16)}
KILL-SWITCH ACTIVE, ${esc((d.mode||'').toUpperCase())}Portfolio drawdown ${d.drawdownPct}%, new entries are blocked across all crypto bots until it recovers.
`
:(d.mode&&d.mode!=='normal')?`
${icon('alert',13)}Risk-reduction mode ${esc(d.mode)}: new positions sized to ${Math.round((d.sizeMult||1)*100)}% (drawdown ${d.drawdownPct}%). Bots keep managing open positions; only new risk is throttled.
`:'';
// drawdown kill-switch ladder (real ddTiers from the Governor, current mode highlighted)
const tiers=(d.ddTiers||[]).slice().reverse();
const ladder=tiers.length?`
${icon('shield',13)}Drawdown kill-switch laddercurrent: ${esc(d.mode||'normal')} at ${d.drawdownPct||0}%
0%normal
${tiers.map(t=>`→
${t.at}%${esc(t.mode)}
`).join('')}
`:'';
// trade audit: every proposal the Governor approved or vetoed (real, honest empty-state when quiet)
const audit=(d.audit||[]);
const aud=audit.length?`
${icon('flag',13)}Trade auditevery proposal, approved or vetoed
Stress-tested performance partitioned across the 3 macro regimes. Reveals where the strategy holds a mathematical edge vs where it requires risk throttles.
Bull Expansion${bull.verdict}
Win Rate${bull.winRate.toFixed(1)}%
Sharpe${bull.sharpe}
Profit Factor${bull.pf}
Max DD-${bull.maxDD}
Crab / Consolidation${crab.verdict}
Win Rate${crab.winRate.toFixed(1)}%
Sharpe${crab.sharpe}
Profit Factor${crab.pf}
Max DD-${crab.maxDD}
Bear / Defensive${bear.verdict}
Win Rate${bear.winRate.toFixed(1)}%
Sharpe${bear.sharpe}
Profit Factor${bear.pf}
Max DD-${bear.maxDD}
${icon('shield', 11)} 35 bps round-trip friction and slippage modeled into all walk-forward regime partitions.
${icon('shield',13)}Real crypto backtest. The same Backtester every strategy is proven on, on historical Binance daily klines for the 10 majors (equal-weight portfolio, ${period} + warmup, 15bps costs). Past performance isn't a promise, it's evidence a rule had an edge, honestly measured.
`;
let body;
if(CRYPTOBT.busy || CRYPTOBT.key!==key){ body=secEmpty('activity','Backtesting…',`Running ${esc(strat)} over the crypto majors on Binance history.`); }
else{ const d=CRYPTOBT.data;
if(!d||d.real===false||(d.totalRet==null&&!(d.pts&&d.pts.length))){ body=secEmpty('alert','Backtest unavailable',esc((d&&d.error)||'No cached result for this strategy/period yet.')); }
else{ const tone=v=>v>0?'up':(v<0?'down':'');
const stat=secStats([
{l:'Total return',v:pct(d.totalRet),s:esc(d.period||period),tone:tone(d.totalRet)},
{l:'CAGR',g:'cagr',v:pct(d.cagr),s:'annualised',tone:tone(d.cagr)},
{l:'Max drawdown',g:'drawdown',v:pct(-Math.abs(d.maxDD||0)),s:'peak-to-trough',tone:'down'},
{l:'Sharpe',g:'sharpe-ratio',v:(d.sharpe==null?'-':(+d.sharpe).toFixed(2)),s:'risk-adjusted',tone:tone(d.sharpe)},
{l:'Win rate',g:'win-rate',v:(d.winRate==null?'-':d.winRate+'%'),s:`${d.trades||0} trades`},
{l:'Avg trade',v:pct(d.avgTrade),s:`${d.timeInMarket!=null?d.timeInMarket+'% in market':''}`,tone:tone(d.avgTrade)},
]);
const bh=d.benchmark||{}; const curve=cxCurve(d.pts,bh.pts);
const legend=curve?`
${icon(held?'check':'alert',12)}${held?'Edge persisted out-of-sample: held up on data it never trained on.':'Edge weakened out-of-sample: strong in-sample but faded on unseen data. Treat with caution.'} Costs: ${d.costBps||15} bps/leg applied.${thin?' Thin sample ('+(d.trades||0)+' trades, <30), a hint, not proof.':''}
`:'';
// ---- vs buy & hold ----
const beat=bh&&bh.totalRet!=null&&d.totalRet>=bh.totalRet;
const benchBlock=(bh&&bh.totalRet!=null)?`
${icon('scale',13)} vs Buy & hold same ${d.universe||10} coins, dashed on the curve
Strategy${pct(d.totalRet)}
Buy & hold${pct(bh.totalRet)}
Alpha · ann.${infoI('Annualised return the strategy added beyond just holding these coins. Positive = real edge; negative = the timing cost more than it added.')}${bh.alpha!=null?pct(bh.alpha):'-'}
Beta${infoI('Sensitivity to simply holding the basket. ~1 moves with it, <1 less exposed, ~0 market-neutral.')}${bh.beta!=null?bh.beta.toFixed(2):'-'}
${icon(beat?'check':'alert',12)}${beat?'The strategy beat simply holding these coins.':'The strategy underperformed buy & hold over this window, an honest result, shown anyway.'}
${icon('trendDown',13)} Drawdown, underwater worst −${Math.abs(d.maxDD||0).toFixed(1)}% · time in market ${d.timeInMarket!=null?d.timeInMarket+'%':'-'}
${ddCurveSVG(d.dd)}
${icon('shield',11)}How deep and how long the strategy sat below its prior peak, the pain you'd have had to sit through.
${icon('shield',13)} Monte-Carlo robustness ${mc.runs} resamples of the trades
Worst 5%${infoI('5th-percentile outcome across the bootstrap resamples of the real trades, a bad-luck draw.')}${pct(mc.p5)}
Median${pct(mc.p50)}
Best 5%${pct(mc.p95)}
Profitable${infoI('Share of resampled runs that ended in profit. >60% = a robust edge; near 50% = a coin-flip.')}${mc.profitableShare}%
${icon(robust?'check':'alert',12)}${robust?'Robust: '+mc.profitableShare+'% of resampled runs profited, so the edge isn’t one lucky sequence.':'Fragile: only '+mc.profitableShare+'% of resampled runs profited; the result leans on a few trades. Treat with caution.'} Across draws, returns spanned ${pct(mc.p5)} to ${pct(mc.p95)}.
`:'';
const wfBlock=walkForwardRegimeHtml(d, strat);
body=stat+curveBlock+benchBlock+vr+wfBlock+ddBlock+monthlyHeat(d.monthly)+decayLine+mcBlock+analytics+log;
}
}
return note+picker+body;
}
// ---- crypto Forward Test + Accuracy (real closed-trade track record from the 24/7 harness) ----
const CRYPTOFWD={loaded:false,busy:false,data:null};
// per-strategy capital allocation (the user's deploy dial, 0-100%), read from + written to the
// backend, which the 24/7 harness honours when sizing (bot/crypto_alloc.py).
const CRYPTOALLOC={loaded:false,busy:false,data:null};
async function loadCryptoAlloc(){ if(CRYPTOALLOC.busy) return; CRYPTOALLOC.busy=true;
try{ CRYPTOALLOC.data=await fetch(`${BOT_API}/api/crypto/allocation`).then(r=>r.json()); }catch(e){ CRYPTOALLOC.data={running:false}; }
CRYPTOALLOC.loaded=true; CRYPTOALLOC.busy=false; }
// write a strategy's allocation weight (0-1), optimistic UI + persists to the harness.
function cxSetAlloc(id, w){
w=Math.max(0,Math.min(1,w));
const rec=((CRYPTOALLOC.data&&CRYPTOALLOC.data.strategies)||[]).find(x=>x.id===id); if(rec) rec.weight=w; // optimistic
fetch(`${BOT_API}/api/crypto/allocation`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id,weight:w})})
.then(r=>r.json()).then(res=>{ if(res&&res.ok) announce(`Allocation for ${id} set to ${Math.round(w*100)}%, the engine sizes within this cap next cycle.`); })
.catch(()=>announce('Could not save allocation, bot API unreachable.'));
}
async function loadCryptoFwd(){ if(CRYPTOFWD.busy) return; CRYPTOFWD.busy=true;
try{ CRYPTOFWD.data=await fetch(`${BOT_API}/api/crypto/forward`).then(r=>r.json()); }catch(e){ CRYPTOFWD.data={running:false}; }
CRYPTOFWD.loaded=true; CRYPTOFWD.busy=false; }
function cryptoForward(mode){ // mode: 'forward' | 'accuracy'
const d=CRYPTOFWD.data;
if(!d){ if(!CRYPTOFWD.busy) loadCryptoFwd().then(()=>{ if(isAlgo()&&state.algo.market==='crypto') renderAlgo(); }); return secEmpty('activity','Loading track record…','Reading closed crypto trades from the harness log.'); }
const rows=d.strategies||[], t=d.totals||{};
if(!rows.length){ return secEmpty('activity','No closed trades yet',`The 24/7 crypto harness books this as it runs, win%, profit factor and expectancy appear once positions close. Open positions are on the Monitor tab.`,
`Why forward evidence beats a backtest →`); }
const acc=mode==='accuracy';
const note=`
${icon('shield',13)}${acc?'Forward accuracy':'Forward test'}, real out-of-sample. Every metric below is from closed paper trades on live Binance data (not a backtest, not fabricated). ${acc?'Win% and profit factor are the honest edge measure.':'This is the live track record the go-live gate would judge.'}
Live regime: ${esc(reg)}The go-live gate judges each strategy net of costs. Strategies proven fit for this regime (green) carry the edge; the rest are stood down to cash, that's survival-first, not idle.
`:'';
const GRAD=50; // go-live sample bar: ≥50 closed trades to be judged
const head=`
${icon('activity',13)}Per-strategy ${acc?'accuracy':'track record'}${rows.length} with closed trades
${head}${body}
`;
}
// ---- crypto Analytics (P&L attribution by instrument / strategy / regime) ----
const CRYPTOAN={loaded:false,busy:false,data:null};
async function loadCryptoAn(){ if(CRYPTOAN.busy) return; CRYPTOAN.busy=true;
try{ CRYPTOAN.data=await fetch(`${BOT_API}/api/crypto/analytics`).then(r=>r.json()); }catch(e){ CRYPTOAN.data={running:false}; }
CRYPTOAN.loaded=true; CRYPTOAN.busy=false; }
const CX_INSTR_LABEL={spot:'Spot',perps:'Perpetuals',options:'Options'};
// realised equity curve: cumulative net P&L over the closed-trade sequence (all real, from the log)
function cxEquityCurve(pts){
if(!pts||pts.length<2) return '';
const ys=pts.map(p=>p.cum), min=Math.min(0,...ys), max=Math.max(0,...ys), rng=(max-min)||1, W=560,H=140;
const X=i=>(i/(pts.length-1)*W), Y=v=>(H-(v-min)/rng*H);
const line=pts.map((p,i)=>`${X(i).toFixed(1)},${Y(p.cum).toFixed(1)}`).join(' ');
const zero=Y(0).toFixed(1), last=pts[pts.length-1].cum, tone=last>=0?'up':'down';
const area=`0,${zero} ${line} ${W},${zero}`;
return ``;
}
// download the closed-trade set the analytics is built from as CSV (real rows, nothing synthesised)
function cxTradesCSV(){
const rows=(CRYPTOAN.data&&CRYPTOAN.data.recent)||[];
if(!rows.length){ announce('No closed trades to export'); return; }
const head=['time','strategy','symbol','pnl','cost','exit_reason','regime'];
const csv=[head.join(',')].concat(rows.map(r=>[r.time,r.strat,r.sym,r.pnl,r.cost,r.reason,r.regime]
.map(x=>`"${String(x==null?'':x).replace(/"/g,'""')}"`).join(','))).join('\n');
const a=document.createElement('a');
a.href=URL.createObjectURL(new Blob([csv],{type:'text/csv'}));
a.download='crypto-closed-trades.csv'; a.click(); URL.revokeObjectURL(a.href);
}
function cryptoAnalytics(){
const d=CRYPTOAN.data;
if(!d){ if(!CRYPTOAN.busy) loadCryptoAn().then(()=>{ if(isAlgo()&&state.algo.market==='crypto') renderAlgo(); }); return secEmpty('activity','Loading analytics…','Attributing the crypto P&L by instrument, strategy, regime, symbol, exit-reason and hour.'); }
if(d.running===false){ return secEmpty('activity','Analytics offline','The trading engine is temporarily offline, analytics resumes attributing its live book once it\'s back.'); }
const t=d.totals||{}, st=d.stats||{}, note=`
${icon('shield',13)}P&L attribution, real, from the closed-trade log. Every realised figure below is computed from actual closed paper trades (not estimated): the equity curve, and the splits by regime, symbol, exit-reason and hour. Open P&L is marked live. Nothing here is synthesised.
${icon('activity',13)}By hour of daynet per hour (UTC clock)
${hrBars}`:'')+
`
${icon('cpu',13)}By regimerealised, from closed trades
${regChips}
`+
`
${icon('activity',13)}Strategy contributorstop by total P&L
${contrib}
`+
feedHead+feed;
}
// ---- Go-live readiness gate (net-of-cost evidence per strategy vs the bar) ----
const READY={data:{},busy:{}};
async function loadReadiness(mkt){ if(READY.busy[mkt]) return; READY.busy[mkt]=true;
try{ READY.data[mkt]=await fetch(`${BOT_API}/api/readiness/book?market=${mkt}`).then(r=>r.json()); }catch(e){ READY.data[mkt]={err:true}; }
READY.busy[mkt]=false; }
function readinessView(mkt){
const d=READY.data[mkt];
const money=mkt==='crypto'?cxMoney:(v=>(v==null||!isFinite(v))?'-':(v<0?'−':'+')+'₹'+Math.abs(Math.round(v)).toLocaleString('en-IN'));
if(!d){ if(!READY.busy[mkt]) loadReadiness(mkt).then(()=>{ if(isAlgo()) renderAlgo(); }); return secEmpty('shield','Assessing go-live readiness…','Scoring each strategy against the bar.'); }
if(d.err){ return secEmpty('shield','Readiness unavailable','The trading engine is temporarily unreachable.'); }
const s=d.summary||{}, bar=d.bar||{};
const verdict=d.goLive?'READY':(s.ready>0?'PARTIAL, not all clear':'NOT READY');
const banner=`
${icon(d.goLive?'check':'shield',22)}
Go-live: ${verdict}${s.ready||0} ready · ${s.gathering||0} gathering · ${s.cull||0} to cull, of ${s.total||0} strategies with closed trades.
Bar: ≥${bar.minTrades} trades · PF ≥ ${bar.minProfitFactor} · ≥${bar.minRegimes} regimes · +expectancy after costs
`;
const note=`
${icon('shield',13)}Honest go-live gate. Every number is net of realistic costs (brokerage + STT/fees + slippage) from CLOSED paper trades. A strategy is READY only with a real sample across multiple regimes, a few lucky trades read as gathering. CULL = auto-benched for negative expectancy.
No closed trades yet, the gate fills as the harness runs
'}
`;
}
// ---- Strategy × regime fit matrix (learned, drives live selection) ----
const RFIT={data:{},busy:{}};
async function loadRegimeFit(mkt){ if(RFIT.busy[mkt]) return; RFIT.busy[mkt]=true;
try{ RFIT.data[mkt]=await fetch(`${BOT_API}/api/regime-fit?market=${mkt}`).then(r=>r.json()); }catch(e){ RFIT.data[mkt]={err:true}; }
RFIT.busy[mkt]=false; }
function regimeFitMatrix(mkt){
const d=RFIT.data[mkt];
if(!d){ if(!RFIT.busy[mkt]) loadRegimeFit(mkt).then(()=>{ if(isAlgo())renderAlgo(); }); return ''; }
if(d.err||!(d.strategies||[]).length) return '';
const regs=d.regimes||[], cur=d.currentRegime;
// BUG FIX (2026-09-20): the header's manual Bull/Neutral/Bear toggle used to change copy
// elsewhere but never touch this matrix, so toggling while on Analytics visibly did nothing -
// confusing, since a user expects the page to react. Can't just overwrite `cur` with the
// toggle though - `cur` is the real, backend-computed live regime driving actual strategy
// selection, faking it would be exactly the kind of fabricated claim this matrix explicitly
// exists to avoid (see the note below). Instead, highlight the toggled regime as a separate
// "viewing" lens alongside the untouched, real "live" marker - both stay visible and honest.
const toggled=state.mode==='manual'?({bull:'Bull',neutral:'Choppy',bear:'Bear'}[state.displayed]):null;
const sel=(toggled&®s.includes(toggled)&&toggled!==cur)?toggled:null;
const note=`
${icon('shield',13)}Regime fit, learned, not assumed. Each cell is a strategy's net-of-cost edge in that regime (needs ${d.minTrades}+ trades to call it). In the live regime (${esc(cur)} ●) the book benches proven losers and deploys proven winners; the safe default stands where evidence is thin, so we run the right strategies for the conditions.${sel?` Viewing ${esc(sel)} (from the header toggle, dashed) - it doesn't change what's actually live, only which column is highlighted below.`:''}
${icon('shield',13)} Paper trading (simulated fills). Live execution is a Pro feature.
${icon('bolt',12)} Real-money live trading requires a Pro plan and a connected exchange. Unlock Pro & Connect →
`;
}
const note=live
?`
${icon('alert',13)}Real orders, your own Binance account. Every buy/sell below places a REAL market order using your connected key. This is not simulated and cannot be undone once filled.
`
:`
${icon('shield',13)}Custom charting & trade signal laboratory. Real Binance candles, honest backtested win rates, simulated fills. Test any signal virtually before going live.
Take Profit (+${(tpPct * 100).toFixed(1)}%)${m.priced ? cryptoFmt(tpPx) : '-'}+${cryptoFmt(potGain)}
Stop Loss (-${(slPct * 100).toFixed(1)}%)${m.priced ? cryptoFmt(slPx) : '-'}-${cryptoFmt(maxLoss)}
Modeled with transparent 35 bps round-trip friction.
`;
}
function tradingPositionsTab(){
const rows={};
state.orders.filter(o=>o.status==='Filled').forEach(o=>{
const r=rows[o.sym]=rows[o.sym]||{sym:o.sym,qty:0,cost:0};
const sq=o.side==='buy'?o.qty:-o.qty;
r.qty+=sq; r.cost+=sq*o.price;
});
const open=Object.values(rows).filter(r=>Math.abs(r.qty)>1e-9);
if(!open.length) return secEmpty('trendUp','No open positions','Place a trade to see it here, live-marked against the current price.');
const cols='grid-template-columns:1fr 90px 110px 110px 110px';
const head=`
` : '';
if (histMode === 'live' && hasExchangeBridge && ex && ex.connected) {
if (!liveOrders.length) {
return subNav + secEmpty('activity', 'No live orders yet', 'When you place live orders with your connected Binance account, your real filled trades, prices, and order IDs appear here.');
}
const cols = 'grid-template-columns:1fr 70px 90px 100px 110px 120px';
const head = `
${icon('shield',13)}Live Binance executions${liveOrders.length} order${liveOrders.length===1?'':'s'} · newest first
${head}${body}
`;
}
if(!paperOrders.length) return subNav + secEmpty('activity','No paper trades yet','Every buy and sell you place in paper mode shows up here, honestly, no fabricated track record.');
const cols='grid-template-columns:1fr 70px 90px 100px 110px';
const head=`
${icon('activity',13)}Paper trade history${paperOrders.length} order${paperOrders.length===1?'':'s'} · newest first
${head}${body}
`;
}
function tradingPlace(){
const m=tradeModel(state.trading.sym);
// BUG FIX (2026-09-20): only price was gated here, qty=0 (a valid state of the qty input's own
// clamp, Math.max(0,...)) reached this point and placed a meaningless zero-size order. Also caps
// Sell at held qty, matching the CTA's own disabled state, see heldQty()/overSell above.
if(!m.priced||!(m.qty>0)||m.overSell) return;
placeOrder({sym:m.sym,side:m.side,qty:m.qty,price:m.px,type:'MARKET'});
}
// Real order execution: a mandatory, visually-distinct confirm step before ANY real order, never
// reusing the paper flow's "simulated" copy or its instant one-click placement. The per-order
// notional cap, cooldown, and daily count are all re-enforced server-side in place-order itself -
// this confirm step is about informed consent, not the actual safety boundary.
function tradingPlaceLive(){
const m=tradeModel(state.trading.sym);
// BUG FIX (2026-09-20): same qty=0 gap as tradingPlace(), but on the real-money path - this let
// a user reach the "place a REAL order" confirm modal with a zero-quantity order.
// NOTE: deliberately NOT applying m.overSell here - heldQty() only sums local paper fills
// (state.orders never gets live fills recorded today, a separate known gap), so it has zero
// visibility into what's actually on the user's real Binance account. Gating a real sell on
// that number would incorrectly block sells of real holdings the app never saw be bought.
if(!m.priced||!(m.qty>0)||state.trading.liveBusy) return;
flowModal({title:'Place a REAL order',confirm:'Place real order',danger:true,
body:`
${m.side==='buy'?'BUY':'SELL'} ${m.qty} ${esc(m.tk)}Real Binance order · your own connected account
Est. price${cryptoFmt(m.px)}
Est. value${cryptoFmt(m.value)}
${icon('alert',13)}This places a real order on your own Binance account, using real money. It is not simulated, and cannot be undone once filled.
`;
}).join('');
const totalTxt=(pf.incomplete?'~':'')+cryptoFmt(pf.total);
const incompleteNote=pf.incomplete?infoI('Some holdings are missing a live price right now, this total is a partial sum, not the full picture.'):'';
const statLine=`
${icon('shield',13)}Simulated DCA, real prices. Set an amount and cadence, then tap Simulate a buy now whenever you want to record one, it books at the live price the moment you click, never backdated.
`;
const form=`
New DCA plan
Coin${coinPicker}
Cadence${cadencePicker}
`;
const plans=inv.dca||[];
const cadLabel=Object.fromEntries(cadences);
const cols='grid-template-columns:1fr 130px 100px 100px 220px';
const list=!plans.length?secEmpty('sprout','No DCA plans yet','Set one up below, e.g. $50 of BTC every week.'):
`
`;
const goals=inv.goals||[];
const list=!goals.length?secEmpty('activity','No goals yet','Set a target below, this is your own target, never a promise or projection.'):
`
${esc(g.label)}${cryptoFmt(total)} of ${cryptoFmt(g.targetValue)}
`;
}).join('');
return form+list;
}
function investingSimulateBuy(id){
const p=(state.investing.dca||[]).find(x=>x.id===id);
if(!p) return;
// BUG FIX (2026-09-20): Pause used to only change the badge/label - a paused plan could still be
// bought via this same click handler, which made "Pause" mean nothing functionally. Matches the
// "Buy now" button's own new disabled state below, kept here too as defense in depth.
if(!p.active) return;
const q=CRYPTO.quotes[p.sym];
if(!CRYPTO.live||!q) return; // never record a buy without a real live price
p.history.push({ts:Date.now(),price:q.ltp,qty:p.amount/q.ltp,amount:p.amount});
saveState(); renderInvesting();
}
function investingToggleDca(id){
const p=(state.investing.dca||[]).find(x=>x.id===id); if(!p) return;
p.active=!p.active; saveState(); renderInvesting();
}
function investingDeleteDca(id){
const p=(state.investing.dca||[]).find(x=>x.id===id); if(!p) return;
const c=CRYPTO_UNIVERSE.find(x=>x.sym===p.sym);
// UX FIX (2026-09-20): this used to block on the browser's own confirm() dialog, the only place
// left in Investing/Trading still doing that - every other destructive action in the terminal
// (Stop a strategy, place a live order) uses flowModal() for a consistent look and feel.
flowModal({title:'Delete DCA plan',confirm:'Delete plan',danger:true,
body:`
${icon('alert',13)}Delete the ${esc(c?c.tk:p.sym)} DCA plan? This removes its buy history too, and can't be undone.
Algo StudioCrypto · live Binance data · paper trading, 24/7
${tabs.map(([k,l])=>``).join('')}
`;
const tabLabel=(tabs.find(t=>t[0]===view)||[,'This view'])[1];
const body=cryptoBody(view,tabLabel);
// Preserve scroll across the full innerHTML rebuild so in-place CTAs (sort, filter, scope toggles, sub-tabs,
// live P&L patches) don't flick/jump to the top. The algo view scrolls EITHER the page, the .pane-center, OR
// the inner .av-scroll depending on layout: capture and restore ALL THREE. Reset to top ONLY on real
// navigation: a market switch or a TAB (view) change. Scope toggles (instrument/holding) are in-tab filters,
// NOT navigation, so they keep your place (that was the "every click jumps me to the hero" bug).
const _scEl=document.scrollingElement||document.documentElement;
const _pane=v.closest('.pane-center');
const _av0=v.querySelector('.av-scroll');
const _vk='crypto/'+view;
const _keep=(state.algo._vk===_vk); state.algo._vk=_vk;
const _sy=_keep?_scEl.scrollTop:0, _py=(_keep&&_pane)?_pane.scrollTop:0, _avy=(_keep&&_av0)?_av0.scrollTop:0;
// crypto instrument scope (Spot/Perps/Options) shows on every tab except Monitor/Positions, which render it inline.
const cxScope=['monitor','positions'].includes(view)?'':cryptoScopeBar();
v.innerHTML=`
${head}
${cryptoStatusBar()}${cxScope}${body}
`;
_scEl.scrollTop=_sy; if(_pane) _pane.scrollTop=_py;
const _av1=v.querySelector('.av-scroll'); if(_av1) _av1.scrollTop=_avy;
v.querySelectorAll('[data-algoview]').forEach(b=>b.onclick=()=>{state.algo.view=b.dataset.algoview;saveState();renderAlgo();});
v.querySelectorAll('[data-algoinstr]').forEach(b=>b.onclick=()=>{ const a=state.algo; if(a.instr===b.dataset.algoinstr) return; a.instr=b.dataset.algoinstr; a.hold='all'; if(a.lib) a.lib.fam='all'; renderAlgo(); });
v.querySelectorAll('[data-algohold]').forEach(b=>b.onclick=()=>{ state.algo.hold=b.dataset.algohold; renderAlgo(); });
v.querySelectorAll('[data-cinstr]').forEach(b=>b.onclick=()=>{ state.algo.cinstr=b.dataset.cinstr; renderAlgo(); });
v.querySelectorAll('[data-cbtstrat]').forEach(b=>b.onclick=()=>{ state.algo.cbt=state.algo.cbt||{strat:'macross',period:'1Y'}; state.algo.cbt.strat=b.dataset.cbtstrat; renderAlgo(); });
v.querySelectorAll('[data-cbtperiod]').forEach(b=>b.onclick=()=>{ state.algo.cbt=state.algo.cbt||{strat:'macross',period:'1Y'}; state.algo.cbt.period=b.dataset.cbtperiod; renderAlgo(); });
v.querySelectorAll('[data-cxcsv]').forEach(b=>b.onclick=cxTradesCSV);
v.querySelectorAll('[data-cxgl]').forEach(b=>b.onclick=e=>{e.stopPropagation();cryptoGoLiveCheck(b.dataset.cxgl);});
v.querySelectorAll('[data-cxalloc]').forEach(sl=>{
let _t=null;
sl.oninput=()=>{ const lab=v.querySelector('[data-cxallocval="'+CSS.escape(sl.dataset.cxalloc)+'"]'); if(lab)lab.textContent=sl.value+'%';
clearTimeout(_t); _t=setTimeout(()=>cxSetAlloc(sl.dataset.cxalloc,(+sl.value)/100), 400); }; // debounce the write while dragging
sl.onchange=()=>{ clearTimeout(_t); cxSetAlloc(sl.dataset.cxalloc,(+sl.value)/100); }; // commit immediately on release
sl.onclick=e=>e.stopPropagation();
});
v.querySelectorAll('[data-algogoto]').forEach(b=>b.onclick=()=>{state.algo.view=b.dataset.algogoto;renderAlgo();});
v.querySelectorAll('[data-cxdep]').forEach(b=>b.onclick=()=>cryptoLibDeploy(b.dataset.cxdep));
// Monitor accordion: click a strategy to reveal its positions + forward accuracy.
// Toggles the class directly (no re-render) → smooth, and survives the 2s live poll.
v.querySelectorAll('[data-monexp]').forEach(el=>{
const tog=e=>{ if(e&&e.target&&e.target.closest('[data-algogl],[data-cxgl],[data-algogoto]'))return;
const id=el.dataset.monexp, m=state.algo.monExpand=state.algo.monExpand||{}; m[id]=!m[id];
const card=el.closest('.mon-card'); if(card)card.classList.toggle('open',m[id]); el.setAttribute('aria-expanded',String(!!m[id])); };
el.onclick=tog;
el.onkeydown=e=>{ if(e.key==='Enter'||e.key===' '){e.preventDefault();tog(e);} };
});
v.querySelectorAll('[data-execmode]').forEach(b=>b.onclick=()=>{state.algo.exec=b.dataset.execmode;renderAlgo();});
v.querySelectorAll('[data-monsort]').forEach(b=>b.onclick=()=>{state.algo.monSort=b.dataset.monsort;renderAlgo();});
v.querySelectorAll('[data-monfilter]').forEach(b=>b.onclick=()=>{state.algo.monFilter=b.dataset.monfilter;renderAlgo();});
v.querySelectorAll('[data-cxlibcat]').forEach(b=>b.onclick=()=>{state.algo.cxLibCat=b.dataset.cxlibcat;renderAlgo();});
state.algo._sig=algoLiveSig(); // snapshot structure so the 2s poll knows when a full re-render is needed
}
/* ===== Info icons + plain-English definitions for every metric/strategy ===== */
function infoI(tip){return tip?`i`:'';}
const POS_ACT={hold:['Hold','hold'],protect:['Protect profit','protect'],watch:['Watch','watch'],exit:['Exit, thesis decayed','exit']};
function posTone(h){ return (h==null)?'na':h>=70?'hi':h>=50?'mid':'lo'; }
function timeAgo(iso){ try{ const s=Math.max(0,(Date.now()-new Date(iso).getTime())/1000);
if(s<60)return Math.round(s)+'s ago'; if(s<3600)return Math.round(s/60)+'m ago';
if(s<86400)return Math.round(s/3600)+'h ago'; return Math.round(s/86400)+'d ago'; }catch(e){return '';} }
function ddCurveSVG(dd){
const W=600,H=70,pad=6;
const pts=(Array.isArray(dd)&&dd.length>1)?dd:[0,0];
const lo=Math.min(...pts,-0.01); // most-negative; avoid /0
const X=i=>i/(pts.length-1)*W, Y=v=>pad+(v/lo)*(H-2*pad); // 0 at top, worst at bottom
const d=pts.map((v,i)=>(i?'L':'M')+X(i).toFixed(1)+','+Y(v).toFixed(1)).join(' ');
const area=d+` L${W},${pad} L0,${pad} Z`;
return ``;
}
function monthlyHeat(monthly){
if(!Array.isArray(monthly)||!monthly.length) return '';
const byY={}; monthly.forEach(m=>{const p=String(m.ym).split('-');(byY[p[0]]=byY[p[0]]||{})[+p[1]]=m.ret;});
const years=Object.keys(byY).sort();
const mn=['J','F','M','A','M','J','J','A','S','O','N','D'];
const cell=v=>{ if(v==null) return '
`;
}
function liveLockedPanel(){
const crypto=state.algo.market==='crypto';
return `
${icon('shield',15)}
Live trading is locked`
+`Nothing places real orders yet. Live turns on only when all three are true:`
+`
A strategy has cleared the Go-Live bar: proven forward, net of costs
`
+`
Your ${crypto?'exchange (Binance)':'broker (Zerodha Kite)'} is connected with trade-only keys
`
+`
ALLOW_LIVE is armed at the OS level on the bot machine, a browser can never do this
`
+`Everything runs in paper until then. This is deliberate, it's what keeps your money safe.
`;
}
/* ============================================================
AI MODE: copilot chat + AI signals (XSS-safe via esc())
============================================================ */
const AI_PROMPTS=['Top movers right now','Find me oversold ideas','Hedge my portfolio','Explain my portfolio health','Best option strategy now'];
const aiCfg=()=>state.aiCfg||(state.aiCfg={endpoint:'',model:'claude-opus-4-8'});
const aiLive=()=>!!(aiCfg().endpoint||'').trim();
function setSurface(s,silent){
state.surface=s; document.documentElement.dataset.surface=s;
const b=$('surfaceToggle'); if(b){ b.innerHTML=icon(s==='night'?'sun':'moon',16); b.setAttribute('aria-label',s==='night'?'Switch to day mode':'Switch to night trading-floor mode'); b.setAttribute('aria-pressed',s==='night'); }
if(s==='night'&&!silent) powerOn();
announce(s==='night'?'Night trading-floor mode on':'Day mode on');
saveState();
}
function toggleSurface(){ cascadeSurface(state.surface==='night'?'day':'night'); }
function tapeLoop(){ doTick(); const vix=+$('sVix').value, night=state.surface==='night'?0.7:1; const delay=clamp((1500-(vix-8)*42)*night,300,1500); state.tapeT=setTimeout(tapeLoop,delay); }
function applyPaneWidths(){
const t=document.querySelector('.terminal'); if(!t)return;
// BUG FIX (2026-09-19): this used to give trader/investor a resizable 3-pane inline width
// (watchlist | chart | order-pad), back when only algo/ai used the single-column layout. All
// four personas now use the same single-container-per-mode layout (#algoView/#tradingView/
// #investHub/#aiView, styles.css's universal `.terminal{grid-template-columns:1fr}`) - the old
// 3-pane layout is retired everywhere, not just for algo/ai. Leaving this stale inline width in
// place for trader/investor overrode that CSS rule (inline style beats a class selector),
// collapsing #investHub/#tradingView into a ~330px column and leaving most of the screen as
// bare ambient background with nothing drawn over it. Always clear it now; there's no persona
// left that needs a JS-computed grid width.
t.style.gridTemplateColumns='';
}
function applyChartHeight(){
const card=$('chartCard'); if(card) card.style.height=state.chartH?state.chartH+'px':'';
}
const sgn=n=>Number.isFinite(n)?(n>=0?'+':'−')+'$'+Math.abs(Math.round(n)).toLocaleString():'-';
const WIDGET_CATALOG={
trader:[
{key:'movers',name:'Top Movers',icon:'trendUp',desc:'Biggest gainers & losers right now',render(){
if(!BOT.live) return `
${icon('shield',13)} Connect the exchange for live movers.
`;
const live=SYMS.filter(s=>s.live!==false); // exclude symbols with no real quote, never a stale price
if(!live.length) return `
]*>|<\/div>$/g,'');panel.appendChild(d);}
}
function updateCardBtns(){
Object.keys(CARD_EL).forEach(k=>{
const el=document.querySelector(CARD_EL[k]); if(!el)return;
const m=(state.cards&&state.cards[k])||'normal';
const mn=el.querySelector('.cc-min'), mx=el.querySelector('.cc-max');
if(mn) mn.innerHTML=icon(m==='min'?'plus':'minus',13);
if(mx) mx.innerHTML=icon(m==='max'?'compress':'expand',13);
});
}
function paneVisible(key){ return ((state.cards&&state.cards[key])||'normal')!=='hidden'; }
function togglePane(key){ if(!state.cards)state.cards={}; state.cards[key]= paneVisible(key)?'hidden':'normal'; applyCardStates(); applyPaneWidths(); renderWidgetGallery(); saveState(); }
function applyCardStates(){
let anyMax=false;
Object.keys(CARD_EL).forEach(k=>{
const el=document.querySelector(CARD_EL[k]); if(!el)return;
const m=(state.cards&&state.cards[k])||'normal';
el.classList.toggle('card-min', m==='min');
el.classList.toggle('card-max', m==='max');
el.classList.toggle('card-hidden', m==='hidden');
if(m==='max')anyMax=true;
});
const term=document.querySelector('.terminal'); if(term) term.classList.toggle('has-max',anyMax);
const scrim=$('cardScrim'); if(scrim) scrim.classList.toggle('show',anyMax);
document.body.classList.toggle('card-maxed',anyMax);
updateCardBtns();
if(window.TPChart&&TPChart.resize){TPChart.resize();setTimeout(()=>TPChart.resize(),70);}
}
function toggleCard(key,which){
if(!state.cards)state.cards={};
const cur=state.cards[key]||'normal', nm=CARD_LABEL[key]||key;
if(which==='close'){ state.cards[key]='hidden'; wsRestoreDismissed=false; }
else if(which==='min'){ state.cards[key]= cur==='min'?'normal':'min'; }
else { if(cur==='max'){state.cards[key]='normal';}
else { Object.keys(CARD_EL).forEach(k=>{if(state.cards[k]==='max')state.cards[k]='normal';}); state.cards[key]='max'; } }
applyCardStates(); applyPaneWidths();
const st=state.cards[key];
if(st==='hidden') quickToast(nm+' hidden','Restore it from + Widgets → Layout panels.');
else announce(`${nm} ${st==='normal'?'restored':st==='min'?'minimized':'maximized'}`);
saveState();
}
function restoreMaxCard(){ let changed=false; Object.keys(CARD_EL).forEach(k=>{if(state.cards[k]==='max'){state.cards[k]='normal';changed=true;}}); if(changed){applyCardStates();saveState();} }
function recompute(opts={}){
syncSliderLabels();
const raw=readSignals(),sc=scoreSignals(raw),S=composite(sc),conf=confidence(S,sc);
// Live: the bot's regime is authoritative for BOTH the 4-state label and the 3-state terminal theme (via THEME_OF).
// What-if / offline: classify the composite with hysteresis, then derive the label from theme + VIX.
const liveReg=(!state.simOverride && BOT.live && BOT.market && BOT.market.engine)?BOT.market.engine.regime:null;
const newEngine=(liveReg&&THEME_OF[liveReg])?THEME_OF[liveReg]:classify(S,state.engine); state.engine=newEngine;
const label=liveReg||regime4(newEngine,raw.vix,state.regimeLabel); state.regimeLabel=label;
renderEngine(S,sc,conf,newEngine,label);$('autoConf').textContent=conf+'%';
renderTopIndex(); renderRegimeBar(state.displayed);
const vixROC=(raw.vix-state.prevVix)/Math.max(1,state.prevVix);
const hard=state.forceHard||vixROC>0.15;state.forceHard=false;
const mismatch=newEngine!==state.displayed;
if(state.mode==='auto'){
if(opts.silent){ if(mismatch) applyRegime(newEngine); } // quiet adoption (boot / first live sync), no toast/cinematic
else if(mismatch&&!state.suggesting){
if(hard){applyRegime(newEngine);infoToast(newEngine,S,conf,sc,true,label);}
else if(conf>=65){suggestToast(newEngine,S,conf,sc,false,label);}
}
}else if(mismatch&&conf>=65&&!state.suggesting&&!opts.silent){suggestToast(newEngine,S,conf,sc,true,label);}
state.prevVix=raw.vix;
renderEngineSrc();
}
/* ---------- Trading Floor (Day/Night) surface ---------- */
function powerOn(){ const f=$('floorSweep'); if(!f)return; f.classList.remove('go'); void f.offsetWidth; f.classList.add('go'); }
/* ---------- day/night CASCADE: trading floor powers on/off, pane by pane ----------
Freeze each visible pane in the OLD theme (inline CSS vars inherit to the whole
subtree), flip the global theme so the canvas changes at once, then release the
panes in a ripple outward from the toggle, each flipping with an accent flash. */
const THEME_VARS=['--bg','--surface','--surface-2','--white','--line','--line-2','--navy','--slate','--slate-2','--green','--green-d','--red','--red-d','--blue','--amber','--tint-down','--tint-warn','--tint-info','--bd-down','--bd-warn','--up-flash','--down-flash','--topbar-bg','--glass','--glass-hi','--shadow','--shadow-hover','--shadow-lg','--accent','--accent-d','--accent-soft','--accent-line'];
const CASCADE_SEL=['.topbar','.ticker-bar','.regime-bar','.pane-left','.chart-card','#investHub','.panel','.order-card','.ctx-card','.mode-switch',
/* algo / ai / trader-desk takeover panes. So day↔night powers on in EVERY persona, not just the 3-pane floor */
'.av-head','.av-scroll','.ai-main','.ai-side','.desk-head','.desk-scroll'];
function cascadeSurface(next){
if(prefersReduced()||!document.querySelector('.topbar')){ setSurface(next); return; }
const cs=getComputedStyle(document.documentElement);
const oldVals={}; THEME_VARS.forEach(v=>{const val=cs.getPropertyValue(v).trim(); if(val) oldVals[v]=val;});
const keys=Object.keys(oldVals);
const btn=$('surfaceToggle'); const br=btn?btn.getBoundingClientRect():{left:innerWidth-40,top:20,width:24,height:24};
const ox=br.left+br.width/2, oy=br.top+br.height/2;
const cards=[];
CASCADE_SEL.forEach(s=>document.querySelectorAll(s).forEach(el=>{ if(el.getClientRects().length) cards.push(el); }));
cards.forEach(el=>{
keys.forEach(v=>el.style.setProperty(v,oldVals[v])); // freeze in old theme
const r=el.getBoundingClientRect(), cx=r.left+r.width/2, cy=r.top+r.height/2;
el.__dist=Math.hypot(cx-ox,cy-oy);
el.style.setProperty('--fx',(((ox-r.left)/Math.max(1,r.width))*100).toFixed(1)+'%'); // flash points back at toggle
el.style.setProperty('--fy',(((oy-r.top)/Math.max(1,r.height))*100).toFixed(1)+'%');
});
cards.sort((a,b)=>a.__dist-b.__dist);
setSurface(next,true); // flip global theme (canvas) now; panes held by local vars
const step=58;
cards.forEach((el,i)=>setTimeout(()=>{
el.classList.add('theme-flip');
keys.forEach(v=>el.style.removeProperty(v)); // release → pane eases to new theme + flashes
setTimeout(()=>{ el.classList.remove('theme-flip'); el.style.removeProperty('--fx'); el.style.removeProperty('--fy'); },620);
}, i*step));
}
/* ---------- live tape: VIX-driven ticks with uptick/downtick flash ---------- */
function flashNum(el,txt,dir){ el.textContent=txt; el.classList.remove('tk-up','tk-down'); void el.offsetWidth; el.classList.add(dir>=0?'tk-up':'tk-down'); }
function doTick(){
// LIVE: real Kite WebSocket ticks (loadTicks) drive every price, never fabricate
// movement on top of them. This synthetic tape only animates the offline demo.
// (BOT is a module-scoped `let`, NOT on window, reference it directly.)
if(typeof BOT!=='undefined' && BOT.live) return;
if(state.algo && state.algo.market==='crypto') return; // crypto tape is fed by real Binance data, never synth-tick it
const vix=+$('sVix').value, vol=clamp((vix-8)/27,0,1), night=state.surface==='night'?1.4:1;
const upd=(el,dec)=>{const base=parseFloat(el.textContent.replace(/,/g,''))||0; if(!base)return;
const mv=(Math.random()-0.5)*base*0.0007*(0.4+vol*3.2)*night;
flashNum(el,(base+mv).toLocaleString('en-IN',{maximumFractionDigits:dec}),mv);};
document.querySelectorAll('#topIndex .tb-seq-a .tix-val').forEach(el=>upd(el,(parseFloat(el.textContent.replace(/,/g,''))||0)>=20000?0:1));
const _sa=document.querySelector('#topIndex .tb-seq-a'),_sb=document.querySelector('#topIndex .tb-seq-b'); if(_sa&&_sb)_sb.innerHTML=_sa.innerHTML; // keep the looped copy in sync
document.querySelectorAll('#wlRows .wl-row .wl-ltp').forEach(el=>{if(Math.random()<0.6+vol*0.4)upd(el, (parseFloat(el.textContent.replace(/,/g,''))||0)>1000?1:2);});
const op=$('ordLtp'); if(op) upd(op,1);
}
/* ============================================================
CARD MINIMIZE / MAXIMIZE (per-card focus & flexibility)
============================================================ */
/* ============================================================
WIDGET LIBRARY: persona-aware, user-composable cards (right rail)
Two distinct catalogs are the trader/investor differentiator.
============================================================ */
/* ============================================================
CONTROLLER
============================================================ */
function syncSliderLabels(){const s=readSignals();
$('vTrend').textContent=(s.trend>=0?'+':'')+s.trend;$('vVix').textContent=s.vix.toFixed(1);
$('vAd').textContent=s.ad.toFixed(2);$('vRsi').textContent=s.rsi;$('vPnl').textContent=(s.pnl>=0?'+':'')+s.pnl.toFixed(1)+'%';}
/* ---- Regime panel ↔ live Kite: feed the real signals into the sliders so the panel reacts as if a human dragged them. */
function syncSlidersFromLive(){
const sg=BOT.live&&BOT.market&&BOT.market.signals; if(!sg) return false;
const set=(id,v,lo,hi)=>{ if(typeof v!=='number'||!isFinite(v)) return; const el=$(id); if(el) el.value=clamp(v,lo,hi); };
set('sTrend',sg.trend,-100,100); set('sVix',sg.vix,8,35); set('sAd',sg.ad,0.2,3);
set('sRsi',sg.rsi,20,80); set('sPnl',sg.pnl,-10,10);
if(typeof sg.macd==='boolean'){ const mt=$('macdToggle'); if(mt){ mt.dataset.on=String(sg.macd); mt.textContent=sg.macd?'Bullish ↑':'Bearish ↓'; } }
return true;
}
// Pull the panel back onto live data (clears a what-if). Called by the "Use live" button.
function resyncLive(){
state.simOverride=false; BOT._regimeSynced=false;
if(BOT.live && syncSlidersFromLive()){ recompute({silent:true}); BOT._regimeSynced=true; }
else { recompute(); }
}
// Honest source badge: LIVE (mirrors Kite) · WHAT-IF (user override) · SIMULATED (offline demo).
function renderEngine(S,sc,conf,regime,label){
$('gaugeNeedle').style.left=clamp((S+100)/200*100,1,99)+'%';
const rd=$('erRegime'); rd.textContent=(label||regime).toUpperCase(); rd.dataset.reg=label||regime; // 4-state label (Bull/Bear/Choppy/High-Vol)
$('erScore').textContent=(S>=0?'+':'')+S; $('erConf').textContent=conf+'%';
const sigs=[['Trend',sc.trend,'.30'],['Volatility',sc.vix,'.20'],['Breadth',sc.ad,'.20'],['Momentum',sc.mom,'.20'],['Personal',sc.pers,'.10']];
$('signals').innerHTML=sigs.map(([nm,v,w])=>{const pos=v>=0,width=Math.abs(v)/100*50;
return `
${nm} ${w}${v>=0?'+':''}${v}
`;}).join('');
}
function renderEngineSrc(){
const el=$('engSrc'); if(!el) return;
if(state.simOverride){
el.className='eng-src sim';
el.innerHTML=`What-if · simulated inputs, not the live market${BOT.live?``:''}`;
} else if(BOT.live && BOT.market){
const reg=(BOT.market.engine&&BOT.market.engine.regime)||'-';
const t=BOT.market.asOf?new Date(BOT.market.asOf).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}):'';
el.className='eng-src live';
el.innerHTML=`● LIVE from Binance · ${esc(reg)}${t?` · ${t}`:''} · updates every 30s`;
} else {
el.className='eng-src off';
el.innerHTML=`Simulated · connect the live engine for the real regime, drag to explore`;
}
const rb=$('esReset'); if(rb) rb.onclick=resyncLive;
}
// Any manual interaction switches the panel into what-if mode (stops live mirroring until "Use live").
function markSimOverride(){ if(!state.simOverride){ state.simOverride=true; renderEngineSrc(); } }
/* ---------- toasts ---------- */
function clearToasts(){$('toastWrap').innerHTML='';state.suggesting=false;}
function suggestToast(regime,S,conf,sc,manual,label){
state.suggesting=true;
const t=document.createElement('div');t.className='toast';
t.innerHTML=`
${icon(regime,22)}
Market shifting to ${(label||regime).toUpperCase()}Composite ${S>=0?'+':''}${S} · ${conf}% confidence · ${reasonText(regime,sc)}
${hard?'Hard signal, switched to '+L:'Switched to '+L}Composite ${S>=0?'+':''}${S} · ${conf}% · ${reasonText(regime,sc)}. No orders were changed.
`;
$('toastWrap').appendChild(t);
t.querySelector('[data-act="ok"]').onclick=()=>dismiss(t);
t.querySelector('[data-act="keep"]').onclick=()=>{setMode('manual',true);dismiss(t);};
t._timer=setTimeout(()=>{if(document.body.contains(t))dismiss(t);},7000);
}
function dismiss(t){if(t._timer)clearTimeout(t._timer);t.classList.add('out');setTimeout(()=>{t.remove();if(!$('toastWrap').children.length)state.suggesting=false;},300);}
/* ---------- mode + engine panel ---------- */
function setMode(mode,silent){state.mode=mode;$('autoPill').classList.toggle('on',mode==='auto');renderRegimeBar(state.displayed);if(mode==='auto'&&!silent){clearToasts();recompute();}}
function openEngine(o){$('engine').classList.toggle('collapsed',!o);$('engineScrim').classList.toggle('show',o);}
/* ---------- simulation ---------- */
function toggleSim(){const btn=$('simRun');
if(state.simTimer){clearInterval(state.simTimer);state.simTimer=null;btn.textContent='▶ Simulate a move (demo)';btn.classList.remove('running');return;}
markSimOverride(); // the random-walk demo is explicitly a what-if, not live data
btn.textContent='⏸ Pause demo';btn.classList.add('running');
state.simTimer=setInterval(()=>{const walk=(id,mn,mx,st)=>{const e=$(id);e.value=clamp(+e.value+(Math.random()-0.5)*st,mn,mx);};
walk('sTrend',-100,100,16);walk('sVix',8,35,1.4);walk('sAd',0.2,3,0.18);walk('sRsi',20,80,5);walk('sPnl',-10,10,0.9);recompute();},1400);}
/* ---------- presets ---------- */
const PRESETS={rally:{sTrend:88,sVix:11.5,sAd:2.6,sRsi:68,sPnl:4.5,macd:true},choppy:{sTrend:8,sVix:16,sAd:1.0,sRsi:50,sPnl:-0.5,macd:true},crash:{sTrend:-78,sVix:24,sAd:0.4,sRsi:32,sPnl:-6,macd:false,hard:true},spike:{sTrend:-32,sVix:28,sAd:0.5,sRsi:38,sPnl:-3.5,macd:false,hard:true}};
function applyPreset(name){const p=PRESETS[name];if(!p)return;
markSimOverride(); // a preset is a hypothetical scenario, not the live market
['sTrend','sVix','sAd','sRsi','sPnl'].forEach(k=>{if(p[k]!=null)$(k).value=p[k];});
const mt=$('macdToggle');mt.dataset.on=String(p.macd);mt.textContent=p.macd?'Bullish ↑':'Bearish ↓';
if(p.hard)state.forceHard=true;clearToasts();recompute();}
/* ---------- init ---------- */
function init(){
['sTrend','sVix','sAd','sRsi','sPnl'].forEach(id=>$(id).addEventListener('input',()=>{markSimOverride();recompute();}));
$('macdToggle').addEventListener('click',()=>{const b=$('macdToggle');const on=b.dataset.on!=='true';b.dataset.on=String(on);b.textContent=on?'Bullish ↑':'Bearish ↓';markSimOverride();recompute();});
document.querySelectorAll('[data-preset]').forEach(b=>b.addEventListener('click',()=>applyPreset(b.dataset.preset)));
$('simRun').addEventListener('click',toggleSim);
$('engineBtn').addEventListener('click',()=>openEngine(true));
$('engineClose').addEventListener('click',()=>openEngine(false));
$('engineScrim').addEventListener('click',()=>openEngine(false));
$('widgetBtn').addEventListener('click',()=>openWidgetGallery(true));
$('wgClose').addEventListener('click',()=>openWidgetGallery(false));
$('widgetGalleryScrim').addEventListener('click',()=>openWidgetGallery(false));
$('autoPill').addEventListener('click',()=>setMode(state.mode==='auto'?'manual':'auto'));
document.querySelectorAll('[data-regime-btn]').forEach(b=>b.addEventListener('click',()=>{setMode('manual',true);clearToasts();cinematicRegime(b.dataset.regimeBtn);}));
// modal close
$('modalClose').addEventListener('click',closeModal);
$('modalCancel').addEventListener('click',closeModal);
$('modalScrim').addEventListener('click',closeModal);
document.addEventListener('keydown',e=>{if(e.key==='Escape')closeModal();});
// orderbook cancel (delegated)
$('panelBody').addEventListener('click',e=>{const c=e.target.closest('[data-cancel]');if(c)cancelOrder(+c.dataset.cancel);});
$('surfaceToggle').addEventListener('click',toggleSurface);
// persona: header toggle switches mode; the first-run gate drives the onboarding wizard
document.querySelectorAll('[data-mode-btn]').forEach(b=>b.addEventListener('click',()=>cinematicPersona(b.dataset.modeBtn)));
document.querySelectorAll('#personaGate [data-persona]').forEach(b=>b.addEventListener('click',()=>onboardPick(b.dataset.persona)));
const pgSkip=$('pgSkip'); if(pgSkip) pgSkip.addEventListener('click',()=>onboardPick('trader'));
// onboarding wizard: step-2 controls (back / finish / copy command / retry)
const pgate=$('personaGate'); if(pgate) pgate.addEventListener('click',e=>{
if(e.target.closest('[data-obback]')) obStep(1);
else if(e.target.closest('[data-obfinish]')) finishOnboarding();
else if(e.target.closest('[data-obretry]')){ loadBotData().then(()=>{ if(onboarding) renderOnboardConnect(); }).catch(()=>{}); }
else if(e.target.closest('[data-obcopy]')){ const c=$('obCmd');
if(c && navigator.clipboard){ navigator.clipboard.writeText(c.textContent.replace(/ /g,' ').trim())
.then(()=>quickToast('Copied','Paste it into your terminal')).catch(()=>{}); } }
});
// keep the floating-mode pill aligned: the pill is sized from button widths in syncFab(),
// but those widths shift when the web font swaps in (FOUT) and on resize, re-measure then.
if(document.fonts&&document.fonts.ready) document.fonts.ready.then(syncFab);
addEventListener('resize',syncFab);
// card minimize / maximize (delegated)
const term=document.querySelector('.terminal');
if(term) term.addEventListener('click',e=>{const b=e.target.closest('[data-cardbtn]');if(b){e.preventDefault();e.stopPropagation();toggleCard(b.dataset.cardkey,b.dataset.cardbtn);}});
const cscrim=$('cardScrim'); if(cscrim) cscrim.addEventListener('click',restoreMaxCard);
document.addEventListener('keydown',e=>{if(e.key==='Escape')restoreMaxCard();});
initWatchlistDnD(); initResize(); initSearch(); initKeyboardNav(); wireTicker();
// ---- mount the interactive chart engine (real Kite candles via /api/candles) ----
if(window.TPChart) TPChart.mount({onTrade:tradeFromChart, onExecuteBracket:executeBracketOrder, persist:saveChart, feed:chartFeed});
// ---- restore persisted session ----
const saved=loadState();
if(saved){
if(Array.isArray(saved.watchlist)) SYMS=saved.watchlist; // rebuild the universal watchlist
state.wlCustom=!!saved.wlCustom;
state.selected=(typeof saved.selected==='string'&&bySym(saved.selected))?saved.selected:null; // validate vs rebuilt list
state.paneW=saved.paneW||null; state.chartH=saved.chartH||null;
if(saved.chart && window.TPChart){ try{ TPChart.restore(saved.chart); }catch(e){} } }
if(saved&&saved.cards) state.cards=saved.cards;
if(saved&&saved.ticker) state.ticker=saved.ticker;
if(saved&&typeof saved.regimeCollapsed==='boolean') state.regimeCollapsed=saved.regimeCollapsed;
// BUG FIX (2026-09-19): this used to force 'algo' unconditionally whenever CRYPTO_ONLY was true,
// which is what kept Investing/Trading permanently unreachable even after the header toggle was
// wired up - CRYPTO_ONLY means "this deployment only has crypto data," not "only Algo Studio is
// Support direct deep-linking via ?mode=trading, ?persona=trader, ?tab=trading
let urlPersona = null;
try {
const sp = new URLSearchParams(window.location.search);
const pParam = (sp.get('persona') || sp.get('mode') || sp.get('tab') || '').toLowerCase();
if (pParam === 'trading' || pParam === 'trader') urlPersona = 'trader';
else if (pParam === 'investing' || pParam === 'investor') urlPersona = 'investor';
else if (pParam === 'algo' || pParam === 'studio') urlPersona = 'algo';
} catch (e) {}
state.persona = urlPersona || ((saved && ['trader', 'investor', 'algo'].indexOf(saved.persona) >= 0) ? saved.persona : 'algo');
renderPlanChip();
renderExchangeChip();
// Trading's paper book and Investing's DCA plans/goals didn't survive a reload before this fix -
// restore them the same validated way as everything else above, and bump ORDER_ID past any
// restored order id so a newly placed order can never collide with one from a prior session.
if(saved&&Array.isArray(saved.orders)&&saved.orders.length){
state.orders=saved.orders;
ORDER_ID=Math.max(ORDER_ID,...saved.orders.map(o=>o.id));
}
if(saved&&saved.trading) state.trading=saved.trading;
if(saved&&saved.investing) state.investing=saved.investing;
state.investSection=(saved&&saved.investSection)||null;
state.layout=(saved&&['originals','charts','watchlist','options','futures','build'].indexOf(saved.layout)>=0)?saved.layout:'originals';
// restore the active Algo sub-tab (Monitor / Library / …) so a refresh keeps you on the page you were on,
// instead of always snapping back to Marketplace (the default) when the async data loads settle.
if(saved&&saved.algo&&typeof saved.algo==='object'){
const AV=['library','opportunity','risk','market','leaderboard','backtest','forward','monitor','accuracy','analytics'];
if(AV.indexOf(saved.algo.view)>=0){
state.algo=state.algo||{bt:{algo:0,period:'1Y'}};
state.algo.view=saved.algo.view;
if(saved.algo.exec==='live'||saved.algo.exec==='paper') state.algo.exec=saved.algo.exec;
}
if(saved.algo.market==='in'||saved.algo.market==='crypto'){ state.algo=state.algo||{bt:{algo:0,period:'1Y'}}; state.algo.market=saved.algo.market; }
}
// CRYPTO_ONLY forces state BEFORE the first setMode/applyRegime/recompute below, not after - those
// calls fan out into renderAlgo() and 30+ other spots gated on state.algo.market==='crypto'. Forcing
// this late (as it used to) meant the very first render of the whole chain ran against unset/legacy
// state and showed the pre-pivot layout for one frame - the flash a fresh login used to hit every time.
// BUG FIX (2026-09-19): this used to also force state.persona back to 'algo' here (undoing the
// real saved-persona read a few lines up) and set #investHub/#deskView to display:none via
// INLINE style - which, since nothing ever clears an inline style, would leave Investing
// permanently stuck hidden forever after boot even after switching to it, regardless of what the
// CSS for the active persona says. Visibility is fully owned by the data-persona CSS rules
// (already !important, already correct per-mode) - no JS inline-style hiding needed here at all.
if(CRYPTO_ONLY){ state.algo=state.algo||{}; state.algo.market='crypto'; }
if(state.layout==='options')state.desk.view='chain'; else if(state.layout==='futures')state.desk.view='futures';
// restore named custom layouts (validate card keys against the live catalog)
const validCard=c=>c&&canvasCatalog().some(w=>w.key===c.key);
const cleanCard=c=>{const o={key:c.key,span:c.span===3?3:c.span===2?2:1}; if(c.grp==='A'||c.grp==='B')o.grp=c.grp; return o;};
// restore named custom layouts (tabs shape; validate card keys against the live catalog)
state.customLayouts=(saved&&Array.isArray(saved.customLayouts))?saved.customLayouts.map(l=>({id:l.id,name:l.name,activeTab:l.activeTab,
tabs:(Array.isArray(l.tabs)?l.tabs:[]).map(t=>({id:t.id,name:t.name,sync:(t.sync&&typeof t.sync==='object')?t.sync:{A:0,B:1},cards:(t.cards||[]).filter(validCard).map(cleanCard)}))})):[];
state.activeCustom=(saved&&typeof saved.activeCustom==='string')?saved.activeCustom:null;
state.aiCfg=(saved&&saved.aiCfg)||null;
// migrate the legacy single canvas into one named layout (one "Main" tab)
if(!state.customLayouts.length && saved && Array.isArray(saved.canvas) && saved.canvas.length){
const cards=saved.canvas.filter(validCard).map(cleanCard);
if(cards.length){const id=newLayoutId(),tid=newTabId(); state.customLayouts=[{id,name:'My Layout',activeTab:tid,tabs:[{id:tid,name:'Main',cards,sync:{A:0,B:1}}]}]; if(state.layout==='build')state.activeCustom=id;}
}
// if 'build' is active but the referenced layout is gone, fall back to a preset
if(state.layout==='build' && !activeCustom()) state.layout='originals';
document.documentElement.dataset.layout=state.layout;
state.widgets=(saved&&saved.widgets)||{trader:WIDGET_DEFAULTS.trader.slice(),investor:WIDGET_DEFAULTS.investor.slice()};
document.documentElement.dataset.persona=state.persona;
document.documentElement.dataset.layout=state.layout;
syncFab();
applyChartHeight();
setSurface(saved&&saved.surface?saved.surface:'day', true); // silent: no power-on sweep on reload
updateClock(); setInterval(updateClock,1000);
const startMode=(saved&&saved.mode)||'auto';
setMode(startMode,true);
applyRegime(startMode==='manual'&&saved&&saved.regime?saved.regime:'bull');
recompute({silent:true});
renderHdrMarket();
if(CRYPTO_ONLY && !(saved&&saved.watchlist)){
SYMS=CRYPTO_UNIVERSE.slice(0,6).map(c=>({sym:c.tk,name:c.name,exch:'CRYPTO',type:'SPOT',key:'CRYPTO:'+c.sym,ltp:0,chg:0,live:false}));
state.wlCustom=true;
}
if(CRYPTO_ONLY || (state.algo&&state.algo.market==='crypto')){ renderTopIndex(); loadCrypto().then(()=>{ if(state.algo.market==='crypto'){ patchCryptoTape(); applyTickerSpeed(); } }); connectCryptoWS(); }
tapeLoop();
if(CRYPTO_ONLY){
loadBotData().then(()=>{ if(typeof renderAlgo==='function'&&isAlgo()) renderAlgo(); });
setInterval(()=>{ if(document.visibilityState==='visible') loadBotData().then(()=>{ if(isAlgo()) renderAlgo(); }); }, 15000);
}
// live crypto prices: the WebSocket drives the tape sub-second; this 5s REST poll is the FALLBACK,
// firing only when the socket isn't delivering (first paint, dropped socket, WS unsupported).
setInterval(()=>{ if(!((CRYPTO_ONLY || (state.algo && state.algo.market==='crypto')) && document.visibilityState==='visible')) return;
if(CWS.on && Date.now()-CWS.lastMsg<8000) return; // socket is live → skip the REST poll
loadCrypto().then(()=>{ patchCryptoTape(); }); }, 5000);
// WATCHDOG (2026-09-20): reached production where init() threw partway through (root cause: an
// unclosed /* comment a few hundred lines up had silently deleted renderEngine() and friends
// from the running code, see the fix on syncSlidersFromLive's comment above), aborting boot
// before it ever reached the WebSocket connect call below - the ticker froze at its first REST
// fetch forever, no console error visible by the time a tab was inspected. That specific cause
// is fixed now, but a silent boot-abort is a class of bug, not a one-off, and nothing previously
// re-checked "is the tape actually still updating" after the fact. If data goes stale for longer
// than any single legitimate gap (poll + socket hiccup) should ever allow, force a fresh
// connect + fetch regardless of what CWS.on currently claims.
setInterval(()=>{ if(!((CRYPTO_ONLY || (state.algo && state.algo.market==='crypto')) && document.visibilityState==='visible')) return;
if(CRYPTO.t && Date.now()-CRYPTO.t<15000) return;
connectCryptoWS(); loadCrypto().then(()=>{ patchCryptoTape(); }); }, 10000);
// Instant refresh the moment the tab regains focus. Background tabs throttle setInterval (Chrome caps
// hidden-tab timers to ~1/min), so on return the crypto tape/book can look frozen until the next tick,
// pull fresh data immediately instead of waiting for it.
const refreshVisible=()=>{
if(document.visibilityState!=='visible') return;
if(CRYPTO_ONLY || (state.algo && state.algo.market==='crypto')){
connectCryptoWS(); // ensure the price socket is up again after the tab was hidden
loadCrypto().then(()=>{
patchCryptoTape();
if(typeof renderTrading==='function' && state.persona==='trader') renderTrading();
if(typeof renderInvesting==='function' && state.persona==='investor') renderInvesting();
});
if(typeof isAlgo==='function' && isAlgo()){ const v=state.algo.view;
if((v==='monitor'||v==='positions')&&typeof loadCryptoMonitor==='function') loadCryptoMonitor().then(()=>{ if(isAlgo()&&(state.algo.view==='monitor'||state.algo.view==='positions')){
if(document.querySelector('.cxm-tbl') && CRYPTOMON._sig===cryptoMonSig()) patchCryptoMon(); else renderAlgo(); } });
else if(v==='risk'&&typeof loadCryptoRisk==='function') loadCryptoRisk().then(()=>{ if(isAlgo()) renderAlgo(); }); }
} else if(BOT.live){ loadTicks(); } // Indian book: pull fresh ticks on return too
};
document.addEventListener('visibilitychange',refreshVisible);
window.addEventListener('focus',refreshVisible);
// live crypto paper book (7s poll), refresh the Monitor/Positions/Risk while the studio is scoped to Crypto
setInterval(()=>{ if(!(isAlgo() && state.algo && state.algo.market==='crypto' && document.visibilityState==='visible')) return;
const v=state.algo.view;
if(v==='monitor'||v==='positions') loadCryptoMonitor().then(()=>{ if(isAlgo()&&state.algo.market==='crypto'&&(state.algo.view==='monitor'||state.algo.view==='positions')){
// steady state → patch the ticking numbers in place (NO rebuild, no flicker); only full-render on a structural change
if(document.querySelector('.cxm-tbl') && CRYPTOMON._sig===cryptoMonSig()) patchCryptoMon(); else renderAlgo();
} });
else if(v==='risk') loadCryptoRisk().then(()=>{ if(isAlgo()&&state.algo.market==='crypto'&&state.algo.view==='risk') renderAlgo(); });
else if(v==='forward'||v==='accuracy') loadCryptoFwd().then(()=>{ if(isAlgo()&&state.algo.market==='crypto'&&(state.algo.view==='forward'||state.algo.view==='accuracy')) renderAlgo(); });
else if(v==='analytics') loadCryptoAn().then(()=>{ if(isAlgo()&&state.algo.market==='crypto'&&state.algo.view==='analytics') renderAlgo(); });
else if(v==='accuracy') loadReadiness('crypto').then(()=>{ if(isAlgo()&&state.algo.market==='crypto'&&state.algo.view==='accuracy') renderAlgo(); });
else if(v==='analytics') loadRegimeFit('crypto'); }, 7000);
mountStableCardCtls(); applyCardStates();
// first-run: onboarding wizard (pick a persona, then connect Kite)
if(!(saved&&saved.persona)) startOnboarding();
}
/* ---------- keyboard nav for segmented controls ---------- */
function initKeyboardNav(){
[document.querySelector('.seg')].forEach(grp=>{ if(!grp)return;
grp.addEventListener('keydown',e=>{
if(e.key!=='ArrowLeft'&&e.key!=='ArrowRight')return;
const btns=[...grp.querySelectorAll('button')];
const i=btns.indexOf(document.activeElement); if(i<0)return;
e.preventDefault();
const ni=(i+(e.key==='ArrowRight'?1:-1)+btns.length)%btns.length;
btns[ni].focus(); btns[ni].click();
});
});
}
/* ---------- watchlist: click-to-select + drag reorder ---------- */
function initWatchlistDnD(){
const wl=$('wlRows');
wl.addEventListener('click',e=>{if(e.target.closest('[data-wlremove]'))return;const row=e.target.closest('.wl-row');if(row)selectSym(row.dataset.key);});
// Keyboard a11y: rows are role="button" tabindex="0", Enter/Space selects, Up/Down roves focus
// (WCAG 2.1.1). Without this the watchlist was mouse-only. The remove button keeps its own focus.
wl.addEventListener('keydown',e=>{
if(e.target.closest('[data-wlremove]'))return; // let the remove button handle its own keys
const row=e.target.closest('.wl-row'); if(!row)return;
if(e.key==='Enter'||e.key===' '){ e.preventDefault(); selectSym(row.dataset.key); return; }
if(e.key==='ArrowDown'||e.key==='ArrowUp'){ e.preventDefault();
const rows=[...wl.querySelectorAll('.wl-row')], i=rows.indexOf(row);
const nxt=rows[e.key==='ArrowDown'?Math.min(i+1,rows.length-1):Math.max(i-1,0)];
if(nxt)nxt.focus();
}
});
wl.addEventListener('dragstart',e=>{const row=e.target.closest('.wl-row');if(!row)return;state.dragKey=row.dataset.key;row.classList.add('dragging');e.dataTransfer.effectAllowed='move';});
wl.addEventListener('dragend',e=>{const row=e.target.closest('.wl-row');if(row)row.classList.remove('dragging');wl.querySelectorAll('.drag-over').forEach(x=>x.classList.remove('drag-over'));});
wl.addEventListener('dragover',e=>{e.preventDefault();const row=e.target.closest('.wl-row');wl.querySelectorAll('.drag-over').forEach(x=>x.classList.remove('drag-over'));if(row&&row.dataset.key!==state.dragKey)row.classList.add('drag-over');});
wl.addEventListener('drop',e=>{e.preventDefault();const row=e.target.closest('.wl-row');if(!row||!state.dragKey)return;
const dragged=byKey(state.dragKey), target=byKey(row.dataset.key);
if(dragged&&target&&dragged!==target){ SYMS.splice(SYMS.indexOf(dragged),1); SYMS.splice(SYMS.indexOf(target),0,dragged); }
state.wlCustom=true; state.dragKey=null; renderWatchlist(state.displayed); saveState();});
// segment-filter tabs (My Watchlist / F&O / Indices)
document.querySelectorAll('.pane-left .wl-tab').forEach((b,i)=>b.onclick=()=>{
document.querySelectorAll('.pane-left .wl-tab').forEach(x=>x.classList.remove('active')); b.classList.add('active');
state.wlTab=['all','fno','idx'][i]||'all'; renderWatchlist(state.displayed);});
// "Add scrip…" box = universal instrument search scoped to the watchlist
initAddScrip();
}
/* ---------- resizable panes + chart ---------- */
function initResize(){
let active=null,sx=0,sl=0,sr=0;
const startPW=()=>{if(!state.paneW){const l=document.querySelector('.pane-left').getBoundingClientRect().width;const rp=document.querySelector('.pane-right').getBoundingClientRect().width;state.paneW={left:Math.round(l),right:Math.round(rp)};}};
const move=e=>{if(!active)return;const dx=e.clientX-sx;
if(active==='left')state.paneW.left=clamp(sl+dx,240,560);else state.paneW.right=clamp(sr-dx,240,560);
applyPaneWidths();};
const up=()=>{active=null;document.body.classList.remove('resizing');window.removeEventListener('mousemove',move);window.removeEventListener('mouseup',up);saveState();};
document.querySelectorAll('[data-resize]').forEach(h=>{
h.addEventListener('mousedown',e=>{e.preventDefault();startPW();active=h.dataset.resize;sx=e.clientX;sl=state.paneW.left;sr=state.paneW.right;document.body.classList.add('resizing');window.addEventListener('mousemove',move);window.addEventListener('mouseup',up);});
h.addEventListener('dblclick',()=>{state.paneW=null;applyPaneWidths();saveState();});
});
const ch=$('chartResize'), card=$('chartCard');
if(ch&&card){let cy=0,sh=0,drag=false;
const cmove=e=>{if(!drag)return;state.chartH=clamp(sh+(e.clientY-cy),300,680);card.style.height=state.chartH+'px';};
const cup=()=>{drag=false;document.body.classList.remove('resizing');window.removeEventListener('mousemove',cmove);window.removeEventListener('mouseup',cup);saveState();};
ch.addEventListener('mousedown',e=>{e.preventDefault();drag=true;cy=e.clientY;sh=card.getBoundingClientRect().height;document.body.classList.add('resizing');window.addEventListener('mousemove',cmove);window.addEventListener('mouseup',cup);});
ch.addEventListener('dblclick',()=>{state.chartH=null;card.style.height='';saveState();});
}
applyChartHeight();
}
/* ===== Universal instrument search (ALL segments), shared by the top-bar search and the
watchlist "Add scrip" box. Hits /api/instruments (the full 128k Kite master), debounced +
abortable, keyboard-navigable. Picking an instrument adds it to the watchlist. ===== */
function instRow(r){
const exp=r.expiry?' · '+new Date(r.expiry).toLocaleDateString('en-IN',{day:'2-digit',month:'short'}):'';
const strike=(r.type==='CE'||r.type==='PE')&&r.strike?(' '+Math.round(r.strike)):'';
const cls2=(''+(r.type||'')).toLowerCase();
return `
`;
document.body.appendChild(ov);
ov.addEventListener('click', e => {
if(e.target === ov || e.target.id === 'smCloseBtn') ov.remove();
});
}
function initTerminalShortcuts(){
document.addEventListener('keydown', e => {
const a = document.activeElement;
const inInput = a && (['INPUT', 'TEXTAREA', 'SELECT'].includes(a.tagName) || a.isContentEditable);
if(inInput) return;
if(e.key === '1'){
e.preventDefault();
applyPersona('investor', {user: true});
playAudioFeedback('click');
quickToast('Mode: Investing', 'Switched to Investing & DCA Simulator workstation.');
} else if(e.key === '2'){
e.preventDefault();
applyPersona('trader', {user: true});
playAudioFeedback('click');
quickToast('Mode: Trading', 'Switched to Trading & Multi-Timeframe Confluence workstation.');
} else if(e.key === '3'){
e.preventDefault();
applyPersona('algo', {user: true});
playAudioFeedback('click');
quickToast('Mode: Algo Studio', 'Switched to Algo Studio & Walk-Forward Resilience Matrix.');
} else if(e.key === 'b' || e.key === 'B'){
e.preventDefault();
selectSym('BTCUSDT');
playAudioFeedback('click');
} else if(e.key === 'e' || e.key === 'E'){
e.preventDefault();
selectSym('ETHUSDT');
playAudioFeedback('click');
} else if(e.key === 's' || e.key === 'S'){
e.preventDefault();
selectSym('SOLUSDT');
playAudioFeedback('click');
} else if(e.key === 'f' || e.key === 'F'){
e.preventDefault();
if(window.TPChart && window.TPChart.toggleFullscreen) window.TPChart.toggleFullscreen();
} else if(e.key === '?'){
e.preventDefault();
toggleShortcutsModal();
} else if(e.key === 'Escape'){
const card = document.getElementById('chartCard');
if(card && card.classList.contains('chart-fullscreen')){
if(window.TPChart && window.TPChart.toggleFullscreen) window.TPChart.toggleFullscreen();
}
const ov = $('shortcutsModalOverlay');
if(ov) ov.remove();
}
});
}
function initAddScrip(){ // watchlist "Add scrip…" box → add any instrument
const si=document.querySelector('.wl-search input'); if(!si) return;
let box=si.parentElement.querySelector('.search-results');
if(!box){ box=document.createElement('div'); box.className='search-results wl-results'; si.parentElement.appendChild(box); }
wireInstSearch(si, box, r=>addInstrument(r));
}
// The old in-app "pricing preview" modal (PLANS/ADDONS tiers, Zerodha-cockpit copy, Razorpay,
// RIA/PMS white-label pitch) was entirely pre-pivot Indian-equity content with its own fake
// tier prices in rupees, disconnected from the real crypto billing (NOWPayments, $19/mo
// Founding Pro) that actually exists on /pricing and /app#pricing. That's why a previous session
// made the chip just navigate to real pricing instead of faking a modal.
// Session 2026-09-16: founder asked for the modal back, for real this time - studio.js's
// ztOpenPricingModal() shows the genuine plans in place (real prices, real NOWPayments checkout on
// "Choose Pro/Elite", same edge function /app#pricing uses) so comparing plans doesn't need a full
// page navigation. studio.js loads before this script, but fall back to the real page if it's ever
// missing (e.g. the local operator terminal, which never loads studio.js) rather than do nothing.
function renderPlanChip(){ const el=$('planChip'); if(!el) return;
el.innerHTML=`${icon('bolt',12)}Pricing`;
el.title='View plans & pricing'; el.onclick=()=>{ window.ztOpenPricingModal ? window.ztOpenPricingModal() : (window.location.href='/app#pricing'); }; }
// A single, obvious, always-visible entry point for "I want to trade with real money" -
// previously only discoverable by already being in Trading mode or digging into Account
// settings. Same window.zt* feature-detection precedent as renderPlanChip() above: on the
// untouched local terminal (no studio.js, no window.ztExchange) this chip simply never shows,
// consistent with Trading mode's own Live toggle already being unreachable there.
function renderExchangeChip(){
const el=$('exChip'); if(!el) return;
if(typeof window.ztExchange==='undefined'){ el.hidden=true; return; }
if(state.headerExchange===undefined){
window.ztExchange.status().then(s=>{ state.headerExchange=s; renderExchangeChip(); });
return; // stay hidden until the first status check resolves, no flash of the wrong state
}
el.hidden=false;
if(state.headerExchange.connected){
el.classList.add('ex-on');
el.innerHTML=`Exchange Connected`;
el.title='Manage your connected exchange';
el.onclick=()=>{ window.location.href='/account'; };
} else {
el.classList.remove('ex-on');
el.innerHTML=`${icon('link',12)}Connect Exchange`;
el.title='Connect your own Binance account to trade with real money';
el.onclick=openConnectExchangeModal;
}
}
function openConnectExchangeModal(){
// MONETIZATION FIX (2026-09-20): live trading is a Pro/Elite feature, enforced server-side in
// exchange-connect/place-order (saas/supabase/functions/_shared/tier.mjs). Check tier first so a
// free-tier user sees an upgrade prompt instead of pasting real credentials only to hit a 403 at
// the very end. Pure UX convenience - if the tier bridge or pricing modal isn't available for any
// reason, fall through to the form rather than hard-blocking; the server-side gate is authoritative.
if(typeof window.ztExchange!=='undefined'&&typeof window.ztExchange.tier==='function'){
window.ztExchange.tier().then(t=>{
if(t==='pro'||t==='elite') openConnectExchangeModalForm();
else if(typeof window.ztOpenPricingModal==='function') window.ztOpenPricingModal();
else openConnectExchangeModalForm();
});
return;
}
openConnectExchangeModalForm();
}
function openConnectExchangeModalForm(){
const trust=[['shield','Non-custodial'],['lock','Encrypted at rest'],['link','Trade-only key']]
.map(([ic,l])=>`${icon(ic,11)}${l}`).join('');
// UX FIX (2026-09-20): both fields now get a show/hide toggle - a masked key/secret with no way
// to verify what was actually pasted is a real point of friction for exactly the two hardest
// values in the whole product to type or eyeball-check (64-char random strings).
const eyeField=(id,ic,label,placeholder)=>`
${icon(ic,13)}
`;
// UX REWORK (2026-09-20): restructured from one dense instructional paragraph into a guided
// 2-step flow - the real confusion this modal risks isn't the paste itself, it's that step 1
// requires LEAVING the page to go do something on Binance before coming back to step 2, and
// nothing previously signaled that explicitly. Reuses the app's own existing patterns (the
// .se-ic hero-icon circle from secEmpty(), the check icon, .flow-links) rather than inventing a
// new visual language just for this one modal.
const checklist=[
['API Management → Create API'],
['Check only "Enable Spot & Margin Trading" — leave "Enable Withdrawals" unchecked'],
].map(([t])=>`
${eyeField('gyokKey','link','API key','Paste your API key')}
${eyeField('gyokSecret','lock','API secret','Paste your API secret')}
`,
wire(body){
body.querySelectorAll('[data-eyefor]').forEach(btn=>{
btn.onclick=()=>{
const inp=body.querySelector('#'+btn.dataset.eyefor);
const show=inp.type==='password';
inp.type=show?'text':'password';
btn.innerHTML=icon(show?'eyeOff':'eye',14);
btn.setAttribute('aria-label',(show?'Hide':'Show')+(btn.dataset.eyefor==='gyokKey'?' API key':' API secret'));
};
});
},
onConfirm(body){
const key=body.querySelector('#gyokKey').value.trim(), secret=body.querySelector('#gyokSecret').value.trim();
const err=body.querySelector('#gyokErr');
const showErr=msg=>{ err.textContent=msg; err.hidden=false; };
if(!key||!secret){ showErr('Enter both the API key and secret.'); return false; }
// UX FIX (2026-09-20): a real Binance API key/secret is always 64 characters - catch an
// obviously partial paste instantly instead of waiting on a round trip to Binance to say so.
if(key.length<40||secret.length<40){ showErr("That looks too short for a real Binance API key/secret, check you copied the full value."); return false; }
err.hidden=true;
const cf=$('modalConfirm'); if(cf){ cf.disabled=true; cf.textContent='Connecting…'; }
window.ztExchange.connect(key,secret).then(res=>{
if(res.ok&&res.data&&res.data.connected){
closeModal();
quickToast('Binance connected','You can now switch Trading mode to Live.');
state.headerExchange={connected:true,connectedAt:new Date().toISOString()};
renderExchangeChip();
if(state.trading){ state.trading.exchangeStatus=undefined; if(state.persona==='trader'&&typeof renderTrading==='function') renderTrading(); }
} else {
// BUG FIX (2026-09-20): this used to closeModal() unconditionally before checking
// success, so a failure - even a specific, actionable one like "withdrawal permission
// enabled" - wiped the form and only flashed a toast; the user had to reopen the modal
// and re-paste both fields from scratch. Keep it open, show the real backend message
// (exchange-connect/index.ts already returns specific, actionable errors) inline instead.
if(cf){ cf.disabled=false; cf.textContent='Connect'; }
showErr((res.data&&res.data.error)||'Could not connect, please check your key/secret and try again.');
}
});
return false; // keep the modal open until the async call resolves either way
}
});
}
document.addEventListener('DOMContentLoaded',init);
initGlossTips(); // delegated listeners on document, safe to attach before DOMContentLoaded