#!/usr/bin/env python3
"""Reproducible retrospective paper comparison, no real orders or portfolio mutation."""
from pathlib import Path
from datetime import datetime,timedelta
from zoneinfo import ZoneInfo
import sys,os,json,inspect,hashlib,argparse
import numpy as np
import pandas as pd
import yfinance as yf
ROOT=Path(__file__).resolve().parent; OUT=ROOT/'site'; CACHE=ROOT/'cache'
SOURCE=Path('/workspace/copytrading/trading_bot_plan')
QUANT=Path('/cursor/stores/store-78bd1bbe-e18e-431a-ab06-ea63538790ba/traders/quant_analisis.py')
# Import the actual, existing CRYPTO engine without modifying it.
sys.path.insert(0,str(SOURCE)); cwd=os.getcwd(); os.chdir(SOURCE)
import bt
os.chdir(cwd)
import importlib.util
spec=importlib.util.spec_from_file_location('traders_quant',QUANT); quant=importlib.util.module_from_spec(spec); spec.loader.exec_module(quant)
ETF=bt.ETF; SLIP=.0005; CAPITAL=200.0
NAMES={'felipe_ma':'Felipe · cruce de medias','felipe_bb':'Felipe · ruptura Bollinger','combinada':'Combinada · tendencia + reversión','spy':'SPY · comprar y mantener'}
# Add observers only to the original execution function, then assert identical results.
original=inspect.getsource(bt.sim)
traced=original.replace('def sim(', 'def observed_sim(',1).replace('    trades=[]','    trades=[]\n    ledger=[]',1)
traced=traced.replace('        # exits during day t','                ledger.append(dict(t=t,j=int(j),action="COMPRA",price=float(px),quantity=float(sh),reason="Señal al cierre anterior",pnl=None))\n        # exits during day t',1)
traced=traced.replace('                fill=ex*(1-slip); cash+=sh*fill-fixed_fee; trades.append((t,j,fill/e-1,d)); del pos[j]', '                fill=ex*(1-slip); cash+=sh*fill-fixed_fee; trades.append((t,j,fill/e-1,d))\n                reason="Stop −2%" if ex<=slx else ("Objetivo +3%" if ex>=tpx else "Límite 10 sesiones")\n                ledger.append(dict(t=t,j=int(j),action="VENTA",price=float(fill),quantity=float(sh),reason=reason,pnl=float(sh*(fill-e)-fixed_fee)))\n                del pos[j]',1)
traced=traced.replace('    return eq,trades','    return eq,trades,ledger,pos,cash',1)
ns={'np':np}; exec(traced,ns); observed=ns['observed_sim']

def clean(x):
 if isinstance(x,dict):return {str(k):clean(v) for k,v in x.items()}
 if isinstance(x,(list,tuple)):return [clean(v) for v in x]
 if isinstance(x,(float,np.floating)):return float(x) if np.isfinite(x) else None
 if isinstance(x,np.integer):return int(x)
 return x

def stats(eq,closed):
 s=pd.Series(eq,dtype=float); r=s.pct_change().dropna(); vol=r.std(ddof=1)*np.sqrt(252); downside=np.sqrt(np.mean(np.minimum(r,0)**2))*np.sqrt(252)
 return clean(dict(value=s.iloc[-1],pnl=s.iloc[-1]-CAPITAL,return_pct=(s.iloc[-1]/CAPITAL-1)*100,max_drawdown_pct=(s/s.cummax()-1).min()*100,closed_trades=len(closed),win_rate_pct=(sum(p>0 for p in closed)/len(closed)*100 if closed else None),sharpe=(r.mean()*252/vol if vol>0 else None),sortino=(r.mean()*252/downside if downside>0 else None),cagr_pct=((s.iloc[-1]/CAPITAL)**(252/max(1,len(r)))-1)*100))

def fetch(refresh,end):
 frames={}; provenance=[]
 for sym in ETF:
  f=CACHE/(sym+'.csv')
  if refresh or not f.exists():
   h=yf.Ticker(sym).history(start='2017-01-01',end=end,auto_adjust=True,raise_errors=True)
   if h.empty:raise RuntimeError('Sin precios reales de '+sym)
   h.index=pd.to_datetime(h.index).tz_localize(None).normalize(); h=h[['Open','High','Low','Close','Volume']].dropna();h.to_csv(f,index_label='Date')
  else:h=pd.read_csv(f,index_col=0,parse_dates=True)
  h=h.loc[h.index<pd.Timestamp(end)]
  frames[sym]=h;provenance.append(dict(ticker=sym,source='Yahoo Finance / yfinance, OHLC ajustado',first=str(h.index[0].date()),last=str(h.index[-1].date()),sha256=hashlib.sha256(f.read_bytes()).hexdigest()))
  print(sym,len(h),h.index[-1].date(),flush=True)
 dates=frames['SPY'].index
 for sym,h in frames.items():
  # Do not forward-fill missing prices; before inception remain NaN and untradeable.
  missing=dates[(dates>=h.index.min())&(dates<=dates.max())].difference(h.index)
  if len(missing):raise RuntimeError(f'{sym}: faltan sesiones {missing.tolist()}')
 return {k:pd.DataFrame({s:h[k] for s,h in frames.items()}).reindex(dates) for k in ['Open','High','Low','Close','Volume']},provenance

def serialize_result(eq,ledger,pos,cash,dates):
 for x in ledger:
  x['date']=str(dates[x.pop('t')].date());x['ticker']=ETF[x.pop('j')]
 holdings=[]
 for j,p in pos.items():
  sh,e,*_=p; px=float(C.iloc[-1,j]);holdings.append(dict(ticker=ETF[j],quantity=sh,entry_price=e,close=px,value=sh*px,unrealized_pnl=sh*(px-e)))
 closed=[x['pnl'] for x in ledger if x['action']=='VENTA']
 return dict(equity=list(eq),metrics={**stats(eq,closed),'transactions':len(ledger),'entries':sum(x['action']=='COMPRA' for x in ledger)},trades=ledger,holdings=holdings,cash=cash)

def run_felipe(key,start,stop):
 ids=np.where((C.index>=pd.Timestamp(start))&(C.index<=pd.Timestamp(stop)))[0]; a,b=ids[0],ids[-1];dates=C.index[a:b+1]
 arrays=[P[k].iloc[a:b+1].values for k in ['Open','High','Low','Close']]
 sig=sigs['MAcross' if key=='felipe_ma' else 'BBbreak'][a:b+1];rank=dv[a:b+1];eligible=(arrays[3]>5)&(np.nan_to_num(rank)>5e6)
 kwargs=dict(tp=.03,sl=.02,maxh=10,K=2,slip=SLIP,fixed_fee=0.,cap0=CAPITAL,elig=eligible)
 ref,rt=bt.sim('ETF',sig,rank,*arrays,**kwargs)
 eq,tr,ledger,pos,cash=observed('ETF',sig,rank,*arrays,**kwargs)
 assert np.allclose(ref,eq,equal_nan=True) and rt==tr,'Observed engine differs from original'
 # serialize holdings at the correct end date (global data last is also stop for normal runs).
 for x in ledger:
  if x['action']=='COMPRA':x['reason']='Cruce SMA5 > SMA20 y cierre > SMA30 (día anterior)' if key=='felipe_ma' else 'Primera ruptura sobre SMA20 + 2σ20 (día anterior)'
 result=serialize_result(eq,ledger,pos,cash,dates)
 for h in result['holdings']:
  h['close']=float(C.loc[dates[-1],h['ticker']]);h['value']=h['quantity']*h['close'];h['unrealized_pnl']=h['quantity']*(h['close']-h['entry_price'])
 return result

def run_combined(start,stop):
 ids=np.where((C.index>=pd.Timestamp(start))&(C.index<=pd.Timestamp(stop)))[0];a,b=ids[0],ids[-1];dates=C.index[a:b+1]
 cash=CAPITAL;pos={};ledger=[];curve=[CAPITAL];lastreb=-9999;cooldown=set()
 # Position keys are (sleeve, ticker index). Core and overlay are tracked separately.
 def sell(key,t,raw,reason):
  nonlocal cash
  p=pos.pop(key);fill=float(raw)*(1-SLIP);cash+=p['q']*fill;ledger.append(dict(date=str(C.index[t].date()),ticker=ETF[key[1]],action='VENTA',price=fill,quantity=p['q'],reason=reason,pnl=p['q']*(fill-p['e']),sleeve=key[0]))
 def buy(key,t,budget,reason,decision=None):
  nonlocal cash
  if budget<1 or not np.isfinite(P['Open'].iloc[t,key[1]]):return
  requested_budget=float(budget)
  fill=float(P['Open'].iloc[t,key[1]])*(1+SLIP)
  # Nominal risk including normal exit slippage <=1%; gaps can exceed it.
  budget=min(budget,cash,prev_equity*.25,prev_equity*.01/(.02+2*SLIP))
  q=budget/fill;cash-=budget;pos[key]=dict(q=q,e=fill,stop=fill*.98)
  ledger.append(dict(date=str(C.index[t].date()),ticker=ETF[key[1]],action='COMPRA',price=fill,quantity=q,reason=reason,pnl=None,sleeve=key[0],decision=dict(signal_date=str(C.index[t-1].date()),prior_close=float(C.iloc[t-1,key[1]]),sma200=float(ma200.iloc[t-1,key[1]]),vol60=float(vols.iloc[t-1,key[1]]),z20=float(z20.iloc[t-1,key[1]]),requested_usd=requested_budget,allocated_usd=float(budget),allocation_pct=float(budget/prev_equity*100),**(decision or {}))))
 for t in range(a+1,b+1):
  prev=t-1;prev_equity=cash+sum(p['q']*C.iloc[prev,j] for (s,j),p in pos.items());cooldown=set()
  # Execute previous-close exits at next open, with stop priority if a gap is below it.
  for key,p in list(pos.items()):
   sleeve,j=key;o=P['Open'].iloc[t,j];cprev=C.iloc[prev,j]
   reason=None
   if o<=p['stop']:reason='Stop −2% con salto de apertura'
   elif cprev<=ma200.iloc[prev,j]:reason='Sale: cierre previo bajo SMA200'
   elif sleeve=='reversion' and z20.iloc[prev,j]>=0:reason='Regreso a media20 (z ≥ 0)'
   if reason:sell(key,t,o,reason);cooldown.add(key)
  if t-a-1-lastreb>=21:
   # Monthly-ish core: 60% maximum; no future volatility or weights.
   for key in list(pos):
    if key[0]=='nucleo':sell(key,t,P['Open'].iloc[t,key[1]],'Rebalanceo cada 21 sesiones')
   js=[ETF.index(s) for s in ['SPY','QQQ','GLD','TLT'] if C.iloc[prev,ETF.index(s)]>ma200.iloc[prev,ETF.index(s)] and vols.iloc[prev,ETF.index(s)]>0]
   if js:
    inv=np.array([1/vols.iloc[prev,j] for j in js]);weights=inv/inv.sum();scale=min(1.,.10/sum(weights[k]*vols.iloc[prev,j] for k,j in enumerate(js)))
    for w,j in zip(weights,js):
     if ('nucleo',j) not in cooldown:buy(('nucleo',j),t,prev_equity*.60*scale*w,'Núcleo: tendencia SMA200, peso inverso a vol60, objetivo vol10%',decision=dict(inverse_vol_weight=float(w),vol_scale=float(scale)))
   lastreb=t-a-1
  # Up to two MR positions, 40% total sleeve, maximum 20% per new entry.
  active=[key for key in pos if key[0]=='reversion'];free=2-len(active)
  candidates=[j for j in range(len(ETF)) if ('reversion',j) not in pos and ('reversion',j) not in cooldown and z20.iloc[prev,j]<-2 and C.iloc[prev,j]>ma200.iloc[prev,j] and vols.iloc[prev,j]>0]
  candidates.sort(key=lambda j:-np.nan_to_num(dv[prev,j]));chosen=candidates[:free]
  if chosen:
   inv=np.array([1/vols.iloc[prev,j] for j in chosen]);weights=inv/inv.sum();available=max(0,prev_equity*.40-sum(pos[k]['q']*C.iloc[prev,k[1]] for k in active))
   for w,j in zip(weights,chosen):buy(('reversion',j),t,min(prev_equity*.20,available*w)*min(1.,.10/vols.iloc[prev,j]),'Reversión: z20 < −2 y precio > SMA200; tamaño inverso a volatilidad',decision=dict(inverse_vol_weight=float(w),vol_scale=float(min(1.,.10/vols.iloc[prev,j]))))
  # Daily OHLC stop, including purchase day; no fixed profit target for combined.
  for key,p in list(pos.items()):
   if P['Low'].iloc[t,key[1]]<=p['stop']:sell(key,t,p['stop'],'Stop protector −2%')
  equity=cash+sum(p['q']*C.iloc[t,j] for (s,j),p in pos.items());assert cash>=-1e-8 and np.isfinite(equity);curve.append(equity)
 holdings=[dict(ticker=ETF[j],sleeve=s,quantity=p['q'],entry_price=p['e'],close=C.iloc[b,j],value=p['q']*C.iloc[b,j],unrealized_pnl=p['q']*(C.iloc[b,j]-p['e'])) for (s,j),p in pos.items()]
 closed=[x['pnl'] for x in ledger if x['action']=='VENTA'];return dict(equity=curve,metrics={**stats(curve,closed),'transactions':len(ledger),'entries':sum(x['action']=='COMPRA' for x in ledger)},trades=ledger,holdings=holdings,cash=cash)

def run_spy(start,stop):
 dates=C.loc[start:stop].index
 # Baseline at start close; all accounts enter no earlier than following open.
 curve=[CAPITAL];cash=CAPITAL;tr=[];hold=[]
 if len(dates)>1:
  fill=P['Open'].loc[dates[1],'SPY']*(1+SLIP);q=CAPITAL/fill;cash=0
  curve+=list(q*C.loc[dates[1]:dates[-1],'SPY']);tr=[dict(date=str(dates[1].date()),ticker='SPY',action='COMPRA',price=fill,quantity=q,reason='Comprar y mantener desde primera apertura',pnl=None)]
  hold=[dict(ticker='SPY',quantity=q,entry_price=fill,close=C.loc[dates[-1],'SPY'],value=curve[-1],unrealized_pnl=curve[-1]-CAPITAL)]
 return dict(equity=curve,metrics={**stats(curve,[]),'transactions':len(tr),'entries':len(tr)},trades=tr,holdings=hold,cash=cash)

def window(start,stop,ledger=True):
 results={k:run_felipe(k,start,stop) for k in ['felipe_ma','felipe_bb']};results['combinada']=run_combined(start,stop);results['spy']=run_spy(start,stop)
 for k,r in results.items():
  assert np.isclose(r['metrics']['value'],r['cash']+sum(h['value'] for h in r['holdings'])),f'Conservation {k}'
  if not ledger:r.pop('trades');r.pop('holdings')
 dates=[str(x.date()) for x in C.loc[start:stop].index]
 return dict(start=dates[0],end=dates[-1],sessions=len(dates)-1,dates=dates,results=results)


def allocations(recent):
 """Replay every actual fill, value shares at real close, and assert daily parity."""
 for key,r in recent['results'].items():
  cash=CAPITAL;qty={sym:0. for sym in ETF};daily=[]
  for i,day in enumerate(recent['dates']):
   for tr in [t for t in r['trades'] if t['date']==day]:
    sym=tr['ticker'];amount=tr['price']*tr['quantity'];sign=1 if tr['action']=='COMPRA' else -1
    qty[sym]+=sign*tr['quantity'];cash-=sign*amount
    if tr['action']=='COMPRA' and 'decision' not in tr:
     prev_equity=r['equity'][max(i-1,0)]
     tr['decision']=dict(allocated_usd=amount,allocation_pct=amount/prev_equity*100,signal_date=recent['dates'][max(i-1,0)])
   values={sym:float(qty[sym]*C.loc[day,sym]) for sym in ETF if abs(qty[sym])>1e-10};values['CASH']=float(max(0,cash));total=sum(values.values())
   assert np.isclose(total,r['equity'][i],atol=1e-7),f'Daily allocation parity {key} {day}: {total} != {r["equity"][i]}'
   daily.append(dict(date=day,total=total,values=values,weights_pct={sym:v/total*100 for sym,v in values.items()}))
  first=next((t['date'] for t in r['trades'] if t['action']=='COMPRA'),None)
  snapshots=[dict(label='Fecha base · cierre',**daily[0]),dict(label='Primera inversión efectiva · cierre',**next((v for v in daily if v['date']==first),daily[0])),dict(label='Actual · último cierre',**daily[-1])]
  r['allocation']=dict(daily=daily,snapshots=snapshots,first_entry_date=first,first_possible_execution=recent['dates'][1],first_possible_snapshot=daily[1])
 # Inputs actually known at base close, rather than weights estimated using future data.
 day=recent['dates'][0];js=[ETF.index(sym) for sym in ['SPY','QQQ','GLD','TLT'] if C.loc[day,sym]>ma200.loc[day,sym] and vols.loc[day,sym]>0]
 invsum=sum(1/vols.loc[day,ETF[j]] for j in js);weighted_vol=sum((1/vols.loc[day,ETF[j]])/invsum*vols.loc[day,ETF[j]] for j in js) if js else 0
 scale=min(1,.10/weighted_vol) if weighted_vol else 0
 recent['core_allocation_inputs']=[dict(ticker=sym,signal_date=day,close=float(C.loc[day,sym]),sma200=float(ma200.loc[day,sym]),vol60=float(vols.loc[day,sym]),eligible=bool(C.loc[day,sym]>ma200.loc[day,sym]),inverse_vol_weight=float((1/vols.loc[day,sym])/invsum if ETF.index(sym) in js else 0),vol_scale=scale,uncapped_target_usd=float(CAPITAL*.60*scale*(1/vols.loc[day,sym])/invsum if ETF.index(sym) in js else 0)) for sym in ['SPY','QQQ','GLD','TLT']]

def practice_snapshot():
 path=QUANT.parent/'portafolio-practica.json';raw=path.read_bytes();obj=json.loads(raw);reb=obj.get('last_quant_rebalance',{});prices=reb.get('live_prices',{})
 statsfile=QUANT.parent/'quant_last_results.json';volstats={}
 if statsfile.exists():volstats={r['symbol']:r.get('ann_vol') for r in json.loads(statsfile.read_text()).get('stats',[])}
 rows=[]
 for p in obj['positions']:
  if p.get('status')!='open':continue
  sym=p['symbol'];px=prices.get(sym)
  if px is None:raise RuntimeError('Practice JSON has no saved valuation price for '+sym)
  rows.append(dict(ticker=sym,quantity=p['quantity'],price=px,value=p['quantity']*px,price_timestamp=reb.get('at',p.get('price_timestamp')),source=p.get('source'),trend_halved=p.get('quant',{}).get('trend_filter_half',False),target_weight=p.get('quant',{}).get('target_weight'),saved_annual_vol=volstats.get(sym)))
 rows.append(dict(ticker='CASH',value=obj['cash_usd']));total=sum(r['value'] for r in rows)
 for r in rows:r['weight_pct']=r['value']/total*100
 assert raw==path.read_bytes(),'Practice state changed during read'
 realized=sum(float(p.get('realized_pnl_usd',0)) for p in obj['positions'] if p.get('status')=='closed')
 unrealized=sum(float(p['quantity'])*prices[p['symbol']]-float(p['entry_value_usd']) for p in obj['positions'] if p.get('status')=='open')
 initial=float(obj['meta']['starting_capital_usd']);cash=float(obj['cash_usd']);invested=total-cash
 # No exact fee field exists in this JSON: do not fabricate one from rounded targets.
 buys=sum(t.get('action') in ('BUY','OPEN_LONG') for t in obj.get('trade_log',[]));sells=sum(t.get('action')=='SELL' or t.get('action','').startswith('close XAU/USD') for t in obj.get('trade_log',[]))
 statement=dict(initial_capital=initial,equity=total,cash=cash,invested=invested,realized_pnl=realized,unrealized_pnl=unrealized,total_pnl=total-initial,total_return_pct=(total/initial-1)*100,slippage_paid=None,fees_paid=None,costs_paid=None,cost_note='El JSON no desglosa todos los costos pagados. No se inventa una cifra.',reconciliation_adjustment=total-initial-realized-unrealized,transactions=buys+sells,entries=buys,exits=sells,closed_trades=len([p for p in obj['positions'] if p.get('status')=='closed']),asof=reb.get('at'),timestamp_type='Estado guardado; precios de su JSON')
 # Retain genuine observations for future manual builds; never synthesize missing closes.
 history_path=CACHE/'practice_balance_history.json';history=json.loads(history_path.read_text()) if history_path.exists() else []
 current=dict(date=reb.get('at','')[:10],timestamp=reb.get('at'),cash=cash,invested=invested,equity=total,type='Estado guardado',daily_change=None,daily_change_pct=None)
 history=[r for r in history if r['date']!=current['date']]+[current];history.sort(key=lambda r:r['date'])
 for i,row in enumerate(history):
  row['daily_change']=row['equity']-history[i-1]['equity'] if i else None
  row['daily_change_pct']=(row['equity']/history[i-1]['equity']-1)*100 if i else None
 history_path.write_text(json.dumps(history,ensure_ascii=False,indent=2))
 return dict(asof=reb.get('at'),total=total,positions=rows,statement=statement,history=history,source_sha256=hashlib.sha256(raw).hexdigest(),source_note='Snapshot de solo lectura: cantidades y precios guardados en el JSON de práctica, no precios nuevos ni cartera reconstruida desde octubre.',page_url='https://cursor.com/canvas/store-8557611e-26b7-442e-b954-085b572f70a3/0fd0b4ea-fffc-468f-a03f-6a6cb8f0e9c1')


def account_statements(recent):
 close_time=datetime.fromisoformat(recent['end']+'T16:00:00').replace(tzinfo=ZoneInfo('America/New_York')).astimezone(ZoneInfo('America/Guayaquil')).isoformat()
 for key,r in recent['results'].items():
  realized=sum(t['pnl'] for t in r['trades'] if t['action']=='VENTA');unrealized=sum(h['unrealized_pnl'] for h in r['holdings']);invested=sum(h['value'] for h in r['holdings'])
  slippage=sum(t['quantity']*(t['price']-t['price']/(1+SLIP) if t['action']=='COMPRA' else t['price']/(1-SLIP)-t['price']) for t in r['trades'])
  assert np.isclose(CAPITAL+realized+unrealized,r['metrics']['value']),f'Statement reconciliation {key}'
  r['statement']=dict(initial_capital=CAPITAL,equity=r['metrics']['value'],cash=r['cash'],invested=invested,realized_pnl=realized,unrealized_pnl=unrealized,total_pnl=r['metrics']['value']-CAPITAL,total_return_pct=r['metrics']['return_pct'],slippage_paid=slippage,fees_paid=0.,costs_paid=slippage,cost_note='Deslizamiento de fills simulados, ya incluido en P/G. Comisión $0.',reconciliation_adjustment=0.,transactions=len(r['trades']),entries=r['metrics']['entries'],exits=r['metrics']['closed_trades'],closed_trades=r['metrics']['closed_trades'],asof=close_time,timestamp_type='Precios al cierre de EE.UU.')
  history=[]
  for i,d in enumerate(r['allocation']['daily']):
   prior=r['allocation']['daily'][i-1]['total'] if i else None
   history.append(dict(date=d['date'],cash=d['values'].get('CASH',0),invested=d['total']-d['values'].get('CASH',0),equity=d['total'],daily_change=d['total']-prior if prior is not None else None,daily_change_pct=(d['total']/prior-1)*100 if prior else None,type='Cierre diario'))
  r['balance_history']=history

if __name__=='__main__':
 ap=argparse.ArgumentParser();ap.add_argument('--refresh',action='store_true');args=ap.parse_args();OUT.mkdir(exist_ok=True);CACHE.mkdir(exist_ok=True)
 now=datetime.now(ZoneInfo('America/New_York'));limit=now.date() if now.hour>=16 else now.date()-timedelta(days=1);end=(limit+timedelta(days=1)).isoformat()
 P,provenance=fetch(args.refresh,end);C=P['Close'][ETF];P={k:v[ETF] for k,v in P.items()};sigs,dv=bt.signals(C.values,P['Volume'].values)
 ma200=C.apply(quant.ma200);z20=C.apply(quant.zscore_20);vols=C.pct_change(fill_method=None).rolling(60,min_periods=60).std()*np.sqrt(252)
 stop=str(C.index[-1].date());recent=window('2026-10-23',stop);allocations(recent);account_statements(recent);practice=practice_snapshot()
 hdates=C.loc['2018-01-01':stop].index;split=int(len(hdates)*.70);isend=str(hdates[split-1].date());oosstart=str(hdates[split].date())
 historical=dict(full=window(str(hdates[0].date()),stop,False),in_sample=window(str(hdates[0].date()),isend,False),out_of_sample=window(oosstart,stop,False),split_date=oosstart)
 data=clean(dict(generated_at=datetime.now(ZoneInfo('America/Guayaquil')).isoformat(),timezone='America/Guayaquil (UTC−5)',capital=CAPITAL,names=NAMES,universe=ETF,recent=recent,historical=historical,practice=practice,provenance=provenance,methodology=dict(slippage=SLIP,commission=0,fractional=True,source_engine_sha256=hashlib.sha256((SOURCE/'bt.py').read_bytes()).hexdigest(),parity_assertions='Curvas y cierres de Felipe comprobados contra bt.sim original en las 4 ventanas',felipe_max_positions=2,felipe_position_fraction=.5,felipe_min_notional=50,felipe_max_hold=10,combined_stop=.02,combined_vol_target=.10,combined_vol_days=60,combined_core_max=.60,combined_overlay_max=.40,combined_rebalance_sessions=21,combined_max_position=.25,risk_budget_max=.01)))
 (OUT/'data.json').write_text(json.dumps(data,ensure_ascii=False,allow_nan=False,separators=(',',':')))
 # Public audit/download artifacts contain no credentials or private account data.
 import shutil
 shutil.copy2(SOURCE/'bt.py',OUT/'engine_reference.py');shutil.copy2(ROOT/'build.py',OUT/'build.py')
 print(json.dumps({'recent':{k:v['metrics'] for k,v in recent['results'].items()},'historical':{phase:{k:v['metrics'] for k,v in historical[phase]['results'].items()} for phase in ['full','in_sample','out_of_sample']},'split':oosstart},indent=2,default=clean))
